diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 90b8e9c303125a..b426d5624722e2 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -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 @@ -201,9 +203,6 @@ The promise namespace mirrors `fs.promises` and includes `readFile`, added: v26.4.0 --> -The base class for all VFS providers. Subclasses implement the essential -primitives (`open`, `stat`, `readdir`, `mkdir`, `rmdir`, `unlink`, -`rename`, ...) and inherit default implementations of the derived The base class for all VFS providers. Subclasses implement the essential primitives (such as `open`, `stat`, `readdir`, `mkdir`, `rmdir`, `unlink`, `rename`, etc.) and inherit default implementations of the derived @@ -304,6 +303,55 @@ added: v26.4.0 The resolved absolute path used as the root. +## Class: `ZipProvider` + + + +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 `"/"`). `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)` + + + +* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive. + ## Implementation details ### `Stats` objects @@ -322,6 +370,10 @@ fields use synthetic but stable values: [`RealFSProvider`]: #class-realfsprovider [`VirtualFileSystem`]: #class-virtualfilesystem [`VirtualProvider`]: #class-virtualprovider +[`ZipProvider`]: #class-zipprovider [`fs.BigIntStats`]: fs.md#class-fsbigintstats [`fs.Stats`]: fs.md#class-fsstats [`node:fs`]: fs.md +[`zipFile.writable`]: zlib.md#zipfilewritable +[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer +[`zlib.ZipFile`]: zlib.md#class-zlibzipfile diff --git a/lib/internal/vfs/providers/archive.js b/lib/internal/vfs/providers/archive.js new file mode 100644 index 00000000000000..4cc65f02836fe4 --- /dev/null +++ b/lib/internal/vfs/providers/archive.js @@ -0,0 +1,526 @@ +'use strict'; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + MathMax, + MathMin, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, +} = 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: { 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; +} + +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} + */ + 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) { + 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 ((isWriteTruncate(flags) || isAppend(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) { + const name = normalize(path); + const fileEntry = this.#getEntrySync(name); + if (fileEntry === null && this.#isDirectory(name)) { + throw createEISDIR('open', path); + } + const exists = fileEntry !== null; + if ((isWriteTruncate(flags) || isAppend(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 an 'archive' provider"); + } + 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 an 'archive' provider"); + } + 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)) { + if (options?.recursive) 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)) { + if (options?.recursive) 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); + } +} + +module.exports = { + ZipProvider, +}; diff --git a/lib/vfs.js b/lib/vfs.js index 0d12229aca72cd..d1e2a75fec5632 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -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/archive'); /** * Creates a new VirtualFileSystem instance. @@ -34,4 +35,5 @@ module.exports = { VirtualProvider, MemoryProvider, RealFSProvider, + ZipProvider, }; diff --git a/test/parallel/test-vfs-zip-provider-handle.js b/test/parallel/test-vfs-zip-provider-handle.js new file mode 100644 index 00000000000000..7c13ea65d02641 --- /dev/null +++ b/test/parallel/test-vfs-zip-provider-handle.js @@ -0,0 +1,443 @@ +// 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 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 || ` 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()); diff --git a/test/parallel/test-vfs-zip-provider.js b/test/parallel/test-vfs-zip-provider.js new file mode 100644 index 00000000000000..ad137457f7a382 --- /dev/null +++ b/test/parallel/test-vfs-zip-provider.js @@ -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()); diff --git a/test/parallel/test-zlib-zip-vfs.js b/test/parallel/test-zlib-zip-vfs.js new file mode 100644 index 00000000000000..cfb0f10b65c5c5 --- /dev/null +++ b/test/parallel/test-zlib-zip-vfs.js @@ -0,0 +1,183 @@ +// 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 path = require('node:path'); +const { test } = require('node:test'); +const vfs = require('node:vfs'); + +let mountCounter = 0; + +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. +function mountArchive(archive) { + const mountPoint = path.resolve(`/vfs-zip-${process.pid}-${mountCounter++}`); + const memfs = vfs.create(); + memfs.writeFileSync('/archive.zip', archive); // provider-relative, before mount + memfs.mount(mountPoint); + return { vpath: path.join(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(); + } +});