@@ -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