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
35 changes: 29 additions & 6 deletions lib/internal/vfs/providers/ziparchive.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class ZipFileHandle extends VirtualFileHandle {
#buffer;
#size;
#dirty = false;
#entryMode;
#modified;

/**
* @param {string} path
Expand All @@ -125,8 +127,15 @@ class ZipFileHandle extends VirtualFileHandle {
* @param {Buffer} initial The entry's current decompressed content, or an
* empty buffer for a new/truncated file
*/
constructor(path, flags, mode, source, name, initial) {
constructor(path, flags, mode, source, name, initial, entry = null, dirty = false) {
super(path, flags, mode);
// An existing entry keeps its own mode and modification time across a
// rewrite; only a newly created file takes the mode open() was given.
this.#entryMode = entry === null ? this.mode : (entry.mode || 0o644);
this.#modified = entry === null ? null : entry.modified;
// Creation and truncation take effect at open time on a real file, so
// such a handle is committed on close even when nothing is written.
this.#dirty = dirty;
this.#source = source;
this.#name = name;
this.#buffer = initial;
Expand Down Expand Up @@ -220,8 +229,14 @@ class ZipFileHandle extends VirtualFileHandle {
this.#doWriteFile(data, options);
}

// Reports the entry's own mode and, until the handle has changed the file,
// its own modification time; once dirty the file is as new as its close.
#doStat() {
return createFileStats(this.#size, { mode: this.mode });
return createFileStats(this.#size, {
__proto__: null,
mode: this.#entryMode,
mtimeMs: this.#dirty || this.#modified === null ? undefined : this.#modified.getTime(),
});
}
async stat(options) {
return this.#doStat();
Expand All @@ -245,13 +260,15 @@ class ZipFileHandle extends VirtualFileHandle {

async close() {
if (this.#dirty && isWritableFlag(this.flags)) {
await this.#source.add(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
await this.#source.add(this.#name, this.#buffer.subarray(0, this.#size),
{ __proto__: null, mode: this.#entryMode });
}
await super.close();
}
closeSync() {
if (this.#dirty && isWritableFlag(this.flags)) {
this.#source.addSync(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
this.#source.addSync(this.#name, this.#buffer.subarray(0, this.#size),
{ __proto__: null, mode: this.#entryMode });
}
super.closeSync();
}
Expand Down Expand Up @@ -350,7 +367,8 @@ class ZipProvider extends VirtualProvider {
if (exists && !isWriteTruncate(flags)) {
initial = await fileEntry.content();
}
return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
return new ZipFileHandle(path, flags, mode, this.#source, name, initial,
fileEntry, !exists || isWriteTruncate(flags));
}
openSync(path, flags, mode) {
flags = normalizeFlags(flags);
Expand All @@ -373,7 +391,8 @@ class ZipProvider extends VirtualProvider {
if (exists && !isWriteTruncate(flags)) {
initial = fileEntry.contentSync();
}
return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
return new ZipFileHandle(path, flags, mode, this.#source, name, initial,
fileEntry, !exists || isWriteTruncate(flags));
}

async stat(path, options) {
Expand Down Expand Up @@ -531,6 +550,9 @@ class ZipProvider extends VirtualProvider {
const newName = normalize(newPath);
const entry = await this.#getEntry(oldName);
if (entry === null) throw createENOENT('rename', oldPath);
// A file cannot take a directory's name; the archive would otherwise
// hold both under it.
if (this.#isDirectory(newName)) throw createEISDIR('rename', newPath);
const content = await entry.content();
await this.#source.add(newName, content, {
mode: entry.mode || undefined,
Expand All @@ -545,6 +567,7 @@ class ZipProvider extends VirtualProvider {
const newName = normalize(newPath);
const entry = this.#getEntrySync(oldName);
if (entry === null) throw createENOENT('rename', oldPath);
if (this.#isDirectory(newName)) throw createEISDIR('rename', newPath);
const content = entry.contentSync();
this.#source.addSync(newName, content, {
mode: entry.mode || undefined,
Expand Down
102 changes: 102 additions & 0 deletions test/parallel/test-vfs-zip-provider-commit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Flags: --experimental-vfs
'use strict';

// A ZipProvider handle commits its content to the archive when it is closed.
// The effects `open(2)` has at open time, and the metadata an entry already
// carries, must survive that model: opening with "w" creates or truncates
// even without a write, rewriting an entry keeps its mode, fstat reports the
// entry's mode, and a file cannot be renamed onto a directory. Each case
// states the real-fs outcome as the expectation. Cases are independent so
// the runner reports each one.

require('../common');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const vfs = require('node:vfs');
const { test } = require('node:test');

// Builds a writable in-memory archive from [name, content, options] triples
// and mounts it, returning the mount point.
function mountZip(entries) {
const list = entries.map(({ 0: name, 1: content, 2: options }) =>
zlib.ZipEntry.createSync(name, Buffer.from(content), options));
const chunks = [];
for (const chunk of zlib.createZipArchiveSync(list)) chunks.push(chunk);
const provider = new vfs.ZipProvider(new zlib.ZipBuffer(Buffer.concat(chunks)));
return vfs.create(provider).mount();
}

test('opening an existing file with "w" truncates it even without a write', () => {
const file = path.join(mountZip([['f.txt', 'hello']]), 'f.txt');
fs.closeSync(fs.openSync(file, 'w'));
assert.strictEqual(fs.readFileSync(file, 'utf8'), '');
});

test('opening a new file with "w" creates it even without a write', () => {
const file = path.join(mountZip([['f.txt', 'hello']]), 'new.txt');
fs.closeSync(fs.openSync(file, 'w'));
assert.strictEqual(fs.existsSync(file), true);
});

test('opening a new file with "a" creates it even without a write', () => {
const file = path.join(mountZip([['f.txt', 'hello']]), 'log.txt');
fs.closeSync(fs.openSync(file, 'a'));
assert.strictEqual(fs.existsSync(file), true);
});

test('appending keeps the entry mode', () => {
const file = path.join(mountZip([['x.sh', 'a', { mode: 0o755 }]]), 'x.sh');
assert.strictEqual(fs.statSync(file).mode & 0o777, 0o755);
fs.appendFileSync(file, 'b');
assert.strictEqual(fs.statSync(file).mode & 0o777, 0o755);
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'ab');
});

test('an in-place write keeps the entry mode', () => {
const file = path.join(mountZip([['x.sh', 'abc', { mode: 0o755 }]]), 'x.sh');
const fd = fs.openSync(file, 'r+');
fs.writeSync(fd, Buffer.from('Z'), 0, 1, 0);
fs.closeSync(fd);
assert.strictEqual(fs.statSync(file).mode & 0o777, 0o755);
});

test('a new file gets the mode passed to open', () => {
const file = path.join(mountZip([['f.txt', 'hello']]), 'new.sh');
const fd = fs.openSync(file, 'w', 0o700);
fs.writeSync(fd, Buffer.from('#!'));
fs.closeSync(fd);
assert.strictEqual(fs.statSync(file).mode & 0o777, 0o700);
});

test('fstat reports the entry mode, not the open() mode argument', () => {
const file = path.join(mountZip([['x.sh', 'a', { mode: 0o755 }]]), 'x.sh');
const fd = fs.openSync(file, 'r');
try {
assert.strictEqual(fs.fstatSync(fd).mode & 0o777, 0o755);
} finally {
fs.closeSync(fd);
}
});

test('fstat reports the entry modification time', () => {
const modified = new Date('2020-01-02T03:04:05Z');
const file = path.join(mountZip([['f.txt', 'a', { modified }]]), 'f.txt');
const fd = fs.openSync(file, 'r');
try {
// ZIP timestamps have two-second resolution, so compare at that grain.
assert.strictEqual(Math.floor(fs.fstatSync(fd).mtimeMs / 2000),
Math.floor(modified.getTime() / 2000));
} finally {
fs.closeSync(fd);
}
});

test('renaming a file onto an existing directory fails with EISDIR', () => {
const mount = mountZip([['dir/', ''], ['f', 'x']]);
assert.throws(() => fs.renameSync(path.join(mount, 'f'), path.join(mount, 'dir')),
{ code: 'EISDIR' });
assert.strictEqual(fs.statSync(path.join(mount, 'dir')).isDirectory(), true);
assert.strictEqual(fs.readFileSync(path.join(mount, 'f'), 'utf8'), 'x');
});
Loading