Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
77b8331
archive: replace an existing file on extract instead of writing into it
robobun Sep 17, 2026
18ff810
archive: write in place when the old name cannot be removed, use the …
robobun Sep 17, 2026
7d540f4
archive: shorten two comments
robobun Sep 17, 2026
c998690
archive: one-line doc comment on create_entry_file
robobun Sep 17, 2026
88ffc17
archive: do not follow a symlink when the in-place fallback opens it
robobun Sep 17, 2026
a82b19c
archive test: await the rejection of the locked symlink case
robobun Sep 17, 2026
c730a25
archive: the in-place fallback truncates only a regular file with one…
robobun Sep 17, 2026
63dbc11
archive: one-line doc comment on truncate_entry_file
robobun Sep 17, 2026
523972f
archive: open the in-place fallback with O_CREAT and O_NONBLOCK
robobun Sep 17, 2026
e740cac
archive: one-line comment on the fallback open flags
robobun Sep 17, 2026
49aacab
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 17, 2026
14d3eeb
archive test: run the locked directory cases as nobody when root
robobun Sep 17, 2026
71d99d5
archive test: drop to nobody with the uid and gid spawn options
robobun Sep 17, 2026
405b45c
ci: retrigger
robobun Sep 17, 2026
be1c186
archive test: make the locked directory cases concurrent and robust a…
robobun Sep 17, 2026
cd783cb
archive test: widen the temp dir ancestors once and restore them
robobun Sep 17, 2026
ca56e2b
archive: with a glob on Windows, do not pass the entry mode to the open
robobun Sep 18, 2026
e5e2fbd
archive: take the file mode tests, the install mode test and the docs…
robobun Sep 18, 2026
8a45d64
types: describe the in-place fallback in the Archive#extract JSDoc
robobun Sep 18, 2026
23ad1c6
archive: scope the docs text on special bits to files, cover a mode w…
robobun Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/runtime/archive.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions src/install/TarballStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1388,10 +1387,10 @@ fn open_output_file(
path_slice: &[OSPathChar],
mode: Mode,
) -> crate::Result<Fd> {
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() {
Expand All @@ -1409,15 +1408,15 @@ 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: {
let Some(dir) = bun_paths::dirname(path_slice) else {
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()),
Expand Down
95 changes: 73 additions & 22 deletions src/libarchive/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fd> {
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),
Comment thread
robobun marked this conversation as resolved.
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<Fd> {
// 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).
Expand Down Expand Up @@ -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 {
Expand All @@ -1230,6 +1285,7 @@ pub mod archiver {
close_handles: true,
log: false,
npm: false,
file_readers: super::FileReaders::Everyone,
}
}
}
Expand Down Expand Up @@ -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_;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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) {
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
Ok(fd) => fd,
Err(err) => match err.get_errno() {
bun_sys::E::EACCES
Expand All @@ -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()),
},
Expand Down
23 changes: 13 additions & 10 deletions src/runtime/api/Archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ impl ExtractContext {
close_handles: true,
log: false,
npm: false,
file_readers: libarchive::FileReaders::FromEntry,
},
) {
Ok(c) => c,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Comment thread
robobun marked this conversation as resolved.
};
Expand Down
71 changes: 62 additions & 9 deletions test/cli/install/bun-install-streaming-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -237,9 +237,20 @@ async function makeRegistry(tgz: Buffer, shasum: string, integrity: string, chun
};
}

async function runInstall(cwd: string, extraEnv: Record<string, string> = {}) {
async function runInstall(cwd: string, extraEnv: Record<string, string> = {}, 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,
Expand Down Expand Up @@ -1036,3 +1047,45 @@ test.concurrent.each([
await new Promise<void>(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) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
});
Loading
Loading