diff --git a/docs/runtime/archive.mdx b/docs/runtime/archive.mdx index 57724d9daa07..96553e5b4908 100644 --- a/docs/runtime/archive.mdx +++ b/docs/runtime/archive.mdx @@ -138,7 +138,9 @@ const count = await archive.extract("./extracted"); console.log(`Extracted ${count} entries`); ``` -`extract()` creates the target directory if it doesn't exist and overwrites existing files. The returned count includes files, directories, and symlinks (on POSIX systems). +`extract()` creates the target directory if it doesn't exist and overwrites existing files. On Linux and macOS, an existing file is removed and created again, as `tar` does, so a hard link to it is not affected and the new file has the mode of the archive entry. If the file cannot be removed (its directory is not writable), it is truncated and written in place, unless it is a symlink or a hard link. A file that Bun writes in place keeps its mode. The returned count includes files, directories, and symlinks (on POSIX systems). + +On Linux and macOS, Bun creates each file with the permission bits of its archive entry, and the process umask applies, as with any new file. Bun does not restore the setuid, setgid, and sticky bits of a file. A file entry with no permission bits counts as `0o644`. **Note**: On Windows, Bun always skips symbolic links during extraction, regardless of privilege level. On Linux and macOS, Bun extracts symlinks normally. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index a42260ff8167..10d74296c28c 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -10249,6 +10249,15 @@ declare module "bun" { * Creates the target directory and any necessary parent directories if they don't exist. * Existing files are overwritten. * + * On Linux and macOS, Bun removes an existing file and creates a new one, so a hard link to + * it does not change. If Bun cannot remove the file (its directory is not writable), Bun + * truncates the file and writes it in place, unless it is a symlink or a hard link. A file + * that Bun writes in place keeps its mode. + * + * On Linux and macOS, each new file gets the permission bits of its archive entry (`0o644` if + * the entry has none), and the process umask applies. Bun does not restore the setuid, setgid, + * and sticky bits of a file. + * * @param path - The directory path to extract to * @param options - Optional extraction options * @param options.glob - Glob pattern(s) to filter entries (positive patterns include, negative patterns starting with `!` exclude) diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index e1ecc6ef4f7d..19b81b8baacd 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -880,10 +880,9 @@ impl TarballStream { FileKind::File => { #[cfg(windows)] let mode: Mode = 0; - // Mask to permission bits so setuid/setgid/sticky bits from the - // archive never reach `openat`'s mode argument. #[cfg(not(windows))] - let mode: Mode = Mode::try_from((entry.perm() & 0o777) | 0o666).expect("int cast"); + let mode: Mode = + bun_libarchive::file_mode(entry.perm(), bun_libarchive::FileReaders::Everyone); let fd = open_output_file(dest, path, path_slice, mode)?; self.entry_count += 1; @@ -1388,10 +1387,10 @@ fn open_output_file( path_slice: &[OSPathChar], mode: Mode, ) -> crate::Result { - let flags = O::WRONLY | O::CREAT | O::TRUNC; #[cfg(windows)] { let _ = mode; + let flags = O::WRONLY | O::CREAT | O::TRUNC; return match bun_sys::openat_windows(dest_fd, path, flags, 0) { Ok(fd) => Ok(fd), Err(e) => match e.get_errno() { @@ -1409,7 +1408,7 @@ fn open_output_file( } #[cfg(not(windows))] { - match bun_sys::openat(dest_fd, path, flags, mode) { + match bun_libarchive::create_entry_file(dest_fd, path, mode) { Ok(fd) => Ok(fd), Err(e) => match e.get_errno() { bun_sys::E::EACCES | bun_sys::E::ENOENT => 'brk: { @@ -1417,7 +1416,7 @@ fn open_output_file( return Err(e.to_zig_err().into()); }; let _ = dest_fd.make_path(dir); - break 'brk bun_sys::openat(dest_fd, path, flags, mode) + break 'brk bun_libarchive::create_entry_file(dest_fd, path, mode) .map_err(|e| e.to_zig_err().into()); } _ => Err(e.to_zig_err().into()), diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index 5827adca1c04..7aeecb947ab5 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -999,6 +999,60 @@ pub fn directory_mode(perm: bun_sys::Mode) -> bun_sys::Mode { mode } +/// Who can read a regular file that an extractor creates. +#[derive(Clone, Copy)] +pub enum FileReaders { + /// Every user: npm's `fmode` (#14467), https://github.com/npm/cli/blob/feb54f7e9a39bd52519221bae4fafc8bc70f235e/node_modules/pacote/lib/fetcher.js#L402-L411 + Everyone, + /// The users the entry names, as GNU tar does. + FromEntry, +} + +/// `openat` mode for a regular file entry, without setuid, setgid and sticky. +pub fn file_mode(perm: bun_sys::Mode, readers: FileReaders) -> bun_sys::Mode { + let mode = perm & 0o777; + match readers { + FileReaders::Everyone => mode | 0o666, + // An unset mode field gets the mode `Bun.Archive` writes. + FileReaders::FromEntry if mode == 0 => 0o644, + FileReaders::FromEntry => mode, + } +} + +/// Opens a regular file entry for writing, replacing an existing file as GNU tar does (#43132). +#[cfg(not(windows))] +pub fn create_entry_file(dir: Fd, path: &ZStr, mode: bun_sys::Mode) -> bun_sys::Maybe { + let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::EXCL; + match bun_sys::openat(dir, path, flags, mode) { + Err(err) if err.get_errno() == bun_sys::E::EEXIST => match bun_sys::unlinkat(dir, path) { + Ok(()) => bun_sys::openat(dir, path, flags, mode), + Err(unlink_err) => truncate_entry_file(dir, path, mode, unlink_err), + }, + result => result, + } +} + +/// Truncates a file that cannot be removed, unless it is a symlink or has other names. +#[cfg(not(windows))] +fn truncate_entry_file( + dir: Fd, + path: &ZStr, + mode: bun_sys::Mode, + unlink_err: bun_sys::Error, +) -> bun_sys::Maybe { + // O_CREAT keeps the kernel's sticky directory checks, O_NONBLOCK makes a FIFO fail with ENXIO. + let flags = + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::NOFOLLOW | bun_sys::O::NONBLOCK; + let fd = bun_sys::openat(dir, path, flags, mode)?; + let guard = scopeguard::guard(fd, |fd| fd.close()); + let stat = bun_sys::fstat(fd)?; + if !bun_sys::is_regular_file(stat.st_mode as bun_sys::Mode) || stat.st_nlink != 1 { + return Err(unlink_err); + } + bun_sys::ftruncate(fd, 0)?; + Ok(scopeguard::ScopeGuard::into_inner(guard)) +} + /// Validates that a symlink target doesn't escape the extraction directory. /// Returns true if the symlink is safe (target stays within extraction dir), /// false if it would escape (e.g., via ../ traversal or absolute path). @@ -1221,6 +1275,7 @@ pub mod archiver { pub close_handles: bool, pub log: bool, pub npm: bool, + pub file_readers: super::FileReaders, } impl Default for ExtractOptions { @@ -1230,6 +1285,7 @@ pub mod archiver { close_handles: true, log: false, npm: false, + file_readers: super::FileReaders::Everyone, } } } @@ -1351,6 +1407,7 @@ impl Archiver { bun_paths::platform::Auto, >(pathname, &mut normalized_buf[..]); let normalized_len = normalized.len(); + normalized_buf[normalized_len] = 0; let pathname: &[u8] = &normalized_buf[..normalized_len]; if pathname.is_empty() || pathname == b"." { continue 'loop_; @@ -1366,14 +1423,14 @@ impl Archiver { let size: usize = usize::try_from(lib::Entry::opaque_ref(entry).size().max(0)).unwrap(); if size > 0 { - let Ok(opened) = bun_sys::openat_a(dir, pathname, bun_sys::O::WRONLY, 0) - else { + // SAFETY: normalized_buf[normalized_len] == 0 (written above). + let pathname_z: &ZStr = + unsafe { ZStr::from_raw(pathname.as_ptr(), pathname.len()) }; + let Ok(stat) = bun_sys::fstatat(dir, pathname_z) else { continue 'loop_; }; - let _close_guard = scopeguard::guard(opened, |fd| fd.close()); - let stat_size = bun_sys::get_file_size(opened)?; - if stat_size > 0 { + if stat.st_size > 0 { let is_already_top_level = dirname.is_empty(); let path_to_use_: &[u8] = 'brk: { let __pathname: &[u8] = pathname; @@ -1680,23 +1737,17 @@ impl Archiver { } } bun_sys::FileKind::File => { - // first https://github.com/npm/cli/blob/feb54f7e9a39bd52519221bae4fafc8bc70f235e/node_modules/pacote/lib/fetcher.js#L65-L66 - // this.fmode = opts.fmode || 0o666 - // - // then https://github.com/npm/cli/blob/feb54f7e9a39bd52519221bae4fafc8bc70f235e/node_modules/pacote/lib/fetcher.js#L402-L411 - // - // we simplify and turn it into `entry.mode || 0o666` because we aren't accepting a umask or fmask option. #[cfg(not(windows))] - let mode: bun_sys::Mode = bun_sys::Mode::try_from( + let mode = file_mode( // SAFETY: entry valid - (lib::Entry::opaque_ref(entry).perm() & 0o777) | 0o666, - ) - .unwrap(); - - let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + lib::Entry::opaque_ref(entry).perm(), + options.file_readers, + ); #[cfg(windows)] - let file_handle_native: Fd = + let file_handle_native: Fd = { + let flags = + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; match bun_sys::openat_windows(dir_fd, path_slice, flags, 0) { Ok(fd) => fd, Err(e) => match e.get_errno() { @@ -1713,16 +1764,16 @@ impl Archiver { } _ => return Err(e.into()), }, - }; + } + }; #[cfg(not(windows))] let file_handle_native: Fd = { - // dir.createFileZ(.{truncate, mode}) → bun_sys::openat // SAFETY: normalized_buf[path_slice.len()] == 0 (written above). let path_z: &ZStr = unsafe { ZStr::from_raw(path_slice.as_ptr(), path_slice.len()) }; - match bun_sys::openat(dir_fd, path_z, flags, mode) { + match create_entry_file(dir_fd, path_z, mode) { Ok(fd) => fd, Err(err) => match err.get_errno() { bun_sys::E::EACCES @@ -1733,7 +1784,7 @@ impl Archiver { return Err(err.into()); } let _ = dir.make_path_u8(dirname); - bun_sys::openat(dir_fd, path_z, flags, mode)? + create_entry_file(dir_fd, path_z, mode)? } _ => return Err(err.into()), }, diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index feffa86a3a3a..3db8bd2be0ae 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -766,6 +766,7 @@ impl ExtractContext { close_handles: true, log: false, npm: false, + file_readers: libarchive::FileReaders::FromEntry, }, ) { Ok(c) => c, @@ -1381,13 +1382,12 @@ fn extract_to_disk_filtered( } bun_sys::FileKind::File => { let size: usize = usize::try_from(entry_ref.size().max(0)).expect("int cast"); - // 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") - } else { - 0o644 - }; + // On Windows, a mode without the owner write bit makes a read-only file, which a later extraction cannot open. + #[cfg(windows)] + let mode: Mode = 0; + #[cfg(not(windows))] + let mode: Mode = + libarchive::file_mode(entry_ref.perm(), libarchive::FileReaders::FromEntry); // Create parent directories if needed (ignore expected errors) if let Some(parent_dir) = bun_core::dirname(pathname) { @@ -1402,13 +1402,16 @@ fn extract_to_disk_filtered( } } - // Create and write the file using bun.sys - let file_fd: Fd = match bun_sys::openat( + #[cfg(windows)] + let opened = bun_sys::openat( dir_fd, pathname_z, bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, mode, - ) { + ); + #[cfg(not(windows))] + let opened = libarchive::create_entry_file(dir_fd, pathname_z, mode); + let file_fd: Fd = match opened { Ok(fd) => fd, Err(_) => continue, }; diff --git a/test/cli/install/bun-install-streaming-extract.test.ts b/test/cli/install/bun-install-streaming-extract.test.ts index 9d9a61304a3c..eec9e0ea3c08 100644 --- a/test/cli/install/bun-install-streaming-extract.test.ts +++ b/test/cli/install/bun-install-streaming-extract.test.ts @@ -6,8 +6,8 @@ // the buffered extractor would produce. import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, readdirSorted, tempDir } from "harness"; -import { createHash } from "node:crypto"; +import { bunEnv, bunExe, isWindows, readdirSorted, tempDir } from "harness"; +import { createHash, randomBytes } from "node:crypto"; import { createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { createServer, type Server } from "node:http"; import { join } from "node:path"; @@ -50,7 +50,7 @@ function pad512(len: number): Buffer { return Buffer.alloc(pad, 0); } -function tarFile(name: string, body: Buffer): Buffer[] { +function tarFile(name: string, body: Buffer, mode?: Uint8Array): Buffer[] { // ustar stores at most 100 bytes of name; longer paths need a pax // 'x' record. npm's `tar` uses pax, so this exercises the resumable // `tar_read_header` path in the libarchive patch. @@ -69,19 +69,19 @@ function tarFile(name: string, body: Buffer): Buffer[] { tarHeader("PaxHeader", pax.length, "x"), pax, pad512(pax.length), - tarHeader(name.slice(0, 99), body.length, "0"), + tarHeader(name.slice(0, 99), body.length, "0", mode), body, pad512(body.length), ]; } - return [tarHeader(name, body.length, "0"), body, pad512(body.length)]; + return [tarHeader(name, body.length, "0", mode), body, pad512(body.length)]; } -type Entry = { path: string; body: Buffer }; +type Entry = { path: string; body: Buffer; mode?: Uint8Array }; function buildTarball(entries: Entry[]): { tgz: Buffer; shasum: string; integrity: string } { const blocks: Buffer[] = []; - for (const { path, body } of entries) blocks.push(...tarFile(`package/${path}`, body)); + for (const { path, body, mode } of entries) blocks.push(...tarFile(`package/${path}`, body, mode)); blocks.push(Buffer.alloc(1024, 0)); // two zero blocks = end-of-archive const tar = Buffer.concat(blocks); const tgz = gzipSync(tar); @@ -237,9 +237,20 @@ async function makeRegistry(tgz: Buffer, shasum: string, integrity: string, chun }; } -async function runInstall(cwd: string, extraEnv: Record = {}) { +async function runInstall(cwd: string, extraEnv: Record = {}, umask?: number) { + const install = ["install", "--verbose", "--linker=hoisted"]; + // The umask is process-wide, so a wrapper process sets it and then runs the install. + const underUmask = (umask: number) => [ + "-e", + `process.umask(${umask}); + const { exitCode } = Bun.spawnSync({ + cmd: [process.execPath, ...${JSON.stringify(install)}], + stdio: ["ignore", "inherit", "inherit"], + }); + process.exit(exitCode ?? 1);`, + ]; await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--verbose", "--linker=hoisted"], + cmd: [bunExe(), ...(umask === undefined ? install : underUmask(umask))], cwd, env: { ...bunEnv, @@ -1036,3 +1047,45 @@ test.concurrent.each([ await new Promise(resolve => server.close(() => resolve())); } }); + +// Like npm (pacote's `fmode`), an install gives every file at least 0o666 +// before the umask, so a package packed with owner-only modes is still readable +// by other users (#14467). Both extractors do it. +test.concurrent.skipIf(isWindows).each([ + ["streaming", {}], + ["buffered", { BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL: "1" }], +] as const)("installs owner-only files readable by every user (%s)", async (label, env) => { + const ownerOnly = Buffer.from(octal(0o600, 8)); + const { tgz, shasum, integrity } = buildTarball([ + { + path: "package.json", + body: Buffer.from(JSON.stringify({ name: "stream-pkg", version: "1.0.0" })), + mode: ownerOnly, + }, + { path: "index.js", body: Buffer.from("module.exports = 'ok';\n"), mode: ownerOnly }, + // Incompressible bulk so the body spans many reads and streaming commits. + { path: "bulk.bin", body: randomBytes(256 * 1024) }, + ]); + await using reg = await makeRegistry(tgz, shasum, integrity, 4096); + + using dir = tempDir("streaming-extract-owner-only", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "stream-pkg": "1.0.0" } }), + "bunfig.toml": Bun.TOML.stringify({ install: { registry: reg.url } }), + }); + + const { stderr, exitCode } = await runInstall(String(dir), { ...env, BUN_INSTALL_STREAMING_MIN_SIZE: "1024" }, 0o022); + expect(stderr).not.toContain("error:"); + if (label === "streaming") { + expect(stderr).toContain("Streamed "); + } else { + expect(stderr).not.toContain("Streamed "); + } + + const pkgRoot = join(String(dir), "node_modules", "stream-pkg"); + const mode = (path: string) => (statSync(join(pkgRoot, path)).mode & 0o777).toString(8); + expect({ "package.json": mode("package.json"), "index.js": mode("index.js") }).toEqual({ + "package.json": "644", + "index.js": "644", + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index 7eef7eaf7420..781ca707c1da 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { existsSync, readdirSync, rmSync } from "node:fs"; -import { join } from "path"; +import { mkfifo } from "mkfifo"; +import fs, { existsSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } 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. @@ -642,6 +644,360 @@ describe("Bun.Archive", () => { expect(JSON.parse(stdout)).toEqual({ count: 3, dir: true, inner: "inner", top: "top" }); expect(exitCode).toBe(0); }); + + const isRoot = !isWindows && process.getuid?.() === 0; + const nobody = (() => { + if (!isRoot) return null; + // /etc/passwd format: name:x:uid:gid:gecos:home:shell. macOS lists + // nobody as -2, which chown does not take. + let passwd: string; + try { + passwd = fs.readFileSync("/etc/passwd", "utf8"); + } catch { + return null; + } + const line = passwd.split("\n").find(l => l.startsWith("nobody:")); + if (!line) return null; + const [, , uid, gid] = line.split(":").map(Number); + return Number.isInteger(uid) && Number.isInteger(gid) && uid >= 0 && gid >= 0 ? { uid, gid } : null; + })(); + const canLockDir = !isWindows && (!isRoot || nobody !== null); + + // The test runner puts TMPDIR in a 0700 directory. `nobody` must reach + // the fixtures, so the ancestors of the temp dir get o+x for the duration. + const widened: [string, number][] = []; + beforeAll(() => { + if (!nobody) return; + for (let p = fs.realpathSync.native(tmpdir()); p !== dirname(p); p = dirname(p)) { + const mode = fs.statSync(p).mode; + if ((mode & 0o011) !== 0o011) { + widened.push([p, mode]); + fs.chmodSync(p, mode | 0o011); + } + } + }); + afterAll(() => { + for (const [p, mode] of widened) fs.chmodSync(p, mode); + }); + + // GNU tar removes a file the destination already holds and creates a new + // one. Writing into the old inode would reach every other name linked to it. + describe.each([ + ["without a glob", undefined], + ["with a glob", { glob: "**" }], + ])("replaces an existing file %s", (_, options) => { + test.skipIf(isWindows)("does not write through a hard link", async () => { + using dir = tempDir("archive-replace-hardlink", { + "store/a.txt": "old", + }); + fs.linkSync(join(String(dir), "store/a.txt"), join(String(dir), "a.txt")); + + const count = await new Bun.Archive({ "a.txt": "new" }).extract(String(dir), options); + + expect(count).toBe(1); + expect(fs.readFileSync(join(String(dir), "a.txt"), "utf8")).toBe("new"); + expect(fs.readFileSync(join(String(dir), "store/a.txt"), "utf8")).toBe("old"); + expect(fs.statSync(join(String(dir), "a.txt")).ino).not.toBe(fs.statSync(join(String(dir), "store/a.txt")).ino); + }); + + test.skipIf(isWindows)("does not write through a symlink", async () => { + using dir = tempDir("archive-replace-symlink", { + "target.txt": "old", + }); + fs.symlinkSync("target.txt", join(String(dir), "a.txt")); + + const count = await new Bun.Archive({ "a.txt": "new" }).extract(String(dir), options); + + expect(count).toBe(1); + expect(fs.lstatSync(join(String(dir), "a.txt")).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(join(String(dir), "a.txt"), "utf8")).toBe("new"); + expect(fs.readFileSync(join(String(dir), "target.txt"), "utf8")).toBe("old"); + }); + + // root can open a read-only file for writing. A write into the old + // file keeps its 0444, the new file has the mode of the entry. + test.skipIf(isWindows)("replaces a read-only file", async () => { + using dir = tempDir("archive-replace-readonly", { + "a.txt": "old", + }); + fs.chmodSync(join(String(dir), "a.txt"), 0o444); + + const count = await new Bun.Archive({ "a.txt": "new" }).extract(String(dir), options); + + expect(count).toBe(1); + expect(fs.readFileSync(join(String(dir), "a.txt"), "utf8")).toBe("new"); + expect(fs.statSync(join(String(dir), "a.txt")).mode & 0o777).toBe(0o644 & ~process.umask()); + }); + + // The old name cannot be removed from a directory the user cannot write. + // Root bypasses that check, so as root the extraction runs in a child + // with the uid of `nobody`. + async function extractInLockedDir(dir: string): Promise<{ count?: number; error?: string }> { + const locked = join(dir, "locked"); + const fixture = join(dir, "fixture.js"); + fs.writeFileSync( + fixture, + `const [dir, glob] = process.argv.slice(2); + new Bun.Archive({ "locked/a.txt": "new" }) + .extract(dir, glob ? { glob } : undefined) + .then(count => console.log(JSON.stringify({ count })), e => console.log(JSON.stringify({ error: e.message })));`, + ); + if (nobody) { + for (const p of fs.readdirSync(dir, { recursive: true })) { + fs.lchownSync(join(dir, String(p)), nobody.uid, nobody.gid); + } + fs.chownSync(dir, nobody.uid, nobody.gid); + } + fs.chmodSync(locked, 0o555); + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture, dir, options?.glob ?? ""], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + ...(nobody ?? {}), + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout); + } finally { + fs.chmodSync(locked, 0o755); + } + } + + function expectLockedFailure(result: { count?: number; error?: string }) { + expect(result).toEqual(options ? { count: 0 } : { error: "ReadError" }); + } + + test.concurrent.skipIf(!canLockDir)("writes in place when the old name cannot be removed", async () => { + using dir = tempDir("archive-replace-in-place", { + "locked/a.txt": "old", + }); + const before = fs.statSync(join(String(dir), "locked/a.txt")).ino; + + expect(await extractInLockedDir(String(dir))).toEqual({ count: 1 }); + + expect(fs.readFileSync(join(String(dir), "locked/a.txt"), "utf8")).toBe("new"); + expect(fs.statSync(join(String(dir), "locked/a.txt")).ino).toBe(before); + }); + + test.concurrent.skipIf(!canLockDir)("does not write through a symlink that cannot be removed", async () => { + using dir = tempDir("archive-replace-locked-symlink", { + "target.txt": "old", + "locked/.keep": "", + }); + fs.symlinkSync("../target.txt", join(String(dir), "locked/a.txt")); + + expectLockedFailure(await extractInLockedDir(String(dir))); + + expect(fs.readFileSync(join(String(dir), "target.txt"), "utf8")).toBe("old"); + }); + + test.concurrent.skipIf(!canLockDir)("does not write through a hard link that cannot be removed", async () => { + using dir = tempDir("archive-replace-locked-hardlink", { + "store/a.txt": "old", + "locked/.keep": "", + }); + fs.linkSync(join(String(dir), "store/a.txt"), join(String(dir), "locked/a.txt")); + + expectLockedFailure(await extractInLockedDir(String(dir))); + + expect(fs.readFileSync(join(String(dir), "store/a.txt"), "utf8")).toBe("old"); + }); + + // Opening a FIFO for writing waits for a reader. The extraction must fail instead. + test.concurrent.skipIf(!canLockDir)("does not wait on a FIFO that cannot be removed", async () => { + using dir = tempDir("archive-replace-locked-fifo", { + "locked/.keep": "", + }); + mkfifo(join(String(dir), "locked/a.txt"), 0o644); + + expectLockedFailure(await extractInLockedDir(String(dir))); + + expect(fs.lstatSync(join(String(dir), "locked/a.txt")).isFIFO()).toBe(true); + }); + + test.skipIf(isWindows)("gives an existing file the mode of the entry", async () => { + const tarball = Buffer.concat([ + ustarHeader("secret.txt", 3, "0", { mode: Buffer.from("0000600\0") }), + Buffer.concat([Buffer.from("new"), Buffer.alloc(512 - 3)]), + Buffer.alloc(1024), + ]); + using dir = tempDir("archive-replace-mode", { + "secret.txt": "old", + }); + fs.chmodSync(join(String(dir), "secret.txt"), 0o644); + + const count = await new Bun.Archive(tarball).extract(String(dir), options); + + expect(count).toBe(1); + expect(fs.readFileSync(join(String(dir), "secret.txt"), "utf8")).toBe("new"); + expect(fs.statSync(join(String(dir), "secret.txt")).mode & 0o777).toBe(0o600); + }); + }); + + // The fixtures below are child processes, because the umask is process-wide. + // Each one runs both extractors: the one without `glob` and the one with it. + const modeField = (mode: number) => Buffer.from(mode.toString(8).padStart(7, "0") + "\0"); + const fileWithMode = (name: string, mode: number, data: string) => [ + ustarHeader(name, data.length, "0", { mode: modeField(mode) }), + Buffer.concat([Buffer.from(data), Buffer.alloc(512 - data.length)]), + ]; + type User = { uid: number; gid: number }; + async function runFixture(dir: string, args: string[], user?: User | null) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "extract.ts", ...args], + env: bunEnv, + cwd: dir, + ...(user ?? {}), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // Both extractors create a file with the permission bits of the entry, and + // the umask applies. `bun install` keeps adding 0o666 (npm's fmode). + test.concurrent.skipIf(isWindows)("creates files with the mode of the entry, with and without a glob", async () => { + const members = [ + // mode in the archive, mode on disk under umask 022, under umask 000 + [0o600, "0600", "0600"], + [0o640, "0640", "0640"], + [0o644, "0644", "0644"], + [0o700, "0700", "0700"], + [0o755, "0755", "0755"], + [0o666, "0644", "0666"], + // A read-only member stays read-only. See the next test. + [0o400, "0400", "0400"], + [0o444, "0444", "0444"], + // setuid, setgid and sticky are dropped. + [0o4755, "0755", "0755"], + [0o2755, "0755", "0755"], + [0o1755, "0755", "0755"], + // No permission bits, with or without a special bit: the mode Bun.Archive itself writes. + [0, "0644", "0644"], + [0o4000, "0644", "0644"], + ] as const; + const name = (mode: number) => "f" + mode.toString(8).padStart(4, "0"); + using dir = tempDir("archive-extract-file-modes", { + "input.tar": Buffer.concat([ + ...members.flatMap(([mode]) => fileWithMode(name(mode), mode, "x")), + // No directory entry comes first. The extractor without `glob` opens such + // a file a second time, after it creates the parent directory. + ...fileWithMode("new/dir/f0600", 0o600, "x"), + Buffer.alloc(1024), + ]), + "extract.ts": ` + import { readdirSync, readFileSync, statSync } from "node:fs"; + const tarball = readFileSync("input.tar"); + const modes = (root: string) => + Object.fromEntries( + readdirSync(root, { recursive: true }) + .map(name => [String(name), statSync(root + "/" + name)] as const) + .filter(([, stat]) => stat.isFile()) + .map(([name, stat]) => [name, (stat.mode & 0o7777).toString(8).padStart(4, "0")]), + ); + const result: Record = {}; + for (const umask of ["022", "000"]) { + process.umask(parseInt(umask, 8)); + await new Bun.Archive(tarball).extract("plain-" + umask); + await new Bun.Archive(tarball).extract("glob-" + umask, { glob: "**" }); + result[umask] = { plain: modes("plain-" + umask), glob: modes("glob-" + umask) }; + } + console.log(JSON.stringify(result)); + `, + }); + + const { stdout, stderr, exitCode } = await runFixture(String(dir), []); + + const expected = (column: 1 | 2) => { + const modes = { + ...Object.fromEntries(members.map(member => [name(member[0]), member[column]])), + "new/dir/f0600": "0600", + }; + return { plain: modes, glob: modes }; + }; + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ "022": expected(1), "000": expected(2) }); + expect(exitCode).toBe(0); + }); + + // These two tests extract `file.txt` a second time, over the file that the + // first extraction created. Argument 1 of the fixture is the umask, argument + // 2 the archive. The fixture creates each destination itself, as `mkdir` does: + // the umask decides who can write to it. + const overwriteFixture = (mode: number) => ({ + "v1.tar": Buffer.concat([...fileWithMode("file.txt", mode, "v1"), Buffer.alloc(1024)]), + "v2.tar": Buffer.concat([...fileWithMode("file.txt", mode, "v2"), Buffer.alloc(1024)]), + "extract.ts": ` + import { mkdirSync, readFileSync, statSync } from "node:fs"; + process.umask(parseInt(process.argv[2], 8)); + const tarball = readFileSync(process.argv[3]); + const result: Record = {}; + for (const [dest, options] of [["plain", undefined], ["glob", { glob: "**" }]] as const) { + mkdirSync(dest, { recursive: true }); + const count = await new Bun.Archive(tarball).extract(dest, options); + const stat = statSync(dest + "/file.txt"); + result[dest] = { + count, + content: readFileSync(dest + "/file.txt", "utf8"), + mode: (stat.mode & 0o777).toString(8), + uid: stat.uid, + }; + } + console.log(JSON.stringify(result)); + `, + }); + // Every user can read the fixture files, whatever the umask of the test run is. + const giveTo = (user: User, dir: string) => { + fs.chownSync(dir, user.uid, user.gid); + for (const entry of readdirSync(dir)) { + fs.chownSync(join(dir, entry), user.uid, user.gid); + fs.chmodSync(join(dir, entry), 0o644); + } + }; + const extracted = (content: string, mode: string, uid: number) => { + const one = { count: 1, content, mode, uid }; + return { stdout: JSON.stringify({ plain: one, glob: one }) + "\n", stderr: "", exitCode: 0 }; + }; + + // Permission bits do not bind root, so as root the fixture runs as `nobody`. + // Windows makes a file read-only when its mode has no owner write bit, so + // there the extractors do not pass the mode of the entry. + test.concurrent.skipIf(isRoot && !nobody)( + "a second extract() replaces a file the archive marks read-only", + async () => { + using dir = tempDir("archive-extract-read-only", overwriteFixture(0o444)); + if (nobody) giveTo(nobody, String(dir)); + const mode = isWindows ? "666" : "444"; + const uid = nobody?.uid ?? process.getuid?.() ?? 0; + + expect(await runFixture(String(dir), ["022", "v1.tar"], nobody)).toEqual(extracted("v1", mode, uid)); + expect(await runFixture(String(dir), ["022", "v2.tar"], nobody)).toEqual(extracted("v2", mode, uid)); + }, + ); + + // The second user cannot write to the file of the first user, but can remove + // it from a directory that the group can write. GNU tar does the same. + test.concurrent.skipIf(!nobody)( + "a second user of the group replaces a file in a directory the group can write", + async () => { + using dir = tempDir("archive-extract-shared-directory", overwriteFixture(0o644)); + const firstUser = nobody!; + const secondUser = { uid: firstUser.uid - 1, gid: firstUser.gid }; + giveTo(firstUser, String(dir)); + fs.chmodSync(String(dir), 0o770); + + expect(await runFixture(String(dir), ["002", "v1.tar"], firstUser)).toEqual( + extracted("v1", "644", firstUser.uid), + ); + expect(await runFixture(String(dir), ["002", "v2.tar"], secondUser)).toEqual( + extracted("v2", "644", secondUser.uid), + ); + }, + ); }); describe("corrupted archives", () => {