Skip to content

Commit aab1304

Browse files
committed
fix(bun2nix): resolve nested file-dep paths against their workspace dir
A "<workspace-name>/<pkg>" lockfile entry records its file-dependency path relative to that workspace's directory, but bun.nix paths resolve from the project root — so a vendored tarball under packages/app rendered as ./vendor/pkg.tgz (missing) and a sibling workspace's ../app/vendor/pkg.tgz escaped the root entirely. Factor package building out of convert_lockfile_to_nix_expression into build_packages, and rewrite CopyToStore paths for entries nested under a workspace (longest workspace-name prefix wins) with lexical ././.. normalization.
1 parent fc63b56 commit aab1304

1 file changed

Lines changed: 105 additions & 1 deletion

File tree

programs/bun2nix/src/lib.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,119 @@ use wasm_bindgen::prelude::*;
2424
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
2525
#[cfg_attr(target_arch = "wasm32", no_mangle)]
2626
pub fn convert_lockfile_to_nix_expression(contents: String, options: Options) -> Result<String> {
27+
let packages = build_packages(&contents)?;
28+
29+
NixExpression::new(packages)?.render_with_options(options)
30+
}
31+
32+
/// # Build Packages from a Lockfile
33+
///
34+
/// Parses a bun lockfile and produces the sorted, de-duplicated list of
35+
/// [`Package`]s it describes.
36+
pub fn build_packages(contents: &str) -> Result<Vec<Package>> {
2737
let lockfile = contents.parse::<Lockfile>()?;
2838

2939
if lockfile.lockfile_version != 1 {
3040
return Err(Error::UnsupportedLockfileVersion(lockfile.lockfile_version));
3141
};
3242

43+
// Workspace name → directory, for resolving nested file-dependency paths:
44+
// bun records a `<workspace-name>/<pkg>` entry's path relative to that
45+
// workspace's directory, but `bun.nix` paths resolve from the project root.
46+
let workspace_dirs: Vec<(String, String)> = lockfile
47+
.workspaces
48+
.iter()
49+
.filter_map(|(dir, ws)| ws.name.clone().map(|name| (name, dir.clone())))
50+
.collect();
51+
3352
let mut packages = lockfile.packages();
3453
packages.sort();
3554
packages.dedup_by(|a, b| a.name == b.name);
3655

37-
NixExpression::new(packages)?.render_with_options(options)
56+
for package in &mut packages {
57+
if let package::Fetcher::CopyToStore { path } = &mut package.fetcher {
58+
// Longest matching `<workspace-name>/` prefix wins; entries whose
59+
// key IS a workspace name (the workspaces themselves) don't match.
60+
let parent_dir = workspace_dirs
61+
.iter()
62+
.filter(|(name, _)| {
63+
!name.is_empty()
64+
&& package.name.len() > name.len() + 1
65+
&& package.name.starts_with(name.as_str())
66+
&& package.name.as_bytes()[name.len()] == b'/'
67+
})
68+
.max_by_key(|(name, _)| name.len())
69+
.map(|(_, dir)| dir.as_str());
70+
if let Some(dir) = parent_dir
71+
&& !dir.is_empty()
72+
{
73+
*path = normalize_path(&format!("{dir}/{path}"));
74+
}
75+
}
76+
}
77+
78+
Ok(packages)
79+
}
80+
81+
/// Collapse `.` and `..` segments lexically (`a/b/../c` → `a/c`).
82+
fn normalize_path(path: &str) -> String {
83+
let mut parts: Vec<&str> = Vec::new();
84+
for seg in path.split('/') {
85+
match seg {
86+
"" | "." => {}
87+
".." => {
88+
if parts.last().is_none_or(|last| *last == "..") {
89+
parts.push("..");
90+
} else {
91+
parts.pop();
92+
}
93+
}
94+
s => parts.push(s),
95+
}
96+
}
97+
parts.join("/")
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::*;
103+
104+
// A vendored tarball nested under a workspace ("<ws-name>/<pkg>") is
105+
// recorded relative to the workspace dir; bun.nix needs it root-relative.
106+
#[test]
107+
fn nested_file_dep_paths_resolve_against_workspace_dir() {
108+
let lock = r#"{
109+
"lockfileVersion": 1,
110+
"workspaces": {
111+
"": { "name": "root" },
112+
"packages/app": { "name": "@oc/app" },
113+
"packages/ui": { "name": "@oc/ui" }
114+
},
115+
"packages": {
116+
"@oc/app": ["@oc/app@workspace:packages/app"],
117+
"@oc/ui": ["@oc/ui@workspace:packages/ui"],
118+
"@oc/app/@oc/client": ["@oc/client@vendor/client-1.0.0.tgz", {}, "sha512-AAAA"],
119+
"@oc/ui/@oc/client": ["@oc/client@../app/vendor/client-1.0.0.tgz", {}, "sha512-AAAA"],
120+
}
121+
}"#;
122+
let pkgs = build_packages(lock).unwrap();
123+
let path_of = |name: &str| {
124+
let p = pkgs.iter().find(|p| p.name == name).unwrap();
125+
match &p.fetcher {
126+
package::Fetcher::CopyToStore { path } => path.clone(),
127+
other => panic!("expected CopyToStore, got {other:?}"),
128+
}
129+
};
130+
131+
assert_eq!(
132+
path_of("@oc/app/@oc/client"),
133+
"packages/app/vendor/client-1.0.0.tgz"
134+
);
135+
assert_eq!(
136+
path_of("@oc/ui/@oc/client"),
137+
"packages/app/vendor/client-1.0.0.tgz"
138+
);
139+
// Workspace entries themselves stay untouched.
140+
assert_eq!(path_of("@oc/app"), "packages/app");
141+
}
38142
}

0 commit comments

Comments
 (0)