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/file.ts b/lib/node/file.ts index ed6c5085c..640ef6760 100644 --- a/lib/node/file.ts +++ b/lib/node/file.ts @@ -2,20 +2,19 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { NodeConstructorData } from './node' + import { FileType } from './fileType' import { Node } from './node' export class File extends Node { - get type(): FileType.File { - return FileType.File + public constructor(...[data, davService]: NodeConstructorData) { + super(data, davService) } - /** - * Returns a clone of the file - */ - clone(): File { - return new File(this.data) + get type(): FileType.File { + return FileType.File } } diff --git a/lib/node/folder.ts b/lib/node/folder.ts index 05d0291d0..296327cd3 100644 --- a/lib/node/folder.ts +++ b/lib/node/folder.ts @@ -2,18 +2,19 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { NodeData } from './nodeData' +import type { NodeConstructorData } from './node' + import { FileType } from './fileType' import { Node } from './node' export class Folder extends Node { - constructor(data: NodeData) { + constructor(...[data, davService]: NodeConstructorData) { // enforcing mimes super({ ...data, mime: 'httpd/unix-directory', - }) + }, davService) } get type(): FileType.Folder { @@ -28,13 +29,6 @@ export class Folder extends Node { return 'httpd/unix-directory' } - /** - * Returns a clone of the folder - */ - clone(): Folder { - return new Folder(this.data) - } - } /** diff --git a/lib/node/node.ts b/lib/node/node.ts index 12e2b3831..e558d46b5 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 { @@ -21,11 +21,14 @@ export enum NodeStatus { LOCKED = 'locked', } +export type NodeConstructorData = [NodeData, RegExp?] + export abstract class Node { - private _data: NodeData private _attributes: Attribute - private _knownDavService = /(remote|public)\.php\/(web)?dav/i + + protected _data: NodeData + protected _knownDavService = /(remote|public)\.php\/(web)?dav/i private readonlyAttributes = Object.entries(Object.getOwnPropertyDescriptors(Node.prototype)) .filter(e => typeof e[1].get === 'function' && e[0] !== '__proto__') @@ -58,13 +61,17 @@ export abstract class Node { }, } as ProxyHandler - constructor(data: NodeData, davService?: RegExp) { + protected constructor(...[data, davService]: NodeConstructorData) { if (!data.mime) { 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 @@ -346,13 +353,6 @@ export abstract class Node { this._data.status = status } - /** - * Get the node data - */ - get data(): NodeData { - return structuredClone(this._data) - } - /** * Move the node to a new destination * @@ -424,7 +424,17 @@ export abstract class Node { /** * Returns a clone of the node */ - abstract clone(): Node + clone(): this { + // @ts-expect-error -- this class is abstract and cannot be instantiated directly but all its children can + return new this.constructor(structuredClone(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..17f5359d7 100644 --- a/lib/node/nodeData.ts +++ b/lib/node/nodeData.ts @@ -4,6 +4,7 @@ */ import { join } from 'path' + import { Permission } from '../permissions' import { NodeStatus } from './node' @@ -157,3 +158,53 @@ 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. + * + * @param data The internal node data + */ +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) + } + } +} + +/** + * Fix a RegExp pattern from string or RegExp to RegExp + * + * @param pattern The pattern as string or RegExp + */ +export const fixRegExp = (pattern: string | RegExp): RegExp => { + if (pattern instanceof RegExp) { + return pattern + } + + // Extract the pattern and flags if it's a string + // Pulled from https://www.npmjs.com/package/regex-parser + const matches = pattern.match(/(\/?)(.+)\1([a-z]*)/i) + + // If there's no match, throw an error + if (!matches) { + throw new Error('Invalid regular expression format.') + } + + // Filter valid flags: 'g', 'i', 'm', 's', 'u', and 'y' + const validFlags = Array.from(new Set(matches[3])) + .filter((flag) => 'gimsuy'.includes(flag)) + .join('') + + // Create the regular expression + return new RegExp(matches[2], validFlags) +}