Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
53 changes: 51 additions & 2 deletions src/paths/component_iterator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ pub enum MakePathStep<E> {
Created,
/// Directory already exists (`EEXIST`). Walk advances forward.
Exists,
/// A parent is missing (`ENOENT`). Walk steps back one component;
/// if there is no previous component the carried error is returned.
/// A parent is missing (`ENOENT`). Returns the error if the walk has
/// already advanced or there is no previous component; else steps back.
Comment thread
robobun marked this conversation as resolved.
NotFound(E),
}

Expand All @@ -217,14 +217,17 @@ pub fn make_path_with<'a, T: PathChar, E>(
let Some(mut comp) = it.last() else {
return Ok(());
};
let mut advanced = false;
loop {
match mkdir(comp.path)? {
MakePathStep::Created | MakePathStep::Exists => {
advanced = true;
comp = match it.next() {
Some(c) => c,
None => return Ok(()),
};
}
MakePathStep::NotFound(e) if advanced => return Err(e),
Comment thread
robobun marked this conversation as resolved.
MakePathStep::NotFound(e) => {
comp = match it.previous() {
Some(c) => c,
Expand Down Expand Up @@ -389,4 +392,50 @@ mod tests {
assert_eq!(it.next().unwrap().name, b"c");
assert!(it.next().is_none());
}

#[test]
fn make_path_terminates_when_parent_exists_but_child_is_enoent() {
// `/a/b` is a dangling symlink: EEXIST itself, ENOENT for any child.
let it = ComponentIterator::init(&b"/a/b/c"[..], PathFormat::Posix).unwrap();
let mut calls = 0u32;
let r = make_path_with(it, |p| {
calls += 1;
assert!(calls < 100, "runaway loop");
match p {
b"/a" | b"/a/b" => Ok(MakePathStep::Exists),
b"/a/b/c" => Ok(MakePathStep::NotFound(())),
_ => unreachable!(),
}
});
assert!(r.is_err());
// leaf ENOENT → parent EEXIST → leaf ENOENT → stop.
assert_eq!(calls, 3);
}

#[test]
fn make_path_walks_back_then_forward() {
// `/a` exists, `/a/b` and `/a/b/c` do not.
let it = ComponentIterator::init(&b"/a/b/c"[..], PathFormat::Posix).unwrap();
let mut created: Vec<&[u8]> = vec![];
let mut calls = 0u32;
let r = make_path_with::<u8, ()>(it, |p| {
calls += 1;
assert!(calls < 100, "runaway loop");
if p == b"/a" {
return Ok(MakePathStep::Exists);
}
if created.iter().any(|c| *c == p) {
return Ok(MakePathStep::Exists);
}
let parent = &p[..p.iter().rposition(|b| *b == b'/').unwrap()];
if parent == b"/a" || created.iter().any(|c| *c == parent) {
created.push(p);
Ok(MakePathStep::Created)
} else {
Ok(MakePathStep::NotFound(()))
}
});
assert!(r.is_ok());
assert_eq!(created, vec![&b"/a/b"[..], &b"/a/b/c"[..]]);
}
}
38 changes: 38 additions & 0 deletions test/cli/install/bun-install-cache-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { symlinkSync } from "node:fs";
import { join } from "node:path";

describe.skipIf(isWindows)("BUN_INSTALL_CACHE_DIR inside a dangling symlink", () => {

Check warning on line 6 in test/cli/install/bun-install-cache-dir.test.ts

View check run for this annotation

Claude / Claude Code Review

Windows skip lacks a reason comment

The Windows-skip comment that was here in an earlier revision was removed in dcf86045 in response to CodeRabbit's "regression tests get exactly the issue URL" rule — but that rule only applies to `test/regression/issue/*.test.ts` (per CodeRabbit's own stored learning quoted later in the same thread), not to `test/cli/install/`. A one-liner like `// symlinkSync needs elevation on Windows; dangling-symlink semantics differ` is permitted here and would match neighboring precedent (`bun-lock.test.ts
Comment thread
robobun marked this conversation as resolved.
Outdated
test("bun install exits instead of spinning in mkdirat", async () => {
using dir = tempDir("cache-dir-dangling", {
"proj/package.json": JSON.stringify({
name: "x",
version: "1.0.0",
dependencies: { "any-pkg": "1.0.0" },
}),
});
const root = String(dir);
symlinkSync(join(root, "does-not-exist"), join(root, "dangling"));

await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: join(root, "proj"),
env: {
...bunEnv,
BUN_INSTALL_CACHE_DIR: join(root, "dangling", "cache"),
BUN_CONFIG_REGISTRY: "http://127.0.0.1:1/",
},
stdout: "pipe",
stderr: "pipe",
timeout: 15_000,
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect({ signalCode: proc.signalCode, stdout, stderr }).toMatchObject({
signalCode: null,
});
expect(stderr).toContain("error");
expect(exitCode).not.toBe(0);
});
});
Loading