From 78a5dec609b4aca6f9cd2b9c109c73834b1bf7e4 Mon Sep 17 00:00:00 2001 From: harjoth Date: Thu, 9 Jul 2026 22:10:07 -0700 Subject: [PATCH] vfs: enforce readonly state on open handles Signed-off-by: harjoth --- lib/internal/vfs/file_handle.js | 23 ++++++++---- lib/internal/vfs/providers/memory.js | 11 +++++- test/parallel/test-vfs-memory-provider.js | 43 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/internal/vfs/file_handle.js b/lib/internal/vfs/file_handle.js index 7b60c9def2b5b4..f7acc107874960 100644 --- a/lib/internal/vfs/file_handle.js +++ b/lib/internal/vfs/file_handle.js @@ -19,6 +19,7 @@ const { } = require('internal/errors'); const { createEBADF, + createEROFS, } = require('internal/vfs/errors'); // Private symbols @@ -365,6 +366,7 @@ class MemoryFileHandle extends VirtualFileHandle { #size; #entry; #getStats; + #isReadOnly; #checkClosed(syscall) { if (this.closed) { @@ -379,13 +381,15 @@ class MemoryFileHandle extends VirtualFileHandle { * @param {Buffer} content The initial file content * @param {object} entry The entry object (for updating content) * @param {Function} getStats Function to get updated stats + * @param {Function} [isReadOnly] Returns the provider's read-only state */ - constructor(path, flags, mode, content, entry, getStats) { + constructor(path, flags, mode, content, entry, getStats, isReadOnly) { super(path, flags, mode); this.#content = content; this.#size = content.length; this.#entry = entry; this.#getStats = getStats; + this.#isReadOnly = isReadOnly; // Handle different open modes if (flags === 'w' || flags === 'w+' || @@ -404,11 +408,16 @@ class MemoryFileHandle extends VirtualFileHandle { } /** - * Throws EBADF if the handle was not opened for writing. + * Throws EBADF if the handle was not opened for writing, or EROFS if the + * provider is read-only. + * @param {string} syscall The system call name */ - #checkWritable() { + #checkWritable(syscall) { if (this.flags === 'r') { - throw createEBADF('write'); + throw createEBADF(syscall); + } + if (this.#isReadOnly?.()) { + throw createEROFS(syscall, this.path); } } @@ -513,7 +522,7 @@ class MemoryFileHandle extends VirtualFileHandle { */ writeSync(buffer, offset, length, position) { this.#checkClosed('write'); - this.#checkWritable(); + this.#checkWritable('write'); // In append mode, always write at the end const useCurrentPosition = isCurrentPosition(position); @@ -612,7 +621,7 @@ class MemoryFileHandle extends VirtualFileHandle { */ writeFileSync(data, options) { this.#checkClosed('write'); - this.#checkWritable(); + this.#checkWritable('write'); const buffer = typeof data === 'string' ? Buffer.from(data, options?.encoding) : data; @@ -681,7 +690,7 @@ class MemoryFileHandle extends VirtualFileHandle { */ truncateSync(len = 0) { this.#checkClosed('ftruncate'); - this.#checkWritable(); + this.#checkWritable('ftruncate'); if (len < this.#size) { // Zero out truncated region to avoid stale data diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index a5681dfab1ca43..598033b6eb49bb 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -543,7 +543,16 @@ class MemoryProvider extends VirtualProvider { } const getStats = (size) => this.#createStats(entry, size); - return new MemoryFileHandle(normalized, flags, mode ?? entry.mode, entry.content, entry, getStats); + const isReadOnly = () => this.readonly; + return new MemoryFileHandle( + normalized, + flags, + mode ?? entry.mode, + entry.content, + entry, + getStats, + isReadOnly, + ); } async open(path, flags, mode) { diff --git a/test/parallel/test-vfs-memory-provider.js b/test/parallel/test-vfs-memory-provider.js index c45ee69f679b1c..5b12c2f6c48f40 100644 --- a/test/parallel/test-vfs-memory-provider.js +++ b/test/parallel/test-vfs-memory-provider.js @@ -583,6 +583,8 @@ const vfs = require('node:vfs'); const myVfs = vfs.create(); myVfs.writeFileSync('/file.txt', 'content'); myVfs.mkdirSync('/dir', { recursive: true }); + const fd = myVfs.openSync('/file.txt', 'r+'); + const appendFd = myVfs.openSync('/file.txt', 'a'); // Set to readonly myVfs.provider.setReadOnly(); @@ -629,12 +631,30 @@ const vfs = require('node:vfs'); assert.throws(() => { myVfs.symlinkSync('/file.txt', '/link'); }, { code: 'EROFS' }); + + // File descriptors opened before setReadOnly() must not bypass it. + assert.throws(() => { + myVfs.writeSync(fd, Buffer.from('new'), 0, 3, 0); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.ftruncateSync(fd, 0); + }, { code: 'EROFS' }); + + assert.throws(() => { + myVfs.writeSync(appendFd, Buffer.from('new'), 0, 3, null); + }, { code: 'EROFS' }); + + assert.strictEqual(myVfs.readFileSync('/file.txt', 'utf8'), 'content'); + myVfs.closeSync(fd); + myVfs.closeSync(appendFd); } // Test async operations on readonly MemoryProvider (async () => { const myVfs = vfs.create(); myVfs.writeFileSync('/readonly.txt', 'content'); + const handle = await myVfs.provider.open('/readonly.txt', 'r+'); myVfs.provider.setReadOnly(); await assert.rejects( @@ -661,4 +681,27 @@ const vfs = require('node:vfs'); myVfs.promises.copyFile('/readonly.txt', '/copy.txt'), { code: 'EROFS' } ); + + await assert.rejects( + handle.write(Buffer.from('new'), 0, 3, 0), + { code: 'EROFS' } + ); + + await assert.rejects( + handle.writeFile('new'), + { code: 'EROFS' } + ); + + await assert.rejects( + handle.truncate(0), + { code: 'EROFS' } + ); + + await assert.rejects( + handle.writev([Buffer.from('n'), Buffer.from('ew')], 0), + { code: 'EROFS' } + ); + + assert.strictEqual(await handle.readFile('utf8'), 'content'); + await handle.close(); })().then(common.mustCall());