Skip to content
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5cd0096
install: refuse local tarball dependencies declared by packages insta…
robobun Aug 15, 2026
e5bd082
test: cover a peer dependency on a local tarball declared by a regist…
robobun Aug 15, 2026
036e62b
install: shorten the doc comments on the local tarball guard
robobun Aug 15, 2026
ecf64ac
install: honour a local tarball edge the root package.json declares i…
robobun Aug 15, 2026
2fef11d
install: share the local-package predicate with the folder guard and …
robobun Oct 1, 2026
1c50ad8
install: shorten the doc comment on is_local_package_id
robobun Oct 1, 2026
84e806c
install: drop the second sentence of the is_local_package_id doc comment
robobun Oct 1, 2026
b9011db
install: use is_dependency_of_local_package in the local tarball guard
robobun Oct 1, 2026
01531a7
install: import DependencyExt in lockfile.rs on its own line
robobun Oct 1, 2026
f391aac
install: drop the parse errors of an ignored lockfile on every log level
robobun Oct 1, 2026
68ad1d4
test: read and check stderr in the --silent broken lockfile test
robobun Oct 1, 2026
c483d0c
install: read a local tarball's own file: tarballs from next to it, a…
robobun Oct 1, 2026
735f12a
install: shorten two comments on the local tarball guard
robobun Oct 1, 2026
ca333f5
install: store where a local tarball's own file: tarball is, from the…
robobun Oct 1, 2026
0c3890c
test: local tarball locations in bun.lock, a bundled edge, and in-pro…
robobun Oct 1, 2026
2586207
install: shorten two comments on locate_next_to_declaring_tarball
robobun Oct 1, 2026
0c7334a
install: locate a local tarball's tarball as enqueue_local_tarball re…
robobun Oct 1, 2026
4258897
test: cover a workspace tarball from the root catalog and from an abs…
robobun Oct 1, 2026
32e0e44
install: find the declaring tarball's edge by its declared path, not …
robobun Oct 1, 2026
517596a
install: shorten a comment, and cover bun update of a workspace tarba…
robobun Oct 1, 2026
20f1224
test: run the update of the workspace tarball chain from the workspace
robobun Oct 1, 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
143 changes: 131 additions & 12 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1706,13 +1706,33 @@ pub fn enqueue_dependency_with_main_and_success_fn(
}
dependency::version::Tag::Tarball => {
let tarball = version.tarball();
let res: Resolution = match &tarball.uri {
dependency::tarball::Uri::Local(path) => {
Resolution::init(ResolutionTagged::LocalTarball(*path))
// Only a package.json read from the project may name a tarball in the project.
if matches!(tarball.uri, dependency::tarball::Uri::Local(_))
&& !version_was_replaced
Comment thread
robobun marked this conversation as resolved.
Resolved
&& !this.lockfile.is_dependency_of_local_package(id)
&& !this.lockfile.has_equal_root_dependency(dependency)
&& let Some(declarer) = this.lockfile.get_parent_pkg_of_dependency(id)
Comment thread
robobun marked this conversation as resolved.
Resolved
&& this.lockfile.packages.items_resolution()[declarer as usize].tag
!= ResolutionTag::LocalTarball
{
Comment thread
robobun marked this conversation as resolved.
Resolved
if dependency.behavior.is_required() {
reject_local_tarball_of_remote_package(this, declarer, dependency);
}
return Ok(());
Comment thread
robobun marked this conversation as resolved.
Resolved
}
Comment thread
robobun marked this conversation as resolved.
Resolved
let local_path: Option<SemverString> = match &tarball.uri {
dependency::tarball::Uri::Local(path) if !version_was_replaced => {
Some(locate_next_to_declaring_tarball(this, id, *path).unwrap_or(*path))
}
dependency::tarball::Uri::Remote(url) => {
dependency::tarball::Uri::Local(path) => Some(*path),
dependency::tarball::Uri::Remote(_) => None,
};
let res: Resolution = match (&tarball.uri, local_path) {
(_, Some(path)) => Resolution::init(ResolutionTagged::LocalTarball(path)),
(dependency::tarball::Uri::Remote(url), None) => {
Resolution::init(ResolutionTagged::RemoteTarball(*url))
}
(dependency::tarball::Uri::Local(_), None) => unreachable!(),
};

// First: see if we already loaded the tarball package in-memory
Expand All @@ -1727,9 +1747,10 @@ pub fn enqueue_dependency_with_main_and_success_fn(
// SAFETY: the enqueue callees copy `url` into the filename store
// before any `string_bytes` resize.
let url = unsafe {
detach_lifetime(match &tarball.uri {
dependency::tarball::Uri::Local(path) => this.lockfile.str(path),
dependency::tarball::Uri::Remote(url) => this.lockfile.str(url),
detach_lifetime(match (&tarball.uri, &local_path) {
(_, Some(path)) => this.lockfile.str(path),
(dependency::tarball::Uri::Remote(url), None) => this.lockfile.str(url),
(dependency::tarball::Uri::Local(_), None) => unreachable!(),
})
};
let task_id = Task::Id::for_tarball(url);
Expand Down Expand Up @@ -1852,6 +1873,42 @@ fn warn_unmet_peer_dependency(
);
}

/// `enqueue_local_tarball` would read the path relative to the project, not to the declarer.
#[cold]
#[inline(never)]
fn reject_local_tarball_of_remote_package(
this: &PackageManager,
declarer: PackageID,
dependency: &Dependency,
) {
let buf = this.lockfile.buffers.string_bytes.as_slice();
let packages = this.lockfile.packages.slice();
let name = bstr::BStr::new(dependency.name.slice(buf));
let literal = bstr::BStr::new(dependency.version.literal.slice(buf));
let declarer_name = bstr::BStr::new(packages.items_name()[declarer as usize].slice(buf));
// A bundled dependency ships inside the declaring package. The root cannot stand in for it.
let notes: Box<[bun_ast::Data]> = if dependency.behavior.is_bundled() {
Box::new([])
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
} else {
Box::new([bun_ast::range_data(
None,
bun_ast::Range::NONE,
bun_ast::alloc_print(format_args!(
"add \"{name}\": \"{literal}\" to the root package.json to install that tarball for {declarer_name} as well",
)),
)])
};
this.log_mut().add_range_error_fmt_with_notes(
None,
bun_ast::Range::NONE,
notes,
format_args!(
"refusing to resolve \"{name}@{literal}\" declared by {declarer_name}@{}: local tarball dependencies are only allowed in the package.json files of this project",
packages.items_resolution()[declarer as usize].fmt(buf, bun_fmt::PathSep::Posix),
),
);
}

/// Allocate and initialise an `.extract` Task for an npm tarball.
/// Shared by the buffered path (`enqueueExtractNPMPackage`) and the
/// streaming path (`createExtractTaskForStreaming`) so both produce
Expand Down Expand Up @@ -2209,13 +2266,75 @@ fn local_tarball_base_dir<'a>(
}

let declarer = lockfile.get_parent_pkg_of_dependency(dependency_id)?;
let declarer_res = &lockfile.packages.items_resolution()[declarer as usize];
let base_dir = match declarer_res.tag {
ResolutionTag::Workspace => declarer_res.workspace(),
ResolutionTag::Folder => declarer_res.folder(),
local_package_dir(lockfile, declarer)
Comment thread
robobun marked this conversation as resolved.
Resolved
}

/// The workspace or `file:` folder directory of `package_id`; `None` for every other package.
fn local_package_dir(lockfile: &Lockfile::Lockfile, package_id: PackageID) -> Option<&[u8]> {
let res = &lockfile.packages.items_resolution()[package_id as usize];
let dir = match res.tag {
ResolutionTag::Workspace => res.workspace(),
ResolutionTag::Folder => res.folder(),
_ => return None,
};
Some(lockfile.str(base_dir))
Some(lockfile.str(dir))
}

/// A local tarball's own `file:` tarballs are next to it, like npm. Returns that location from the top-level dir.
fn locate_next_to_declaring_tarball(
this: &mut PackageManager,
dependency_id: DependencyID,
declared: SemverString,
) -> Option<SemverString> {
let lockfile = &this.lockfile;
let declarer = lockfile.get_parent_pkg_of_dependency(dependency_id)?;
let declarer_res = &lockfile.packages.items_resolution()[declarer as usize];
if declarer_res.tag != ResolutionTag::LocalTarball {
return None;
}
let declared_path = lockfile.str(&declared);
if bun_paths::is_absolute(declared_path) {
return None;
}
// Where `enqueue_local_tarball` read the declaring tarball from.
let declarer_tarball = lockfile.str(declarer_res.local_tarball());
let mut declarer_buf = bun_paths::path_buffer_pool::get();
let declarer_location = if bun_paths::is_absolute(declarer_tarball) {
declarer_tarball
} else {
match lockfile
.first_dependency_declaring_local_tarball(declarer_tarball)
.and_then(|edge| lockfile.get_parent_pkg_of_dependency(edge))
.and_then(|declarer_of_declarer| local_package_dir(lockfile, declarer_of_declarer))
{
Some(base_dir) => Path::resolve_path::join_string_buf::<Path::platform::Posix>(
&mut *declarer_buf,
&[base_dir, declarer_tarball],
),
None => declarer_tarball,
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
}
};
let declarer_dir = bun_paths::dirname(declarer_location).unwrap_or(b"");
let mut location_buf = bun_paths::path_buffer_pool::get();
let joined = Path::resolve_path::join_string_buf::<Path::platform::Posix>(
&mut *location_buf,
&[declarer_dir, declared_path],
);
// The lockfile reader takes a bare `x.tgz` for an npm version.
let location: Vec<u8> = if bun_paths::is_absolute(joined)
|| joined.starts_with(b"./")
|| joined.starts_with(b"../")
{
joined.to_vec()
} else {
[b"./".as_slice(), joined].concat()
};
let mut builder = this.lockfile.string_builder();
builder.count(&location);
builder.allocate().unwrap_or_oom();
let located = builder.append::<SemverString>(&location);
builder.clamp();
Some(located)
}

fn update_name_and_name_hash_from_version_replacement(
Expand Down
36 changes: 36 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use bun_sha_hmac as Crypto;
use bun_sys::{self as sys, Fd, File};

use crate::config_version::ConfigVersion;
use crate::dependency::DependencyExt as _;
use crate::migration;
use crate::package_manager::WorkspaceFilter;
use crate::package_manager_real::{
Expand Down Expand Up @@ -800,6 +801,41 @@ impl Lockfile {
None
}

/// The first edge, in package order, declared as `file:<path>` with exactly that tarball path.
pub(crate) fn first_dependency_declaring_local_tarball(
&self,
path: &[u8],
) -> Option<DependencyID> {
let buf = self.buffers.string_bytes.as_slice();
let dependencies = self.buffers.dependencies.as_slice();
for dependency_list in self.packages.items_dependencies() {
let begin = dependency_list.begin() as usize;
let found = dependency_list
.get(dependencies)
.iter()
.position(|dependency| {
dependency.version.tag == dependency::Tag::Tarball
&& matches!(
&dependency.version.tarball().uri,
dependency::tarball::Uri::Local(declared) if declared.slice(buf) == path
)
});
if let Some(i) = found {
return Some(DependencyID::try_from(begin + i).expect("int cast"));
}
}
None
}

/// Does the root package.json declare the same dependency (name and specifier) itself?
pub(crate) fn has_equal_root_dependency(&self, dependency: &Dependency) -> bool {
Comment thread
robobun marked this conversation as resolved.
Resolved
let buf = self.buffers.string_bytes.as_slice();
self.packages.items_dependencies()[0]
.get(self.buffers.dependencies.as_slice())
.iter()
.any(|root_dependency| root_dependency.eql(dependency, buf, buf))
}

pub(crate) fn get_workspace_pkg_if_workspace_dep(&self, id: DependencyID) -> PackageID {
let packages = self.packages.slice();
let resolutions = packages.items_resolution();
Expand Down
Loading
Loading