mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
vfs: add ZipProvider
Add a node:vfs provider backed by a node:zlib ZIP archive - a ZipBuffer held in memory or a ZipFile on disk - that exposes the archive's members as a virtual filesystem tree. Directories are recognized both explicitly (a "name/" entry) and implicitly (any entry under "name/"), and a file opened for writing commits its content as a new archive entry when its handle is closed. The provider is read-only unless the backing archive is writable, and offers both asynchronous and synchronous operations. Available as vfs.ZipProvider. Signed-off-by: Philipp Dunkel <pip@pipobscure.com> PR-URL: https://github.com/nodejs/node/pull/64915 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
1 parent
3942bf7f94
commit
2247054dda
7 files changed
+1671
-4
No files matched your search
+59
-4
@@ -38,10 +38,12 @@ Mounting a VFS only redirects supported [`node:fs`][] calls whose resolved paths
|
||||
are under the mount point. It does not prevent code from using other paths or
|
||||
other Node.js APIs to access resources available to the process.
|
||||
[`RealFSProvider`][] maps VFS paths under its configured root and rejects paths
|
||||
that resolve outside that root, but that check is not a security boundary. Do
|
||||
not rely on VFS to run untrusted code; use operating-system-level isolation,
|
||||
such as separate users, containers, or platform sandboxes, when a security
|
||||
boundary is required.
|
||||
that resolve outside that root, but that check is not a security boundary.
|
||||
[`ZipProvider`][] has no real file-system paths of its own to escape; its
|
||||
entries only ever exist within the archive's own namespace. Do not rely on VFS
|
||||
to run untrusted code; use operating-system-level isolation, such as separate
|
||||
users, containers, or platform sandboxes, when a security boundary is
|
||||
required.
|
||||
|
||||
## Basic usage
|
||||
|
||||
@@ -523,6 +525,55 @@ added: v26.4.0
|
||||
|
||||
The resolved absolute path used as the root.
|
||||
|
||||
## Class: `ZipProvider`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
A provider that exposes the entries of a ZIP archive - either a
|
||||
[`zlib.ZipBuffer`][] (in memory) or a [`zlib.ZipFile`][] (on disk) - through
|
||||
the VFS API. `provider.readonly` reflects the archive's own
|
||||
[`zipFile.writable`][] flag: a `ZipBuffer` is always writable, and a
|
||||
`ZipFile` is writable only when opened with `{ writable: true }`.
|
||||
|
||||
Directories are recognized both explicitly (an entry whose name ends in `/`)
|
||||
and implicitly (any entry name starting with `"<dir>/"`). `readdir()` does
|
||||
not support `{ recursive: true }`. Because a ZIP member cannot be edited or
|
||||
read in place - only fully written or fully decompressed - a file opened for
|
||||
writing only commits its content (as a new archive entry) when the handle is
|
||||
closed.
|
||||
|
||||
Every method has a synchronous counterpart (`openSync()`, `statSync()`,
|
||||
`readdirSync()`, and so on), backed by the equally complete synchronous
|
||||
surface [`zlib.ZipBuffer`][]/[`zlib.ZipFile`][] expose. As with those, the
|
||||
synchronous methods here block the Node.js event loop and further JavaScript
|
||||
execution until the operation - including any deflate/inflate pass -
|
||||
completes.
|
||||
|
||||
```cjs
|
||||
const vfs = require('node:vfs');
|
||||
const zlib = require('node:zlib');
|
||||
const { readFileSync } = require('node:fs');
|
||||
|
||||
async function main() {
|
||||
const zip = new zlib.ZipBuffer(readFileSync('archive.zip'));
|
||||
const archiveVfs = vfs.create(new vfs.ZipProvider(zip));
|
||||
|
||||
console.log(await archiveVfs.promises.readdir('/'));
|
||||
await archiveVfs.promises.writeFile('/new.txt', 'hello');
|
||||
}
|
||||
main();
|
||||
```
|
||||
|
||||
### `new ZipProvider(source)`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive.
|
||||
|
||||
## Implementation details
|
||||
|
||||
### `Stats` objects
|
||||
@@ -544,6 +595,7 @@ fields use synthetic but stable values:
|
||||
[`RealFSProvider`]: #class-realfsprovider
|
||||
[`VirtualFileSystem`]: #class-virtualfilesystem
|
||||
[`VirtualProvider`]: #class-virtualprovider
|
||||
[`ZipProvider`]: #class-zipprovider
|
||||
[`fs.BigIntStats`]: fs.md#class-fsstats
|
||||
[`fs.Stats`]: fs.md#class-fsstats
|
||||
[`import.meta.resolve()`]: esm.md#importmetaresolvespecifier
|
||||
@@ -555,5 +607,8 @@ fields use synthetic but stable values:
|
||||
[`vfs.mountPointURL`]: #vfsmountpointurl
|
||||
[`vfs.mountPoint`]: #vfsmountpoint
|
||||
[`vfs.unmount()`]: #vfsunmount
|
||||
[`zipFile.writable`]: zlib.md#zipfilewritable
|
||||
[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer
|
||||
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
|
||||
[loading from `node_modules` folders]: modules.md#loading-from-node_modules-folders
|
||||
[the global folders]: modules.md#loading-from-the-global-folders
|
||||
@@ -0,0 +1,580 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
ArrayPrototypeIndexOf,
|
||||
ArrayPrototypePush,
|
||||
MathMax,
|
||||
MathMin,
|
||||
StringPrototypeIndexOf,
|
||||
StringPrototypeSlice,
|
||||
StringPrototypeStartsWith,
|
||||
SymbolAsyncDispose,
|
||||
SymbolDispose,
|
||||
} = primordials;
|
||||
|
||||
const { Buffer } = require('buffer');
|
||||
const {
|
||||
codes: {
|
||||
ERR_INVALID_ARG_TYPE,
|
||||
ERR_METHOD_NOT_IMPLEMENTED,
|
||||
},
|
||||
} = require('internal/errors');
|
||||
const { VirtualProvider } = require('internal/vfs/provider');
|
||||
const { VirtualFileHandle } = require('internal/vfs/file_handle');
|
||||
const {
|
||||
createEEXIST,
|
||||
createEISDIR,
|
||||
createENOENT,
|
||||
createENOTDIR,
|
||||
createENOTEMPTY,
|
||||
createEROFS,
|
||||
} = require('internal/vfs/errors');
|
||||
const { createFileStats, createDirectoryStats } = require('internal/vfs/stats');
|
||||
const { Dirent } = require('internal/fs/utils');
|
||||
const {
|
||||
fs: {
|
||||
O_APPEND,
|
||||
O_CREAT,
|
||||
O_EXCL,
|
||||
O_RDWR,
|
||||
O_TRUNC,
|
||||
O_WRONLY,
|
||||
UV_DIRENT_DIR,
|
||||
UV_DIRENT_FILE,
|
||||
},
|
||||
} = internalBinding('constants');
|
||||
const { ZipBuffer, ZipFile } = require('internal/zip');
|
||||
|
||||
const EMPTY_BUFFER = Buffer.alloc(0);
|
||||
|
||||
function normalize(vfsPath) {
|
||||
return StringPrototypeStartsWith(vfsPath, '/') ? StringPrototypeSlice(vfsPath, 1) : vfsPath;
|
||||
}
|
||||
|
||||
// Converts numeric open flags (e.g. `fs.constants.O_RDWR`) to the flag strings
|
||||
// the helpers below understand, so a caller passing `node:fs`-style numeric
|
||||
// flags through the VFS is handled the same way `fs` would. Strings pass
|
||||
// through unchanged; anything else falls back to 'r'.
|
||||
function normalizeFlags(flags) {
|
||||
if (typeof flags === 'string') return flags;
|
||||
if (typeof flags !== 'number') return 'r';
|
||||
const rdwr = (flags & O_RDWR) !== 0;
|
||||
const append = (flags & O_APPEND) !== 0;
|
||||
const excl = (flags & O_EXCL) !== 0;
|
||||
const write = (flags & O_WRONLY) !== 0 || (flags & O_CREAT) !== 0 || (flags & O_TRUNC) !== 0;
|
||||
if (append) return 'a' + (excl ? 'x' : '') + (rdwr ? '+' : '');
|
||||
if (write) return 'w' + (excl ? 'x' : '') + (rdwr ? '+' : '');
|
||||
if (rdwr) return 'r+';
|
||||
return 'r';
|
||||
}
|
||||
|
||||
function isCurrentPosition(position) {
|
||||
return position === null || position === undefined || position === -1;
|
||||
}
|
||||
|
||||
function isWriteTruncate(flags) {
|
||||
return flags === 'w' || flags === 'w+' || flags === 'wx' || flags === 'wx+';
|
||||
}
|
||||
|
||||
function isAppend(flags) {
|
||||
return flags === 'a' || flags === 'a+' || flags === 'ax' || flags === 'ax+';
|
||||
}
|
||||
|
||||
function isReadableFlag(flags) {
|
||||
return flags !== 'w' && flags !== 'a' && flags !== 'wx' && flags !== 'ax';
|
||||
}
|
||||
|
||||
function isWritableFlag(flags) {
|
||||
return flags !== 'r';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'}
|
||||
*/
|
||||
function methodOption(method) {
|
||||
if (method === 0) return 'store';
|
||||
if (method === 93) return 'zstd';
|
||||
return 'deflate';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* only committed - as a brand-new entry - when the handle is closed. Since
|
||||
* this is all in-memory buffer manipulation with no real I/O, every method
|
||||
* and its `*Sync` counterpart share one private implementation.
|
||||
*/
|
||||
class ZipFileHandle extends VirtualFileHandle {
|
||||
#source;
|
||||
#name;
|
||||
#buffer;
|
||||
#size;
|
||||
#dirty = false;
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} flags
|
||||
* @param {number} mode
|
||||
* @param {ZipBuffer | ZipFile} source
|
||||
* @param {string} name The archive-relative entry name
|
||||
* @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) {
|
||||
super(path, flags, mode);
|
||||
this.#source = source;
|
||||
this.#name = name;
|
||||
this.#buffer = initial;
|
||||
this.#size = initial.length;
|
||||
if (isAppend(flags)) this.position = this.#size;
|
||||
}
|
||||
|
||||
#checkReadable() {
|
||||
if (!isReadableFlag(this.flags)) throw createEISDIR('read', this.path);
|
||||
}
|
||||
#checkWritable() {
|
||||
if (!isWritableFlag(this.flags)) throw createEISDIR('write', this.path);
|
||||
}
|
||||
#ensureCapacity(size) {
|
||||
if (size <= this.#buffer.length) return;
|
||||
const capacity = MathMax(size, this.#buffer.length * 2);
|
||||
const grown = Buffer.alloc(capacity);
|
||||
this.#buffer.copy(grown, 0, 0, this.#size);
|
||||
this.#buffer = grown;
|
||||
}
|
||||
|
||||
#doRead(buffer, offset, length, position) {
|
||||
this.#checkReadable();
|
||||
const useCurrent = isCurrentPosition(position);
|
||||
const pos = useCurrent ? this.position : position;
|
||||
const available = MathMax(0, this.#size - pos);
|
||||
const bytesRead = MathMin(length, available);
|
||||
if (bytesRead > 0) this.#buffer.copy(buffer, offset, pos, pos + bytesRead);
|
||||
if (useCurrent) this.position = pos + bytesRead;
|
||||
return { __proto__: null, bytesRead, buffer };
|
||||
}
|
||||
async read(buffer, offset, length, position) {
|
||||
return this.#doRead(buffer, offset, length, position);
|
||||
}
|
||||
readSync(buffer, offset, length, position) {
|
||||
return this.#doRead(buffer, offset, length, position);
|
||||
}
|
||||
|
||||
#doWrite(buffer, offset, length, position) {
|
||||
this.#checkWritable();
|
||||
const useCurrent = isCurrentPosition(position);
|
||||
const pos = isAppend(this.flags) ? this.#size : (useCurrent ? this.position : position);
|
||||
this.#ensureCapacity(pos + length);
|
||||
buffer.copy(this.#buffer, pos, offset, offset + length);
|
||||
if (pos + length > this.#size) this.#size = pos + length;
|
||||
this.#dirty = true;
|
||||
if (useCurrent) this.position = pos + length;
|
||||
return { __proto__: null, bytesWritten: length, buffer };
|
||||
}
|
||||
async write(buffer, offset, length, position) {
|
||||
return this.#doWrite(buffer, offset, length, position);
|
||||
}
|
||||
writeSync(buffer, offset, length, position) {
|
||||
return this.#doWrite(buffer, offset, length, position);
|
||||
}
|
||||
|
||||
#doReadFile(options) {
|
||||
this.#checkReadable();
|
||||
const encoding = typeof options === 'string' ? options : options?.encoding;
|
||||
const content = this.#buffer.subarray(0, this.#size);
|
||||
return encoding && encoding !== 'buffer' ? content.toString(encoding) : Buffer.from(content);
|
||||
}
|
||||
async readFile(options) {
|
||||
return this.#doReadFile(options);
|
||||
}
|
||||
readFileSync(options) {
|
||||
return this.#doReadFile(options);
|
||||
}
|
||||
|
||||
// Replaces content, except in append mode ('a'/'a+'/'ax'/'ax+'), where it
|
||||
// appends to the existing content instead - matching MemoryFileHandle and
|
||||
// what makes `appendFile()`/`appendFileSync()` (built on this, by
|
||||
// VirtualProvider's defaults) actually append.
|
||||
#doWriteFile(data, options) {
|
||||
this.#checkWritable();
|
||||
const content = typeof data === 'string' ? Buffer.from(data, options?.encoding) : Buffer.from(data);
|
||||
if (isAppend(this.flags)) {
|
||||
this.#ensureCapacity(this.#size + content.length);
|
||||
content.copy(this.#buffer, this.#size);
|
||||
this.#size += content.length;
|
||||
} else {
|
||||
this.#buffer = content;
|
||||
this.#size = content.length;
|
||||
}
|
||||
this.#dirty = true;
|
||||
}
|
||||
async writeFile(data, options) {
|
||||
this.#doWriteFile(data, options);
|
||||
}
|
||||
writeFileSync(data, options) {
|
||||
this.#doWriteFile(data, options);
|
||||
}
|
||||
|
||||
#doStat() {
|
||||
return createFileStats(this.#size, { mode: this.mode });
|
||||
}
|
||||
async stat(options) {
|
||||
return this.#doStat();
|
||||
}
|
||||
statSync(options) {
|
||||
return this.#doStat();
|
||||
}
|
||||
|
||||
#doTruncate(len) {
|
||||
this.#checkWritable();
|
||||
this.#ensureCapacity(len);
|
||||
this.#size = len;
|
||||
this.#dirty = true;
|
||||
}
|
||||
async truncate(len = 0) {
|
||||
this.#doTruncate(len);
|
||||
}
|
||||
truncateSync(len = 0) {
|
||||
this.#doTruncate(len);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#dirty && isWritableFlag(this.flags)) {
|
||||
await this.#source.add(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
closeSync() {
|
||||
if (this.#dirty && isWritableFlag(this.flags)) {
|
||||
this.#source.addSync(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode });
|
||||
}
|
||||
super.closeSync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `node:vfs` provider backed by a ZIP archive: either a [`ZipBuffer`][] (in
|
||||
* memory) or a [`ZipFile`][] (on disk). Read-only unless the underlying
|
||||
* archive is writable (a `ZipBuffer`, or a `ZipFile` opened with
|
||||
* `{ writable: true }`). Every method has a synchronous counterpart, backed
|
||||
* by the equally complete synchronous surface `ZipBuffer`/`ZipFile` expose;
|
||||
* as with those, the synchronous methods here block the Node.js event loop
|
||||
* and further JavaScript execution until the operation (including any
|
||||
* deflate/inflate pass) completes.
|
||||
*/
|
||||
class ZipProvider extends VirtualProvider {
|
||||
#source;
|
||||
|
||||
/**
|
||||
* @param {ZipBuffer | ZipFile} source
|
||||
*/
|
||||
constructor(source) {
|
||||
super();
|
||||
if (!(source instanceof ZipBuffer) && !(source instanceof ZipFile)) {
|
||||
throw new ERR_INVALID_ARG_TYPE('source', ['ZipBuffer', 'ZipFile'], source);
|
||||
}
|
||||
this.#source = source;
|
||||
}
|
||||
|
||||
get readonly() { return !this.#source.writable; }
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {Promise<import('internal/zip').ZipEntry | null>}
|
||||
*/
|
||||
async #getEntry(name) {
|
||||
return this.#source.has(name) ? this.#source.get(name) : null;
|
||||
}
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {import('internal/zip').ZipEntry | null}
|
||||
*/
|
||||
#getEntrySync(name) {
|
||||
if (!this.#source.has(name)) return null;
|
||||
// `ZipBuffer.prototype.get` is already synchronous (it has no `getSync`
|
||||
// of its own); `ZipFile.prototype.get` is asynchronous, so its `getSync`
|
||||
// is used instead when present.
|
||||
return typeof this.#source.getSync === 'function' ?
|
||||
this.#source.getSync(name) : this.#source.get(name);
|
||||
}
|
||||
/**
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#deleteEntrySync(name) {
|
||||
// Same reasoning as `#getEntrySync`: `ZipBuffer.prototype.delete` is
|
||||
// already synchronous; `ZipFile.prototype.delete` is not, so its
|
||||
// `deleteSync` is used instead when present.
|
||||
return typeof this.#source.deleteSync === 'function' ?
|
||||
this.#source.deleteSync(name) : this.#source.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `name` (no trailing slash) is a directory: either explicitly
|
||||
* (a `"name/"` entry) or implicitly (some entry starts with `"name/"`).
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#isDirectory(name) {
|
||||
const prefix = `${name}/`;
|
||||
if (this.#source.has(prefix)) return true;
|
||||
for (const key of this.#source.keys()) {
|
||||
if (StringPrototypeStartsWith(key, prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async open(path, flags, mode) {
|
||||
flags = normalizeFlags(flags);
|
||||
const name = normalize(path);
|
||||
const fileEntry = await this.#getEntry(name);
|
||||
if (fileEntry === null && this.#isDirectory(name)) {
|
||||
throw createEISDIR('open', path);
|
||||
}
|
||||
const exists = fileEntry !== null;
|
||||
if (isWritableFlag(flags) && this.readonly) {
|
||||
throw createEROFS('open', path);
|
||||
}
|
||||
if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) {
|
||||
throw createEEXIST('open', path);
|
||||
}
|
||||
if (!exists && (flags === 'r' || flags === 'r+')) {
|
||||
throw createENOENT('open', path);
|
||||
}
|
||||
let initial = EMPTY_BUFFER;
|
||||
if (exists && !isWriteTruncate(flags)) {
|
||||
initial = await fileEntry.content();
|
||||
}
|
||||
return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
|
||||
}
|
||||
openSync(path, flags, mode) {
|
||||
flags = normalizeFlags(flags);
|
||||
const name = normalize(path);
|
||||
const fileEntry = this.#getEntrySync(name);
|
||||
if (fileEntry === null && this.#isDirectory(name)) {
|
||||
throw createEISDIR('open', path);
|
||||
}
|
||||
const exists = fileEntry !== null;
|
||||
if (isWritableFlag(flags) && this.readonly) {
|
||||
throw createEROFS('open', path);
|
||||
}
|
||||
if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) {
|
||||
throw createEEXIST('open', path);
|
||||
}
|
||||
if (!exists && (flags === 'r' || flags === 'r+')) {
|
||||
throw createENOENT('open', path);
|
||||
}
|
||||
let initial = EMPTY_BUFFER;
|
||||
if (exists && !isWriteTruncate(flags)) {
|
||||
initial = fileEntry.contentSync();
|
||||
}
|
||||
return new ZipFileHandle(path, flags, mode, this.#source, name, initial);
|
||||
}
|
||||
|
||||
async stat(path, options) {
|
||||
const name = normalize(path);
|
||||
if (name === '') return createDirectoryStats({ mode: 0o755 });
|
||||
const entry = await this.#getEntry(name) ?? await this.#getEntry(`${name}/`);
|
||||
if (entry !== null) {
|
||||
return entry.isDirectory ?
|
||||
createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) :
|
||||
createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() });
|
||||
}
|
||||
if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 });
|
||||
throw createENOENT('stat', path);
|
||||
}
|
||||
statSync(path, options) {
|
||||
const name = normalize(path);
|
||||
if (name === '') return createDirectoryStats({ mode: 0o755 });
|
||||
const entry = this.#getEntrySync(name) ?? this.#getEntrySync(`${name}/`);
|
||||
if (entry !== null) {
|
||||
return entry.isDirectory ?
|
||||
createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) :
|
||||
createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() });
|
||||
}
|
||||
if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 });
|
||||
throw createENOENT('stat', path);
|
||||
}
|
||||
|
||||
#readdirEntries(path, name, options, stats) {
|
||||
if (!stats.isDirectory()) throw createENOTDIR('scandir', path);
|
||||
const prefix = name === '' ? '' : `${name}/`;
|
||||
const withFileTypes = options?.withFileTypes === true;
|
||||
const names = [];
|
||||
const isDir = [];
|
||||
for (const key of this.#source.keys()) {
|
||||
if (!StringPrototypeStartsWith(key, prefix)) continue;
|
||||
const rest = StringPrototypeSlice(key, prefix.length);
|
||||
if (rest === '') continue; // The directory's own explicit entry
|
||||
const slash = StringPrototypeIndexOf(rest, '/');
|
||||
const childName = slash === -1 ? rest : StringPrototypeSlice(rest, 0, slash);
|
||||
const childIsDir = slash !== -1;
|
||||
const existingIndex = ArrayPrototypeIndexOf(names, childName);
|
||||
if (existingIndex !== -1) {
|
||||
if (childIsDir) isDir[existingIndex] = true;
|
||||
continue;
|
||||
}
|
||||
ArrayPrototypePush(names, childName);
|
||||
ArrayPrototypePush(isDir, childIsDir);
|
||||
}
|
||||
const result = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
if (withFileTypes) {
|
||||
ArrayPrototypePush(result, new Dirent(names[i], isDir[i] ? UV_DIRENT_DIR : UV_DIRENT_FILE, name));
|
||||
} else {
|
||||
ArrayPrototypePush(result, names[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async readdir(path, options) {
|
||||
if (options?.recursive) {
|
||||
throw new ERR_METHOD_NOT_IMPLEMENTED('readdir with { recursive: true } on a ZipProvider');
|
||||
}
|
||||
const name = normalize(path);
|
||||
return this.#readdirEntries(path, name, options, await this.stat(path));
|
||||
}
|
||||
readdirSync(path, options) {
|
||||
if (options?.recursive) {
|
||||
throw new ERR_METHOD_NOT_IMPLEMENTED('readdirSync with { recursive: true } on a ZipProvider');
|
||||
}
|
||||
const name = normalize(path);
|
||||
return this.#readdirEntries(path, name, options, this.statSync(path));
|
||||
}
|
||||
|
||||
async mkdir(path, options) {
|
||||
if (this.readonly) throw createEROFS('mkdir', path);
|
||||
const name = normalize(path);
|
||||
if (await this.exists(path)) {
|
||||
// `{ recursive: true }` only tolerates an existing *directory*; an
|
||||
// existing file (or any non-directory) still collides with EEXIST.
|
||||
if (options?.recursive && this.#isDirectory(name)) return undefined;
|
||||
throw createEEXIST('mkdir', path);
|
||||
}
|
||||
await this.#source.add(`${name}/`, EMPTY_BUFFER, { mode: options?.mode });
|
||||
return undefined;
|
||||
}
|
||||
mkdirSync(path, options) {
|
||||
if (this.readonly) throw createEROFS('mkdir', path);
|
||||
const name = normalize(path);
|
||||
if (this.existsSync(path)) {
|
||||
// `{ recursive: true }` only tolerates an existing *directory*; an
|
||||
// existing file (or any non-directory) still collides with EEXIST.
|
||||
if (options?.recursive && this.#isDirectory(name)) return undefined;
|
||||
throw createEEXIST('mkdir', path);
|
||||
}
|
||||
this.#source.addSync(`${name}/`, EMPTY_BUFFER, { mode: options?.mode });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async rmdir(path) {
|
||||
if (this.readonly) throw createEROFS('rmdir', path);
|
||||
const name = normalize(path);
|
||||
const stats = await this.stat(path);
|
||||
if (!stats.isDirectory()) throw createENOTDIR('rmdir', path);
|
||||
const prefix = `${name}/`;
|
||||
for (const key of this.#source.keys()) {
|
||||
if (key !== prefix && StringPrototypeStartsWith(key, prefix)) {
|
||||
throw createENOTEMPTY('rmdir', path);
|
||||
}
|
||||
}
|
||||
if (!this.#source.has(prefix)) {
|
||||
// An implicit-only directory can never be empty (something has to be
|
||||
// under it for it to exist at all), so getting here means `path` is
|
||||
// not a directory this provider can remove.
|
||||
throw createENOENT('rmdir', path);
|
||||
}
|
||||
await this.#source.delete(prefix);
|
||||
}
|
||||
rmdirSync(path) {
|
||||
if (this.readonly) throw createEROFS('rmdir', path);
|
||||
const name = normalize(path);
|
||||
const stats = this.statSync(path);
|
||||
if (!stats.isDirectory()) throw createENOTDIR('rmdir', path);
|
||||
const prefix = `${name}/`;
|
||||
for (const key of this.#source.keys()) {
|
||||
if (key !== prefix && StringPrototypeStartsWith(key, prefix)) {
|
||||
throw createENOTEMPTY('rmdir', path);
|
||||
}
|
||||
}
|
||||
if (!this.#source.has(prefix)) {
|
||||
throw createENOENT('rmdir', path);
|
||||
}
|
||||
this.#deleteEntrySync(prefix);
|
||||
}
|
||||
|
||||
async unlink(path) {
|
||||
if (this.readonly) throw createEROFS('unlink', path);
|
||||
const name = normalize(path);
|
||||
if (!this.#source.has(name)) {
|
||||
throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path);
|
||||
}
|
||||
await this.#source.delete(name);
|
||||
}
|
||||
unlinkSync(path) {
|
||||
if (this.readonly) throw createEROFS('unlink', path);
|
||||
const name = normalize(path);
|
||||
if (!this.#source.has(name)) {
|
||||
throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path);
|
||||
}
|
||||
this.#deleteEntrySync(name);
|
||||
}
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
if (this.readonly) throw createEROFS('rename', oldPath);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the backing archive. A `ZipFile` releases its file descriptor; a
|
||||
* `ZipBuffer` is purely in-memory and has nothing to close, so this is a
|
||||
* no-op for it. Aliased as `Symbol.asyncDispose` so a provider can be used
|
||||
* with `await using`.
|
||||
*/
|
||||
async close() {
|
||||
if (typeof this.#source.close === 'function') await this.#source.close();
|
||||
}
|
||||
/**
|
||||
* Synchronous counterpart of `close()`, aliased as `Symbol.dispose` for
|
||||
* `using`.
|
||||
*/
|
||||
closeSync() {
|
||||
if (typeof this.#source.closeSync === 'function') this.#source.closeSync();
|
||||
}
|
||||
}
|
||||
|
||||
ZipProvider.prototype[SymbolAsyncDispose] = ZipProvider.prototype.close;
|
||||
ZipProvider.prototype[SymbolDispose] = ZipProvider.prototype.closeSync;
|
||||
|
||||
module.exports = {
|
||||
ZipProvider,
|
||||
};
|
||||
@@ -8,6 +8,7 @@ const { VirtualFileSystem } = require('internal/vfs/file_system');
|
||||
const { VirtualProvider } = require('internal/vfs/provider');
|
||||
const { MemoryProvider } = require('internal/vfs/providers/memory');
|
||||
const { RealFSProvider } = require('internal/vfs/providers/real');
|
||||
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
|
||||
|
||||
/**
|
||||
* Creates a new VirtualFileSystem instance.
|
||||
@@ -34,4 +35,5 @@ module.exports = {
|
||||
VirtualProvider,
|
||||
MemoryProvider,
|
||||
RealFSProvider,
|
||||
ZipProvider,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
// Flags: --experimental-vfs
|
||||
'use strict';
|
||||
|
||||
// Exercises ZipProvider's Symbol.dispose / Symbol.asyncDispose aliases:
|
||||
// disposing a provider closes its backing archive (a ZipFile releases its
|
||||
// file descriptor; a ZipBuffer has nothing to close, so disposal is a no-op),
|
||||
// and the aliases work with the `using` / `await using` declarations.
|
||||
|
||||
const common = require('../common');
|
||||
const tmpdir = require('../common/tmpdir');
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fsPromises = require('fs/promises');
|
||||
const zlib = require('zlib');
|
||||
const vfs = require('node:vfs');
|
||||
|
||||
tmpdir.refresh();
|
||||
|
||||
async function buildArchive(entries) {
|
||||
const chunks = [];
|
||||
for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function writeArchive(name) {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, name);
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Symbol.asyncDispose closes a ZipFile-backed provider's archive.
|
||||
{
|
||||
const zip = await zlib.ZipFile.open(await writeArchive('dispose-async.zip'));
|
||||
let closed = 0;
|
||||
const original = zip.close.bind(zip);
|
||||
zip.close = async (...args) => { closed++; return original(...args); };
|
||||
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
await provider[Symbol.asyncDispose]();
|
||||
assert.strictEqual(closed, 1);
|
||||
}
|
||||
|
||||
// Symbol.dispose closes a ZipFile-backed provider's archive synchronously.
|
||||
{
|
||||
const zip = zlib.ZipFile.openSync(await writeArchive('dispose-sync.zip'));
|
||||
let closed = 0;
|
||||
const original = zip.closeSync.bind(zip);
|
||||
zip.closeSync = (...args) => { closed++; return original(...args); };
|
||||
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
provider[Symbol.dispose]();
|
||||
assert.strictEqual(closed, 1);
|
||||
}
|
||||
|
||||
// `await using` disposes the provider (and closes the archive) at block exit.
|
||||
{
|
||||
const zip = await zlib.ZipFile.open(await writeArchive('dispose-await-using.zip'));
|
||||
let closed = 0;
|
||||
const original = zip.close.bind(zip);
|
||||
zip.close = async (...args) => { closed++; return original(...args); };
|
||||
|
||||
{
|
||||
await using provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, true);
|
||||
assert.strictEqual(closed, 0); // Not yet disposed.
|
||||
}
|
||||
assert.strictEqual(closed, 1); // Disposed at block exit.
|
||||
}
|
||||
|
||||
// `using` disposes the provider synchronously at block exit.
|
||||
{
|
||||
const zip = zlib.ZipFile.openSync(await writeArchive('dispose-using.zip'));
|
||||
let closed = 0;
|
||||
const original = zip.closeSync.bind(zip);
|
||||
zip.closeSync = (...args) => { closed++; return original(...args); };
|
||||
|
||||
{
|
||||
using provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, true);
|
||||
}
|
||||
assert.strictEqual(closed, 1);
|
||||
}
|
||||
|
||||
// A ZipBuffer has nothing to close: both aliases are safe no-ops.
|
||||
{
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const provider = new vfs.ZipProvider(new zlib.ZipBuffer(archive));
|
||||
provider[Symbol.dispose](); // Does not throw.
|
||||
await provider[Symbol.asyncDispose](); // Does not throw.
|
||||
}
|
||||
})().then(common.mustCall());
|
||||
@@ -0,0 +1,514 @@
|
||||
// Flags: --experimental-vfs
|
||||
'use strict';
|
||||
|
||||
// Additional ZipProvider coverage beyond test-vfs-zip-provider.js:
|
||||
// direct ZipFileHandle read/write/stat/truncate (both explicit and
|
||||
// current position, append-mode positioning, buffer growth), readFile/
|
||||
// writeFile encoding and data-type variants, compression-method-preserving
|
||||
// rename() (methodOption()), ZipFile-backed synchronous delete, an explicit
|
||||
// empty-directory open() EISDIR, the readdir child-directory dedup path, and
|
||||
// a few error branches (EROFS on open(), ENOTDIR on rmdir(), ENOENT on
|
||||
// rename()) not exercised by the base test.
|
||||
|
||||
const common = require('../common');
|
||||
const tmpdir = require('../common/tmpdir');
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsPromises = require('fs/promises');
|
||||
const zlib = require('zlib');
|
||||
const vfs = require('node:vfs');
|
||||
|
||||
tmpdir.refresh();
|
||||
|
||||
async function buildArchive(entries, comment) {
|
||||
const chunks = [];
|
||||
for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function buildArchiveSync(entries, comment) {
|
||||
return Buffer.concat([...zlib.createZipArchiveSync(entries, comment)]);
|
||||
}
|
||||
|
||||
// --- direct ZipFileHandle read/write/stat/truncate (async) -------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('a.txt', Buffer.from('hello world')),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
const handle = await provider.open('/a.txt', 'r+');
|
||||
|
||||
// Explicit position leaves the handle's own position untouched.
|
||||
const buf = Buffer.alloc(5);
|
||||
const { bytesRead } = await handle.read(buf, 0, 5, 0);
|
||||
assert.strictEqual(bytesRead, 5);
|
||||
assert.strictEqual(buf.toString(), 'hello');
|
||||
assert.strictEqual(handle.position, 0);
|
||||
|
||||
// Current position (undefined/null/-1) advances the handle's position.
|
||||
const buf2 = Buffer.alloc(5);
|
||||
await handle.read(buf2, 0, 5, null);
|
||||
assert.strictEqual(buf2.toString(), 'hello');
|
||||
assert.strictEqual(handle.position, 5);
|
||||
const buf3 = Buffer.alloc(1);
|
||||
await handle.read(buf3, 0, 1, -1);
|
||||
assert.strictEqual(buf3.toString(), ' ');
|
||||
assert.strictEqual(handle.position, 6);
|
||||
|
||||
// Reading past EOF yields 0 bytes without advancing further than available.
|
||||
const tail = Buffer.alloc(100);
|
||||
const { bytesRead: tailRead } = await handle.read(tail, 0, 100, 6);
|
||||
assert.strictEqual(tailRead, 5); // 'world'.length
|
||||
|
||||
// write() at an explicit position (overwrite), then at the current
|
||||
// position (append past the buffer's current size, forcing growth).
|
||||
await handle.write(Buffer.from('HELLO'), 0, 5, 0);
|
||||
const stat1 = await handle.stat();
|
||||
assert.strictEqual(stat1.size, 11);
|
||||
|
||||
handle.position = 11;
|
||||
await handle.write(Buffer.from('!!!'), 0, 3, null);
|
||||
const stat2 = await handle.stat();
|
||||
assert.strictEqual(stat2.size, 14);
|
||||
|
||||
await handle.truncate(5);
|
||||
const stat3 = await handle.stat();
|
||||
assert.strictEqual(stat3.size, 5);
|
||||
|
||||
await handle.truncate(8);
|
||||
const stat4 = await handle.stat();
|
||||
assert.strictEqual(stat4.size, 8);
|
||||
|
||||
await handle.close();
|
||||
|
||||
const archiveVfs = vfs.create(provider);
|
||||
const final = await archiveVfs.promises.readFile('/a.txt');
|
||||
assert.strictEqual(final.length, 8);
|
||||
assert.strictEqual(final.subarray(0, 5).toString(), 'HELLO');
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- direct ZipFileHandle read/write/stat/truncate (sync) --------------
|
||||
(() => {
|
||||
const archive = buildArchiveSync([zlib.ZipEntry.createSync('b.txt', Buffer.from('0123456789'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const handle = provider.openSync('/b.txt', 'r+');
|
||||
|
||||
const buf = Buffer.alloc(4);
|
||||
const { bytesRead } = handle.readSync(buf, 0, 4, 2);
|
||||
assert.strictEqual(bytesRead, 4);
|
||||
assert.strictEqual(buf.toString(), '2345');
|
||||
|
||||
handle.writeSync(Buffer.from('AB'), 0, 2, 0);
|
||||
assert.strictEqual(handle.statSync().size, 10);
|
||||
|
||||
// A write growing well beyond current capacity exercises #ensureCapacity's
|
||||
// doubling path.
|
||||
handle.writeSync(Buffer.alloc(1000, 0x58 /* 'X' */), 0, 1000, 10);
|
||||
assert.strictEqual(handle.statSync().size, 1010);
|
||||
|
||||
handle.truncateSync(3);
|
||||
assert.strictEqual(handle.statSync().size, 3);
|
||||
assert.strictEqual(handle.readFileSync('utf8'), 'AB2');
|
||||
|
||||
handle.closeSync();
|
||||
})();
|
||||
|
||||
// --- append-mode positioning: writes always land at the current end -------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('c.txt', Buffer.from('xy'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
const handle = await provider.open('/c.txt', 'a');
|
||||
assert.strictEqual(handle.position, 2); // Positioned at EOF on open
|
||||
|
||||
// Even with an explicit (wrong) position, append mode writes at the end.
|
||||
await handle.write(Buffer.from('z'), 0, 1, 0);
|
||||
assert.strictEqual((await handle.stat()).size, 3);
|
||||
await handle.close();
|
||||
|
||||
const archiveVfs = vfs.create(provider);
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/c.txt', 'utf8'), 'xyz');
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- readFile/readFileSync encoding variants + writeFile data types --------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('d.txt', Buffer.from('café'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
const handle = await provider.open('/d.txt', 'r');
|
||||
const asBuffer = await handle.readFile();
|
||||
assert.ok(Buffer.isBuffer(asBuffer));
|
||||
const asStringShorthand = await handle.readFile('utf8');
|
||||
assert.strictEqual(asStringShorthand, 'café');
|
||||
const asStringOption = await handle.readFile({ encoding: 'utf8' });
|
||||
assert.strictEqual(asStringOption, 'café');
|
||||
const asExplicitBuffer = await handle.readFile({ encoding: 'buffer' });
|
||||
assert.ok(Buffer.isBuffer(asExplicitBuffer));
|
||||
await handle.close();
|
||||
|
||||
const writeHandle = await provider.open('/e.txt', 'w');
|
||||
await writeHandle.writeFile(Buffer.from('buffer-data'));
|
||||
await writeHandle.close();
|
||||
assert.strictEqual(await (await provider.open('/e.txt', 'r')).readFile('utf8'), 'buffer-data');
|
||||
|
||||
const writeHandle2 = await provider.open('/f.txt', 'w');
|
||||
await writeHandle2.writeFile('string-data', { encoding: 'utf8' });
|
||||
await writeHandle2.close();
|
||||
assert.strictEqual(await (await provider.open('/f.txt', 'r')).readFile('utf8'), 'string-data');
|
||||
})().then(common.mustCall());
|
||||
|
||||
(() => {
|
||||
const archive = buildArchiveSync([zlib.ZipEntry.createSync('g.txt', Buffer.from('sync-café'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
const handle = provider.openSync('/g.txt', 'r');
|
||||
assert.ok(Buffer.isBuffer(handle.readFileSync()));
|
||||
assert.strictEqual(handle.readFileSync('utf8'), 'sync-café');
|
||||
assert.strictEqual(handle.readFileSync({ encoding: 'utf8' }), 'sync-café');
|
||||
assert.ok(Buffer.isBuffer(handle.readFileSync({ encoding: 'buffer' })));
|
||||
handle.closeSync();
|
||||
|
||||
const writeHandle = provider.openSync('/h.txt', 'w');
|
||||
writeHandle.writeFileSync(Buffer.from('sync-buffer-data'));
|
||||
writeHandle.closeSync();
|
||||
assert.strictEqual(provider.openSync('/h.txt', 'r').readFileSync('utf8'), 'sync-buffer-data');
|
||||
})();
|
||||
|
||||
// --- rename() preserves the original compression method (methodOption) ----
|
||||
(async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }),
|
||||
await zlib.ZipEntry.create('deflate.bin', Buffer.from('b'.repeat(100)), { method: 'deflate' }),
|
||||
await zlib.ZipEntry.create('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
const methodsBefore = new Map([
|
||||
['store.bin', zip.get('store.bin').method],
|
||||
['deflate.bin', zip.get('deflate.bin').method],
|
||||
['zstd.bin', zip.get('zstd.bin').method],
|
||||
]);
|
||||
|
||||
await archiveVfs.promises.rename('/store.bin', '/store-renamed.bin');
|
||||
await archiveVfs.promises.rename('/deflate.bin', '/deflate-renamed.bin');
|
||||
await archiveVfs.promises.rename('/zstd.bin', '/zstd-renamed.bin');
|
||||
|
||||
assert.strictEqual(zip.get('store-renamed.bin').method, methodsBefore.get('store.bin'));
|
||||
assert.strictEqual(zip.get('deflate-renamed.bin').method, methodsBefore.get('deflate.bin'));
|
||||
assert.strictEqual(zip.get('zstd-renamed.bin').method, methodsBefore.get('zstd.bin'));
|
||||
|
||||
// Content must still round-trip correctly under the reproduced method.
|
||||
assert.strictEqual((await zip.get('store-renamed.bin').content()).toString(), 'a'.repeat(100));
|
||||
assert.strictEqual((await zip.get('deflate-renamed.bin').content()).toString(), 'b'.repeat(100));
|
||||
assert.strictEqual((await zip.get('zstd-renamed.bin').content()).toString(), 'c'.repeat(100));
|
||||
})().then(common.mustCall());
|
||||
|
||||
(() => {
|
||||
const archive = buildArchiveSync([
|
||||
zlib.ZipEntry.createSync('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }),
|
||||
zlib.ZipEntry.createSync('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
const storeMethod = zip.get('store.bin').method;
|
||||
const zstdMethod = zip.get('zstd.bin').method;
|
||||
archiveVfs.renameSync('/store.bin', '/store-renamed.bin');
|
||||
archiveVfs.renameSync('/zstd.bin', '/zstd-renamed.bin');
|
||||
assert.strictEqual(zip.get('store-renamed.bin').method, storeMethod);
|
||||
assert.strictEqual(zip.get('zstd-renamed.bin').method, zstdMethod);
|
||||
})();
|
||||
|
||||
// --- rename() with a missing source is rejected with ENOENT ---------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('only.txt', Buffer.from('x'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
await assert.rejects(
|
||||
archiveVfs.promises.rename('/missing.txt', '/renamed.txt'),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assert.throws(
|
||||
() => archiveVfs.renameSync('/missing.txt', '/renamed.txt'),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- rmdir() on a plain file is rejected with ENOTDIR ----------------------
|
||||
// (called on the provider directly: the vfs router validates directory-ness
|
||||
// itself before delegating for some operations, which would otherwise never
|
||||
// exercise ZipProvider's own check).
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('file.txt', Buffer.from('x'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await assert.rejects(provider.rmdir('/file.txt'), { code: 'ENOTDIR' });
|
||||
assert.throws(() => provider.rmdirSync('/file.txt'), { code: 'ENOTDIR' });
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- open(): EEXIST/ENOENT/EISDIR-on-wrong-direction, called directly on
|
||||
// the provider so the router can't short-circuit before delegating ---------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await assert.rejects(provider.open('/a.txt', 'wx'), { code: 'EEXIST' });
|
||||
assert.throws(() => provider.openSync('/a.txt', 'wx'), { code: 'EEXIST' });
|
||||
await assert.rejects(provider.open('/missing.txt', 'r'), { code: 'ENOENT' });
|
||||
assert.throws(() => provider.openSync('/missing.txt', 'r'), { code: 'ENOENT' });
|
||||
|
||||
// A handle opened write-only can't be read from, and vice versa.
|
||||
const writeOnly = await provider.open('/w.txt', 'w');
|
||||
await assert.rejects(writeOnly.read(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' });
|
||||
await writeOnly.close();
|
||||
const readOnly = await provider.open('/a.txt', 'r');
|
||||
await assert.rejects(readOnly.write(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' });
|
||||
await readOnly.close();
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- normalize(): a path without a leading slash is used as-is ------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
const stats = await provider.stat('a.txt');
|
||||
assert.strictEqual(stats.isFile(), true);
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- mkdir(): both the with-options and no-options shapes, called
|
||||
// directly on the provider ---------------------------------------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await provider.mkdir('/no-opts');
|
||||
assert.strictEqual((await provider.stat('/no-opts')).isDirectory(), true);
|
||||
await provider.mkdir('/with-opts', { mode: 0o700 });
|
||||
assert.strictEqual((await provider.stat('/with-opts')).isDirectory(), true);
|
||||
|
||||
provider.mkdirSync('/no-opts-sync');
|
||||
assert.strictEqual(provider.statSync('/no-opts-sync').isDirectory(), true);
|
||||
provider.mkdirSync('/with-opts-sync', { mode: 0o700 });
|
||||
assert.strictEqual(provider.statSync('/with-opts-sync').isDirectory(), true);
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- readdir() skips a directory's own explicit entry when listing it -----
|
||||
(async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('dir/', Buffer.alloc(0)),
|
||||
await zlib.ZipEntry.create('dir/child.txt', Buffer.from('x')),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
const entries = await archiveVfs.promises.readdir('/dir');
|
||||
assert.deepStrictEqual(entries, ['child.txt']);
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- an entry not made by a Unix zip tool reports mode 0, so stat() and
|
||||
// rename() fall back to their documented defaults --------------------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('foreign.txt', Buffer.from('x')),
|
||||
await zlib.ZipEntry.create('foreign-dir/', Buffer.alloc(0)),
|
||||
]);
|
||||
const tampered = Buffer.from(archive);
|
||||
// The central header's "version made by" high byte selects the platform;
|
||||
// anything other than 3 (Unix) makes `mode` report 0 (see zip.js's
|
||||
// `CentralFileHeader.prototype.mode`).
|
||||
// The central directory follows *all* entries' local sections, and each
|
||||
// entry's central header follows the previous entries' central headers.
|
||||
const localSectionsLength = (30 + 'foreign.txt'.length + 'x'.length) +
|
||||
(30 + 'foreign-dir/'.length + 0);
|
||||
const fileHeaderStart = localSectionsLength;
|
||||
const dirHeaderStart = fileHeaderStart + (46 + 'foreign.txt'.length);
|
||||
const fileMadeByOffset = fileHeaderStart + 5;
|
||||
const dirMadeByOffset = dirHeaderStart + 5;
|
||||
assert.strictEqual(tampered[fileMadeByOffset], 3); // sanity: was Unix-made
|
||||
assert.strictEqual(tampered[dirMadeByOffset], 3);
|
||||
tampered[fileMadeByOffset] = 0; // MS-DOS/FAT
|
||||
tampered[dirMadeByOffset] = 0;
|
||||
|
||||
const zip = new zlib.ZipBuffer(tampered);
|
||||
assert.strictEqual(zip.get('foreign.txt').mode, 0);
|
||||
assert.strictEqual(zip.get('foreign-dir/').mode, 0);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
// stat()'s `entry.mode || <default>` fallback, for both a file and an
|
||||
// (explicit) directory entry.
|
||||
const fileStats = await archiveVfs.promises.stat('/foreign.txt');
|
||||
assert.strictEqual(fileStats.mode & 0o777, 0o644);
|
||||
const dirStats = await archiveVfs.promises.stat('/foreign-dir');
|
||||
assert.strictEqual(dirStats.mode & 0o777, 0o755);
|
||||
assert.strictEqual(archiveVfs.statSync('/foreign.txt').mode & 0o777, 0o644);
|
||||
assert.strictEqual(archiveVfs.statSync('/foreign-dir').mode & 0o777, 0o755);
|
||||
|
||||
// rename()'s `mode: entry.mode || undefined` fallback (undefined lets
|
||||
// ZipEntry.create() pick its own default mode instead of reproducing 0).
|
||||
await archiveVfs.promises.rename('/foreign.txt', '/renamed.txt');
|
||||
assert.strictEqual((await archiveVfs.promises.stat('/renamed.txt')).mode & 0o777, 0o644);
|
||||
})().then(common.mustCall());
|
||||
|
||||
(() => {
|
||||
const archive = buildArchiveSync([zlib.ZipEntry.createSync('foreign.txt', Buffer.from('x'))]);
|
||||
const tampered = Buffer.from(archive);
|
||||
const centralHeaderStart = 30 + 'foreign.txt'.length + 'x'.length;
|
||||
const madeByOffset = centralHeaderStart + 5;
|
||||
tampered[madeByOffset] = 0;
|
||||
|
||||
const zip = new zlib.ZipBuffer(tampered);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
archiveVfs.renameSync('/foreign.txt', '/renamed.txt');
|
||||
assert.strictEqual(archiveVfs.statSync('/renamed.txt').mode & 0o777, 0o644);
|
||||
})();
|
||||
|
||||
// --- opening an explicit (empty) directory entry is rejected with EISDIR --
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await assert.rejects(provider.open('/empty-dir', 'r'), { code: 'EISDIR' });
|
||||
assert.throws(() => provider.openSync('/empty-dir', 'r'), { code: 'EISDIR' });
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- readdir dedups a child directory reached through multiple entries ----
|
||||
(async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('dir/one.txt', Buffer.from('1')),
|
||||
await zlib.ZipEntry.create('dir/two.txt', Buffer.from('2')),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
// Both entries imply the same 'dir' child at the root; it must appear once.
|
||||
const rootEntries = await archiveVfs.promises.readdir('/');
|
||||
assert.deepStrictEqual(rootEntries, ['dir']);
|
||||
const dirEntries = await archiveVfs.promises.readdir('/dir');
|
||||
assert.deepStrictEqual(dirEntries.sort(), ['one.txt', 'two.txt']);
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- open() with a write flag against a readonly (ZipFile) archive: EROFS --
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-handle-readonly.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = await zlib.ZipFile.open(filePath);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await assert.rejects(provider.open('/new.txt', 'w'), { code: 'EROFS' });
|
||||
assert.throws(() => provider.openSync('/new.txt', 'w'), { code: 'EROFS' });
|
||||
|
||||
await zip.close();
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- ZipFile-backed writable archive: sync unlink/rmdir (deleteSync) ------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-handle-writable-sync.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = await zlib.ZipFile.open(filePath, { writable: true });
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
archiveVfs.mkdirSync('/somedir');
|
||||
assert.strictEqual(archiveVfs.statSync('/somedir').isDirectory(), true);
|
||||
archiveVfs.rmdirSync('/somedir');
|
||||
assert.throws(() => archiveVfs.statSync('/somedir'), { code: 'ENOENT' });
|
||||
|
||||
archiveVfs.unlinkSync('/a.txt');
|
||||
assert.throws(() => archiveVfs.statSync('/a.txt'), { code: 'ENOENT' });
|
||||
|
||||
await zip.close();
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- numeric open flags are normalized like node:fs (O_* constants) --------
|
||||
(async () => {
|
||||
const { O_RDONLY, O_WRONLY, O_CREAT, O_TRUNC } = fs.constants;
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('hello'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
// A numeric read-only flag reads the existing entry.
|
||||
const rh = await provider.open('/a.txt', O_RDONLY);
|
||||
assert.strictEqual(await rh.readFile('utf8'), 'hello');
|
||||
await rh.close();
|
||||
|
||||
// A numeric write+create+truncate flag creates and commits a new entry.
|
||||
const wh = await provider.open('/num.txt', O_WRONLY | O_CREAT | O_TRUNC);
|
||||
await wh.writeFile('written via numeric flags');
|
||||
await wh.close();
|
||||
assert.strictEqual(await provider.readFile('/num.txt', 'utf8'), 'written via numeric flags');
|
||||
|
||||
// The synchronous surface normalizes numeric flags identically.
|
||||
const sh = provider.openSync('/a.txt', O_RDONLY);
|
||||
assert.strictEqual(sh.readFileSync('utf8'), 'hello');
|
||||
sh.closeSync();
|
||||
const swh = provider.openSync('/num-sync.txt', O_WRONLY | O_CREAT | O_TRUNC);
|
||||
swh.writeFileSync('sync numeric');
|
||||
swh.closeSync();
|
||||
assert.strictEqual(provider.readFileSync('/num-sync.txt', 'utf8'), 'sync numeric');
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- readonly archive: any writable open flag (including 'r+' and numeric
|
||||
// O_RDWR) is rejected with EROFS, matching MemoryProvider/real fs -----------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-handle-rplus-readonly.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = await zlib.ZipFile.open(filePath); // read-only
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
// 'r+' opens for writing, so it is refused even though it also reads.
|
||||
await assert.rejects(provider.open('/a.txt', 'r+'), { code: 'EROFS' });
|
||||
assert.throws(() => provider.openSync('/a.txt', 'r+'), { code: 'EROFS' });
|
||||
|
||||
// Numeric O_RDWR is likewise a writable flag.
|
||||
await assert.rejects(provider.open('/a.txt', fs.constants.O_RDWR), { code: 'EROFS' });
|
||||
assert.throws(() => provider.openSync('/a.txt', fs.constants.O_RDWR), { code: 'EROFS' });
|
||||
|
||||
// Plain read access is still allowed.
|
||||
const h = await provider.open('/a.txt', 'r');
|
||||
assert.strictEqual(await h.readFile('utf8'), 'x');
|
||||
await h.close();
|
||||
|
||||
await zip.close();
|
||||
})().then(common.mustCall());
|
||||
|
||||
// --- mkdir({ recursive: true }) over an existing FILE still throws EEXIST;
|
||||
// over an existing DIRECTORY it stays a no-op -------------------------------
|
||||
(async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
|
||||
await assert.rejects(provider.mkdir('/a.txt', { recursive: true }), { code: 'EEXIST' });
|
||||
assert.throws(() => provider.mkdirSync('/a.txt', { recursive: true }), { code: 'EEXIST' });
|
||||
|
||||
// Recursive mkdir over an existing directory remains a no-op (no throw).
|
||||
await provider.mkdir('/d');
|
||||
await provider.mkdir('/d', { recursive: true });
|
||||
provider.mkdirSync('/d', { recursive: true });
|
||||
assert.strictEqual((await provider.stat('/d')).isDirectory(), true);
|
||||
})().then(common.mustCall());
|
||||
@@ -0,0 +1,243 @@
|
||||
// Flags: --experimental-vfs
|
||||
'use strict';
|
||||
|
||||
// Exercises ZipProvider (node:vfs backed by node:zlib's ZipBuffer/
|
||||
// ZipFile): construction validation, readonly reflecting the archive's own
|
||||
// writability, stat/readdir over explicit and implicit directories, and the
|
||||
// full async and synchronous CRUD surface, against both a ZipBuffer and a
|
||||
// ZipFile (opened both via open() and openSync()) source.
|
||||
|
||||
const common = require('../common');
|
||||
const tmpdir = require('../common/tmpdir');
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fsPromises = require('fs/promises');
|
||||
const zlib = require('zlib');
|
||||
const vfs = require('node:vfs');
|
||||
|
||||
tmpdir.refresh();
|
||||
|
||||
async function buildArchive(entries, comment) {
|
||||
const chunks = [];
|
||||
for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Construction validation.
|
||||
assert.throws(() => new vfs.ZipProvider({}), { code: 'ERR_INVALID_ARG_TYPE' });
|
||||
assert.throws(() => new vfs.ZipProvider(null), { code: 'ERR_INVALID_ARG_TYPE' });
|
||||
|
||||
// --- ZipBuffer-backed: always writable ------------------------------------
|
||||
{
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('a.txt', Buffer.from('hello')),
|
||||
await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')),
|
||||
await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0)),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, false);
|
||||
assert.strictEqual(provider.supportsSymlinks, false);
|
||||
assert.strictEqual(provider.supportsWatch, false);
|
||||
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
// stat: file, implicit directory, explicit directory, root.
|
||||
const fileStat = await archiveVfs.promises.stat('/a.txt');
|
||||
assert.strictEqual(fileStat.isFile(), true);
|
||||
assert.strictEqual(fileStat.size, 5);
|
||||
|
||||
const implicitDirStat = await archiveVfs.promises.stat('/dir');
|
||||
assert.strictEqual(implicitDirStat.isDirectory(), true);
|
||||
|
||||
const explicitDirStat = await archiveVfs.promises.stat('/empty-dir');
|
||||
assert.strictEqual(explicitDirStat.isDirectory(), true);
|
||||
|
||||
const rootStat = await archiveVfs.promises.stat('/');
|
||||
assert.strictEqual(rootStat.isDirectory(), true);
|
||||
|
||||
await assert.rejects(archiveVfs.promises.stat('/missing.txt'), { code: 'ENOENT' });
|
||||
|
||||
// readdir: root lists both files and directories, deduped.
|
||||
const rootEntries = await archiveVfs.promises.readdir('/');
|
||||
assert.deepStrictEqual(rootEntries.sort(), ['a.txt', 'dir', 'empty-dir']);
|
||||
|
||||
const dirEntries = await archiveVfs.promises.readdir('/dir');
|
||||
assert.deepStrictEqual(dirEntries, ['b.txt']);
|
||||
|
||||
const withTypes = await archiveVfs.promises.readdir('/', { withFileTypes: true });
|
||||
const byName = new Map(withTypes.map((d) => [d.name, d]));
|
||||
assert.strictEqual(byName.get('a.txt').isFile(), true);
|
||||
assert.strictEqual(byName.get('dir').isDirectory(), true);
|
||||
|
||||
await assert.rejects(archiveVfs.promises.readdir('/a.txt'), { code: 'ENOTDIR' });
|
||||
await assert.rejects(
|
||||
archiveVfs.promises.readdir('/', { recursive: true }),
|
||||
{ code: 'ERR_METHOD_NOT_IMPLEMENTED' },
|
||||
);
|
||||
|
||||
// readFile / writeFile round trip (new file).
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'hello');
|
||||
await archiveVfs.promises.writeFile('/new.txt', 'brand new');
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/new.txt', 'utf8'), 'brand new');
|
||||
assert.strictEqual(zip.has('new.txt'), true);
|
||||
|
||||
// Overwriting an existing file.
|
||||
await archiveVfs.promises.writeFile('/a.txt', 'overwritten');
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'overwritten');
|
||||
|
||||
// appendFile.
|
||||
await archiveVfs.promises.writeFile('/append.txt', 'ab');
|
||||
await archiveVfs.promises.appendFile('/append.txt', 'cd');
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/append.txt', 'utf8'), 'abcd');
|
||||
|
||||
// mkdir + rmdir.
|
||||
await archiveVfs.promises.mkdir('/newdir');
|
||||
assert.strictEqual((await archiveVfs.promises.stat('/newdir')).isDirectory(), true);
|
||||
await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EEXIST' });
|
||||
await archiveVfs.promises.mkdir('/newdir', { recursive: true }); // No throw, already exists
|
||||
await archiveVfs.promises.rmdir('/newdir');
|
||||
await assert.rejects(archiveVfs.promises.stat('/newdir'), { code: 'ENOENT' });
|
||||
|
||||
// Rmdir refuses a non-empty directory.
|
||||
await assert.rejects(archiveVfs.promises.rmdir('/dir'), { code: 'ENOTEMPTY' });
|
||||
|
||||
// unlink.
|
||||
await archiveVfs.promises.unlink('/append.txt');
|
||||
await assert.rejects(archiveVfs.promises.stat('/append.txt'), { code: 'ENOENT' });
|
||||
await assert.rejects(archiveVfs.promises.unlink('/missing.txt'), { code: 'ENOENT' });
|
||||
await assert.rejects(archiveVfs.promises.unlink('/dir'), { code: 'EISDIR' });
|
||||
|
||||
// rename.
|
||||
await archiveVfs.promises.writeFile('/rename-me.txt', 'content');
|
||||
await archiveVfs.promises.rename('/rename-me.txt', '/renamed.txt');
|
||||
assert.strictEqual(zip.has('rename-me.txt'), false);
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/renamed.txt', 'utf8'), 'content');
|
||||
|
||||
// open() flag semantics.
|
||||
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' });
|
||||
}
|
||||
|
||||
// --- ZipFile-backed, read-only: writes rejected with EROFS ----------------
|
||||
{
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-readonly.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = await zlib.ZipFile.open(filePath);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, true);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'x');
|
||||
await assert.rejects(archiveVfs.promises.writeFile('/new.txt', 'y'), { code: 'EROFS' });
|
||||
await assert.rejects(archiveVfs.promises.unlink('/a.txt'), { code: 'EROFS' });
|
||||
await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EROFS' });
|
||||
await assert.rejects(archiveVfs.promises.rmdir('/newdir'), { code: 'EROFS' });
|
||||
await assert.rejects(archiveVfs.promises.rename('/a.txt', '/b.txt'), { code: 'EROFS' });
|
||||
|
||||
// The synchronous surface rejects the same way.
|
||||
assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x');
|
||||
assert.throws(() => archiveVfs.writeFileSync('/new.txt', 'y'), { code: 'EROFS' });
|
||||
assert.throws(() => archiveVfs.unlinkSync('/a.txt'), { code: 'EROFS' });
|
||||
assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EROFS' });
|
||||
assert.throws(() => archiveVfs.rmdirSync('/newdir'), { code: 'EROFS' });
|
||||
assert.throws(() => archiveVfs.renameSync('/a.txt', '/b.txt'), { code: 'EROFS' });
|
||||
|
||||
await zip.close();
|
||||
}
|
||||
|
||||
// --- ZipFile-backed, opened writable: mutations persist to disk ----------
|
||||
{
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-writable.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = await zlib.ZipFile.open(filePath, { writable: true });
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, false);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
await archiveVfs.promises.writeFile('/b.txt', 'new content');
|
||||
await zip.close();
|
||||
|
||||
const reopened = await zlib.ZipFile.open(filePath);
|
||||
assert.strictEqual((await (await reopened.get('b.txt')).content()).toString(), 'new content');
|
||||
await reopened.close();
|
||||
}
|
||||
|
||||
// --- ZipBuffer-backed, fully synchronous CRUD ------------------------------
|
||||
{
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('a.txt', Buffer.from('hello')),
|
||||
await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')),
|
||||
]);
|
||||
const zip = new zlib.ZipBuffer(archive);
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
// stat/readdir.
|
||||
assert.strictEqual(archiveVfs.statSync('/a.txt').isFile(), true);
|
||||
assert.strictEqual(archiveVfs.statSync('/dir').isDirectory(), true);
|
||||
assert.throws(() => archiveVfs.statSync('/missing.txt'), { code: 'ENOENT' });
|
||||
assert.deepStrictEqual(archiveVfs.readdirSync('/').sort(), ['a.txt', 'dir']);
|
||||
assert.throws(() => archiveVfs.readdirSync('/a.txt'), { code: 'ENOTDIR' });
|
||||
assert.throws(
|
||||
() => archiveVfs.readdirSync('/', { recursive: true }),
|
||||
{ code: 'ERR_METHOD_NOT_IMPLEMENTED' },
|
||||
);
|
||||
|
||||
// readFile/writeFile/appendFile round trip.
|
||||
assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'hello');
|
||||
archiveVfs.writeFileSync('/new.txt', 'brand new');
|
||||
assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new');
|
||||
archiveVfs.appendFileSync('/new.txt', '!');
|
||||
assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new!');
|
||||
|
||||
// mkdir/rmdir.
|
||||
archiveVfs.mkdirSync('/newdir');
|
||||
assert.strictEqual(archiveVfs.statSync('/newdir').isDirectory(), true);
|
||||
assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EEXIST' });
|
||||
archiveVfs.mkdirSync('/newdir', { recursive: true }); // No throw, already exists.
|
||||
archiveVfs.rmdirSync('/newdir');
|
||||
assert.throws(() => archiveVfs.statSync('/newdir'), { code: 'ENOENT' });
|
||||
assert.throws(() => archiveVfs.rmdirSync('/dir'), { code: 'ENOTEMPTY' });
|
||||
|
||||
// unlink.
|
||||
archiveVfs.unlinkSync('/new.txt');
|
||||
assert.throws(() => archiveVfs.statSync('/new.txt'), { code: 'ENOENT' });
|
||||
assert.throws(() => archiveVfs.unlinkSync('/missing.txt'), { code: 'ENOENT' });
|
||||
assert.throws(() => archiveVfs.unlinkSync('/dir'), { code: 'EISDIR' });
|
||||
|
||||
// rename.
|
||||
archiveVfs.writeFileSync('/rename-me.txt', 'content');
|
||||
archiveVfs.renameSync('/rename-me.txt', '/renamed.txt');
|
||||
assert.strictEqual(zip.has('rename-me.txt'), false);
|
||||
assert.strictEqual(archiveVfs.readFileSync('/renamed.txt', 'utf8'), 'content');
|
||||
|
||||
// open() flag semantics.
|
||||
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' });
|
||||
}
|
||||
|
||||
// --- ZipFile-backed via openSync: sync-only round trip on disk -----------
|
||||
{
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
|
||||
const filePath = path.join(tmpdir.path, 'vfs-archive-opensync.zip');
|
||||
await fsPromises.writeFile(filePath, archive);
|
||||
const zip = zlib.ZipFile.openSync(filePath, { writable: true });
|
||||
const provider = new vfs.ZipProvider(zip);
|
||||
assert.strictEqual(provider.readonly, false);
|
||||
const archiveVfs = vfs.create(provider);
|
||||
|
||||
assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x');
|
||||
archiveVfs.writeFileSync('/b.txt', 'new content');
|
||||
zip.closeSync();
|
||||
|
||||
const reopened = zlib.ZipFile.openSync(filePath);
|
||||
assert.strictEqual(reopened.getSync('b.txt').contentSync().toString(), 'new content');
|
||||
reopened.closeSync();
|
||||
}
|
||||
})().then(common.mustCall());
|
||||
@@ -0,0 +1,180 @@
|
||||
// Flags: --experimental-vfs
|
||||
'use strict';
|
||||
|
||||
// Exercises the node:zlib ZipFile read/write surface over a node:vfs virtual
|
||||
// file descriptor. An in-memory archive is placed in a mounted MemoryProvider
|
||||
// VFS and opened with ZipFile through its mounted path, so every fd operation
|
||||
// ZipFile performs - positional read/write, fstat, ftruncate, open/close, in
|
||||
// both the asynchronous and the synchronous flavour - is served by the VFS
|
||||
// rather than a real OS fd. This validates that ZipFile's fd abstraction is
|
||||
// complete enough to run unchanged on a purely virtual descriptor.
|
||||
|
||||
require('../common');
|
||||
|
||||
const assert = require('node:assert');
|
||||
const zlib = require('node:zlib');
|
||||
const fs = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vfs = require('node:vfs');
|
||||
|
||||
async function buildArchive(entries, comment) {
|
||||
const chunks = [];
|
||||
for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
// Places `archive` in a freshly mounted in-memory VFS and returns the mounted
|
||||
// path of the archive file plus a cleanup that unmounts the VFS. mount() picks
|
||||
// the mount point itself, inside a reserved namespace.
|
||||
function mountArchive(archive) {
|
||||
const memfs = vfs.create();
|
||||
memfs.writeFileSync('/archive.zip', archive); // provider-relative, before mount
|
||||
const mountPoint = memfs.mount();
|
||||
return { vpath: `${mountPoint}/archive.zip`, cleanup: () => memfs.unmount() };
|
||||
}
|
||||
|
||||
test('ZipFile.open() reads a VFS-backed archive through an async virtual fd', async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('a.txt', Buffer.from('hello vfs')),
|
||||
await zlib.ZipEntry.create('dir/b.bin', Buffer.from([1, 2, 3, 4]), { method: 'store' }),
|
||||
await zlib.ZipEntry.create('z.txt', Buffer.from('Z'.repeat(4096)), { method: 'zstd' }),
|
||||
]);
|
||||
const { vpath, cleanup } = mountArchive(archive);
|
||||
try {
|
||||
const zf = await zlib.ZipFile.open(vpath);
|
||||
try {
|
||||
assert.strictEqual(zf.size, 3);
|
||||
assert.deepStrictEqual([...zf.keys()].sort(), ['a.txt', 'dir/b.bin', 'z.txt']);
|
||||
|
||||
// A lazy, file-backed entry: it retains no content and reads straight
|
||||
// from the virtual fd on demand.
|
||||
const a = await zf.get('a.txt');
|
||||
assert.strictEqual(a.rawContent, null);
|
||||
assert.strictEqual((await a.content()).toString(), 'hello vfs');
|
||||
assert.strictEqual(await zf.get('a.txt'), a); // Cached handle identity
|
||||
|
||||
// The contentIterator() streams (decompressing on the way) from the fd.
|
||||
const chunks = [];
|
||||
for await (const c of (await zf.get('z.txt')).contentIterator()) chunks.push(c);
|
||||
assert.strictEqual(Buffer.concat(chunks).toString(), 'Z'.repeat(4096));
|
||||
|
||||
// stream() resolves to a Readable over the virtual fd.
|
||||
const rs = await zf.stream('dir/b.bin');
|
||||
const out = [];
|
||||
for await (const c of rs) out.push(c);
|
||||
assert.deepStrictEqual(Buffer.concat(out), Buffer.from([1, 2, 3, 4]));
|
||||
} finally {
|
||||
await zf.close();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('ZipFile.openSync() reads a VFS-backed archive through a synchronous virtual fd', async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('a.txt', Buffer.from('sync hello')),
|
||||
await zlib.ZipEntry.create('b.bin', Buffer.from([9, 8, 7]), { method: 'store' }),
|
||||
]);
|
||||
const { vpath, cleanup } = mountArchive(archive);
|
||||
try {
|
||||
const zf = zlib.ZipFile.openSync(vpath);
|
||||
try {
|
||||
assert.strictEqual(zf.getSync('a.txt').contentSync().toString(), 'sync hello');
|
||||
assert.deepStrictEqual([...zf.getSync('b.bin').contentSync()], [9, 8, 7]);
|
||||
assert.deepStrictEqual([...zf.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.bin']);
|
||||
} finally {
|
||||
zf.closeSync();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('ZipFile writable mutations run against an async virtual fd', async () => {
|
||||
const archive = await buildArchive([
|
||||
await zlib.ZipEntry.create('keep.txt', Buffer.from('keep')),
|
||||
await zlib.ZipEntry.create('drop.txt', Buffer.from('drop')),
|
||||
]);
|
||||
const { vpath, cleanup } = mountArchive(archive);
|
||||
try {
|
||||
const sizeBefore = fs.statSync(vpath).size;
|
||||
const zw = await zlib.ZipFile.open(vpath, { writable: true });
|
||||
try {
|
||||
await zw.add('added.txt', Buffer.from('a new member over vfs'));
|
||||
assert.strictEqual(await zw.delete('drop.txt'), true);
|
||||
} finally {
|
||||
await zw.close();
|
||||
}
|
||||
// Positional writes + ftruncate actually altered the virtual file.
|
||||
assert.notStrictEqual(fs.statSync(vpath).size, sizeBefore);
|
||||
|
||||
const zr = await zlib.ZipFile.open(vpath);
|
||||
try {
|
||||
assert.deepStrictEqual([...zr.keys()].sort(), ['added.txt', 'keep.txt']);
|
||||
assert.strictEqual((await (await zr.get('added.txt')).content()).toString(), 'a new member over vfs');
|
||||
assert.strictEqual(zr.has('drop.txt'), false);
|
||||
} finally {
|
||||
await zr.close();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('ZipFile sync mutations (addEntrySync) run against a synchronous virtual fd', async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('a'))]);
|
||||
const { vpath, cleanup } = mountArchive(archive);
|
||||
try {
|
||||
const zw = zlib.ZipFile.openSync(vpath, { writable: true });
|
||||
try {
|
||||
zw.addSync('b.txt', Buffer.from('b sync'));
|
||||
assert.deepStrictEqual([...zw.keys()].sort(), ['a.txt', 'b.txt']);
|
||||
} finally {
|
||||
zw.closeSync();
|
||||
}
|
||||
const zr = zlib.ZipFile.openSync(vpath);
|
||||
try {
|
||||
assert.strictEqual(zr.getSync('b.txt').contentSync().toString(), 'b sync');
|
||||
} finally {
|
||||
zr.closeSync();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('addEntry() promotes a streaming entry to file-backed on a virtual fd', async () => {
|
||||
const archive = await buildArchive([await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]);
|
||||
const { vpath, cleanup } = mountArchive(archive);
|
||||
try {
|
||||
const payload = 'streamed over vfs'.repeat(32);
|
||||
const zw = await zlib.ZipFile.open(vpath, { writable: true });
|
||||
try {
|
||||
async function* source() {
|
||||
yield Buffer.from(payload.slice(0, 5));
|
||||
yield Buffer.from(payload.slice(5));
|
||||
}
|
||||
const streamEntry = zlib.ZipEntry.createStream('s.txt', source());
|
||||
const returned = await zw.addEntry(streamEntry);
|
||||
assert.strictEqual(returned, streamEntry);
|
||||
|
||||
// Promoted in place against the virtual fd: now readable and re-streamable.
|
||||
assert.strictEqual((await streamEntry.content()).toString(), payload);
|
||||
const it = [];
|
||||
for await (const c of streamEntry.contentIterator()) it.push(c);
|
||||
assert.strictEqual(Buffer.concat(it).toString(), payload);
|
||||
} finally {
|
||||
await zw.close();
|
||||
}
|
||||
|
||||
const zr = await zlib.ZipFile.open(vpath);
|
||||
try {
|
||||
assert.strictEqual((await (await zr.get('s.txt')).content()).toString(), payload);
|
||||
} finally {
|
||||
await zr.close();
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user