diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js index f369cdd22f76..0d6f9ae8bcd5 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, @@ -89,17 +90,41 @@ 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: entry.method === 0 ? 'store' : entry.method === 93 ? 'zstd' : 'deflate', + }; +} + +/** + * 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()) { + if (StringPrototypeStartsWith(name, prefix)) { + ArrayPrototypePush(entries, { + oldName: name, + newName: newName + StringPrototypeSlice(name, oldName.length), + }); + } + } + return entries; } /** @@ -530,28 +555,54 @@ 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); + 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]; + 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); + 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]; + 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 -----------