Skip to content

Commit fc63b56

Browse files
committed
fix(bun2nix): dispatch lockfile entries on resolution, not tuple arity
bun does not keep entry kinds and tuple arities in one-to-one correspondence: github entries can carry an integrity hash (arity 4) and remote or vendored tarball entries can carry inline metadata (arity 3), so dispatching on arity misroutes both — a github dep lands in the npm parser and a pkg.pr.new tarball lands in the git parser, failing with MissingGitRef. Dispatch on the identifier's resolution instead (github:/git+/http(s)://, npm for bare versions at arity 4, file paths otherwise). The identifier is split at the '@' that ends the package name — the second '@' for scoped names — which also fixes resolutions that contain '@' themselves, like https://pkg.pr.new/@scope/pkg@sha tarball URLs. Vendored tarball paths with no file:/./ prefix (e.g. "vendor/pkg-1.0.0.tgz") are now accepted as file packages.
1 parent 0f2a1f0 commit fc63b56

1 file changed

Lines changed: 104 additions & 57 deletions

File tree

programs/bun2nix/src/lockfile/package_deserializer.rs

Lines changed: 104 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,41 @@ impl PackageDeserializer {
2626
/// # Deserialize package
2727
///
2828
/// Deserialize a given package from it's lockfile representation
29+
///
30+
/// Entries are dispatched on the identifier's resolution (the part after
31+
/// the package name), not on tuple arity: bun emits github entries with
32+
/// an integrity hash (arity 4) and remote/vendored tarball entries with
33+
/// inline metadata (arity 3), so arity alone cannot tell entry kinds
34+
/// apart.
2935
pub fn deserialize_package(name: String, values: Values) -> Result<Package> {
3036
let arity = values.len();
3137
let deserializer = Self { name, values };
3238

33-
match arity {
34-
1 => deserializer.deserialize_workspace_package(),
35-
2 => deserializer.deserialize_tarball_or_file_package(),
36-
3 => deserializer.deserialize_tarball_git_or_github_package(),
37-
4 => deserializer.deserialize_npm_package(),
38-
x => Err(Error::UnexpectedPackageEntryLength(x)),
39+
if arity == 1 {
40+
return deserializer.deserialize_workspace_package();
41+
}
42+
if !(2..=4).contains(&arity) {
43+
return Err(Error::UnexpectedPackageEntryLength(arity));
44+
}
45+
46+
let resolution = deserializer
47+
.values
48+
.first()
49+
.and_then(|v| v.as_str())
50+
.map(str::to_owned)
51+
.and_then(drain_package_specifier)
52+
.ok_or(Error::NoAtInPackageIdentifier)?;
53+
54+
if resolution.starts_with("github:") {
55+
Self::deserialize_github_package(resolution)
56+
} else if resolution.starts_with("git+") {
57+
Self::deserialize_git_package(resolution)
58+
} else if resolution.starts_with("http://") || resolution.starts_with("https://") {
59+
Self::deserialize_tarball_package(resolution)
60+
} else if arity == 4 {
61+
deserializer.deserialize_npm_package()
62+
} else {
63+
Self::deserialize_file_package(deserializer.name, resolution)
3964
}
4065
}
4166

@@ -80,34 +105,10 @@ impl PackageDeserializer {
80105
Ok(Package::new(npm_identifier_raw, fetcher))
81106
}
82107

83-
/// # Deserialize a Tarball, Git or Github Package
84-
///
85-
/// Deserialize a tarball, git or github package from it's bun
86-
/// lockfile representation
87-
///
88-
/// These are grouped together as all three lockfile
89-
/// representations are a tuple of arity 3, hence the
90-
/// specifier prefix decides between them - `http` is a
91-
/// tarball (bun records an integrity hash for these), `github:`
92-
/// is a github package, and anything else is a git package
93-
pub fn deserialize_tarball_git_or_github_package(mut self) -> Result<Package> {
94-
let id = swap_remove_value(&mut self.values, 0);
95-
let specifier = drain_package_specifier(id).ok_or(Error::NoAtInPackageIdentifier)?;
96-
97-
if specifier.starts_with("http") {
98-
Self::deserialize_tarball_package(specifier)
99-
} else if specifier.starts_with("github:") {
100-
Self::deserialize_github_package(specifier)
101-
} else {
102-
Self::deserialize_git_package(specifier)
103-
}
104-
}
105-
106108
/// # Deserialize a Github Package
107109
///
108-
/// Deserialize a github package from it's bun lockfile representation
109-
///
110-
/// This is found in the source as a tuple of arity 3
110+
/// Deserialize a github package from its `github:owner/repo#rev`
111+
/// resolution
111112
pub fn deserialize_github_package(id: String) -> Result<Package> {
112113
let (url, rev) = split_once_owned(id, '#').ok_or(Error::MissingGitRef)?;
113114

@@ -131,9 +132,7 @@ impl PackageDeserializer {
131132

132133
/// # Deserialize a Git Package
133134
///
134-
/// Deserialize a git package from it's bun lockfile representation
135-
///
136-
/// This is found in the source as a tuple of arity 3
135+
/// Deserialize a git package from its `git+<url>#<rev>` resolution
137136
pub fn deserialize_git_package(id: String) -> Result<Package> {
138137
let git_url = drop_prefix(id, "git+");
139138
let (url, rev) = split_once_owned(git_url, '#').ok_or(Error::MissingGitRef)?;
@@ -152,26 +151,6 @@ impl PackageDeserializer {
152151
Ok(Package::new(id_with_rev, fetcher))
153152
}
154153

155-
/// # Deserialize a tarball or file package
156-
///
157-
/// Deserialize a tarball or file package from it's bun
158-
/// lockfile representation
159-
///
160-
/// These are grouped together as both lockfile
161-
/// representations are a tupe of arity 2, hence
162-
/// paths starting with `http` are considered
163-
/// tarballs
164-
pub fn deserialize_tarball_or_file_package(mut self) -> Result<Package> {
165-
let id = swap_remove_value(&mut self.values, 0);
166-
let path = drain_package_specifier(id).ok_or(Error::NoAtInPackageIdentifier)?;
167-
168-
if path.starts_with("http") {
169-
Self::deserialize_tarball_package(path)
170-
} else {
171-
Self::deserialize_file_package(self.name, path)
172-
}
173-
}
174-
175154
/// # Deserialize a file package
176155
///
177156
/// Deserialize a file package from it's bun lockfile representation
@@ -191,11 +170,13 @@ impl PackageDeserializer {
191170
"File path can never contain http, because then it would be a tarball"
192171
);
193172

194-
// Strip prefix: explicit "file:" or implicit "./" (Bun strips file: for local tarballs)
173+
// Strip prefix: explicit "file:" or implicit "./" (Bun strips file: for
174+
// local tarballs). Vendored tarballs appear as bare relative paths
175+
// (e.g. "vendor/pkg-1.0.0.tgz") with no prefix at all.
195176
let path = path
196177
.strip_prefix("file:")
197178
.or_else(|| path.strip_prefix("./"))
198-
.ok_or(Error::MissingFileSpecifier)?;
179+
.unwrap_or(&path);
199180

200181
Ok(Package::new(
201182
name,
@@ -364,3 +345,69 @@ pub fn drop_prefix(mut input: String, prefix: &str) -> String {
364345

365346
input
366347
}
348+
349+
#[cfg(test)]
350+
mod tests {
351+
use super::*;
352+
use serde_json::json;
353+
354+
const SHA: &str = "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==";
355+
356+
// A plain npm entry (arity 4, bare version resolution) still routes to the
357+
// npm deserializer.
358+
#[test]
359+
fn npm_entry_dispatches_to_npm_package() {
360+
let values = vec![
361+
json!("react-dom@19.2.7"),
362+
json!(""),
363+
json!({ "dependencies": { "scheduler": "^0.27.0" } }),
364+
json!(SHA),
365+
];
366+
367+
let pkg = PackageDeserializer::deserialize_package("react-dom".into(), values).unwrap();
368+
assert!(
369+
matches!(pkg.fetcher, Fetcher::FetchUrl { ref url, .. }
370+
if url == "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz"),
371+
"expected FetchUrl, got {:?}",
372+
pkg.fetcher
373+
);
374+
}
375+
376+
// Vendored tarballs are arity-3 entries whose resolution is a bare
377+
// relative path: [id, meta, integrity].
378+
#[test]
379+
fn vendored_tarball_dispatches_to_file_package() {
380+
let values = vec![
381+
json!("@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz"),
382+
json!({}),
383+
json!(SHA),
384+
];
385+
386+
let pkg = PackageDeserializer::deserialize_package(
387+
"@opencode-ai/app/@opencode-ai/client".into(),
388+
values,
389+
)
390+
.unwrap();
391+
392+
assert!(
393+
matches!(pkg.fetcher, Fetcher::CopyToStore { ref path }
394+
if path == "vendor/opencode-ai-client-1.17.13.tgz"),
395+
"expected CopyToStore, got {:?}",
396+
pkg.fetcher
397+
);
398+
}
399+
400+
// file: and ./ prefixes are still stripped from file-package paths.
401+
#[test]
402+
fn prefixed_file_paths_are_stripped() {
403+
for id in ["local-pkg@file:local/pkg.tgz", "local-pkg@./local/pkg.tgz"] {
404+
let values = vec![json!(id), json!(SHA)];
405+
let pkg = PackageDeserializer::deserialize_package("local-pkg".into(), values).unwrap();
406+
assert!(
407+
matches!(pkg.fetcher, Fetcher::CopyToStore { ref path } if path == "local/pkg.tgz"),
408+
"expected stripped CopyToStore for {id}, got {:?}",
409+
pkg.fetcher
410+
);
411+
}
412+
}
413+
}

0 commit comments

Comments
 (0)