From e0e9df8d794a5ffbd3e6cdbddaef0b0285b96414 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:04:36 -0700 Subject: [PATCH 1/3] vfs: support renaming implicit ZIP directories Treat archive entry prefixes as directories when renaming with ZipProvider. Move all descendant entries to the new prefix for both asynchronous and synchronous operations. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol --- lib/internal/vfs/providers/ziparchive.js | 75 +++++++++++++++++++----- test/parallel/test-vfs-zip-provider.js | 8 +++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js index f369cdd22f76..56f7719f656f 100644 --- a/lib/internal/vfs/providers/ziparchive.js +++ b/lib/internal/vfs/providers/ziparchive.js @@ -23,6 +23,7 @@ const { VirtualProvider } = require('internal/vfs/provider'); const { VirtualFileHandle } = require('internal/vfs/file_handle'); const { createEEXIST, + createEINVAL, createEISDIR, createENOENT, createENOTDIR, @@ -102,6 +103,28 @@ function methodOption(method) { return 'deflate'; } +function renameOptions(entry) { + return { + mode: entry.mode || undefined, + modified: entry.modified, + method: methodOption(entry.method), + }; +} + +function directoryRenames(source, oldName, newName) { + const entries = []; + const prefix = `${oldName}/`; + for (const name of source.keys()) { + if (StringPrototypeStartsWith(name, prefix)) { + ArrayPrototypePush(entries, { + oldName: name, + newName: newName + StringPrototypeSlice(name, oldName.length), + }); + } + } + return entries; +} + /** * A file handle over one ZIP entry. ZIP members can't be edited in place * (they're a single compressed blob), so writes accumulate in memory and are @@ -530,28 +553,48 @@ class ZipProvider extends VirtualProvider { const oldName = normalize(oldPath); const newName = normalize(newPath); const entry = await this.#getEntry(oldName); - if (entry === null) throw createENOENT('rename', oldPath); - const content = await entry.content(); - await this.#source.add(newName, content, { - mode: entry.mode || undefined, - modified: entry.modified, - method: methodOption(entry.method), - }); - await this.#source.delete(oldName); + const entries = entry === null ? + directoryRenames(this.#source, oldName, newName) : + [{ oldName, newName, entry }]; + if (entries.length === 0) throw createENOENT('rename', oldPath); + if (oldName === newName) return; + if (entry === null && StringPrototypeStartsWith(newName, `${oldName}/`)) { + throw createEINVAL('rename', oldPath); + } + + for (let i = 0; i < entries.length; i++) { + const item = entries[i]; + item.entry ??= await this.#getEntry(item.oldName); + await this.#source.add( + item.newName, await item.entry.content(), renameOptions(item.entry)); + } + for (let i = 0; i < entries.length; i++) { + await this.#source.delete(entries[i].oldName); + } } renameSync(oldPath, newPath) { if (this.readonly) throw createEROFS('rename', oldPath); const oldName = normalize(oldPath); const newName = normalize(newPath); const entry = this.#getEntrySync(oldName); - if (entry === null) throw createENOENT('rename', oldPath); - const content = entry.contentSync(); - this.#source.addSync(newName, content, { - mode: entry.mode || undefined, - modified: entry.modified, - method: methodOption(entry.method), - }); - this.#deleteEntrySync(oldName); + const entries = entry === null ? + directoryRenames(this.#source, oldName, newName) : + [{ oldName, newName, entry }]; + if (entries.length === 0) throw createENOENT('rename', oldPath); + if (oldName === newName) return; + if (entry === null && StringPrototypeStartsWith(newName, `${oldName}/`)) { + throw createEINVAL('rename', oldPath); + } + + for (let i = 0; i < entries.length; i++) { + const item = entries[i]; + item.entry ??= this.#getEntrySync(item.oldName); + this.#source.addSync( + item.newName, item.entry.contentSync(), renameOptions(item.entry)); + } + for (let i = 0; i < entries.length; i++) { + this.#deleteEntrySync(entries[i].oldName); + } } /** diff --git a/test/parallel/test-vfs-zip-provider.js b/test/parallel/test-vfs-zip-provider.js index ad137457f7a3..3d563a70e212 100644 --- a/test/parallel/test-vfs-zip-provider.js +++ b/test/parallel/test-vfs-zip-provider.js @@ -119,6 +119,10 @@ async function buildArchive(entries, comment) { await assert.rejects(archiveVfs.promises.open('/does-not-exist.txt', 'r'), { code: 'ENOENT' }); await assert.rejects(archiveVfs.promises.open('/a.txt', 'wx'), { code: 'EEXIST' }); await assert.rejects(archiveVfs.promises.open('/dir', 'r'), { code: 'EISDIR' }); + + await archiveVfs.promises.rename('/dir', '/renamed-dir'); + await assert.rejects(archiveVfs.promises.stat('/dir'), { code: 'ENOENT' }); + assert.strictEqual(await archiveVfs.promises.readFile('/renamed-dir/b.txt', 'utf8'), 'nested'); } // --- ZipFile-backed, read-only: writes rejected with EROFS ---------------- @@ -220,6 +224,10 @@ async function buildArchive(entries, comment) { assert.throws(() => archiveVfs.openSync('/does-not-exist.txt', 'r'), { code: 'ENOENT' }); assert.throws(() => archiveVfs.openSync('/a.txt', 'wx'), { code: 'EEXIST' }); assert.throws(() => archiveVfs.openSync('/dir', 'r'), { code: 'EISDIR' }); + + archiveVfs.renameSync('/dir', '/renamed-dir'); + assert.throws(() => archiveVfs.statSync('/dir'), { code: 'ENOENT' }); + assert.strictEqual(archiveVfs.readFileSync('/renamed-dir/b.txt', 'utf8'), 'nested'); } // --- ZipFile-backed via openSync: sync-only round trip on disk ----------- From 2a48716394f9178f1e44a2113d6c0061792c4753 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:22:22 -0700 Subject: [PATCH 2/3] fixup! vfs: support renaming implicit ZIP directories --- lib/internal/vfs/providers/ziparchive.js | 34 +++++++++++++----------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js index 56f7719f656f..a5ca3b606c8d 100644 --- a/lib/internal/vfs/providers/ziparchive.js +++ b/lib/internal/vfs/providers/ziparchive.js @@ -90,28 +90,30 @@ function isWritableFlag(flags) { } /** - * The `options.method` value that reproduces `method` (a `zipEntry.method` - * raw compression method number) on `add()`/`addSync()`, so `rename()` - * doesn't silently recompress an entry with a different method than the one - * it already had (e.g. turning a zstd-compressed entry into a stored one). - * @param {number} method - * @returns {'store' | 'zstd' | 'deflate'} + * Builds the options needed to preserve an entry's metadata and compression + * method when adding it under a new name. Converts the raw ZIP compression + * method number to the corresponding `add()`/`addSync()` option. + * @param {ZipEntry} entry + * @returns {{ mode: number | undefined, modified: Date, + * method: 'store' | 'zstd' | 'deflate' }} */ -function methodOption(method) { - if (method === 0) return 'store'; - if (method === 93) return 'zstd'; - return 'deflate'; -} - function renameOptions(entry) { return { mode: entry.mode || undefined, modified: entry.modified, - method: methodOption(entry.method), + method: entry.method === 0 ? 'store' : entry.method === 93 ? 'zstd' : 'deflate', }; } -function directoryRenames(source, oldName, newName) { +/** + * Finds the entries belonging to an implicit directory and maps their names + * from the old directory prefix to the new one. + * @param {ZipBuffer | ZipFile} source + * @param {string} oldName + * @param {string} newName + * @returns {Array<{ oldName: string, newName: string }>} + */ +function getDirectoryRenames(source, oldName, newName) { const entries = []; const prefix = `${oldName}/`; for (const name of source.keys()) { @@ -554,7 +556,7 @@ class ZipProvider extends VirtualProvider { const newName = normalize(newPath); const entry = await this.#getEntry(oldName); const entries = entry === null ? - directoryRenames(this.#source, oldName, newName) : + getDirectoryRenames(this.#source, oldName, newName) : [{ oldName, newName, entry }]; if (entries.length === 0) throw createENOENT('rename', oldPath); if (oldName === newName) return; @@ -578,7 +580,7 @@ class ZipProvider extends VirtualProvider { const newName = normalize(newPath); const entry = this.#getEntrySync(oldName); const entries = entry === null ? - directoryRenames(this.#source, oldName, newName) : + getDirectoryRenames(this.#source, oldName, newName) : [{ oldName, newName, entry }]; if (entries.length === 0) throw createENOENT('rename', oldPath); if (oldName === newName) return; From 1bb560aa80ebc5b77481d196f2b4a222e7743036 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:33:16 -0700 Subject: [PATCH 3/3] fixup! vfs: support renaming implicit ZIP directories --- lib/internal/vfs/providers/ziparchive.js | 34 ++++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js index a5ca3b606c8d..0d6f9ae8bcd5 100644 --- a/lib/internal/vfs/providers/ziparchive.js +++ b/lib/internal/vfs/providers/ziparchive.js @@ -555,14 +555,17 @@ class ZipProvider extends VirtualProvider { const oldName = normalize(oldPath); const newName = normalize(newPath); const entry = await this.#getEntry(oldName); - const entries = entry === null ? - getDirectoryRenames(this.#source, oldName, newName) : - [{ oldName, newName, entry }]; - if (entries.length === 0) throw createENOENT('rename', oldPath); - if (oldName === newName) return; - if (entry === null && StringPrototypeStartsWith(newName, `${oldName}/`)) { - throw createEINVAL('rename', oldPath); + let entries; + if (entry === null) { + entries = getDirectoryRenames(this.#source, oldName, newName); + if (entries.length === 0) throw createENOENT('rename', oldPath); + if (StringPrototypeStartsWith(newName, `${oldName}/`)) { + throw createEINVAL('rename', oldPath); + } + } else { + entries = [{ oldName, newName, entry }]; } + if (oldName === newName) return; for (let i = 0; i < entries.length; i++) { const item = entries[i]; @@ -579,14 +582,17 @@ class ZipProvider extends VirtualProvider { const oldName = normalize(oldPath); const newName = normalize(newPath); const entry = this.#getEntrySync(oldName); - const entries = entry === null ? - getDirectoryRenames(this.#source, oldName, newName) : - [{ oldName, newName, entry }]; - if (entries.length === 0) throw createENOENT('rename', oldPath); - if (oldName === newName) return; - if (entry === null && StringPrototypeStartsWith(newName, `${oldName}/`)) { - throw createEINVAL('rename', oldPath); + let entries; + if (entry === null) { + entries = getDirectoryRenames(this.#source, oldName, newName); + if (entries.length === 0) throw createENOENT('rename', oldPath); + if (StringPrototypeStartsWith(newName, `${oldName}/`)) { + throw createEINVAL('rename', oldPath); + } + } else { + entries = [{ oldName, newName, entry }]; } + if (oldName === newName) return; for (let i = 0; i < entries.length; i++) { const item = entries[i];