From fc9a78a748a0ee4838a3da1dd2552d1ec3abafc6 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sun, 22 Mar 2026 15:10:05 -0500 Subject: [PATCH] feat: update depot version to 0.30.0 and enhance manual source validation and lifecycle hook handling --- Cargo.lock | 2 +- Cargo.toml | 2 +- meson.build | 2 +- src/commands.rs | 223 +++++++++++++++++++++++++++++++++-------- src/install/scripts.rs | 196 ++++++++++++++++++++++++++++++++---- src/planner.rs | 172 +++++++++++++++++++++++++++++-- src/source/mod.rs | 137 +++++++++++++++++++++---- 7 files changed, 637 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca69ba2..f4a66d3 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.29.1" +version = "0.30.0" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 80f154e..b5c9b1d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.29.1" +version = "0.30.0" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index bfc0c9a..4d6817c 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.29.1', + version: '0.30.0', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/commands.rs b/src/commands.rs index a89d710..55cfa4c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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> { 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(); diff --git a/src/install/scripts.rs b/src/install/scripts.rs index cae73b3..b88d50c 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -250,8 +250,43 @@ pub fn run_hook_if_present( rootfs: &Path, pkg_name: &str, ) -> Result { + 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 { + 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 { 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 { - // 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 { + 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(); diff --git a/src/planner.rs b/src/planner.rs index 1aa42f2..dfde9fc 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -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); + } } diff --git a/src/source/mod.rs b/src/source/mod.rs index 05542a1..617f04e 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -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 { + 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 { + 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 = 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 = 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 {