From 130b7b1792fdbb852c2494a5e2f2d81e15c102f4 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:40:03 -0700 Subject: [PATCH] vfs: make lchown update symlink metadata Route mounted lchown operations through VFS lchown handling and add memory provider support that does not follow the final symlink. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5 --- doc/api/vfs.md | 1 + lib/internal/vfs/file_system.js | 7 +++- lib/internal/vfs/provider.js | 12 ++++++ lib/internal/vfs/providers/memory.js | 7 ++++ lib/internal/vfs/setup.js | 2 +- test/parallel/test-vfs-lchown-symlink.js | 48 ++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-vfs-lchown-symlink.js diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 665af0167cd343..7a1695d8b35529 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -156,6 +156,7 @@ signatures as their [`node:fs`][] counterparts: * `linkSync(existingPath, newPath)` * `chmodSync(path, mode)` * `chownSync(path, uid, gid)` +* `lchownSync(path, uid, gid)` * `utimesSync(path, atime, mtime)` * `lutimesSync(path, atime, mtime)` * `mkdtempSync(prefix)` diff --git a/lib/internal/vfs/file_system.js b/lib/internal/vfs/file_system.js index 542b63d5f079b0..e47ed4c6108097 100644 --- a/lib/internal/vfs/file_system.js +++ b/lib/internal/vfs/file_system.js @@ -506,6 +506,11 @@ class VirtualFileSystem { this[kProvider].chownSync(providerPath, uid, gid); } + lchownSync(filePath, uid, gid) { + const providerPath = this.#toProviderPath(filePath); + this[kProvider].lchownSync(providerPath, uid, gid); + } + utimesSync(filePath, atime, mtime) { const providerPath = this.#toProviderPath(filePath); this[kProvider].utimesSync(providerPath, atime, mtime); @@ -1234,7 +1239,7 @@ class VirtualFileSystem { async lchown(filePath, uid, gid) { const providerPath = toProviderPath(filePath); - provider.chownSync(providerPath, uid, gid); + provider.lchownSync(providerPath, uid, gid); }, async utimes(filePath, atime, mtime) { diff --git a/lib/internal/vfs/provider.js b/lib/internal/vfs/provider.js index 32c238a23fe510..9b2650773f6ad5 100644 --- a/lib/internal/vfs/provider.js +++ b/lib/internal/vfs/provider.js @@ -235,6 +235,18 @@ class VirtualProvider { throw new ERR_METHOD_NOT_IMPLEMENTED('renameSync'); } + /** + * Changes ownership of a path without following the final symbolic link. + * Providers with symlink support should override this. + * @param {string} path The path + * @param {number} uid The user id + * @param {number} gid The group id + * @returns {void} + */ + lchownSync(path, uid, gid) { + return this.chownSync(path, uid, gid); + } + // === DEFAULT IMPLEMENTATIONS (built on primitives) === /** diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 8490340101bc07..880ccae2f4a23b 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -971,6 +971,13 @@ class MemoryProvider extends VirtualProvider { entry.ctime = DateNow(); } + lchownSync(path, uid, gid) { + const entry = this.#getEntry(path, 'chown', false); + if (uid >= 0) entry.uid = uid; + if (gid >= 0) entry.gid = gid; + entry.ctime = DateNow(); + } + utimesSync(path, atime, mtime) { const entry = this.#getEntry(path, 'utime', true); entry.atime = toMs(atime); diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index e2e641ad841561..b063c31a0a32c0 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -343,7 +343,7 @@ function createVfsHandlers() { vfsOpVoid(path, (vfs, n) => vfs.symlinkSync(target, n, type)), chmodSync: (path, mode) => vfsOpVoid(path, (vfs, n) => vfs.chmodSync(n, mode)), chownSync: (path, uid, gid) => vfsOpVoid(path, (vfs, n) => vfs.chownSync(n, uid, gid)), - lchownSync: (path, uid, gid) => vfsOpVoid(path, (vfs, n) => vfs.chownSync(n, uid, gid)), + lchownSync: (path, uid, gid) => vfsOpVoid(path, (vfs, n) => vfs.lchownSync(n, uid, gid)), utimesSync: (path, atime, mtime) => vfsOpVoid(path, (vfs, n) => vfs.utimesSync(n, atime, mtime)), lutimesSync: (path, atime, mtime) => diff --git a/test/parallel/test-vfs-lchown-symlink.js b/test/parallel/test-vfs-lchown-symlink.js new file mode 100644 index 00000000000000..161e6df5168f1b --- /dev/null +++ b/test/parallel/test-vfs-lchown-symlink.js @@ -0,0 +1,48 @@ +// Flags: --experimental-vfs +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); +const vfs = require('node:vfs'); + +const mountPoint = path.resolve('/tmp/vfs-lchown-' + process.pid); +const myVfs = vfs.create(); +myVfs.writeFileSync('/sync-target.txt', 'target'); +myVfs.symlinkSync('/sync-target.txt', '/sync-link.txt'); +myVfs.writeFileSync('/async-target.txt', 'target'); +myVfs.symlinkSync('/async-target.txt', '/async-link.txt'); +myVfs.writeFileSync('/promise-target.txt', 'target'); +myVfs.symlinkSync('/promise-target.txt', '/promise-link.txt'); +myVfs.mount(mountPoint); + +function assertOwnership(filePath, uid, gid) { + const stats = myVfs.lstatSync(filePath); + assert.strictEqual(stats.uid, uid); + assert.strictEqual(stats.gid, gid); +} + +(async () => { + try { + fs.lchownSync(path.join(mountPoint, 'sync-link.txt'), 123, 456); + assertOwnership('/sync-target.txt', 0, 0); + assertOwnership('/sync-link.txt', 123, 456); + + await new Promise((resolve, reject) => { + fs.lchown(path.join(mountPoint, 'async-link.txt'), 234, 567, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + assertOwnership('/async-target.txt', 0, 0); + assertOwnership('/async-link.txt', 234, 567); + + await fsp.lchown(path.join(mountPoint, 'promise-link.txt'), 345, 678); + assertOwnership('/promise-target.txt', 0, 0); + assertOwnership('/promise-link.txt', 345, 678); + } finally { + myVfs.unmount(); + } +})().then(common.mustCall());