From cf23dc380bed80815694901431c9faa0338610f3 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 1 Jun 2026 15:44:22 +0200 Subject: [PATCH] refactor(fs): Use structuredClone for Resource statInfo deep copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `clone` package recreates internal-slot-backed values like Date and Temporal.Instant as prototype-only copies, stripping the slot. On Node 26 fs.Stats exposes Temporal.Instant getters whose toJSON performs a strict internal-slot check and throws, breaking any consumer that JSON-stringifies a cloned stat (e.g. @ui5/builder's theme worker IPC). Replace `clone` with a small helper that preserves the prototype, copies function-valued own properties by reference, and runs each data value through structuredClone — which correctly handles Date, Temporal, Map, Set, typed arrays, etc. sourceMetadata uses structuredClone directly. The unmaintained `clone` dependency is removed from @ui5/fs. This is an up-port of https://github.com/SAP/ui5-fs/pull/697 hence the commit message is not using the "fix" prefix. --- package-lock.json | 1 - packages/fs/lib/Resource.js | 22 +++++++++++++-- packages/fs/package.json | 1 - packages/fs/test/lib/Resource.js | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) 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"); +});