From 31aedda2acc06b64218536461e07addb8be5df3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:06:29 +0000 Subject: [PATCH 1/4] Bun.Archive: extract() with a glob rejects at a file or directory entry that the file system refuses extract_to_disk_filtered skipped a file or directory entry when its mkdir or open failed, and the promise resolved with a smaller count. extract() without a glob rejects in the same cases for a file entry. - File entry: open first. If the parent directory is missing, make it and open once more. A failure rejects with the error of that system call (code, syscall, path). - Directory entry: mkdir first. The entry is made when mkdir succeeds or when a directory holds the name. Anything else rejects. - A file written with a glob gets the owner-write bit, so a later entry of the same name and a second extract can open it again. - A symlink entry that cannot be made is still skipped, as without a glob. - crate::Error::ReadError and OpenError had this one user and are gone. --- src/runtime/api/Archive.rs | 109 +++++++++------ src/runtime/error.rs | 6 - test/js/bun/archive.test.ts | 272 +++++++++++++++++++++++++++++++++++- 3 files changed, 334 insertions(+), 53 deletions(-) diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index cf70e4fff35b..149186a75eae 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -675,6 +675,22 @@ pub enum ExtractError { pub(crate) enum ExtractResult { Success(u32), Err(ExtractError), + /// The file system refused an entry. Boxed: the job allocation keeps its size class. + SysErr(Box), +} + +impl ExtractResult { + /// `err` with its path below `root`. An error that carries no path gets `entry`. + fn refused(err: &bun_sys::Error, root: &[u8], entry: &[u8]) -> ExtractResult { + let below: &[u8] = if err.path.is_empty() { + entry + } else { + &err.path + }; + let mut path = bun_paths::join_sep_maybe_z::(&[root, below]).into_vec(); + bun_paths::resolve_path::posix_to_platform_in_place(&mut path[root.len()..]); + ExtractResult::SysErr(Box::new(err.with_path(&path))) + } } pub(crate) struct ExtractContext { @@ -697,6 +713,7 @@ impl TaskContext for ExtractContext { ExtractResult::Err(e) => PromiseResult::Reject( global.create_error_instance(format_args!("{}", <&'static str>::from(e))), ), + ExtractResult::SysErr(sys_err) => PromiseResult::Reject(sys_err.to_js(global)), }) } } @@ -705,15 +722,11 @@ impl ExtractContext { fn do_run(&mut self) -> ExtractResult { // If we have glob patterns, use filtered extraction if self.glob_patterns.is_some() { - let count = match extract_to_disk_filtered( + return extract_to_disk_filtered( self.store.shared_view(), &self.path, self.glob_patterns.as_deref(), - ) { - Ok(c) => c, - Err(_) => return ExtractResult::Err(ExtractError::ReadError), - }; - return ExtractResult::Success(count); + ); } // Otherwise use the fast path without filtering @@ -1246,13 +1259,13 @@ fn extract_to_disk_filtered( file_buffer: &[u8], root: &[u8], glob_patterns: Option<&[GlobPattern]>, -) -> crate::Result { +) -> ExtractResult { use libarchive::lib; let archive = lib::ReadArchive::new(); configure_archive_reader(&archive); if archive.read_open_memory(file_buffer) != lib::Result::Ok { - return Err(crate::Error::ReadError); + return ExtractResult::Err(ExtractError::ReadError); } // Open/create target directory using bun.sys @@ -1262,7 +1275,7 @@ fn extract_to_disk_filtered( if bun_paths::is_absolute(root) { break 'brk match bun_sys::open_a(root, bun_sys::O::RDONLY | bun_sys::O::DIRECTORY, 0) { Ok(fd) => fd, - Err(_) => return Err(crate::Error::OpenError), + Err(_) => return ExtractResult::Err(ExtractError::ReadError), }; } else { break 'brk match bun_sys::openat_a( @@ -1272,7 +1285,7 @@ fn extract_to_disk_filtered( 0, ) { Ok(fd) => fd, - Err(_) => return Err(crate::Error::OpenError), + Err(_) => return ExtractResult::Err(ExtractError::ReadError), }; } }; @@ -1286,7 +1299,9 @@ fn extract_to_disk_filtered( match archive.read_next_header(&mut entry) { lib::Result::Eof => break, lib::Result::Retry => continue, - lib::Result::Failed | lib::Result::Fatal => return Err(crate::Error::ReadError), + lib::Result::Failed | lib::Result::Fatal => { + return ExtractResult::Err(ExtractError::ReadError); + } lib::Result::Ok | lib::Result::Warn => {} } let entry_ref = lib::Entry::opaque_ref(entry); @@ -1294,10 +1309,10 @@ fn extract_to_disk_filtered( #[cfg(not(windows))] let raw_pathname_z = entry_ref.pathname(); #[cfg(windows)] - let raw_pathname_zbox = ZBox::from_vec_with_nul( - entry_pathname_utf8(entry_ref) - .map_err(|_| crate::Error::Alloc(bun_alloc::AllocError))?, - ); + let raw_pathname_zbox = match entry_pathname_utf8(entry_ref) { + Ok(name) => ZBox::from_vec_with_nul(name), + Err(_) => return ExtractResult::Err(ExtractError::ReadError), + }; #[cfg(windows)] let raw_pathname_z = raw_pathname_zbox.as_zstr(); let raw_pathname = raw_pathname_z.as_bytes(); @@ -1330,11 +1345,22 @@ fn extract_to_disk_filtered( match kind { bun_sys::FileKind::Directory => { - match dir_fd.make_path(pathname) { - // Directory already exists - don't count as extracted - Err(e) if e.get_errno() == bun_sys::E::EEXIST => continue, - Err(_) => continue, - Ok(()) => {} + // The entry `x/` is the directory `x`. + let name_len = strings::without_trailing_slash(pathname).len(); + normalized_buf[name_len] = 0; + let name = bun_core::ZStr::from_buf(&normalized_buf[..], name_len); + let made = match bun_sys::mkdirat(dir_fd, name, 0o755) { + Err(err) if err.get_errno() == bun_sys::E::ENOENT => { + dir_fd.make_path(name.as_bytes()) + } + // Any other refusal is fine when a directory holds the name. + Err(_) if matches!(bun_sys::directory_exists_at(dir_fd, name), Ok(true)) => { + Ok(()) + } + made => made, + }; + if let Err(err) = made { + return ExtractResult::refused(&err, root, name.as_bytes()); } count += 1; } @@ -1343,33 +1369,30 @@ fn extract_to_disk_filtered( // Sanitize permissions: use entry perms masked to 0o777, or default 0o644 let entry_perm = entry_ref.perm(); let mode: Mode = if entry_perm != 0 { - Mode::try_from(entry_perm & 0o777).expect("int cast") + // Without owner-write, a second extract or a later entry of this name gets EACCES. + Mode::try_from(entry_perm & 0o777).expect("int cast") | 0o200 } else { 0o644 }; - // Create parent directories if needed (ignore expected errors) - if let Some(parent_dir) = bun_core::dirname(pathname) { - match dir_fd.make_path(parent_dir) { - // Expected: directory already exists - Err(e) if e.get_errno() == bun_sys::E::EEXIST => {} - // Permission errors: skip this file, will fail at openat - Err(e) if e.get_errno() == bun_sys::E::EACCES => {} - // Other errors: skip, will fail at openat - Err(_) => {} - Ok(()) => {} + let open = || { + bun_sys::openat( + dir_fd, + pathname_z, + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, + mode, + ) + }; + let opened = open().or_else(|err| match bun_core::dirname(pathname) { + // The parent directory is missing. + Some(parent) if err.get_errno() == bun_sys::E::ENOENT => { + dir_fd.make_path(parent).and_then(|()| open()) } - } - - // Create and write the file using bun.sys - let file_fd: Fd = match bun_sys::openat( - dir_fd, - pathname_z, - bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, - mode, - ) { + _ => Err(err), + }); + let file_fd: Fd = match opened { Ok(fd) => fd, - Err(_) => continue, + Err(err) => return ExtractResult::refused(&err, root, pathname), }; let write_success = size == 0 @@ -1379,7 +1402,7 @@ fn extract_to_disk_filtered( if !write_success { // Remove partial file on failure let _ = bun_sys::unlinkat(dir_fd, pathname_z); - return Err(crate::Error::ReadError); + return ExtractResult::Err(ExtractError::ReadError); } count += 1; } @@ -1415,5 +1438,5 @@ fn extract_to_disk_filtered( } } - Ok(count) + ExtractResult::Success(count) } diff --git a/src/runtime/error.rs b/src/runtime/error.rs index 254c6d9d673a..dd7ffcf16abf 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -116,10 +116,6 @@ pub enum Error { InvalidHeaderName, #[error("FormatError")] FormatError, - #[error("ReadError")] - ReadError, - #[error("OpenError")] - OpenError, #[error("UnexpectedPendingResolution")] UnexpectedPendingResolution, #[error("AsyncModule")] @@ -396,8 +392,6 @@ impl Error { Self::UnableToEncode => "UnableToEncode", Self::InvalidHeaderName => "InvalidHeaderName", Self::FormatError => "FormatError", - Self::ReadError => "ReadError", - Self::OpenError => "OpenError", Self::UnexpectedPendingResolution => "UnexpectedPendingResolution", Self::AsyncModule => "AsyncModule", Self::BlobNotFound => "BlobNotFound", diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index 98da1af4e6fd..b225a377d817 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -1,8 +1,20 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isMacOS, isRoot, isWindows, tempDir } from "harness"; import { randomBytes } from "node:crypto"; -import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync } from "node:fs"; -import { join } from "path"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative } from "path"; // Minimal ustar tarball builder (pathnames must be <100 bytes). `name` accepts // a Buffer so tests can put raw, non-UTF-8 byte sequences into the name field. @@ -12,7 +24,7 @@ function ustarHeader( name: string | Buffer, size: number, typeflag: string = "0", - fields: { mode?: Uint8Array; mtime?: Uint8Array } = {}, + fields: { mode?: Uint8Array; mtime?: Uint8Array; linkname?: string } = {}, ): Buffer { const nameBytes = typeof name === "string" ? Buffer.from(name) : name; if (nameBytes.length > 99) throw new Error("ustar name too long: " + name); @@ -27,6 +39,7 @@ function ustarHeader( if (fields.mtime) h.set(fields.mtime.subarray(0, 12), 136); h.write(" ", 148); h.write(typeflag, 156); + if (fields.linkname) h.write(fields.linkname, 157); h.write("ustar\0", 257); h.write("00", 263); let sum = 0; @@ -2266,6 +2279,257 @@ describe("Bun.Archive", () => { expect(count).toBe(0); }); + + describe("an entry that the file system refuses", () => { + type Options = { glob: string | readonly string[] }; + type Outcome = { settled: string; files: string[] }; + + // What the call settled with, and what the destination holds afterwards. + async function extractInto(dest: string, tar: Uint8Array, options?: Options): Promise { + const settled = await new Bun.Archive(tar).extract(dest, options).then( + count => `resolved ${count}`, + (error: NodeJS.ErrnoException) => + error.code + ? `rejected ${error.code} ${error.syscall} ${relative(dest, error.path!).replaceAll("\\", "/")}` + : `rejected ${error.message}`, + ); + const files = readdirSync(dest, { recursive: true }).map(name => String(name).replaceAll("\\", "/")); + return { settled, files: files.sort() }; + } + + const tarOf = (...entries: Buffer[]) => new Uint8Array(Buffer.concat([...entries, Buffer.alloc(1024)])); + const file = (name: string) => ustarEntry(name, Buffer.from(name)); + const directory = (name: string) => ustarHeader(name, 0, "5"); + const symlink = (name: string, linkname: string) => ustarHeader(name, 0, "2", { linkname }); + + const nestedThenTopLevel = tarOf(file("d/a.txt"), file("b.txt")); + + // Makes `path` read-only, and returns what undoes it. + const readOnly = (path: string, mode: number) => { + const before = statSync(path).mode; + chmodSync(path, mode); + return () => chmodSync(path, before); + }; + + type Prepare = (dest: string) => void | (() => void); + + // `plain` is extract(dir), and `glob` is extract(dir, { glob: "**" }). Each gets its own destination. + async function bothCallForms(tar: Uint8Array, prepare: Prepare) { + const outcome: Record = {}; + for (const [form, options] of [ + ["plain", undefined], + ["glob", { glob: "**" }], + ] as const) { + using dir = tempDir("archive-refused-entry", {}); + const restore = prepare(String(dir)); + try { + outcome[form] = await extractInto(String(dir), tar, options); + } finally { + restore?.(); + } + } + return outcome; + } + + // With a glob the rejection is the error of the system call that was refused. + const fileRows: { + name: string; + skip?: boolean; + tar: Uint8Array; + prepare: Prepare; + plain: Outcome; + glob: Outcome; + }[] = [ + { + name: "a file holds the name of the directory of an entry", + tar: nestedThenTopLevel, + prepare: dest => writeFileSync(join(dest, "d"), "in the way"), + plain: { settled: "rejected ReadError", files: ["d"] }, + glob: { settled: `rejected ${isWindows ? "ENOENT" : "ENOTDIR"} open d/a.txt`, files: ["d"] }, + }, + { + name: "a directory holds the name of a file entry", + tar: nestedThenTopLevel, + prepare: dest => mkdirSync(join(dest, "b.txt")), + plain: { settled: "rejected ReadError", files: ["b.txt", "d", "d/a.txt"] }, + glob: { + settled: `rejected ${isWindows ? "EEXIST" : "EISDIR"} open b.txt`, + files: ["b.txt", "d", "d/a.txt"], + }, + }, + { + name: "a read-only file holds the name of a file entry", + // Root can write a file of any mode. + skip: isRoot, + tar: nestedThenTopLevel, + prepare: dest => { + writeFileSync(join(dest, "b.txt"), "in the way"); + return readOnly(join(dest, "b.txt"), 0o444); + }, + plain: { settled: "rejected ReadError", files: ["b.txt", "d", "d/a.txt"] }, + glob: { + settled: `rejected ${isWindows ? "EPERM" : "EACCES"} open b.txt`, + files: ["b.txt", "d", "d/a.txt"], + }, + }, + { + name: "a dangling symlink holds the name of the directory of an entry", + // A symlink needs a privilege on Windows. + skip: isWindows, + tar: nestedThenTopLevel, + prepare: dest => symlinkSync("missing", join(dest, "d")), + plain: { settled: "rejected ReadError", files: ["d"] }, + glob: { settled: "rejected ENOENT open d/a.txt", files: ["d"] }, + }, + { + name: "a dangling symlink holds the name of a directory above an entry", + skip: isWindows, + tar: tarOf(file("s/sub/a.txt"), file("z.txt")), + prepare: dest => symlinkSync("missing", join(dest, "s")), + plain: { settled: "rejected ReadError", files: ["s"] }, + glob: { settled: "rejected ENOENT mkdir s/sub", files: ["s"] }, + }, + { + name: "the destination is read-only, and the entry is in a directory", + // Root ignores the mode of a directory, and Windows has no such mode. + skip: isWindows || isRoot, + tar: tarOf(file("d/a.txt")), + prepare: dest => readOnly(dest, 0o555), + plain: { settled: "rejected ReadError", files: [] }, + glob: { settled: "rejected EACCES mkdir d", files: [] }, + }, + { + name: "the destination is read-only, and the entry is at the top level", + skip: isWindows || isRoot, + tar: tarOf(file("b.txt")), + prepare: dest => readOnly(dest, 0o555), + plain: { settled: "rejected ReadError", files: [] }, + glob: { settled: "rejected EACCES open b.txt", files: [] }, + }, + ]; + + for (const row of fileRows) { + test.concurrent.skipIf(!!row.skip)(`a file entry rejects in both call forms when ${row.name}`, async () => { + expect(await bothCallForms(row.tar, row.prepare)).toEqual({ plain: row.plain, glob: row.glob }); + }); + } + + // extract(dir) resolves for these on POSIX: it counts a directory entry that it cannot make. + const directoryRows: { name: string; skip?: boolean; tar: Uint8Array; prepare: Prepare; glob: Outcome }[] = [ + { + name: "a file holds its name", + tar: tarOf(directory("x/")), + prepare: dest => writeFileSync(join(dest, "x"), "in the way"), + glob: { settled: "rejected EEXIST mkdir x", files: ["x"] }, + }, + { + name: "a file holds the name of its parent", + tar: tarOf(directory("x/y/")), + prepare: dest => writeFileSync(join(dest, "x"), "in the way"), + glob: { + settled: isWindows ? expect.stringMatching(/^rejected E\w+ mkdir x/) : "rejected ENOTDIR mkdir x/y", + files: ["x"], + }, + }, + { + name: "a dangling symlink holds the name of its parent", + skip: isWindows, + tar: tarOf(directory("x/y/")), + prepare: dest => symlinkSync("missing", join(dest, "x")), + glob: { settled: "rejected ENOENT mkdir x/y", files: ["x"] }, + }, + { + name: "the destination is read-only", + skip: isWindows || isRoot, + tar: tarOf(directory("x/")), + prepare: dest => readOnly(dest, 0o555), + glob: { settled: "rejected EACCES mkdir x", files: [] }, + }, + ]; + + for (const row of directoryRows) { + test.concurrent.skipIf(!!row.skip)(`a directory entry rejects with a glob when ${row.name}`, async () => { + using dir = tempDir("archive-refused-directory", {}); + const restore = row.prepare(String(dir)); + try { + expect(await extractInto(String(dir), row.tar, { glob: "**" })).toEqual(row.glob); + } finally { + restore?.(); + } + }); + } + + test.concurrent.each([{ glob: ["**"] }, { glob: ["!other/**"] }])( + "every spelling of the glob rejects: %j", + async options => { + using dir = tempDir("archive-refused-spelling", { "d": "in the way" }); + expect(await extractInto(String(dir), nestedThenTopLevel, options)).toEqual(fileRows[0].glob); + }, + ); + + test.concurrent("resolves when the glob leaves the refused entry out", async () => { + using dir = tempDir("archive-refused-entry-filtered-out", { "d": "in the way" }); + expect(await extractInto(String(dir), nestedThenTopLevel, { glob: ["**", "!d/**"] })).toEqual({ + settled: "resolved 1", + files: ["b.txt", "d"], + }); + }); + + test.concurrent("a directory entry below directories that the archive does not list is made", async () => { + using dir = tempDir("archive-directory-entry-below-missing", {}); + expect(await extractInto(String(dir), tarOf(directory("a/b/c/")), { glob: "**" })).toEqual({ + settled: "resolved 1", + files: ["a", "a/b", "a/b/c"], + }); + }); + + test.concurrent("a second extract with a glob writes every entry again", async () => { + using dir = tempDir("archive-glob-second-extract", {}); + // The mode of each member is 0444: no write bit. + const readOnlyMember = (name: string, data: string) => + Buffer.concat([ + ustarHeader(name, data.length, "0", { mode: Buffer.from("0000444\0") }), + Buffer.from(data.padEnd(512, "\0")), + ]); + const membersThen = (data: string) => + tarOf(directory("d/"), readOnlyMember("d/r.txt", "overwritten"), readOnlyMember("d/r.txt", data)); + + const written = join(String(dir), "d/r.txt"); + + expect(await extractInto(String(dir), membersThen("first"), { glob: "**" })).toEqual({ + settled: "resolved 3", + files: ["d", "d/r.txt"], + }); + expect(readFileSync(written, "utf8")).toBe("first"); + // Root can open a file that has no write bit, so the mode is the proof for every user. + expect(statSync(written).mode & 0o200).toBe(0o200); + + expect(await extractInto(String(dir), membersThen("second"), { glob: "**" })).toEqual({ + settled: "resolved 3", + files: ["d", "d/r.txt"], + }); + expect(readFileSync(written, "utf8")).toBe("second"); + }); + + // Not changed: a symlink entry that cannot be made is skipped. extract(dir) counts it, a glob does not. + test.concurrent.skipIf(isWindows)("a symlink entry whose name is taken does not fail the call", async () => { + const tar = tarOf(file("b.txt"), symlink("l", "b.txt")); + expect(await bothCallForms(tar, dest => writeFileSync(join(dest, "l"), "in the way"))).toEqual({ + plain: { settled: "resolved 2", files: ["b.txt", "l"] }, + glob: { settled: "resolved 1", files: ["b.txt", "l"] }, + }); + }); + + // extract(dir) makes symlinks last, so the entry below `s` becomes a file in a directory `s`. + // With a glob the symlink is made in archive order, and the entry below it cannot be written. + test.concurrent.skipIf(isWindows)("an entry below a dangling symlink of the same archive", async () => { + const tar = tarOf(symlink("s", "missing"), file("s/x.txt")); + expect(await bothCallForms(tar, () => {})).toEqual({ + plain: { settled: "resolved 2", files: ["s", "s/x.txt"] }, + glob: { settled: "rejected ENOENT open s/x.txt", files: ["s"] }, + }); + }); + }); }); describe("concurrent operations", () => { From b3b09c3485e8615459b33003c0e2d20f0115dc46 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:26:31 +0000 Subject: [PATCH 2/4] Bun.Archive: name mkdir as the system call of a refused directory entry on every platform Windows makes a directory with an open call, so a refused directory entry reported open as its system call there. The directory arm now reports mkdir, as on POSIX. The test expects the Windows error code of a directory entry below a file (ENOENT). --- src/runtime/api/Archive.rs | 4 +++- test/js/bun/archive.test.ts | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index 149186a75eae..a3806b8a7fc9 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -1359,7 +1359,9 @@ fn extract_to_disk_filtered( } made => made, }; - if let Err(err) = made { + if let Err(mut err) = made { + // Windows makes a directory with an open call, and names that call in the error. + err.syscall = bun_sys::Tag::mkdir; return ExtractResult::refused(&err, root, name.as_bytes()); } count += 1; diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index b225a377d817..64896d009120 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -2426,10 +2426,7 @@ describe("Bun.Archive", () => { name: "a file holds the name of its parent", tar: tarOf(directory("x/y/")), prepare: dest => writeFileSync(join(dest, "x"), "in the way"), - glob: { - settled: isWindows ? expect.stringMatching(/^rejected E\w+ mkdir x/) : "rejected ENOTDIR mkdir x/y", - files: ["x"], - }, + glob: { settled: `rejected ${isWindows ? "ENOENT" : "ENOTDIR"} mkdir x/y`, files: ["x"] }, }, { name: "a dangling symlink holds the name of its parent", From 983e6c7bc69f5d7ac5eeaec74aceb5acc18c0505 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:42:30 +0000 Subject: [PATCH 3/4] Bun.Archive: name mkdir and the directory when the parent of a file entry cannot be made On Windows the error of a failed mkdir names an open call and carries no path, so a file entry whose parent directory could not be made rejected with the open call and the path of the file. Both arms now build that error in one place: the system call is mkdir, and the path is the directory. --- src/runtime/api/Archive.rs | 21 +++++++++++++++------ test/js/bun/archive.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index a3806b8a7fc9..8689289f3bd2 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -1253,6 +1253,15 @@ pub(crate) fn match_glob_patterns(patterns: &[GlobPattern], pathname: &[u8]) -> !has_positive_patterns || matches_positive } +/// The error of a mkdir of `dir`. On Windows it names an open call and carries no path. +fn mkdir_error(mut err: bun_sys::Error, dir: &[u8]) -> bun_sys::Error { + err.syscall = bun_sys::Tag::mkdir; + if err.path.is_empty() { + err.path = dir.into(); + } + err +} + /// Extract archive to disk with glob pattern filtering. /// Supports negative patterns with "!" prefix (e.g., "!node_modules/**"). fn extract_to_disk_filtered( @@ -1359,9 +1368,8 @@ fn extract_to_disk_filtered( } made => made, }; - if let Err(mut err) = made { - // Windows makes a directory with an open call, and names that call in the error. - err.syscall = bun_sys::Tag::mkdir; + if let Err(err) = made { + let err = mkdir_error(err, name.as_bytes()); return ExtractResult::refused(&err, root, name.as_bytes()); } count += 1; @@ -1387,9 +1395,10 @@ fn extract_to_disk_filtered( }; let opened = open().or_else(|err| match bun_core::dirname(pathname) { // The parent directory is missing. - Some(parent) if err.get_errno() == bun_sys::E::ENOENT => { - dir_fd.make_path(parent).and_then(|()| open()) - } + Some(parent) if err.get_errno() == bun_sys::E::ENOENT => dir_fd + .make_path(parent) + .map_err(|err| mkdir_error(err, parent)) + .and_then(|()| open()), _ => Err(err), }); let file_fd: Fd = match opened { diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index 64896d009120..0e3781efb0a3 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -2347,6 +2347,17 @@ describe("Bun.Archive", () => { plain: { settled: "rejected ReadError", files: ["d"] }, glob: { settled: `rejected ${isWindows ? "ENOENT" : "ENOTDIR"} open d/a.txt`, files: ["d"] }, }, + { + name: "a file holds the name of a directory above an entry", + tar: tarOf(file("s/sub/a.txt"), file("z.txt")), + prepare: dest => writeFileSync(join(dest, "s"), "in the way"), + plain: { settled: "rejected ReadError", files: ["s"] }, + // Windows answers ENOENT for the open, and then cannot make `s/sub`. + glob: { + settled: isWindows ? "rejected ENOENT mkdir s/sub" : "rejected ENOTDIR open s/sub/a.txt", + files: ["s"], + }, + }, { name: "a directory holds the name of a file entry", tar: nestedThenTopLevel, From 8f3961e1b44e0bd7cbfebbde9b44f4352c805402 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:59:43 +0000 Subject: [PATCH 4/4] ci: retrigger