diff --git a/package-lock.json b/package-lock.json index da05287a1e7..6434310757e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18285,7 +18285,6 @@ "dependencies": { "@ui5/logger": "^5.0.0-alpha.4", "async-mutex": "^0.5.0", - "clone": "^2.1.2", "escape-string-regexp": "^5.0.0", "globby": "^15.0.0", "graceful-fs": "^4.2.11", diff --git a/packages/fs/lib/Resource.js b/packages/fs/lib/Resource.js index 527fdb6b1d4..a37b451baaa 100644 --- a/packages/fs/lib/Resource.js +++ b/packages/fs/lib/Resource.js @@ -1,7 +1,6 @@ import {Readable, PassThrough} from "node:stream"; import {buffer as streamToBuffer} from "node:stream/consumers"; import ssri from "ssri"; -import clone from "clone"; import posixPath from "node:path/posix"; import {setTimeout} from "node:timers/promises"; import {Mutex} from "async-mutex"; @@ -13,6 +12,23 @@ let deprecatedGetStatInfoCalled = false; const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"]; +// Deep-copy an fs.Stats-like object while preserving prototype methods and +// internal-slot-backed values (Date, Temporal.Instant, Map, Set, typed arrays, …). +// structuredClone rejects functions (used by synthetic statInfo objects), so +// function-valued own properties are copied by reference; data values go +// through structuredClone, which keeps internal slots intact. +function cloneStatInfo(statInfo) { + if (!statInfo || typeof statInfo !== "object") { + return statInfo; + } + const target = Object.create(Object.getPrototypeOf(statInfo)); + for (const key of Object.getOwnPropertyNames(statInfo)) { + const value = statInfo[key]; + target[key] = typeof value === "function" ? value : structuredClone(value); + } + return target; +} + const CONTENT_TYPES = { BUFFER: "buffer", STREAM: "stream", @@ -796,12 +812,12 @@ class Resource { const options = { path: this.#path, - statInfo: this.#statInfo, // Will be cloned in constructor + statInfo: cloneStatInfo(this.#statInfo), isDirectory: this.#isDirectory, byteSize: this.#isDirectory ? undefined : await this.getSize(), lastModified: this.#lastModified, integrity: this.#isDirectory ? undefined : (this.#contentType ? await this.getIntegrity() : undefined), - sourceMetadata: clone(this.#sourceMetadata) + sourceMetadata: structuredClone(this.#sourceMetadata) }; switch (this.#contentType) { diff --git a/packages/fs/package.json b/packages/fs/package.json index 357610b45b0..0d329bbbabb 100644 --- a/packages/fs/package.json +++ b/packages/fs/package.json @@ -58,7 +58,6 @@ "dependencies": { "@ui5/logger": "^5.0.0-alpha.4", "async-mutex": "^0.5.0", - "clone": "^2.1.2", "escape-string-regexp": "^5.0.0", "globby": "^15.0.0", "graceful-fs": "^4.2.11", diff --git a/packages/fs/test/lib/Resource.js b/packages/fs/test/lib/Resource.js index cab2c430b51..4e6267f44af 100644 --- a/packages/fs/test/lib/Resource.js +++ b/packages/fs/test/lib/Resource.js @@ -2041,3 +2041,51 @@ test("getInode: Preserved across setBuffer", (t) => { t.is(resource.getInode(), inode, "Inode is unchanged after setBuffer (same on-disk slot, content modified)"); }); + +test("Resource: clone preserves prototype methods on real fs.Stats", async (t) => { + const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html"); + const statInfo = await stat(fsPath); + + const resource = new Resource({ + path: "/some/path", + statInfo, + buffer: Buffer.from("Content") + }); + + const clonedStat = (await resource.clone()).getStatInfo(); + + t.is(typeof clonedStat.isFile, "function", "isFile method preserved on clone"); + t.true(clonedStat.isFile(), "isFile() returns the expected value"); + t.true(clonedStat.mtime instanceof Date, "mtime is still a Date"); + // The original mtime is computed lazily from mtimeMs via a prototype getter. + // Both sides should yield the same time without throwing. + t.is(clonedStat.mtime.getTime(), statInfo.mtime.getTime(), "mtime time value preserved"); + // Regression: JSON.stringify must not throw on the cloned stat. On Node 26 + // fs.Stats exposes Temporal.Instant getters whose toJSON requires the + // internal slot — copying those values via a plain `clone` package strips + // the slot and breaks JSON.stringify. structuredClone-based copying keeps + // the prototype intact and lets the lazy getters compute fresh values. + t.notThrows(() => JSON.stringify(clonedStat), "cloned statInfo can be JSON-stringified"); +}); + +test("Resource: clone deep-copies Date values in synthetic statInfo", async (t) => { + const mtime = new Date("2024-01-02T03:04:05Z"); + const resource = new Resource({ + path: "/some/path", + statInfo: { + isFile: () => true, + isDirectory: () => false, + mtime, + }, + buffer: Buffer.from("Content") + }); + + const clonedStat = (await resource.clone()).getStatInfo(); + + t.true(clonedStat.mtime instanceof Date, "mtime is a Date on the clone"); + t.not(clonedStat.mtime, mtime, "mtime is a fresh Date instance"); + t.is(clonedStat.mtime.getTime(), mtime.getTime(), "mtime time value preserved"); + // Sanity-check that the cloned Date is fully functional (the bug exposed by + // the old `clone` package was that Date copies lost their internal slot). + t.notThrows(() => clonedStat.mtime.toJSON(), "cloned Date supports toJSON"); +});