Commit dd5cf4cc758 for nodejs

commit dd5cf4cc7588c55490750f7973695d613a27c635
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date:   Tue Sep 22 07:24:21 2026 -0700

    vfs: validate ZipProvider parent directories

    Reject attempts to create archive entries beneath non-directory
    parents. Cover open, mkdir, and rename in the sync and async APIs.

    Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com>
    Assisted-by: codex
    PR-URL: https://github.com/nodejs/node/pull/65829
    Fixes: https://github.com/nodejs/node/issues/65828
    Reviewed-By: James M Snell <jasnell@gmail.com>

diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js
index 9ed3a64a4e0..23ac09cad51 100644
--- a/lib/internal/vfs/providers/ziparchive.js
+++ b/lib/internal/vfs/providers/ziparchive.js
@@ -361,6 +361,24 @@ class ZipProvider extends VirtualProvider {
     return false;
   }

+  /**
+   * Throws when an ancestor of `name` is a file. Missing ancestors are valid:
+   * ZIP archives represent directories implicitly when they contain entries
+   * beneath them.
+   * @param {string} name
+   * @param {string} syscall
+   * @param {string} path
+   */
+  #validateParentDirectories(name, syscall, path) {
+    let slash = StringPrototypeIndexOf(name, '/');
+    while (slash !== -1) {
+      if (this.#source.has(StringPrototypeSlice(name, 0, slash))) {
+        throw createENOTDIR(syscall, path);
+      }
+      slash = StringPrototypeIndexOf(name, '/', slash + 1);
+    }
+  }
+
   async open(path, flags, mode) {
     const name = normalize(path);
     const fileEntry = await this.#getEntry(name);
@@ -377,6 +395,7 @@ class ZipProvider extends VirtualProvider {
     if (!exists && mustExist(flags)) {
       throw createENOENT('open', path);
     }
+    if (!exists) this.#validateParentDirectories(name, 'open', path);
     let initial = EMPTY_BUFFER;
     if (exists && !isWriteTruncate(flags)) {
       initial = await fileEntry.content();
@@ -400,6 +419,7 @@ class ZipProvider extends VirtualProvider {
     if (!exists && mustExist(flags)) {
       throw createENOENT('open', path);
     }
+    if (!exists) this.#validateParentDirectories(name, 'open', path);
     let initial = EMPTY_BUFFER;
     if (exists && !isWriteTruncate(flags)) {
       initial = fileEntry.contentSync();
@@ -482,6 +502,7 @@ class ZipProvider extends VirtualProvider {
   async mkdir(path, options) {
     if (this.readonly) throw createEROFS('mkdir', path);
     const name = normalize(path);
+    this.#validateParentDirectories(name, 'mkdir', path);
     if (await this.exists(path)) {
       // `{ recursive: true }` only tolerates an existing *directory*; an
       // existing file (or any non-directory) still collides with EEXIST.
@@ -494,6 +515,7 @@ class ZipProvider extends VirtualProvider {
   mkdirSync(path, options) {
     if (this.readonly) throw createEROFS('mkdir', path);
     const name = normalize(path);
+    this.#validateParentDirectories(name, 'mkdir', path);
     if (this.existsSync(path)) {
       // `{ recursive: true }` only tolerates an existing *directory*; an
       // existing file (or any non-directory) still collides with EEXIST.
@@ -574,6 +596,7 @@ class ZipProvider extends VirtualProvider {
       entries = [{ oldName, newName, entry }];
     }
     if (oldName === newName) return;
+    this.#validateParentDirectories(newName, 'rename', newPath);

     for (let i = 0; i < entries.length; i++) {
       const item = entries[i];
@@ -602,6 +625,7 @@ class ZipProvider extends VirtualProvider {
       entries = [{ oldName, newName, entry }];
     }
     if (oldName === newName) return;
+    this.#validateParentDirectories(newName, 'rename', newPath);

     for (let i = 0; i < entries.length; i++) {
       const item = entries[i];
diff --git a/test/parallel/test-vfs-zip-provider.js b/test/parallel/test-vfs-zip-provider.js
index 843a606a5de..ef86e6cb6a4 100644
--- a/test/parallel/test-vfs-zip-provider.js
+++ b/test/parallel/test-vfs-zip-provider.js
@@ -83,6 +83,24 @@ async function buildArchive(entries, comment) {
     assert.strictEqual(await archiveVfs.promises.readFile('/new.txt', 'utf8'), 'brand new');
     assert.strictEqual(zip.has('new.txt'), true);

+    // Entries cannot be created beneath a file.
+    await assert.rejects(
+      archiveVfs.promises.writeFile('/a.txt/child.txt', 'child'),
+      { code: 'ENOTDIR' },
+    );
+    await assert.rejects(
+      archiveVfs.promises.mkdir('/a.txt/child'),
+      { code: 'ENOTDIR' },
+    );
+    await assert.rejects(
+      archiveVfs.promises.rename('/new.txt', '/a.txt/renamed.txt'),
+      { code: 'ENOTDIR' },
+    );
+    assert.strictEqual(zip.has('a.txt/child.txt'), false);
+    assert.strictEqual(zip.has('a.txt/child/'), false);
+    assert.strictEqual(zip.has('a.txt/renamed.txt'), false);
+    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');
@@ -226,6 +244,24 @@ async function buildArchive(entries, comment) {
     archiveVfs.appendFileSync('/new.txt', '!');
     assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new!');

+    // Entries cannot be created beneath a file.
+    assert.throws(
+      () => archiveVfs.writeFileSync('/a.txt/child.txt', 'child'),
+      { code: 'ENOTDIR' },
+    );
+    assert.throws(
+      () => archiveVfs.mkdirSync('/a.txt/child'),
+      { code: 'ENOTDIR' },
+    );
+    assert.throws(
+      () => archiveVfs.renameSync('/new.txt', '/a.txt/renamed.txt'),
+      { code: 'ENOTDIR' },
+    );
+    assert.strictEqual(zip.has('a.txt/child.txt'), false);
+    assert.strictEqual(zip.has('a.txt/child/'), false);
+    assert.strictEqual(zip.has('a.txt/renamed.txt'), false);
+    assert.strictEqual(zip.has('new.txt'), true);
+
     // mkdir/rmdir.
     archiveVfs.mkdirSync('/newdir');
     assert.strictEqual(archiveVfs.statSync('/newdir').isDirectory(), true);