Skip to content

Commit cf23dc3

Browse files
committed
refactor(fs): Use structuredClone for Resource statInfo deep copy
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 SAP/ui5-fs#697 hence the commit message is not using the "fix" prefix.
1 parent 2655ce8 commit cf23dc3

4 files changed

Lines changed: 67 additions & 5 deletions

File tree

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/fs/lib/Resource.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {Readable, PassThrough} from "node:stream";
22
import {buffer as streamToBuffer} from "node:stream/consumers";
33
import ssri from "ssri";
4-
import clone from "clone";
54
import posixPath from "node:path/posix";
65
import {setTimeout} from "node:timers/promises";
76
import {Mutex} from "async-mutex";
@@ -13,6 +12,23 @@ let deprecatedGetStatInfoCalled = false;
1312

1413
const ALLOWED_SOURCE_METADATA_KEYS = ["adapter", "fsPath", "contentModified"];
1514

15+
// Deep-copy an fs.Stats-like object while preserving prototype methods and
16+
// internal-slot-backed values (Date, Temporal.Instant, Map, Set, typed arrays, …).
17+
// structuredClone rejects functions (used by synthetic statInfo objects), so
18+
// function-valued own properties are copied by reference; data values go
19+
// through structuredClone, which keeps internal slots intact.
20+
function cloneStatInfo(statInfo) {
21+
if (!statInfo || typeof statInfo !== "object") {
22+
return statInfo;
23+
}
24+
const target = Object.create(Object.getPrototypeOf(statInfo));
25+
for (const key of Object.getOwnPropertyNames(statInfo)) {
26+
const value = statInfo[key];
27+
target[key] = typeof value === "function" ? value : structuredClone(value);
28+
}
29+
return target;
30+
}
31+
1632
const CONTENT_TYPES = {
1733
BUFFER: "buffer",
1834
STREAM: "stream",
@@ -796,12 +812,12 @@ class Resource {
796812

797813
const options = {
798814
path: this.#path,
799-
statInfo: this.#statInfo, // Will be cloned in constructor
815+
statInfo: cloneStatInfo(this.#statInfo),
800816
isDirectory: this.#isDirectory,
801817
byteSize: this.#isDirectory ? undefined : await this.getSize(),
802818
lastModified: this.#lastModified,
803819
integrity: this.#isDirectory ? undefined : (this.#contentType ? await this.getIntegrity() : undefined),
804-
sourceMetadata: clone(this.#sourceMetadata)
820+
sourceMetadata: structuredClone(this.#sourceMetadata)
805821
};
806822

807823
switch (this.#contentType) {

packages/fs/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"dependencies": {
5959
"@ui5/logger": "^5.0.0-alpha.4",
6060
"async-mutex": "^0.5.0",
61-
"clone": "^2.1.2",
6261
"escape-string-regexp": "^5.0.0",
6362
"globby": "^15.0.0",
6463
"graceful-fs": "^4.2.11",

packages/fs/test/lib/Resource.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,3 +2041,51 @@ test("getInode: Preserved across setBuffer", (t) => {
20412041
t.is(resource.getInode(), inode,
20422042
"Inode is unchanged after setBuffer (same on-disk slot, content modified)");
20432043
});
2044+
2045+
test("Resource: clone preserves prototype methods on real fs.Stats", async (t) => {
2046+
const fsPath = path.join("test", "fixtures", "application.a", "webapp", "index.html");
2047+
const statInfo = await stat(fsPath);
2048+
2049+
const resource = new Resource({
2050+
path: "/some/path",
2051+
statInfo,
2052+
buffer: Buffer.from("Content")
2053+
});
2054+
2055+
const clonedStat = (await resource.clone()).getStatInfo();
2056+
2057+
t.is(typeof clonedStat.isFile, "function", "isFile method preserved on clone");
2058+
t.true(clonedStat.isFile(), "isFile() returns the expected value");
2059+
t.true(clonedStat.mtime instanceof Date, "mtime is still a Date");
2060+
// The original mtime is computed lazily from mtimeMs via a prototype getter.
2061+
// Both sides should yield the same time without throwing.
2062+
t.is(clonedStat.mtime.getTime(), statInfo.mtime.getTime(), "mtime time value preserved");
2063+
// Regression: JSON.stringify must not throw on the cloned stat. On Node 26
2064+
// fs.Stats exposes Temporal.Instant getters whose toJSON requires the
2065+
// internal slot — copying those values via a plain `clone` package strips
2066+
// the slot and breaks JSON.stringify. structuredClone-based copying keeps
2067+
// the prototype intact and lets the lazy getters compute fresh values.
2068+
t.notThrows(() => JSON.stringify(clonedStat), "cloned statInfo can be JSON-stringified");
2069+
});
2070+
2071+
test("Resource: clone deep-copies Date values in synthetic statInfo", async (t) => {
2072+
const mtime = new Date("2024-01-02T03:04:05Z");
2073+
const resource = new Resource({
2074+
path: "/some/path",
2075+
statInfo: {
2076+
isFile: () => true,
2077+
isDirectory: () => false,
2078+
mtime,
2079+
},
2080+
buffer: Buffer.from("Content")
2081+
});
2082+
2083+
const clonedStat = (await resource.clone()).getStatInfo();
2084+
2085+
t.true(clonedStat.mtime instanceof Date, "mtime is a Date on the clone");
2086+
t.not(clonedStat.mtime, mtime, "mtime is a fresh Date instance");
2087+
t.is(clonedStat.mtime.getTime(), mtime.getTime(), "mtime time value preserved");
2088+
// Sanity-check that the cloned Date is fully functional (the bug exposed by
2089+
// the old `clone` package was that Date copies lost their internal slot).
2090+
t.notThrows(() => clonedStat.mtime.toJSON(), "cloned Date supports toJSON");
2091+
});

0 commit comments

Comments
 (0)