Skip to content
Open
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
103 changes: 77 additions & 26 deletions lib/internal/vfs/providers/ziparchive.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { VirtualProvider } = require('internal/vfs/provider');
const { VirtualFileHandle } = require('internal/vfs/file_handle');
const {
createEEXIST,
createEINVAL,
createEISDIR,
createENOENT,
createENOTDIR,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
8 changes: 8 additions & 0 deletions test/parallel/test-vfs-zip-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------
Expand Down Expand Up @@ -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 -----------
Expand Down
Loading