feat: update depot version to 0.30.0 and enhance manual source validation and lifecycle hook handling

This commit is contained in:
2026-03-22 15:10:05 -05:00
parent bfbec3ff8e
commit fc9a78a748
7 changed files with 637 additions and 97 deletions
Generated
+1 -1
View File
@@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
[[package]]
name = "depot"
version = "0.29.1"
version = "0.30.0"
dependencies = [
"anyhow",
"ar",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "depot"
version = "0.29.1"
version = "0.30.0"
edition = "2024"
[lints.rust]
+1 -1
View File
@@ -1,6 +1,6 @@
project(
'depot',
version: '0.29.1',
version: '0.30.0',
meson_version: '>=0.60.0',
default_options: ['buildtype=release'],
)
+179 -44
View File
@@ -1276,6 +1276,11 @@ struct PlannedPackageInstall {
staged: PlannedStagedInstall,
}
#[derive(Clone, Copy)]
struct PendingLifecycleHook {
hook: install::scripts::Hook,
}
#[cfg(test)]
#[derive(Debug, Clone)]
struct InstalledPackageOutcome {
@@ -1782,7 +1787,7 @@ fn install_staged_to_rootfs(
rootfs: &Path,
config: &config::Config,
plan: &PlannedStagedInstall,
) -> Result<()> {
) -> Result<Option<PendingLifecycleHook>> {
let staged_scripts_dir = install::scripts::staged_scripts_dir(destdir);
let installed_scripts_dir =
install::scripts::installed_scripts_dir(rootfs, &pkg_spec.package.name);
@@ -1848,23 +1853,13 @@ fn install_staged_to_rootfs(
&pkg_spec.package.name,
)?;
if plan.is_update {
let _ = install::scripts::run_hook_if_present(
&installed_scripts_dir,
install::scripts::Hook::PostUpdate,
rootfs,
&pkg_spec.package.name,
)?;
} else {
let _ = install::scripts::run_hook_if_present(
&installed_scripts_dir,
install::scripts::Hook::PostInstall,
rootfs,
&pkg_spec.package.name,
)?;
}
Ok(())
Ok(Some(PendingLifecycleHook {
hook: if plan.is_update {
install::scripts::Hook::PostUpdate
} else {
install::scripts::Hook::PostInstall
},
}))
}
fn install_planned_packages_to_rootfs(
@@ -1873,6 +1868,7 @@ fn install_planned_packages_to_rootfs(
config: &config::Config,
) -> Result<()> {
let mut removed_replacements = HashSet::new();
let mut pending_post_hooks = Vec::new();
for (idx, plan) in plans.iter().enumerate() {
ui::info(format!(
"{}/{} Installing package {}-{}-{}",
@@ -1887,7 +1883,20 @@ fn install_planned_packages_to_rootfs(
remove_installed_package_with_hooks(package, rootfs, config)?;
}
}
install_staged_to_rootfs(&plan.spec, &plan.destdir, rootfs, config, &plan.staged)?;
if let Some(hook) =
install_staged_to_rootfs(&plan.spec, &plan.destdir, rootfs, config, &plan.staged)?
{
pending_post_hooks.push((plan.spec.package.name.clone(), hook));
}
}
for (pkg_name, pending_hook) in pending_post_hooks {
let installed_scripts_dir = install::scripts::installed_scripts_dir(rootfs, &pkg_name);
let _ = install::scripts::run_hook_if_present_or_defer(
&installed_scripts_dir,
pending_hook.hook,
rootfs,
&pkg_name,
)?;
}
install::scripts::run_deferred_hooks_if_possible(rootfs)?;
Ok(())
@@ -3306,28 +3315,31 @@ fn compare_version_fallback(left: &str, right: &str) -> Ordering {
ri += 1;
}
let l_num = &left[l_start..li];
let r_num = &right[r_start..ri];
let l_trimmed = std::str::from_utf8(l_num)
.unwrap_or_default()
.trim_start_matches('0');
let r_trimmed = std::str::from_utf8(r_num)
.unwrap_or_default()
.trim_start_matches('0');
let l_raw = std::str::from_utf8(&left[l_start..li]).unwrap_or_default();
let r_raw = std::str::from_utf8(&right[r_start..ri]).unwrap_or_default();
let l_trimmed = l_raw.trim_start_matches('0');
let r_trimmed = r_raw.trim_start_matches('0');
let l_cmp = if l_trimmed.is_empty() { "0" } else { l_trimmed };
let r_cmp = if r_trimmed.is_empty() { "0" } else { r_trimmed };
match l_cmp.len().cmp(&r_cmp.len()) {
Ordering::Equal => match l_cmp.cmp(r_cmp) {
Ordering::Equal => {}
non_eq => return non_eq,
},
match l_cmp
.len()
.cmp(&r_cmp.len())
.then_with(|| l_cmp.cmp(r_cmp))
.then_with(|| l_raw.len().cmp(&r_raw.len()))
.then_with(|| l_raw.cmp(r_raw))
{
Ordering::Equal => {}
non_eq => return non_eq,
}
continue;
}
match lch.to_ascii_lowercase().cmp(&rch.to_ascii_lowercase()) {
match lch
.to_ascii_lowercase()
.cmp(&rch.to_ascii_lowercase())
.then_with(|| lch.cmp(&rch))
{
Ordering::Equal => {
li += 1;
ri += 1;
@@ -3339,14 +3351,20 @@ fn compare_version_fallback(left: &str, right: &str) -> Ordering {
left.len().cmp(&right.len())
}
fn canonical_update_version(raw: &str) -> &str {
raw.strip_prefix('v')
.filter(|rest| rest.chars().next().is_some_and(|ch| ch.is_ascii_digit()))
.unwrap_or(raw)
}
fn compare_versions_for_updates(left: &str, right: &str) -> Ordering {
let left_semver = left.trim_start_matches('v');
let right_semver = right.trim_start_matches('v');
if let (Ok(left), Ok(right)) = (
semver::Version::parse(left_semver),
semver::Version::parse(right_semver),
) {
return left.cmp(&right);
let left = canonical_update_version(left);
let right = canonical_update_version(right);
if let (Ok(left), Ok(right)) = (semver::Version::parse(left), semver::Version::parse(right)) {
match left.cmp(&right) {
Ordering::Equal => {}
non_eq => return non_eq,
}
}
if left.len() == 8
@@ -4342,6 +4360,10 @@ fn run_direct_install_request(
})?;
let db_path = config.installed_db_path(options.rootfs);
if staging_dir.is_none() {
source::preflight_manual_sources(&pkg_spec, &config.cache_dir)?;
}
if staging_dir.is_none() {
if options.no_deps
&& should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs)
@@ -7158,8 +7180,7 @@ optional = []
}
#[test]
fn install_planned_packages_to_rootfs_defers_replacement_removal_until_replacing_plan()
-> Result<()> {
fn install_planned_packages_to_rootfs_runs_post_hooks_after_batch_install() -> Result<()> {
let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?;
let mut cfg = config::Config::for_rootfs(rootfs.path());
cfg.db_dir = rootfs.path().join("var/lib/depot");
@@ -7224,7 +7245,7 @@ optional = []
fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?;
fs::write(
alpha_dest.join("scripts/post_install"),
"if [ -e \"$DEPOT_ROOTFS/usr/bin/find\" ]; then printf '%s' present > \"$DEPOT_ROOTFS/alpha-marker\"; else printf '%s' missing > \"$DEPOT_ROOTFS/alpha-marker\"; fi\n",
"cat \"$DEPOT_ROOTFS/usr/bin/find\" > \"$DEPOT_ROOTFS/alpha-marker\"\n",
)?;
let replacement_spec = package::PackageSpec {
@@ -7278,7 +7299,7 @@ optional = []
assert_eq!(
fs::read_to_string(rootfs.path().join("alpha-marker"))?,
"present"
"new-find"
);
assert_eq!(
fs::read_to_string(rootfs.path().join("usr/bin/find"))?,
@@ -7625,6 +7646,59 @@ optional = []
compare_versions_for_updates("1.10", "1.9"),
Ordering::Greater
);
assert_eq!(
compare_versions_for_updates("v1.0.0", "1.0.0"),
Ordering::Equal
);
}
#[test]
fn compare_versions_for_updates_is_transitive_for_mixed_formats() {
let versions = [
"01",
"1a",
"1.0.0",
"1.2.0",
"1.2.0rc2",
"v1.0.0",
"1.0.0+meta",
"20260107.1",
"lts_2026_01_07",
];
for left in versions {
for middle in versions {
for right in versions {
let left_middle = compare_versions_for_updates(left, middle);
let middle_right = compare_versions_for_updates(middle, right);
let left_right = compare_versions_for_updates(left, right);
if left_middle == Ordering::Less && middle_right == Ordering::Less {
assert_eq!(
left_right,
Ordering::Less,
"expected transitive ordering for {left} < {middle} < {right}"
);
}
if left_middle == Ordering::Greater && middle_right == Ordering::Greater {
assert_eq!(
left_right,
Ordering::Greater,
"expected transitive ordering for {left} > {middle} > {right}"
);
}
if left_middle == Ordering::Equal && middle_right == Ordering::Equal {
assert_eq!(
left_right,
Ordering::Equal,
"expected transitive equality for {left} == {middle} == {right}"
);
}
}
}
}
}
#[test]
@@ -7814,6 +7888,67 @@ optional = []
Ok(())
}
#[test]
fn direct_install_checks_manual_sources_before_dependency_resolution() -> Result<()> {
let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?;
let spec_dir = tempfile::tempdir().context("Failed to create temp spec dir")?;
let spec_path = spec_dir.path().join("demo.toml");
fs::write(
&spec_path,
r#"[package]
name = "demo"
version = "1.0.0"
revision = 1
description = "demo"
homepage = "https://example.test/demo"
license = "MIT"
[build]
type = "custom"
[dependencies]
runtime = ["definitely-missing-dep"]
optional = []
[[manual_sources]]
file = "missing.patch"
"#,
)?;
let mut config = config::Config::for_rootfs(rootfs.path());
config.build_dir = rootfs.path().join("var/cache/depot/build");
config.cache_dir = rootfs.path().join("var/cache/depot/sources");
config.db_dir = rootfs.path().join("var/lib/depot");
ui::set_assume_yes(true);
let result = run_direct_install_request(
DirectInstallOptions {
rootfs: rootfs.path(),
no_deps: false,
no_flags: false,
cross_prefix: None,
clean: false,
dry_run: false,
lib32_only: false,
install_test_deps: false,
},
&config,
spec_path,
);
ui::set_assume_yes(false);
let err = result.expect_err("missing manual source should fail before dependency install");
assert!(
err.to_string()
.contains("Manual source not found: missing.patch")
);
assert!(
!err.to_string()
.contains("Could not find package spec for dependency")
);
Ok(())
}
#[test]
fn suppress_nested_install_output_for_planned_context() {
let mut env = TestEnv::new();
+176 -20
View File
@@ -250,8 +250,43 @@ pub fn run_hook_if_present(
rootfs: &Path,
pkg_name: &str,
) -> Result<bool> {
Ok(matches!(
dispatch_hook_if_present(script_dir, hook, rootfs, pkg_name, false)?,
HookDispatch::Ran
))
}
/// Run a lifecycle hook if present, or queue it for later replay when the
/// target rootfs cannot yet execute scripts inside a real chroot.
///
/// Returns `true` when a hook script was found and either executed or queued.
pub fn run_hook_if_present_or_defer(
script_dir: &Path,
hook: Hook,
rootfs: &Path,
pkg_name: &str,
) -> Result<bool> {
Ok(matches!(
dispatch_hook_if_present(script_dir, hook, rootfs, pkg_name, true)?,
HookDispatch::Ran | HookDispatch::Deferred
))
}
enum HookDispatch {
Missing,
Ran,
Deferred,
}
fn dispatch_hook_if_present(
script_dir: &Path,
hook: Hook,
rootfs: &Path,
pkg_name: &str,
allow_defer: bool,
) -> Result<HookDispatch> {
let Some(script_path) = resolve_hook_script(script_dir, hook, pkg_name)? else {
return Ok(false);
return Ok(HookDispatch::Missing);
};
crate::log_info!(
@@ -260,7 +295,7 @@ pub fn run_hook_if_present(
script_path.display()
);
match run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)? {
match run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook, allow_defer)? {
HookRunOutcome::Ran(status) => {
if !status.success() {
bail!(
@@ -269,14 +304,18 @@ pub fn run_hook_if_present(
script_path.display()
);
}
Ok(HookDispatch::Ran)
}
HookRunOutcome::Deferred(script_rel) => {
queue_deferred_hook(rootfs, pkg_name, hook, &script_rel)?;
Ok(HookDispatch::Deferred)
}
}
Ok(true)
}
enum HookRunOutcome {
Ran(std::process::ExitStatus),
Deferred(PathBuf),
}
#[derive(Default)]
@@ -418,29 +457,40 @@ fn run_script_with_rootfs_context(
rootfs: &Path,
pkg_name: &str,
hook: Hook,
allow_defer: bool,
) -> Result<HookRunOutcome> {
// When root and rootfs provides /bin/sh, run inside a real chroot so scripts
// can rely on `cd /` and relative paths resolving within that rootfs.
if fakeroot::is_root()
&& should_use_chroot(rootfs)
&& let Ok(rel) = script_path.strip_prefix(rootfs)
{
if should_use_chroot(rootfs) && fakeroot::is_root() {
if rootfs.join("bin/sh").exists() {
let status = run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false)?;
let status = if let Ok(rel) = script_path.strip_prefix(rootfs) {
run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false)?
} else {
run_hook_script_contents_in_chroot(rootfs, script_path, pkg_name, hook, false)?
};
return Ok(HookRunOutcome::Ran(status));
}
crate::log_warn!(
"Running lifecycle hook {} for {} without chroot because {} has no /bin/sh",
if allow_defer {
let rel = script_path.strip_prefix(rootfs).with_context(|| {
format!(
"Cannot defer lifecycle hook {} for {} because {} is outside {}",
hook.canonical_name(),
pkg_name,
script_path.display(),
rootfs.display()
)
})?;
return Ok(HookRunOutcome::Deferred(rel.to_path_buf()));
}
anyhow::bail!(
"Lifecycle hook {} for {} requires {}/bin/sh before it can run",
hook.canonical_name(),
pkg_name,
rootfs.display()
);
}
// Fallback (non-root / no rootfs shell / script outside rootfs):
// execute with host /bin/sh while setting cwd to rootfs, so relative paths
// inside scripts resolve against that rootfs root.
// Live-root installs can execute directly with the host shell.
let script_arg = if let Ok(rel) = script_path.strip_prefix(rootfs) {
PathBuf::from(format!("./{}", rel.to_string_lossy()))
} else {
@@ -506,6 +556,84 @@ fn run_hook_script_in_chroot(
})
}
fn run_hook_script_contents_in_chroot(
rootfs: &Path,
script_path: &Path,
pkg_name: &str,
hook: Hook,
quiet: bool,
) -> Result<std::process::ExitStatus> {
let _mounts = mount_chroot_filesystems(rootfs)?;
let mut cmd = Command::new("chroot");
cmd.arg(rootfs)
.arg("/bin/sh")
.arg("-s")
.env("DEPOT_PACKAGE", pkg_name)
.env("DEPOT_ROOTFS", "/")
.env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase())
.env("PATH", crate::runtime_env::safe_script_path())
.stdin(Stdio::piped());
if quiet {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
let mut child = cmd.spawn().with_context(|| {
format!(
"Failed to execute lifecycle hook {} in chroot at {}",
hook.canonical_name(),
rootfs.display()
)
})?;
let script_bytes = fs::read(script_path).with_context(|| {
format!(
"Failed to read lifecycle hook {} at {}",
hook.canonical_name(),
script_path.display()
)
})?;
let mut stdin = child.stdin.take().with_context(|| {
format!(
"Failed to open stdin for lifecycle hook {} in chroot at {}",
hook.canonical_name(),
rootfs.display()
)
})?;
stdin.write_all(&script_bytes).with_context(|| {
format!(
"Failed to stream lifecycle hook {} into chroot at {}",
hook.canonical_name(),
rootfs.display()
)
})?;
drop(stdin);
child.wait().with_context(|| {
format!(
"Failed to wait for lifecycle hook {} in chroot at {}",
hook.canonical_name(),
rootfs.display()
)
})
}
fn queue_deferred_hook(rootfs: &Path, pkg_name: &str, hook: Hook, script_rel: &Path) -> Result<()> {
let path = deferred_hooks_file(rootfs);
let mut hooks = read_deferred_hooks(&path)?;
let item = DeferredHook {
pkg_name: pkg_name.to_string(),
hook,
script_rel: script_rel.to_path_buf(),
};
if !hooks.iter().any(|existing| {
existing.pkg_name == item.pkg_name
&& existing.hook == item.hook
&& existing.script_rel == item.script_rel
}) {
hooks.push(item);
write_deferred_hooks(&path, &hooks)?;
}
Ok(())
}
fn deferred_hooks_file(rootfs: &Path) -> PathBuf {
rootfs.join(DEFERRED_HOOKS_FILE_REL)
}
@@ -602,7 +730,7 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
for item in hooks {
let script_path = rootfs.join(&item.script_rel);
if !script_path.exists() {
crate::log_warn!(
crate::log_info!(
"Dropping deferred lifecycle hook {} for {} because script is missing: {}",
item.hook.canonical_name(),
item.pkg_name,
@@ -614,7 +742,7 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
match run_hook_script_in_chroot(rootfs, &item.script_rel, &item.pkg_name, item.hook, true) {
Ok(status) if status.success() => {}
Ok(status) => {
crate::log_warn!(
crate::log_info!(
"Deferred lifecycle hook {} for {} failed with status {} (kept queued)",
item.hook.canonical_name(),
item.pkg_name,
@@ -623,7 +751,7 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
remaining.push(item);
}
Err(err) => {
crate::log_warn!(
crate::log_info!(
"Deferred lifecycle hook {} for {} failed with error (kept queued): {}",
item.hook.canonical_name(),
item.pkg_name,
@@ -636,7 +764,7 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
write_deferred_hooks(&path, &remaining)?;
if !remaining.is_empty() {
crate::log_warn!(
crate::log_info!(
"{} deferred lifecycle hook(s) remain queued in {}",
remaining.len(),
path.display()
@@ -1209,6 +1337,34 @@ mod tests {
assert_eq!(loaded[1].script_rel, hooks[1].script_rel);
}
#[test]
fn queue_deferred_hook_dedupes_entries() {
let tmp = tempfile::tempdir().unwrap();
queue_deferred_hook(
tmp.path(),
"foo",
Hook::PostInstall,
Path::new("usr/share/depot/foo/scripts/post_install"),
)
.unwrap();
queue_deferred_hook(
tmp.path(),
"foo",
Hook::PostInstall,
Path::new("usr/share/depot/foo/scripts/post_install"),
)
.unwrap();
let loaded = read_deferred_hooks(&deferred_hooks_file(tmp.path())).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].pkg_name, "foo");
assert_eq!(loaded[0].hook, Hook::PostInstall);
assert_eq!(
loaded[0].script_rel,
PathBuf::from("usr/share/depot/foo/scripts/post_install")
);
}
#[test]
fn sync_staged_scripts_to_rootfs_replaces_existing_tree() {
let tmp = tempfile::tempdir().unwrap();
+163 -9
View File
@@ -280,6 +280,7 @@ impl<'a> Resolver<'a> {
)
};
if let Some(&idx) = self.by_package.get(&package_name) {
self.bail_if_active_cycle(&package_name)?;
self.mark_requested_by(idx, requested_by);
return Ok(idx);
}
@@ -382,7 +383,7 @@ impl<'a> Resolver<'a> {
if let Some(dep_idx) =
self.resolve_dep_node(dep, None, format!("{} needs {}", record.name, dep))?
{
self.graph.add_edge(dep_idx, idx, ());
self.add_dependency_edge(dep_idx, idx, &record.name)?;
}
}
@@ -641,14 +642,7 @@ impl<'a> Resolver<'a> {
}
fn push_stack(&mut self, pkg: &str) -> Result<()> {
if self.stack.iter().any(|s| s == pkg) {
let mut chain = self.stack.join(" -> ");
if !chain.is_empty() {
chain.push_str(" -> ");
}
chain.push_str(pkg);
anyhow::bail!("Dependency cycle detected: {}", chain);
}
self.bail_if_active_cycle(pkg)?;
self.stack.push(pkg.to_string());
Ok(())
}
@@ -658,6 +652,48 @@ impl<'a> Resolver<'a> {
self.stack.pop();
}
}
fn add_dependency_edge(
&mut self,
dep_idx: NodeIndex,
dependent_idx: NodeIndex,
dependent_package: &str,
) -> Result<()> {
let dep_package = self
.graph
.node_weight(dep_idx)
.with_context(|| format!("Missing dependency plan node {dep_idx:?}"))?
.step
.package
.clone();
if self.is_active_stack_member(&dep_package) {
crate::log_warn!(
"dependency cycle detected: {} will be installed before its {} dependency",
dependent_package,
dep_package
);
return Ok(());
}
self.graph.add_edge(dep_idx, dependent_idx, ());
Ok(())
}
fn bail_if_active_cycle(&self, pkg: &str) -> Result<()> {
if let Some(position) = self.stack.iter().position(|entry| entry == pkg) {
let mut chain = self.stack[position..].join(" -> ");
if !chain.is_empty() {
chain.push_str(" -> ");
}
chain.push_str(pkg);
anyhow::bail!("Dependency cycle detected: {}", chain);
}
Ok(())
}
fn is_active_stack_member(&self, pkg: &str) -> bool {
self.stack.iter().any(|entry| entry == pkg)
}
}
fn source_deps_for_install(
@@ -862,6 +898,7 @@ mod tests {
Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source,
};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn mk_spec() -> PackageSpec {
@@ -1137,4 +1174,121 @@ mod tests {
assert!(plan.steps.is_empty());
assert!(plan.actionable_steps().next().is_none());
}
#[test]
fn build_dependency_install_plan_reports_source_cycle_chain() {
let rootfs = tempfile::tempdir().unwrap();
let repo_root = tempfile::tempdir().unwrap();
let config = Config::for_rootfs(rootfs.path());
let alpha_dir = repo_root.path().join("alpha");
let beta_dir = repo_root.path().join("beta");
fs::create_dir_all(&alpha_dir).unwrap();
fs::create_dir_all(&beta_dir).unwrap();
fs::write(
alpha_dir.join("alpha.toml"),
r#"
[build]
type = "meta"
[dependencies]
runtime = ["beta"]
[package]
description = "alpha"
homepage = "https://example.test/alpha"
license = "MIT"
name = "alpha"
version = "1.0.0"
"#,
)
.unwrap();
fs::write(
beta_dir.join("beta.toml"),
r#"
[build]
type = "meta"
[dependencies]
runtime = ["alpha"]
[package]
description = "beta"
homepage = "https://example.test/beta"
license = "MIT"
name = "beta"
version = "1.0.0"
"#,
)
.unwrap();
let err = build_dependency_install_plan(
&config,
rootfs.path(),
&["alpha".to_string()],
PlannerOptions {
assume_yes: false,
prefer_binary: false,
local_sibling_root: Some(repo_root.path().to_path_buf()),
include_test_deps: false,
lib32_only_requested_specs: false,
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"Dependency cycle detected: alpha -> beta -> alpha"
);
}
#[test]
fn add_dependency_edge_skips_active_binary_cycle_back_edge() {
let rootfs = tempfile::tempdir().unwrap();
let config = Config::for_rootfs(rootfs.path());
let mut resolver = super::Resolver::new(
&config,
rootfs.path(),
PlannerOptions {
assume_yes: false,
prefer_binary: true,
local_sibling_root: None,
include_test_deps: false,
lib32_only_requested_specs: false,
},
);
let freetype2 = resolver.graph.add_node(super::NodeData {
step: super::PlannedStep {
package: "freetype2".into(),
action: super::PlanAction::InstallBinary,
origin: super::PlanOrigin::Installed,
requested_by: vec!["requested".into()],
},
});
let harfbuzz = resolver.graph.add_node(super::NodeData {
step: super::PlannedStep {
package: "harfbuzz".into(),
action: super::PlanAction::InstallBinary,
origin: super::PlanOrigin::Installed,
requested_by: vec!["requested".into()],
},
});
resolver.stack = vec!["freetype2".into(), "harfbuzz".into()];
resolver
.add_dependency_edge(freetype2, harfbuzz, "harfbuzz")
.unwrap();
assert_eq!(resolver.graph.edge_count(), 0);
resolver.stack.clear();
resolver
.add_dependency_edge(freetype2, harfbuzz, "harfbuzz")
.unwrap();
assert_eq!(resolver.graph.edge_count(), 1);
}
}
+116 -21
View File
@@ -23,6 +23,120 @@ fn expand_manual_source_value(spec: &PackageSpec, raw: &str) -> String {
.replace("${CARCH}", carch)
}
fn manual_local_entries(manual: &crate::package::ManualSource) -> Vec<String> {
let mut local_entries = Vec::new();
if let Some(file) = manual.file.as_ref()
&& !file.trim().is_empty()
{
local_entries.push(file.clone());
}
local_entries.extend(
manual
.files
.iter()
.filter(|s| !s.trim().is_empty())
.cloned(),
);
local_entries
}
fn manual_url_entries(manual: &crate::package::ManualSource) -> Vec<String> {
let mut url_entries = Vec::new();
if let Some(url) = manual.url.as_ref()
&& !url.trim().is_empty()
{
url_entries.push(url.clone());
}
url_entries.extend(manual.urls.iter().filter(|s| !s.trim().is_empty()).cloned());
url_entries
}
/// Validate manual sources early, before dependency installation and build work.
///
/// Local entries are checked for existence and optional checksum correctness.
/// Remote entries are fetched into the manual-source cache so build-time source
/// preparation can reuse the verified result later.
pub fn preflight_manual_sources(spec: &PackageSpec, cache_dir: &Path) -> Result<()> {
if spec.manual_sources.is_empty() {
return Ok(());
}
let manual_entry_count: usize = spec
.manual_sources
.iter()
.map(|m| manual_local_entries(m).len() + manual_url_entries(m).len())
.sum();
crate::log_info!("Checking {} manual source(s)...", manual_entry_count);
for manual in &spec.manual_sources {
let local_entries = manual_local_entries(manual);
let url_entries = manual_url_entries(manual);
if !local_entries.is_empty() {
for raw_file in local_entries {
let file = expand_manual_source_value(spec, &raw_file);
let src_path = spec.spec_dir.join(&file);
if !src_path.exists() {
bail!(
"Manual source not found: {} (expected at {})",
file,
src_path.display()
);
}
if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip")
&& !verify_file_hash(&src_path, expected_hash)?
{
bail!("Checksum mismatch for {}: expected {}", file, expected_hash);
}
}
continue;
}
if !url_entries.is_empty() {
for raw_url in url_entries {
let expanded_url = expand_manual_source_value(spec, &raw_url);
let parsed = Url::parse(&expanded_url)
.with_context(|| format!("Invalid URL: {}", expanded_url))?;
if parsed.scheme() == "file" {
let src_path = parsed
.to_file_path()
.map_err(|_| anyhow::anyhow!("Invalid file URL: {}", expanded_url))?;
if !src_path.exists() {
bail!("Manual source file URL not found: {}", src_path.display());
}
if let Some(expected_hash) = manual.sha256.as_ref().filter(|h| *h != "skip")
&& !verify_file_hash(&src_path, expected_hash)?
{
bail!(
"Checksum mismatch for {}: expected {}",
expanded_url,
expected_hash
);
}
continue;
}
let source = crate::package::Source {
url: expanded_url,
sha256: manual.sha256.clone().unwrap_or_else(|| "skip".to_string()),
extract_dir: "manual-source".to_string(),
patches: Vec::new(),
post_extract: Vec::new(),
cherry_pick: Vec::new(),
};
let _ = fetcher::fetch_archive(spec, &source, &cache_dir.join("manual"))?;
}
continue;
}
bail!("Manual source must define one of 'file', 'files', 'url', or 'urls'");
}
Ok(())
}
/// Copy manual sources to the build directory before fetching remote sources.
///
/// Manual sources support:
@@ -47,27 +161,8 @@ pub fn copy_manual_sources(spec: &PackageSpec, cache_dir: &Path, build_dir: &Pat
crate::log_info!("Copying {} manual source(s)...", manual_entry_count);
for manual in &spec.manual_sources {
let mut local_entries: Vec<String> = Vec::new();
if let Some(file) = manual.file.as_ref()
&& !file.trim().is_empty()
{
local_entries.push(file.clone());
}
local_entries.extend(
manual
.files
.iter()
.filter(|s| !s.trim().is_empty())
.cloned(),
);
let mut url_entries: Vec<String> = Vec::new();
if let Some(url) = manual.url.as_ref()
&& !url.trim().is_empty()
{
url_entries.push(url.clone());
}
url_entries.extend(manual.urls.iter().filter(|s| !s.trim().is_empty()).cloned());
let local_entries = manual_local_entries(manual);
let url_entries = manual_url_entries(manual);
if !local_entries.is_empty() {
for raw_file in local_entries {