Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions packages/fs/lib/Resource.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/fs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions packages/fs/test/lib/Resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading