diff --git a/__tests__/files/cloning.spec.ts b/__tests__/files/cloning.spec.ts new file mode 100644 index 000000000..7b3c8156d --- /dev/null +++ b/__tests__/files/cloning.spec.ts @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, test } from 'vitest' +import { File, NodeData, NodeStatus } from '../../lib/node/index.ts' +import { Permission } from '../../lib/permissions.ts' + +describe('File cloning', () => { + test('Clone preserves all basic properties', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)), + crtime: new Date(Date.UTC(1990, 0, 1, 0, 0, 0)), + size: 12345, + permissions: Permission.ALL, + root: '/files/emma', + status: NodeStatus.NEW, + }) + + const clone = file.clone() + + expect(clone).toBeInstanceOf(File) + expect(clone).not.toBe(file) + expect(clone.source).toBe(file.source) + expect(clone.mime).toBe(file.mime) + expect(clone.owner).toBe(file.owner) + expect(clone.size).toBe(file.size) + expect(clone.permissions).toBe(file.permissions) + expect(clone.root).toBe(file.root) + expect(clone.status).toBe(file.status) + expect(clone.mtime?.toISOString()).toBe(file.mtime?.toISOString()) + expect(clone.crtime?.toISOString()).toBe(file.crtime?.toISOString()) + }) + + test('Clone preserves attributes', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + attributes: { + favorite: true, + customProp: 'value', + nested: { key: 'value' }, + }, + }) + + const clone = file.clone() + + expect(clone.attributes).toStrictEqual(file.attributes) + expect(clone.attributes.favorite).toBe(true) + expect(clone.attributes.customProp).toBe('value') + expect(clone.attributes.nested).toStrictEqual({ key: 'value' }) + }) + + test('Clone is independent from original', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + size: 100, + attributes: { + test: 'original', + }, + }) + + const clone = file.clone() + + // Modify the clone + clone.size = 200 + clone.mime = 'image/png' + clone.attributes.test = 'modified' + clone.attributes.newProp = 'new' + + // Original should be unchanged + expect(file.size).toBe(100) + expect(file.mime).toBe('image/jpeg') + expect(file.attributes.test).toBe('original') + expect(file.attributes.newProp).toBeUndefined() + }) + + test('Clone works with minimal file', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt', + owner: 'emma', + }) + + const clone = file.clone() + + expect(clone).toBeInstanceOf(File) + expect(clone.source).toBe(file.source) + expect(clone.mime).toBe('application/octet-stream') + expect(clone.owner).toBe('emma') + }) + + test('Clone works with remote file', () => { + const file = new File({ + source: 'https://domain.com/Photos/picture.jpg', + mime: 'image/jpeg', + owner: null, + }) + + const clone = file.clone() + + expect(clone).toBeInstanceOf(File) + expect(clone.source).toBe(file.source) + expect(clone.owner).toBeNull() + expect(clone.isDavResource).toBe(false) + expect(clone.permissions).toBe(Permission.READ) + }) +}) + +describe('File serialization and deserialization', () => { + test('toJSON and JSON.parse roundtrip preserves all properties', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)), + crtime: new Date(Date.UTC(1990, 0, 1, 0, 0, 0)), + size: 12345, + permissions: Permission.ALL, + root: '/files/emma', + status: NodeStatus.LOADING, + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(reconstructed).toBeInstanceOf(File) + expect(reconstructed.source).toBe(file.source) + expect(reconstructed.mime).toBe(file.mime) + expect(reconstructed.owner).toBe(file.owner) + expect(reconstructed.size).toBe(file.size) + expect(reconstructed.permissions).toBe(file.permissions) + expect(reconstructed.root).toBe(file.root) + expect(reconstructed.status).toBe(file.status) + expect(reconstructed.mtime?.toISOString()).toBe(file.mtime?.toISOString()) + expect(reconstructed.crtime?.toISOString()).toBe(file.crtime?.toISOString()) + }) + + test('toString and JSON.parse preserves attributes', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + attributes: { + favorite: true, + tags: ['work', 'important'], + metadata: { author: 'Emma', version: 2 }, + }, + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(reconstructed.attributes).toStrictEqual(file.attributes) + expect(reconstructed.attributes.favorite).toBe(true) + expect(reconstructed.attributes.tags).toStrictEqual(['work', 'important']) + expect(reconstructed.attributes.metadata).toStrictEqual({ author: 'Emma', version: 2 }) + }) + + test('toString and JSON.parse works with minimal file', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt', + owner: 'emma', + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(reconstructed).toBeInstanceOf(File) + expect(reconstructed.source).toBe(file.source) + expect(reconstructed.mime).toBe('application/octet-stream') + expect(reconstructed.owner).toBe('emma') + }) + + test('toString and JSON.parse is independent from original', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + size: 100, + attributes: { + test: 'original', + }, + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + // Modify the reconstructed file + reconstructed.size = 200 + reconstructed.mime = 'image/png' + reconstructed.attributes.test = 'modified' + + // Original should be unchanged + expect(file.size).toBe(100) + expect(file.mime).toBe('image/jpeg') + expect(file.attributes.test).toBe('original') + }) + + test('toString and JSON.parse preserves displayname', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + attributes: { + displayname: 'My Vacation Photo', + }, + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(reconstructed.displayname).toBe('My Vacation Photo') + expect(reconstructed.basename).toBe('picture.jpg') + }) + + test('toString and JSON.parse works with remote file', () => { + const file = new File({ + source: 'https://domain.com/Photos/picture.jpg', + mime: 'image/jpeg', + owner: null, + }) + + expect(file.owner).toBeNull() + expect(file.isDavResource).toBe(false) + expect(file.permissions).toBe(Permission.READ) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(reconstructed).toBeInstanceOf(File) + expect(reconstructed.source).toBe(file.source) + expect(reconstructed.owner).toBeNull() + expect(reconstructed.isDavResource).toBe(false) + expect(reconstructed.permissions).toBe(Permission.READ) + }) + + test('toString and JSON.parse preserves all NodeStatus values', () => { + const statuses = [NodeStatus.NEW, NodeStatus.FAILED, NodeStatus.LOADING, NodeStatus.LOCKED] + + for (const status of statuses) { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt', + owner: 'emma', + status, + }) + + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + expect(reconstructed.status).toBe(status) + } + }) + + test('toString output is valid JSON', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + size: 12345, + }) + + const str = file.toJSON() + expect(() => JSON.parse(str)).not.toThrow() + + const parsed = JSON.parse(str) + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBeGreaterThanOrEqual(1) + expect(parsed[0]).toHaveProperty('source') + }) +}) + +describe('Cloning methods comparison', () => { + test('clone() and toString/parse produce equivalent files', () => { + const file = new File({ + source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg', + mime: 'image/jpeg', + owner: 'emma', + mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)), + size: 12345, + permissions: Permission.ALL, + root: '/files/emma', + attributes: { + favorite: true, + tags: ['work'], + }, + }) + + const cloned = file.clone() + const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?] + const reconstructed = new File(parsed[0], parsed[1]) + + expect(cloned.source).toBe(reconstructed.source) + expect(cloned.mime).toBe(reconstructed.mime) + expect(cloned.owner).toBe(reconstructed.owner) + expect(cloned.size).toBe(reconstructed.size) + expect(cloned.permissions).toBe(reconstructed.permissions) + expect(cloned.root).toBe(reconstructed.root) + expect(cloned.mtime?.toISOString()).toBe(reconstructed.mtime?.toISOString()) + expect(cloned.attributes).toStrictEqual(reconstructed.attributes) + }) +}) diff --git a/lib/node/node.ts b/lib/node/node.ts index 8f241a907..a8db012b0 100644 --- a/lib/node/node.ts +++ b/lib/node/node.ts @@ -7,7 +7,7 @@ import { encodePath } from '@nextcloud/paths' import { Permission } from '../permissions' import { FileType } from './fileType' -import { Attribute, isDavResource, NodeData, validateData } from './nodeData' +import { Attribute, fixDates, fixRegExp, isDavResource, NodeData, validateData } from './nodeData' import logger from '../utils/logger' export enum NodeStatus { @@ -66,8 +66,12 @@ export abstract class Node { data.mime = 'application/octet-stream' } + // Fix primitive types if needed + fixDates(data) + davService = fixRegExp(davService || this._knownDavService) + // Validate data - validateData(data, davService || this._knownDavService) + validateData(data, davService) this._data = { // TODO: Remove with next major release, this is just for compatibility @@ -425,6 +429,13 @@ export abstract class Node { return new this.constructor(this.data, this._knownDavService) } + /** + * JSON representation of the node + */ + toJSON(): string { + return JSON.stringify([structuredClone(this._data), this._knownDavService.toString()]) + } + } /** diff --git a/lib/node/nodeData.ts b/lib/node/nodeData.ts index c387ba202..4508de81c 100644 --- a/lib/node/nodeData.ts +++ b/lib/node/nodeData.ts @@ -4,6 +4,8 @@ */ import { join } from 'path' +import RegexParser from 'regex-parser' + import { Permission } from '../permissions' import { NodeStatus } from './node' @@ -157,3 +159,30 @@ export const validateData = (data: NodeData, davService: RegExp) => { throw new Error('Status must be a valid NodeStatus') } } + +/** + * In case we try to create a node from deserialized data, + * we need to fix date types. + */ +export const fixDates = (data: NodeData) => { + if (data.mtime && typeof data.mtime === 'string') { + if (!isNaN(Date.parse(data.mtime)) + && JSON.stringify(new Date(data.mtime)) === JSON.stringify(data.mtime)) { + data.mtime = new Date(data.mtime) + } + } + + if (data.crtime && typeof data.crtime === 'string') { + if (!isNaN(Date.parse(data.crtime)) + && JSON.stringify(new Date(data.crtime)) === JSON.stringify(data.crtime)) { + data.crtime = new Date(data.crtime) + } + } +} + +export const fixRegExp = (pattern: string | RegExp): RegExp => { + if (typeof pattern === 'string') { + return RegexParser(pattern) + } + return pattern +} diff --git a/package-lock.json b/package-lock.json index a50db8436..029cd0434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nextcloud/sharing": "^0.3.0", "cancelable-promise": "^4.3.1", "is-svg": "^6.1.0", + "regex-parser": "^2.3.1", "typescript-event-target": "^1.1.1", "webdav": "^5.8.0" }, @@ -8482,6 +8483,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", diff --git a/package.json b/package.json index 936d4d19e..698d5dff9 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@nextcloud/sharing": "^0.3.0", "cancelable-promise": "^4.3.1", "is-svg": "^6.1.0", + "regex-parser": "^2.3.1", "typescript-event-target": "^1.1.1", "webdav": "^5.8.0" }, @@ -89,7 +90,7 @@ "packageManager": [ { "name": "npm", - "version": "^11.3.0" + "version": "^10.0.0 || ^11.0.0" } ], "runtime": {