diff --git a/src/builder/autotools.rs b/src/builder/autotools.rs old mode 100755 new mode 100644 index b848223..bdf17e1 --- a/src/builder/autotools.rs +++ b/src/builder/autotools.rs @@ -1201,550 +1201,4 @@ fn expand_configure_arg(spec: &PackageSpec, arg: &str, envs: &[(String, String)] } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; - use std::path::PathBuf; - use tempfile::tempdir; - - #[test] - fn test_expand_shell_commands_simple() -> Result<()> { - let out = expand_shell_commands("x $(echo foo) y", "gcc")?; - assert_eq!(out, "x foo y"); - Ok(()) - } - - #[test] - fn test_expand_shell_commands_replace_cc() -> Result<()> { - // The command contains $CC which should be replaced with provided cc - let out = expand_shell_commands("start $($CC -v >/dev/null; echo OK) end", "mycc")?; - // Since the inner command echoes OK, after replacing $CC it should run and include OK - assert!(out.contains("OK") || out.contains("")); - Ok(()) - } - - #[test] - fn test_expand_with_envs_prefers_provided_envs() { - let envs = vec![ - ("CARCH".to_string(), "x86_64".to_string()), - ("CHOST".to_string(), "x86_64-sfg-linux-gnu".to_string()), - ]; - let out = expand_with_envs("--with-gcc-arch=$CARCH --host=${CHOST}", &envs); - assert!(out.contains("--with-gcc-arch=x86_64")); - assert!(out.contains("--host=x86_64-sfg-linux-gnu")); - } - - #[test] - fn test_expand_configure_arg_expands_spec_and_env_vars() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.2.3".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let envs = vec![("CARCH".to_string(), "aarch64".to_string())]; - let expanded = - expand_configure_arg(&spec, "--program-prefix=$name-$version-$CARCH-", &envs); - assert_eq!(expanded, "--program-prefix=foo-1.2.3-aarch64-"); - } - - #[test] - fn test_expand_configure_arg_expands_host_build_dir_env() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.2.3".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let envs = vec![( - crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), - "/tmp/build-host".to_string(), - )]; - let expanded = expand_configure_arg( - &spec, - "--with-build-tools=$DEPOT_BUILD_HOST_DIR/tools", - &envs, - ); - assert_eq!(expanded, "--with-build-tools=/tmp/build-host/tools"); - } - - #[test] - fn test_num_cpus_at_least_one() { - let n = num_cpus(); - assert!(n >= 1); - } - - #[test] - fn test_configure_help_supports_host_build() { - let help = "Usage: configure [OPTION]...\n --host=HOST cross host\n --build=BUILD"; - assert!(configure_help_supports_option(help, "--host")); - assert!(configure_help_supports_option(help, "--build")); - assert!(!configure_help_supports_option(help, "--target")); - } - - #[test] - fn test_configure_help_supports_enable_disable_aliases() { - let help = " --enable-static build static libraries\n --with-zlib=DIR"; - assert!(configure_help_supports_option(help, "--disable-static")); - assert!(configure_help_supports_option(help, "--without-zlib")); - } - - #[test] - fn test_configure_help_supports_option_requires_exact_match() { - let help = "\ - --host-cc=HOSTCC use host C compiler - --build-suffix=SUFFIX library name suffix []"; - assert!(!configure_help_supports_option(help, "--host")); - assert!(!configure_help_supports_option(help, "--build")); - } - - #[test] - fn test_looks_like_configure_help_text_accepts_bootstrap_style_output() { - let help = "\ -Usage: ./bootstrap [...] -Options: - --help - --prefix=PREFIX"; - assert!(looks_like_configure_help_text(help)); - } - - #[test] - fn test_looks_like_configure_help_text_rejects_non_help_output() { - assert!(!looks_like_configure_help_text("")); - assert!(!looks_like_configure_help_text( - "Unknown option: --disable-static" - )); - } - - #[test] - fn test_configure_supports_option_defaults_by_configure_file_usage() { - assert!(configure_supports_option(None, "--host", "")); - assert!(!configure_supports_option( - None, - "--host", - "build-aux/Configure" - )); - } - - #[test] - fn test_default_configure_install_dirs_injects_expected_paths() { - let flags = BuildFlags::default(); - let help = "\ ---bindir=DIR ---sbindir=DIR ---libdir=DIR ---libexecdir=DIR ---sysconfdir=DIR ---localstatedir=DIR ---sharedstatedir=DIR ---includedir=DIR ---datarootdir=DIR ---datadir=DIR ---mandir=DIR ---infodir=DIR"; - let args = default_configure_install_dirs(&flags, Some(help)); - assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); - assert!(args.iter().any(|a| a == "--sbindir=/usr/bin")); - assert!(args.iter().any(|a| a == "--libdir=/usr/lib")); - assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib")); - assert!(args.iter().any(|a| a == "--sysconfdir=/etc")); - assert!(args.iter().any(|a| a == "--localstatedir=/var")); - assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib")); - assert!(args.iter().any(|a| a == "--includedir=/usr/include")); - assert!(args.iter().any(|a| a == "--datarootdir=/usr/share")); - assert!(args.iter().any(|a| a == "--datadir=/usr/share")); - assert!(args.iter().any(|a| a == "--mandir=/usr/share/man")); - assert!(args.iter().any(|a| a == "--infodir=/usr/share/info")); - } - - #[test] - fn test_default_configure_install_dirs_respects_explicit_user_overrides() { - let flags = BuildFlags { - configure: vec![ - "--sbindir=/sbin".to_string(), - "--libdir=/custom/lib".to_string(), - "--datadir=/custom/share".to_string(), - ], - ..BuildFlags::default() - }; - let help = "--bindir=DIR --sbindir=DIR --libdir=DIR --datadir=DIR"; - let args = default_configure_install_dirs(&flags, Some(help)); - assert!(!args.iter().any(|a| a.starts_with("--sbindir="))); - assert!(!args.iter().any(|a| a.starts_with("--libdir="))); - assert!(!args.iter().any(|a| a.starts_with("--datadir="))); - assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); - } - - #[test] - fn test_default_configure_install_dirs_lib32_uses_lib32_dirs() { - let help = "--libdir=DIR --libexecdir=DIR"; - let flags = BuildFlags { - lib32_variant: true, - ..BuildFlags::default() - }; - let args = default_configure_install_dirs(&flags, Some(help)); - assert!(args.iter().any(|a| a == "--libdir=/usr/lib32")); - assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32")); - } - - #[test] - fn test_default_configure_install_dirs_skips_when_not_advertised() { - let flags = BuildFlags::default(); - let args = default_configure_install_dirs(&flags, Some("--prefix=PREFIX")); - assert!(args.is_empty()); - } - - #[test] - fn test_configure_long_option_extracts_long_option_name() { - assert_eq!( - configure_long_option(" --disable-static "), - Some("--disable-static") - ); - assert_eq!( - configure_long_option("--with-zlib=/usr"), - Some("--with-zlib") - ); - assert_eq!(configure_long_option("prefix=/usr"), None); - assert_eq!(configure_long_option("-C"), None); - } - - #[test] - fn test_add_auto_configure_arg_if_supported_skips_unsupported_long_option() { - let mut cmd = Command::new("configure"); - add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "--disable-nls"); - - let args: Vec = cmd - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect(); - assert!(args.is_empty()); - } - - #[test] - fn test_add_auto_configure_arg_if_supported_keeps_supported_alias_and_non_option_args() { - let mut cmd = Command::new("configure"); - add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "--disable-static"); - add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "srcdir"); - - let args: Vec = cmd - .get_args() - .map(|arg| arg.to_string_lossy().to_string()) - .collect(); - assert_eq!(args, vec!["--disable-static", "srcdir"]); - } - - #[test] - fn test_install_destdir_path_uses_build_dir_for_lib32() { - let build_dir = Path::new("/tmp/build"); - let destdir = Path::new("/tmp/pkg"); - assert_eq!( - install_destdir_path(build_dir, destdir, false), - destdir.to_path_buf() - ); - assert_eq!( - install_destdir_path(build_dir, destdir, true), - build_dir.join("destdir") - ); - } - - #[test] - fn test_makefile_content_has_target_detects_check_and_test() { - let content = r#" -.PHONY: all check -all: - @echo all -check: - @echo check -"#; - assert!(makefile_content_has_target(content, "check")); - assert!(!makefile_content_has_target(content, "test")); - } - - #[test] - fn test_makefile_content_has_target_ignores_assignments() { - let content = r#" -TEST := value -VAR:=$(shell echo hi) -foo: bar - @true -"#; - assert!(!makefile_content_has_target(content, "TEST")); - assert!(!makefile_content_has_target(content, "VAR")); - assert!(!makefile_content_has_target(content, "check")); - } - - #[test] - fn test_maybe_find_autotools_test_target_respects_skip_tests() -> Result<()> { - let tmp = tempdir().unwrap(); - std::fs::write(tmp.path().join("Makefile"), "check:\n\t@true\n").unwrap(); - - let skipped = maybe_find_autotools_test_target(tmp.path(), true)?; - assert_eq!(skipped, None); - - let detected = maybe_find_autotools_test_target(tmp.path(), false)?; - assert_eq!(detected, Some("check")); - Ok(()) - } - - #[test] - fn test_resolve_make_dirs_defaults_to_build_dir() -> Result<()> { - let tmp = tempdir().unwrap(); - let dirs = resolve_make_dirs(tmp.path(), &[], "build.flags.make_dirs")?; - assert_eq!(dirs, vec![tmp.path().to_path_buf()]); - Ok(()) - } - - #[test] - fn test_resolve_make_dirs_resolves_multiple_relative_dirs() -> Result<()> { - let tmp = tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join("lib"))?; - std::fs::create_dir_all(tmp.path().join("libelf"))?; - let dirs = resolve_make_dirs( - tmp.path(), - &["lib".to_string(), "libelf".to_string()], - "build.flags.make_dirs", - )?; - assert_eq!( - dirs, - vec![tmp.path().join("lib"), tmp.path().join("libelf")] - ); - Ok(()) - } - - #[test] - fn test_add_make_variable_overrides_accepts_valid_assignments() -> Result<()> { - let mut cmd = Command::new("make"); - add_make_variable_overrides( - &mut cmd, - &[ - "CC=clang".to_string(), - "V=1".to_string(), - " CFLAGS=-O2 -pipe ".to_string(), - ], - "build", - )?; - let args: Vec = cmd - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect(); - assert_eq!(args, vec!["CC=clang", "V=1", "CFLAGS=-O2 -pipe"]); - Ok(()) - } - - #[test] - fn test_add_make_variable_overrides_rejects_invalid_assignment() { - let mut cmd = Command::new("make"); - let err = add_make_variable_overrides(&mut cmd, &["not-an-assignment".to_string()], "test") - .expect_err("expected invalid assignment to fail"); - assert!(err.to_string().contains("expected NAME=VALUE")); - } - - #[test] - fn test_has_make_variable_override_detects_destdir() { - assert!(has_make_variable_override( - &["DESTDIR=/tmp/pkg".to_string()], - "DESTDIR" - )); - assert!(has_make_variable_override( - &[" DESTDIR =/tmp/pkg ".to_string()], - "DESTDIR" - )); - assert!(!has_make_variable_override( - &["V=1".to_string(), "PREFIX=/usr".to_string()], - "DESTDIR" - )); - } - - #[test] - fn test_resolve_make_exec_defaults_and_trims() { - assert_eq!(resolve_make_exec(""), "make"); - assert_eq!(resolve_make_exec(" "), "make"); - assert_eq!(resolve_make_exec(" ninja "), "ninja"); - } - - #[test] - fn test_make_exec_supports_make_assignments_detects_make_variants() { - assert!(make_exec_supports_make_assignments("make")); - assert!(make_exec_supports_make_assignments("/usr/bin/gmake")); - assert!(!make_exec_supports_make_assignments("ninja")); - } - - #[test] - fn test_phase_targets_merges_singular_plural_and_default() { - assert_eq!( - phase_targets("bootstrap", &["stage1".into(), "stage2".into()], None), - vec![ - "bootstrap".to_string(), - "stage1".to_string(), - "stage2".to_string() - ] - ); - assert_eq!( - phase_targets("", &[], Some("install")), - vec!["install".to_string()] - ); - } - - #[test] - fn test_add_make_variable_overrides_if_supported_rejects_ninja_vars() { - let mut cmd = Command::new("ninja"); - let err = add_make_variable_overrides_if_supported( - &mut cmd, - "ninja", - &["V=1".to_string()], - "build", - ) - .expect_err("ninja should reject make variable override syntax"); - assert!(err.to_string().contains("build.flags.make_vars")); - } - - #[test] - fn test_resolve_actual_src_expands_source_subdir_vars() { - let tmp = tempdir().unwrap(); - let src_root = tmp.path().join("srcroot"); - let expanded = src_root.join("expect5.45.4").join("unix"); - std::fs::create_dir_all(&expanded).unwrap(); - - let spec = PackageSpec { - package: PackageInfo { - name: "expect".into(), - real_name: None, - version: "5.45.4".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags { - source_subdir: "$name$version/unix".into(), - ..BuildFlags::default() - }, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let resolved = resolve_actual_src(&spec, &src_root).unwrap(); - assert_eq!(resolved, expanded); - } - - #[test] - fn test_resolve_configure_path_defaults_to_source_configure() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let actual_src = PathBuf::from("/tmp/src"); - let configure = resolve_configure_path(&spec, &actual_src); - assert_eq!(configure, actual_src.join("configure")); - } - - #[test] - fn test_resolve_configure_path_uses_configure_file_and_expands_vars() { - let flags = BuildFlags { - configure_file: "build-aux/$name-configure".into(), - ..BuildFlags::default() - }; - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Autotools, - flags, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let actual_src = PathBuf::from("/tmp/src"); - let configure = resolve_configure_path(&spec, &actual_src); - assert_eq!(configure, actual_src.join("build-aux/foo-configure")); - } -} +mod tests; diff --git a/src/builder/autotools/tests.rs b/src/builder/autotools/tests.rs new file mode 100644 index 0000000..dfec0a7 --- /dev/null +++ b/src/builder/autotools/tests.rs @@ -0,0 +1,540 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; +use std::path::PathBuf; +use tempfile::tempdir; + +#[test] +fn test_expand_shell_commands_simple() -> Result<()> { + let out = expand_shell_commands("x $(echo foo) y", "gcc")?; + assert_eq!(out, "x foo y"); + Ok(()) +} + +#[test] +fn test_expand_shell_commands_replace_cc() -> Result<()> { + // The command contains $CC which should be replaced with provided cc + let out = expand_shell_commands("start $($CC -v >/dev/null; echo OK) end", "mycc")?; + // Since the inner command echoes OK, after replacing $CC it should run and include OK + assert!(out.contains("OK") || out.contains("")); + Ok(()) +} + +#[test] +fn test_expand_with_envs_prefers_provided_envs() { + let envs = vec![ + ("CARCH".to_string(), "x86_64".to_string()), + ("CHOST".to_string(), "x86_64-sfg-linux-gnu".to_string()), + ]; + let out = expand_with_envs("--with-gcc-arch=$CARCH --host=${CHOST}", &envs); + assert!(out.contains("--with-gcc-arch=x86_64")); + assert!(out.contains("--host=x86_64-sfg-linux-gnu")); +} + +#[test] +fn test_expand_configure_arg_expands_spec_and_env_vars() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.2.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let envs = vec![("CARCH".to_string(), "aarch64".to_string())]; + let expanded = expand_configure_arg(&spec, "--program-prefix=$name-$version-$CARCH-", &envs); + assert_eq!(expanded, "--program-prefix=foo-1.2.3-aarch64-"); +} + +#[test] +fn test_expand_configure_arg_expands_host_build_dir_env() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.2.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let envs = vec![( + crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), + "/tmp/build-host".to_string(), + )]; + let expanded = expand_configure_arg( + &spec, + "--with-build-tools=$DEPOT_BUILD_HOST_DIR/tools", + &envs, + ); + assert_eq!(expanded, "--with-build-tools=/tmp/build-host/tools"); +} + +#[test] +fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); +} + +#[test] +fn test_configure_help_supports_host_build() { + let help = "Usage: configure [OPTION]...\n --host=HOST cross host\n --build=BUILD"; + assert!(configure_help_supports_option(help, "--host")); + assert!(configure_help_supports_option(help, "--build")); + assert!(!configure_help_supports_option(help, "--target")); +} + +#[test] +fn test_configure_help_supports_enable_disable_aliases() { + let help = " --enable-static build static libraries\n --with-zlib=DIR"; + assert!(configure_help_supports_option(help, "--disable-static")); + assert!(configure_help_supports_option(help, "--without-zlib")); +} + +#[test] +fn test_configure_help_supports_option_requires_exact_match() { + let help = "\ + --host-cc=HOSTCC use host C compiler + --build-suffix=SUFFIX library name suffix []"; + assert!(!configure_help_supports_option(help, "--host")); + assert!(!configure_help_supports_option(help, "--build")); +} + +#[test] +fn test_looks_like_configure_help_text_accepts_bootstrap_style_output() { + let help = "\ +Usage: ./bootstrap [...] +Options: + --help + --prefix=PREFIX"; + assert!(looks_like_configure_help_text(help)); +} + +#[test] +fn test_looks_like_configure_help_text_rejects_non_help_output() { + assert!(!looks_like_configure_help_text("")); + assert!(!looks_like_configure_help_text( + "Unknown option: --disable-static" + )); +} + +#[test] +fn test_configure_supports_option_defaults_by_configure_file_usage() { + assert!(configure_supports_option(None, "--host", "")); + assert!(!configure_supports_option( + None, + "--host", + "build-aux/Configure" + )); +} + +#[test] +fn test_default_configure_install_dirs_injects_expected_paths() { + let flags = BuildFlags::default(); + let help = "\ +--bindir=DIR +--sbindir=DIR +--libdir=DIR +--libexecdir=DIR +--sysconfdir=DIR +--localstatedir=DIR +--sharedstatedir=DIR +--includedir=DIR +--datarootdir=DIR +--datadir=DIR +--mandir=DIR +--infodir=DIR"; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--sbindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--sysconfdir=/etc")); + assert!(args.iter().any(|a| a == "--localstatedir=/var")); + assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib")); + assert!(args.iter().any(|a| a == "--includedir=/usr/include")); + assert!(args.iter().any(|a| a == "--datarootdir=/usr/share")); + assert!(args.iter().any(|a| a == "--datadir=/usr/share")); + assert!(args.iter().any(|a| a == "--mandir=/usr/share/man")); + assert!(args.iter().any(|a| a == "--infodir=/usr/share/info")); +} + +#[test] +fn test_default_configure_install_dirs_respects_explicit_user_overrides() { + let flags = BuildFlags { + configure: vec![ + "--sbindir=/sbin".to_string(), + "--libdir=/custom/lib".to_string(), + "--datadir=/custom/share".to_string(), + ], + ..BuildFlags::default() + }; + let help = "--bindir=DIR --sbindir=DIR --libdir=DIR --datadir=DIR"; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(!args.iter().any(|a| a.starts_with("--sbindir="))); + assert!(!args.iter().any(|a| a.starts_with("--libdir="))); + assert!(!args.iter().any(|a| a.starts_with("--datadir="))); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); +} + +#[test] +fn test_default_configure_install_dirs_lib32_uses_lib32_dirs() { + let help = "--libdir=DIR --libexecdir=DIR"; + let flags = BuildFlags { + lib32_variant: true, + ..BuildFlags::default() + }; + let args = default_configure_install_dirs(&flags, Some(help)); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib32")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32")); +} + +#[test] +fn test_default_configure_install_dirs_skips_when_not_advertised() { + let flags = BuildFlags::default(); + let args = default_configure_install_dirs(&flags, Some("--prefix=PREFIX")); + assert!(args.is_empty()); +} + +#[test] +fn test_configure_long_option_extracts_long_option_name() { + assert_eq!( + configure_long_option(" --disable-static "), + Some("--disable-static") + ); + assert_eq!( + configure_long_option("--with-zlib=/usr"), + Some("--with-zlib") + ); + assert_eq!(configure_long_option("prefix=/usr"), None); + assert_eq!(configure_long_option("-C"), None); +} + +#[test] +fn test_add_auto_configure_arg_if_supported_skips_unsupported_long_option() { + let mut cmd = Command::new("configure"); + add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "--disable-nls"); + + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert!(args.is_empty()); +} + +#[test] +fn test_add_auto_configure_arg_if_supported_keeps_supported_alias_and_non_option_args() { + let mut cmd = Command::new("configure"); + add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "--disable-static"); + add_auto_configure_arg_if_supported(&mut cmd, Some("--enable-static"), "srcdir"); + + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert_eq!(args, vec!["--disable-static", "srcdir"]); +} + +#[test] +fn test_install_destdir_path_uses_build_dir_for_lib32() { + let build_dir = Path::new("/tmp/build"); + let destdir = Path::new("/tmp/pkg"); + assert_eq!( + install_destdir_path(build_dir, destdir, false), + destdir.to_path_buf() + ); + assert_eq!( + install_destdir_path(build_dir, destdir, true), + build_dir.join("destdir") + ); +} + +#[test] +fn test_makefile_content_has_target_detects_check_and_test() { + let content = r#" +.PHONY: all check +all: + @echo all +check: + @echo check +"#; + assert!(makefile_content_has_target(content, "check")); + assert!(!makefile_content_has_target(content, "test")); +} + +#[test] +fn test_makefile_content_has_target_ignores_assignments() { + let content = r#" +TEST := value +VAR:=$(shell echo hi) +foo: bar + @true +"#; + assert!(!makefile_content_has_target(content, "TEST")); + assert!(!makefile_content_has_target(content, "VAR")); + assert!(!makefile_content_has_target(content, "check")); +} + +#[test] +fn test_maybe_find_autotools_test_target_respects_skip_tests() -> Result<()> { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("Makefile"), "check:\n\t@true\n").unwrap(); + + let skipped = maybe_find_autotools_test_target(tmp.path(), true)?; + assert_eq!(skipped, None); + + let detected = maybe_find_autotools_test_target(tmp.path(), false)?; + assert_eq!(detected, Some("check")); + Ok(()) +} + +#[test] +fn test_resolve_make_dirs_defaults_to_build_dir() -> Result<()> { + let tmp = tempdir().unwrap(); + let dirs = resolve_make_dirs(tmp.path(), &[], "build.flags.make_dirs")?; + assert_eq!(dirs, vec![tmp.path().to_path_buf()]); + Ok(()) +} + +#[test] +fn test_resolve_make_dirs_resolves_multiple_relative_dirs() -> Result<()> { + let tmp = tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("lib"))?; + std::fs::create_dir_all(tmp.path().join("libelf"))?; + let dirs = resolve_make_dirs( + tmp.path(), + &["lib".to_string(), "libelf".to_string()], + "build.flags.make_dirs", + )?; + assert_eq!( + dirs, + vec![tmp.path().join("lib"), tmp.path().join("libelf")] + ); + Ok(()) +} + +#[test] +fn test_add_make_variable_overrides_accepts_valid_assignments() -> Result<()> { + let mut cmd = Command::new("make"); + add_make_variable_overrides( + &mut cmd, + &[ + "CC=clang".to_string(), + "V=1".to_string(), + " CFLAGS=-O2 -pipe ".to_string(), + ], + "build", + )?; + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + assert_eq!(args, vec!["CC=clang", "V=1", "CFLAGS=-O2 -pipe"]); + Ok(()) +} + +#[test] +fn test_add_make_variable_overrides_rejects_invalid_assignment() { + let mut cmd = Command::new("make"); + let err = add_make_variable_overrides(&mut cmd, &["not-an-assignment".to_string()], "test") + .expect_err("expected invalid assignment to fail"); + assert!(err.to_string().contains("expected NAME=VALUE")); +} + +#[test] +fn test_has_make_variable_override_detects_destdir() { + assert!(has_make_variable_override( + &["DESTDIR=/tmp/pkg".to_string()], + "DESTDIR" + )); + assert!(has_make_variable_override( + &[" DESTDIR =/tmp/pkg ".to_string()], + "DESTDIR" + )); + assert!(!has_make_variable_override( + &["V=1".to_string(), "PREFIX=/usr".to_string()], + "DESTDIR" + )); +} + +#[test] +fn test_resolve_make_exec_defaults_and_trims() { + assert_eq!(resolve_make_exec(""), "make"); + assert_eq!(resolve_make_exec(" "), "make"); + assert_eq!(resolve_make_exec(" ninja "), "ninja"); +} + +#[test] +fn test_make_exec_supports_make_assignments_detects_make_variants() { + assert!(make_exec_supports_make_assignments("make")); + assert!(make_exec_supports_make_assignments("/usr/bin/gmake")); + assert!(!make_exec_supports_make_assignments("ninja")); +} + +#[test] +fn test_phase_targets_merges_singular_plural_and_default() { + assert_eq!( + phase_targets("bootstrap", &["stage1".into(), "stage2".into()], None), + vec![ + "bootstrap".to_string(), + "stage1".to_string(), + "stage2".to_string() + ] + ); + assert_eq!( + phase_targets("", &[], Some("install")), + vec!["install".to_string()] + ); +} + +#[test] +fn test_add_make_variable_overrides_if_supported_rejects_ninja_vars() { + let mut cmd = Command::new("ninja"); + let err = + add_make_variable_overrides_if_supported(&mut cmd, "ninja", &["V=1".to_string()], "build") + .expect_err("ninja should reject make variable override syntax"); + assert!(err.to_string().contains("build.flags.make_vars")); +} + +#[test] +fn test_resolve_actual_src_expands_source_subdir_vars() { + let tmp = tempdir().unwrap(); + let src_root = tmp.path().join("srcroot"); + let expanded = src_root.join("expect5.45.4").join("unix"); + std::fs::create_dir_all(&expanded).unwrap(); + + let spec = PackageSpec { + package: PackageInfo { + name: "expect".into(), + real_name: None, + version: "5.45.4".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags { + source_subdir: "$name$version/unix".into(), + ..BuildFlags::default() + }, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let resolved = resolve_actual_src(&spec, &src_root).unwrap(); + assert_eq!(resolved, expanded); +} + +#[test] +fn test_resolve_configure_path_defaults_to_source_configure() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let actual_src = PathBuf::from("/tmp/src"); + let configure = resolve_configure_path(&spec, &actual_src); + assert_eq!(configure, actual_src.join("configure")); +} + +#[test] +fn test_resolve_configure_path_uses_configure_file_and_expands_vars() { + let flags = BuildFlags { + configure_file: "build-aux/$name-configure".into(), + ..BuildFlags::default() + }; + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Autotools, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let actual_src = PathBuf::from("/tmp/src"); + let configure = resolve_configure_path(&spec, &actual_src); + assert_eq!(configure, actual_src.join("build-aux/foo-configure")); +} diff --git a/src/builder/cmake.rs b/src/builder/cmake.rs old mode 100755 new mode 100644 index 9f9a1b9..d103d5a --- a/src/builder/cmake.rs +++ b/src/builder/cmake.rs @@ -810,440 +810,4 @@ fn resolve_actual_src( } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, PackageInfo, PackageSpec}; - use crate::test_support::TestEnv; - use tempfile::tempdir; - - #[test] - fn test_expand_env_vars_replaces_vars() { - // Set a test env var - let mut env = TestEnv::new(); - env.set_var("DEPOT_TEST_FOO", "bar"); - let input = "$DEPOT_TEST_FOO and ${DEPOT_TEST_FOO}"; - let out = expand_env_vars(input); - assert!(out.contains("bar")); - assert_eq!(out, "bar and bar"); - } - - #[test] - fn test_expand_with_envs_prefers_provided_envs() { - let envs = vec![ - ("CXX".to_string(), "my-cxx".to_string()), - ("CC".to_string(), "my-cc".to_string()), - ]; - let s = "-DCMAKE_C_COMPILER=$CC -DCMAKE_CXX_COMPILER=${CXX} -DROOT=$HOME"; - let out = expand_with_envs(s, &envs); - assert!(out.contains("my-cc")); - assert!(out.contains("my-cxx")); - // $HOME should be expanded from process env (may be present) - } - - #[test] - fn test_expand_with_envs_expands_host_build_dir() { - let envs = vec![( - crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), - "/tmp/build-host".to_string(), - )]; - let out = expand_with_envs("-DTOOLS_DIR=$DEPOT_BUILD_HOST_DIR/bin", &envs); - assert_eq!(out, "-DTOOLS_DIR=/tmp/build-host/bin"); - } - - #[test] - fn test_num_cpus_at_least_one() { - let n = num_cpus(); - assert!(n >= 1); - } - - #[test] - fn test_phase_targets_merges_singular_and_plural() { - assert_eq!( - phase_targets("bootstrap", &["stage1".into(), "stage2".into()]), - vec![ - "bootstrap".to_string(), - "stage1".to_string(), - "stage2".to_string() - ] - ); - assert!(phase_targets("", &[]).is_empty()); - } - - #[test] - fn test_cmake_uses_default_ctest_without_explicit_targets() { - assert!(cmake_uses_default_ctest(&BuildFlags::default())); - - let explicit_single = BuildFlags { - make_test_target: "test".into(), - ..BuildFlags::default() - }; - assert!(!cmake_uses_default_ctest(&explicit_single)); - - let explicit_many = BuildFlags { - make_test_targets: vec!["check".into()], - ..BuildFlags::default() - }; - assert!(!cmake_uses_default_ctest(&explicit_many)); - } - - #[test] - fn test_cmake_generator_for_make_exec_detects_ninja_and_make() { - assert_eq!(cmake_generator_for_make_exec("ninja"), Some("Ninja")); - assert_eq!( - cmake_generator_for_make_exec("/usr/bin/gmake"), - Some("Unix Makefiles") - ); - assert_eq!(cmake_generator_for_make_exec("samurai"), None); - } - - #[test] - fn test_cmake_configure_flag_detectors() { - assert!(cmake_configure_flags_specify_generator(&[ - "-G".to_string(), - "Ninja".to_string() - ])); - assert!(cmake_configure_flags_specify_generator(&[ - "--generator=Unix Makefiles".to_string() - ])); - assert!(!cmake_configure_flags_specify_generator(&[ - "-DCMAKE_BUILD_TYPE=Release".to_string() - ])); - - assert!(cmake_configure_flags_set_make_program(&[ - "-DCMAKE_MAKE_PROGRAM=/usr/bin/ninja".to_string() - ])); - assert!(!cmake_configure_flags_set_make_program(&[ - "-DCMAKE_C_COMPILER=clang".to_string() - ])); - } - - #[test] - fn test_cmake_cache_entry_value_supports_plain_and_typed_entries() { - let flags = vec![ - "-DCMAKE_INSTALL_PREFIX=/usr".to_string(), - "-DCMAKE_INSTALL_LIBDIR:PATH=/usr/lib64".to_string(), - ]; - - assert_eq!( - cmake_cache_entry_value(&flags, "CMAKE_INSTALL_PREFIX"), - Some("/usr") - ); - assert_eq!( - cmake_cache_entry_value(&flags, "CMAKE_INSTALL_LIBDIR"), - Some("/usr/lib64") - ); - assert_eq!( - cmake_cache_entry_value(&flags, "CMAKE_INSTALL_BINDIR"), - None - ); - } - - #[test] - fn test_effective_cmake_install_prefix_prefers_explicit_configure_flag() { - let flags = BuildFlags { - prefix: "/usr".into(), - configure: vec!["-DCMAKE_INSTALL_PREFIX=/opt/soundtouch".into()], - ..BuildFlags::default() - }; - - assert_eq!(effective_cmake_install_prefix(&flags), "/opt/soundtouch"); - } - - #[test] - fn test_cmake_dir_value_for_prefix_converts_prefix_owned_absolute_paths() { - assert_eq!(cmake_dir_value_for_prefix("/usr", "/usr/lib".into()), "lib"); - assert_eq!( - cmake_dir_value_for_prefix("/usr", "/usr/share/man".into()), - "share/man" - ); - } - - #[test] - fn test_cmake_dir_value_for_prefix_keeps_non_prefix_absolute_paths() { - assert_eq!(cmake_dir_value_for_prefix("/usr", "/etc".into()), "/etc"); - assert_eq!( - cmake_dir_value_for_prefix("/opt/pkg", "/usr/bin".into()), - "/usr/bin" - ); - } - - #[test] - fn test_cmake_install_dir_args_include_prefix_relative_defaults() { - let args = cmake_install_dir_args(&BuildFlags::default(), "/usr"); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SBINDIR=bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_LOCALSTATEDIR=/var") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_SHAREDSTATEDIR=/var/lib") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_DATAROOTDIR=share") - ); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_INFODIR=share/info") - ); - } - - #[test] - fn test_cmake_install_dir_args_use_lib32_defaults() { - let flags = BuildFlags { - lib32_variant: true, - ..BuildFlags::default() - }; - - let args = cmake_install_dir_args(&flags, "/usr"); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib32")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib32")); - } - - #[test] - fn test_cmake_lib32_target_args_include_compiler_target_defaults() { - let flags = BuildFlags { - lib32_variant: true, - chost: "x86_64-sfg-linux-gnu".into(), - ..BuildFlags::default() - }; - - let args = cmake_lib32_target_args(&flags, None); - assert!(args.iter().any(|a| a == "-DCMAKE_SYSTEM_PROCESSOR=i686")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_C_COMPILER_TARGET=i686-sfg-linux-gnu") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_ASM_COMPILER_TARGET=i686-sfg-linux-gnu") - ); - } - - #[test] - fn test_cmake_lib32_target_args_respect_explicit_overrides() { - let flags = BuildFlags { - lib32_variant: true, - chost: "x86_64-sfg-linux-gnu".into(), - configure: vec![ - "-DCMAKE_C_COMPILER_TARGET=i686-custom-linux-gnu".into(), - "-DCMAKE_SYSTEM_PROCESSOR=i686".into(), - ], - ..BuildFlags::default() - }; - - let args = cmake_lib32_target_args(&flags, None); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_C_COMPILER_TARGET=")) - ); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_SYSTEM_PROCESSOR=")) - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") - ); - } - - #[test] - fn test_cmake_depot_sysroot_args_skip_live_rootfs() { - let args = cmake_depot_sysroot_args(&BuildFlags::default(), "/"); - assert!(args.is_empty()); - } - - #[test] - fn test_cmake_depot_sysroot_args_include_non_live_rootfs_defaults() { - let args = cmake_depot_sysroot_args(&BuildFlags::default(), "/tmp/depot-root"); - assert!(args.iter().any(|a| a == "-DCMAKE_SYSROOT=/tmp/depot-root")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY") - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY") - ); - } - - #[test] - fn test_cmake_depot_sysroot_args_respect_explicit_configure_overrides() { - let flags = BuildFlags { - configure: vec![ - "-DCMAKE_SYSROOT=/opt/custom-root".into(), - "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY:STRING=BOTH".into(), - ], - ..BuildFlags::default() - }; - - let args = cmake_depot_sysroot_args(&flags, "/tmp/depot-root"); - assert!(!args.iter().any(|a| a.starts_with("-DCMAKE_SYSROOT="))); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=")) - ); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER") - ); - } - - #[test] - fn test_cmake_install_dir_args_respect_explicit_user_overrides() { - let flags = BuildFlags { - configure: vec![ - "-DCMAKE_INSTALL_SBINDIR=/sbin".to_string(), - "-DCMAKE_INSTALL_LIBDIR:PATH=/custom/lib".to_string(), - "-DCMAKE_INSTALL_DATADIR=/custom/share".to_string(), - ], - ..BuildFlags::default() - }; - - let args = cmake_install_dir_args(&flags, "/usr"); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_INSTALL_SBINDIR=")) - ); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_INSTALL_LIBDIR=")) - ); - assert!( - !args - .iter() - .any(|a| a.starts_with("-DCMAKE_INSTALL_DATADIR=")) - ); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); - } - - #[test] - fn test_cmake_install_dir_args_convert_custom_prefix_owned_dirs() { - let flags = BuildFlags { - prefix: "/opt/soundtouch".into(), - bindir: "/opt/soundtouch/bin".into(), - libdir: "/opt/soundtouch/lib64".into(), - includedir: "/opt/soundtouch/include/soundtouch".into(), - datadir: "/opt/soundtouch/share".into(), - mandir: "/opt/soundtouch/share/man".into(), - ..BuildFlags::default() - }; - - let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib64")); - assert!( - args.iter() - .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include/soundtouch") - ); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); - } - - #[test] - fn test_cmake_install_dir_args_keep_absolute_dirs_outside_prefix() { - let flags = BuildFlags { - prefix: "/opt/soundtouch".into(), - bindir: "/usr/bin".into(), - sysconfdir: "/etc".into(), - ..BuildFlags::default() - }; - - let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=/usr/bin")); - assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); - } - - #[test] - fn resolve_actual_src_prefers_srcdir_then_specdir_and_handles_absolute() { - let tmp = tempdir().unwrap(); - let src_root = tmp.path().join("srcroot"); - let spec_dir = tmp.path().join("specdir"); - let external = tmp.path().join("external"); - let expanded = src_root.join("x-1.0").join("sub"); - std::fs::create_dir_all(src_root.join("sub")).unwrap(); - std::fs::create_dir_all(&expanded).unwrap(); - // create directories for candidates - std::fs::create_dir_all(spec_dir.join("../llvm")).unwrap(); - std::fs::create_dir_all(&external).unwrap(); - - let spec = PackageSpec { - package: PackageInfo { - name: "x".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::CMake, - flags: BuildFlags { - source_subdir: "sub".into(), - ..BuildFlags::default() - }, - }, - dependencies: Default::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: spec_dir.clone(), - }; - - // case: relative path under src_dir - let p = resolve_actual_src(&spec, &src_root).unwrap(); - assert!(p.ends_with("sub")); - - // case: ../llvm should resolve relative to spec_dir - let mut spec2 = spec.clone(); - spec2.build.flags.source_subdir = "../llvm".into(); - let p2 = resolve_actual_src(&spec2, &src_root).unwrap(); - assert!(p2.ends_with("llvm")); - - // case: absolute path - let mut spec3 = spec.clone(); - spec3.build.flags.source_subdir = external.to_string_lossy().into_owned(); - let p3 = resolve_actual_src(&spec3, &src_root).unwrap(); - assert_eq!(p3, external); - - // case: variable expansion in source_subdir - let mut spec4 = spec.clone(); - spec4.build.flags.source_subdir = "$name-$version/sub".into(); - let p4 = resolve_actual_src(&spec4, &src_root).unwrap(); - assert_eq!(p4, expanded); - } -} +mod tests; diff --git a/src/builder/cmake/tests.rs b/src/builder/cmake/tests.rs new file mode 100644 index 0000000..aa1185f --- /dev/null +++ b/src/builder/cmake/tests.rs @@ -0,0 +1,435 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, PackageInfo, PackageSpec}; +use crate::test_support::TestEnv; +use tempfile::tempdir; + +#[test] +fn test_expand_env_vars_replaces_vars() { + // Set a test env var + let mut env = TestEnv::new(); + env.set_var("DEPOT_TEST_FOO", "bar"); + let input = "$DEPOT_TEST_FOO and ${DEPOT_TEST_FOO}"; + let out = expand_env_vars(input); + assert!(out.contains("bar")); + assert_eq!(out, "bar and bar"); +} + +#[test] +fn test_expand_with_envs_prefers_provided_envs() { + let envs = vec![ + ("CXX".to_string(), "my-cxx".to_string()), + ("CC".to_string(), "my-cc".to_string()), + ]; + let s = "-DCMAKE_C_COMPILER=$CC -DCMAKE_CXX_COMPILER=${CXX} -DROOT=$HOME"; + let out = expand_with_envs(s, &envs); + assert!(out.contains("my-cc")); + assert!(out.contains("my-cxx")); + // $HOME should be expanded from process env (may be present) +} + +#[test] +fn test_expand_with_envs_expands_host_build_dir() { + let envs = vec![( + crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), + "/tmp/build-host".to_string(), + )]; + let out = expand_with_envs("-DTOOLS_DIR=$DEPOT_BUILD_HOST_DIR/bin", &envs); + assert_eq!(out, "-DTOOLS_DIR=/tmp/build-host/bin"); +} + +#[test] +fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); +} + +#[test] +fn test_phase_targets_merges_singular_and_plural() { + assert_eq!( + phase_targets("bootstrap", &["stage1".into(), "stage2".into()]), + vec![ + "bootstrap".to_string(), + "stage1".to_string(), + "stage2".to_string() + ] + ); + assert!(phase_targets("", &[]).is_empty()); +} + +#[test] +fn test_cmake_uses_default_ctest_without_explicit_targets() { + assert!(cmake_uses_default_ctest(&BuildFlags::default())); + + let explicit_single = BuildFlags { + make_test_target: "test".into(), + ..BuildFlags::default() + }; + assert!(!cmake_uses_default_ctest(&explicit_single)); + + let explicit_many = BuildFlags { + make_test_targets: vec!["check".into()], + ..BuildFlags::default() + }; + assert!(!cmake_uses_default_ctest(&explicit_many)); +} + +#[test] +fn test_cmake_generator_for_make_exec_detects_ninja_and_make() { + assert_eq!(cmake_generator_for_make_exec("ninja"), Some("Ninja")); + assert_eq!( + cmake_generator_for_make_exec("/usr/bin/gmake"), + Some("Unix Makefiles") + ); + assert_eq!(cmake_generator_for_make_exec("samurai"), None); +} + +#[test] +fn test_cmake_configure_flag_detectors() { + assert!(cmake_configure_flags_specify_generator(&[ + "-G".to_string(), + "Ninja".to_string() + ])); + assert!(cmake_configure_flags_specify_generator(&[ + "--generator=Unix Makefiles".to_string() + ])); + assert!(!cmake_configure_flags_specify_generator(&[ + "-DCMAKE_BUILD_TYPE=Release".to_string() + ])); + + assert!(cmake_configure_flags_set_make_program(&[ + "-DCMAKE_MAKE_PROGRAM=/usr/bin/ninja".to_string() + ])); + assert!(!cmake_configure_flags_set_make_program(&[ + "-DCMAKE_C_COMPILER=clang".to_string() + ])); +} + +#[test] +fn test_cmake_cache_entry_value_supports_plain_and_typed_entries() { + let flags = vec![ + "-DCMAKE_INSTALL_PREFIX=/usr".to_string(), + "-DCMAKE_INSTALL_LIBDIR:PATH=/usr/lib64".to_string(), + ]; + + assert_eq!( + cmake_cache_entry_value(&flags, "CMAKE_INSTALL_PREFIX"), + Some("/usr") + ); + assert_eq!( + cmake_cache_entry_value(&flags, "CMAKE_INSTALL_LIBDIR"), + Some("/usr/lib64") + ); + assert_eq!( + cmake_cache_entry_value(&flags, "CMAKE_INSTALL_BINDIR"), + None + ); +} + +#[test] +fn test_effective_cmake_install_prefix_prefers_explicit_configure_flag() { + let flags = BuildFlags { + prefix: "/usr".into(), + configure: vec!["-DCMAKE_INSTALL_PREFIX=/opt/soundtouch".into()], + ..BuildFlags::default() + }; + + assert_eq!(effective_cmake_install_prefix(&flags), "/opt/soundtouch"); +} + +#[test] +fn test_cmake_dir_value_for_prefix_converts_prefix_owned_absolute_paths() { + assert_eq!(cmake_dir_value_for_prefix("/usr", "/usr/lib".into()), "lib"); + assert_eq!( + cmake_dir_value_for_prefix("/usr", "/usr/share/man".into()), + "share/man" + ); +} + +#[test] +fn test_cmake_dir_value_for_prefix_keeps_non_prefix_absolute_paths() { + assert_eq!(cmake_dir_value_for_prefix("/usr", "/etc".into()), "/etc"); + assert_eq!( + cmake_dir_value_for_prefix("/opt/pkg", "/usr/bin".into()), + "/usr/bin" + ); +} + +#[test] +fn test_cmake_install_dir_args_include_prefix_relative_defaults() { + let args = cmake_install_dir_args(&BuildFlags::default(), "/usr"); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SBINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_LOCALSTATEDIR=/var") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_SHAREDSTATEDIR=/var/lib") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_DATAROOTDIR=share") + ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_INFODIR=share/info") + ); +} + +#[test] +fn test_cmake_install_dir_args_use_lib32_defaults() { + let flags = BuildFlags { + lib32_variant: true, + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, "/usr"); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib32")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBEXECDIR=lib32")); +} + +#[test] +fn test_cmake_lib32_target_args_include_compiler_target_defaults() { + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".into(), + ..BuildFlags::default() + }; + + let args = cmake_lib32_target_args(&flags, None); + assert!(args.iter().any(|a| a == "-DCMAKE_SYSTEM_PROCESSOR=i686")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_C_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_ASM_COMPILER_TARGET=i686-sfg-linux-gnu") + ); +} + +#[test] +fn test_cmake_lib32_target_args_respect_explicit_overrides() { + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".into(), + configure: vec![ + "-DCMAKE_C_COMPILER_TARGET=i686-custom-linux-gnu".into(), + "-DCMAKE_SYSTEM_PROCESSOR=i686".into(), + ], + ..BuildFlags::default() + }; + + let args = cmake_lib32_target_args(&flags, None); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_C_COMPILER_TARGET=")) + ); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_SYSTEM_PROCESSOR=")) + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_CXX_COMPILER_TARGET=i686-sfg-linux-gnu") + ); +} + +#[test] +fn test_cmake_depot_sysroot_args_skip_live_rootfs() { + let args = cmake_depot_sysroot_args(&BuildFlags::default(), "/"); + assert!(args.is_empty()); +} + +#[test] +fn test_cmake_depot_sysroot_args_include_non_live_rootfs_defaults() { + let args = cmake_depot_sysroot_args(&BuildFlags::default(), "/tmp/depot-root"); + assert!(args.iter().any(|a| a == "-DCMAKE_SYSROOT=/tmp/depot-root")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY") + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY") + ); +} + +#[test] +fn test_cmake_depot_sysroot_args_respect_explicit_configure_overrides() { + let flags = BuildFlags { + configure: vec![ + "-DCMAKE_SYSROOT=/opt/custom-root".into(), + "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY:STRING=BOTH".into(), + ], + ..BuildFlags::default() + }; + + let args = cmake_depot_sysroot_args(&flags, "/tmp/depot-root"); + assert!(!args.iter().any(|a| a.starts_with("-DCMAKE_SYSROOT="))); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=")) + ); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER") + ); +} + +#[test] +fn test_cmake_install_dir_args_respect_explicit_user_overrides() { + let flags = BuildFlags { + configure: vec![ + "-DCMAKE_INSTALL_SBINDIR=/sbin".to_string(), + "-DCMAKE_INSTALL_LIBDIR:PATH=/custom/lib".to_string(), + "-DCMAKE_INSTALL_DATADIR=/custom/share".to_string(), + ], + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, "/usr"); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_INSTALL_SBINDIR=")) + ); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_INSTALL_LIBDIR=")) + ); + assert!( + !args + .iter() + .any(|a| a.starts_with("-DCMAKE_INSTALL_DATADIR=")) + ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); +} + +#[test] +fn test_cmake_install_dir_args_convert_custom_prefix_owned_dirs() { + let flags = BuildFlags { + prefix: "/opt/soundtouch".into(), + bindir: "/opt/soundtouch/bin".into(), + libdir: "/opt/soundtouch/lib64".into(), + includedir: "/opt/soundtouch/include/soundtouch".into(), + datadir: "/opt/soundtouch/share".into(), + mandir: "/opt/soundtouch/share/man".into(), + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_LIBDIR=lib64")); + assert!( + args.iter() + .any(|a| a == "-DCMAKE_INSTALL_INCLUDEDIR=include/soundtouch") + ); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_DATADIR=share")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_MANDIR=share/man")); +} + +#[test] +fn test_cmake_install_dir_args_keep_absolute_dirs_outside_prefix() { + let flags = BuildFlags { + prefix: "/opt/soundtouch".into(), + bindir: "/usr/bin".into(), + sysconfdir: "/etc".into(), + ..BuildFlags::default() + }; + + let args = cmake_install_dir_args(&flags, effective_cmake_install_prefix(&flags)); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_BINDIR=/usr/bin")); + assert!(args.iter().any(|a| a == "-DCMAKE_INSTALL_SYSCONFDIR=/etc")); +} + +#[test] +fn resolve_actual_src_prefers_srcdir_then_specdir_and_handles_absolute() { + let tmp = tempdir().unwrap(); + let src_root = tmp.path().join("srcroot"); + let spec_dir = tmp.path().join("specdir"); + let external = tmp.path().join("external"); + let expanded = src_root.join("x-1.0").join("sub"); + std::fs::create_dir_all(src_root.join("sub")).unwrap(); + std::fs::create_dir_all(&expanded).unwrap(); + // create directories for candidates + std::fs::create_dir_all(spec_dir.join("../llvm")).unwrap(); + std::fs::create_dir_all(&external).unwrap(); + + let spec = PackageSpec { + package: PackageInfo { + name: "x".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::CMake, + flags: BuildFlags { + source_subdir: "sub".into(), + ..BuildFlags::default() + }, + }, + dependencies: Default::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: spec_dir.clone(), + }; + + // case: relative path under src_dir + let p = resolve_actual_src(&spec, &src_root).unwrap(); + assert!(p.ends_with("sub")); + + // case: ../llvm should resolve relative to spec_dir + let mut spec2 = spec.clone(); + spec2.build.flags.source_subdir = "../llvm".into(); + let p2 = resolve_actual_src(&spec2, &src_root).unwrap(); + assert!(p2.ends_with("llvm")); + + // case: absolute path + let mut spec3 = spec.clone(); + spec3.build.flags.source_subdir = external.to_string_lossy().into_owned(); + let p3 = resolve_actual_src(&spec3, &src_root).unwrap(); + assert_eq!(p3, external); + + // case: variable expansion in source_subdir + let mut spec4 = spec.clone(); + spec4.build.flags.source_subdir = "$name-$version/sub".into(); + let p4 = resolve_actual_src(&spec4, &src_root).unwrap(); + assert_eq!(p4, expanded); +} diff --git a/src/builder/meson.rs b/src/builder/meson.rs old mode 100755 new mode 100644 index a34d2a1..29dacf0 --- a/src/builder/meson.rs +++ b/src/builder/meson.rs @@ -736,349 +736,4 @@ fn resolve_actual_src(spec: &crate::package::PackageSpec, src_dir: &Path) -> Res } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source}; - use crate::test_support::TestEnv; - use tempfile::tempdir; - - #[test] - fn test_num_cpus_at_least_one() { - let n = num_cpus(); - assert!(n >= 1); - } - - #[test] - fn test_meson_setup_args_include_configure_flags() { - let flags = BuildFlags { - prefix: "/usr".to_string(), - configure: vec!["-Dmanpages=false".to_string()], - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert!(args.iter().any(|a| a == "-Dmanpages=false")); - assert!(args.iter().any(|a| a == "--prefix=/usr")); - assert!(args.iter().any(|a| a == "--buildtype=release")); - } - - #[test] - fn test_meson_setup_args_expand_host_build_dir() { - let flags = BuildFlags { - configure: vec!["-Dtools_dir=$DEPOT_BUILD_HOST_DIR/bin".into()], - ..BuildFlags::default() - }; - - let args = meson_setup_args( - &flags, - None, - &[( - crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), - "/tmp/build-host".to_string(), - )], - ); - assert!(args.iter().any(|a| a == "-Dtools_dir=/tmp/build-host/bin")); - } - - #[test] - fn test_configure_pkg_config_env_uses_lib32_dirs_and_pkgconf() -> Result<()> { - let tmp = tempdir()?; - let pkgconf = tmp.path().join("pkgconf"); - std::fs::write(&pkgconf, "#!/bin/sh\nexit 0\n")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&pkgconf)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&pkgconf, perms)?; - } - - let mut env = TestEnv::new(); - env.set_var("PATH", tmp.path()); - env.set_var("PKG_CONFIG", ""); - - let mut env_vars = Vec::new(); - let flags = BuildFlags { - lib32_variant: true, - rootfs: "/".into(), - ..BuildFlags::default() - }; - configure_pkg_config_env(&mut env_vars, &flags, None); - - assert!( - env_vars - .iter() - .any(|(k, v)| k == "PKG_CONFIG" && v.ends_with("/pkgconf")) - ); - assert!(env_vars.iter().any(|(k, v)| { - k == "PKG_CONFIG_LIBDIR" && v == "/usr/lib32/pkgconfig:/usr/share/pkgconfig" - })); - Ok(()) - } - - #[test] - fn test_generate_lib32_meson_cross_file_writes_pkg_config_binary_when_available() -> Result<()> - { - let tmp = tempdir()?; - let tools = tempdir()?; - let pkgconf = tools.path().join("pkgconf"); - std::fs::write(&pkgconf, "#!/bin/sh\nexit 0\n")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&pkgconf)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&pkgconf, perms)?; - } - - let mut env = TestEnv::new(); - env.set_var("PATH", tools.path()); - env.set_var("PKG_CONFIG", ""); - - let flags = BuildFlags { - chost: "x86_64-sfg-linux-gnu".into(), - ..BuildFlags::default() - }; - - let path = generate_lib32_meson_cross_file(&flags, tmp.path())?; - let content = std::fs::read_to_string(path)?; - assert!(content.contains("pkg-config = '/")); - assert!(content.contains("pkgconf")); - Ok(()) - } - - #[test] - fn test_meson_setup_args_include_install_dirs() { - let args = meson_setup_args(&BuildFlags::default(), None, &[]); - assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); - assert!(args.iter().any(|a| a == "--sbindir=/usr/bin")); - assert!(args.iter().any(|a| a == "--libdir=/usr/lib")); - assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib")); - assert!(args.iter().any(|a| a == "--sysconfdir=/etc")); - assert!(args.iter().any(|a| a == "--localstatedir=/var")); - assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib")); - assert!(args.iter().any(|a| a == "--includedir=/usr/include")); - assert!(args.iter().any(|a| a == "--datadir=/usr/share")); - assert!(args.iter().any(|a| a == "--mandir=/usr/share/man")); - assert!(args.iter().any(|a| a == "--infodir=/usr/share/info")); - } - - #[test] - fn test_meson_setup_args_derive_dirs_from_datarootdir() { - let flags = BuildFlags { - datarootdir: "/opt/share-root".to_string(), - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert!(!args.iter().any(|a| a.starts_with("--datarootdir="))); - assert!(args.iter().any(|a| a == "--datadir=/opt/share-root")); - assert!(args.iter().any(|a| a == "--mandir=/opt/share-root/man")); - assert!(args.iter().any(|a| a == "--infodir=/opt/share-root/info")); - } - - #[test] - fn test_meson_setup_args_honor_explicit_prefix() { - let flags = BuildFlags { - prefix: "/usr".to_string(), - configure: vec!["--prefix=/opt".to_string()], - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert_eq!(args.iter().filter(|a| a.starts_with("--prefix")).count(), 1); - assert!(args.iter().any(|a| a == "--prefix=/opt")); - } - - #[test] - fn test_meson_setup_args_honor_explicit_install_dirs() { - let flags = BuildFlags { - configure: vec![ - "--sbindir=/sbin".to_string(), - "--libdir=/custom/lib".to_string(), - "--datadir=/custom/share".to_string(), - ], - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert!(!args.iter().any(|a| a == "--sbindir=/usr/bin")); - assert!(!args.iter().any(|a| a == "--libdir=/usr/lib")); - assert!(!args.iter().any(|a| a == "--datadir=/usr/share")); - assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); - } - - #[test] - fn test_meson_setup_args_use_lib32_defaults() { - let flags = BuildFlags { - lib32_variant: true, - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert!(args.iter().any(|a| a == "--libdir=/usr/lib32")); - assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32")); - } - - #[test] - fn test_meson_setup_args_include_linker_override() { - let flags = BuildFlags { - ld: "ld.lld".to_string(), - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert!(args.iter().any(|a| a == "-Dc_ld=ld.lld")); - assert!(args.iter().any(|a| a == "-Dcpp_ld=ld.lld")); - } - - #[test] - fn test_meson_setup_args_honor_explicit_linker_override() { - let flags = BuildFlags { - ld: "ld.lld".to_string(), - configure: vec!["-Dc_ld=gold".to_string(), "-Dcpp_ld=gold".to_string()], - ..BuildFlags::default() - }; - - let args = meson_setup_args(&flags, None, &[]); - assert_eq!(args.iter().filter(|a| *a == "-Dc_ld=gold").count(), 1); - assert_eq!(args.iter().filter(|a| *a == "-Dcpp_ld=gold").count(), 1); - assert!(!args.iter().any(|a| a == "-Dc_ld=ld.lld")); - assert!(!args.iter().any(|a| a == "-Dcpp_ld=ld.lld")); - } - - #[test] - fn test_compiler_command_with_lib32_target_adds_clang_target() { - let parts = compiler_command_with_lib32_target("clang -m32", "i686-sfg-linux-gnu"); - assert_eq!( - parts, - vec![ - "clang".to_string(), - "-m32".to_string(), - "--target=i686-sfg-linux-gnu".to_string() - ] - ); - } - - #[test] - fn test_compiler_command_with_lib32_target_skips_non_clang_compilers() { - let parts = compiler_command_with_lib32_target("gcc -m32", "i686-sfg-linux-gnu"); - assert_eq!(parts, vec!["gcc".to_string(), "-m32".to_string()]); - } - - #[test] - fn test_generate_lib32_meson_cross_file_sets_x86_host_machine() -> Result<()> { - let tmp = tempdir()?; - let flags = BuildFlags { - lib32_variant: true, - chost: "x86_64-sfg-linux-gnu".to_string(), - cc: "clang -m32".to_string(), - cxx: "clang++ -m32".to_string(), - ar: "llvm-ar".to_string(), - strip: "llvm-strip".to_string(), - ld: "ld.lld".to_string(), - nm: "llvm-nm".to_string(), - objcopy: "llvm-objcopy".to_string(), - objdump: "llvm-objdump".to_string(), - readelf: "llvm-readelf".to_string(), - ..BuildFlags::default() - }; - - let path = generate_lib32_meson_cross_file(&flags, tmp.path())?; - let content = fs::read_to_string(path)?; - assert!(content.contains("Generated by depot for target: i686-sfg-linux-gnu")); - assert!(content.contains("c = ['clang', '-m32', '--target=i686-sfg-linux-gnu']")); - assert!(content.contains("cpp = ['clang++', '-m32', '--target=i686-sfg-linux-gnu']")); - assert!(content.contains("strip = 'llvm-strip'")); - assert!(content.contains("ld = 'ld.lld'")); - assert!(content.contains("nm = 'llvm-nm'")); - assert!(content.contains("objcopy = 'llvm-objcopy'")); - assert!(content.contains("objdump = 'llvm-objdump'")); - assert!(content.contains("readelf = 'llvm-readelf'")); - assert!(content.contains("cpu_family = 'x86'")); - assert!(content.contains("cpu = 'i686'")); - Ok(()) - } - - #[test] - fn test_resolve_build_dir_uses_flag() { - let flags = BuildFlags { - build_dir: Some("build".to_string()), - ..BuildFlags::default() - }; - let src = Path::new("/tmp/src"); - assert_eq!( - resolve_build_dir(src, &flags), - PathBuf::from("/tmp/src/build") - ); - } - - #[test] - fn test_meson_test_suites_uses_single_and_multiple_targets() { - let flags = BuildFlags { - make_test_target: "unit".to_string(), - make_test_targets: vec!["integration".to_string(), " smoke ".to_string()], - ..BuildFlags::default() - }; - assert_eq!( - meson_test_suites(&flags), - vec![ - "unit".to_string(), - "integration".to_string(), - "smoke".to_string() - ] - ); - } - - #[test] - fn test_meson_test_suites_empty_without_targets() { - assert!(meson_test_suites(&BuildFlags::default()).is_empty()); - } - - #[test] - fn test_resolve_actual_src_uses_source_subdir_under_source() -> Result<()> { - let src = tempdir()?; - let spec_dir = tempdir()?; - fs::create_dir_all(src.path().join("sub"))?; - - let spec = PackageSpec { - package: PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "u".into(), - sha256: "s".into(), - extract_dir: "e".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Meson, - flags: BuildFlags { - source_subdir: "sub".into(), - ..BuildFlags::default() - }, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: spec_dir.path().to_path_buf(), - }; - - let resolved = resolve_actual_src(&spec, src.path())?; - assert_eq!(resolved, src.path().join("sub")); - Ok(()) - } -} +mod tests; diff --git a/src/builder/meson/tests.rs b/src/builder/meson/tests.rs new file mode 100644 index 0000000..2b34b9a --- /dev/null +++ b/src/builder/meson/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source}; +use crate::test_support::TestEnv; +use tempfile::tempdir; + +#[test] +fn test_num_cpus_at_least_one() { + let n = num_cpus(); + assert!(n >= 1); +} + +#[test] +fn test_meson_setup_args_include_configure_flags() { + let flags = BuildFlags { + prefix: "/usr".to_string(), + configure: vec!["-Dmanpages=false".to_string()], + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert!(args.iter().any(|a| a == "-Dmanpages=false")); + assert!(args.iter().any(|a| a == "--prefix=/usr")); + assert!(args.iter().any(|a| a == "--buildtype=release")); +} + +#[test] +fn test_meson_setup_args_expand_host_build_dir() { + let flags = BuildFlags { + configure: vec!["-Dtools_dir=$DEPOT_BUILD_HOST_DIR/bin".into()], + ..BuildFlags::default() + }; + + let args = meson_setup_args( + &flags, + None, + &[( + crate::builder::DEPOT_BUILD_HOST_DIR_ENV.to_string(), + "/tmp/build-host".to_string(), + )], + ); + assert!(args.iter().any(|a| a == "-Dtools_dir=/tmp/build-host/bin")); +} + +#[test] +fn test_configure_pkg_config_env_uses_lib32_dirs_and_pkgconf() -> Result<()> { + let tmp = tempdir()?; + let pkgconf = tmp.path().join("pkgconf"); + std::fs::write(&pkgconf, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&pkgconf)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&pkgconf, perms)?; + } + + let mut env = TestEnv::new(); + env.set_var("PATH", tmp.path()); + env.set_var("PKG_CONFIG", ""); + + let mut env_vars = Vec::new(); + let flags = BuildFlags { + lib32_variant: true, + rootfs: "/".into(), + ..BuildFlags::default() + }; + configure_pkg_config_env(&mut env_vars, &flags, None); + + assert!( + env_vars + .iter() + .any(|(k, v)| k == "PKG_CONFIG" && v.ends_with("/pkgconf")) + ); + assert!(env_vars.iter().any(|(k, v)| { + k == "PKG_CONFIG_LIBDIR" && v == "/usr/lib32/pkgconfig:/usr/share/pkgconfig" + })); + Ok(()) +} + +#[test] +fn test_generate_lib32_meson_cross_file_writes_pkg_config_binary_when_available() -> Result<()> { + let tmp = tempdir()?; + let tools = tempdir()?; + let pkgconf = tools.path().join("pkgconf"); + std::fs::write(&pkgconf, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&pkgconf)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&pkgconf, perms)?; + } + + let mut env = TestEnv::new(); + env.set_var("PATH", tools.path()); + env.set_var("PKG_CONFIG", ""); + + let flags = BuildFlags { + chost: "x86_64-sfg-linux-gnu".into(), + ..BuildFlags::default() + }; + + let path = generate_lib32_meson_cross_file(&flags, tmp.path())?; + let content = std::fs::read_to_string(path)?; + assert!(content.contains("pkg-config = '/")); + assert!(content.contains("pkgconf")); + Ok(()) +} + +#[test] +fn test_meson_setup_args_include_install_dirs() { + let args = meson_setup_args(&BuildFlags::default(), None, &[]); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--sbindir=/usr/bin")); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib")); + assert!(args.iter().any(|a| a == "--sysconfdir=/etc")); + assert!(args.iter().any(|a| a == "--localstatedir=/var")); + assert!(args.iter().any(|a| a == "--sharedstatedir=/var/lib")); + assert!(args.iter().any(|a| a == "--includedir=/usr/include")); + assert!(args.iter().any(|a| a == "--datadir=/usr/share")); + assert!(args.iter().any(|a| a == "--mandir=/usr/share/man")); + assert!(args.iter().any(|a| a == "--infodir=/usr/share/info")); +} + +#[test] +fn test_meson_setup_args_derive_dirs_from_datarootdir() { + let flags = BuildFlags { + datarootdir: "/opt/share-root".to_string(), + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert!(!args.iter().any(|a| a.starts_with("--datarootdir="))); + assert!(args.iter().any(|a| a == "--datadir=/opt/share-root")); + assert!(args.iter().any(|a| a == "--mandir=/opt/share-root/man")); + assert!(args.iter().any(|a| a == "--infodir=/opt/share-root/info")); +} + +#[test] +fn test_meson_setup_args_honor_explicit_prefix() { + let flags = BuildFlags { + prefix: "/usr".to_string(), + configure: vec!["--prefix=/opt".to_string()], + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert_eq!(args.iter().filter(|a| a.starts_with("--prefix")).count(), 1); + assert!(args.iter().any(|a| a == "--prefix=/opt")); +} + +#[test] +fn test_meson_setup_args_honor_explicit_install_dirs() { + let flags = BuildFlags { + configure: vec![ + "--sbindir=/sbin".to_string(), + "--libdir=/custom/lib".to_string(), + "--datadir=/custom/share".to_string(), + ], + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert!(!args.iter().any(|a| a == "--sbindir=/usr/bin")); + assert!(!args.iter().any(|a| a == "--libdir=/usr/lib")); + assert!(!args.iter().any(|a| a == "--datadir=/usr/share")); + assert!(args.iter().any(|a| a == "--bindir=/usr/bin")); +} + +#[test] +fn test_meson_setup_args_use_lib32_defaults() { + let flags = BuildFlags { + lib32_variant: true, + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert!(args.iter().any(|a| a == "--libdir=/usr/lib32")); + assert!(args.iter().any(|a| a == "--libexecdir=/usr/lib32")); +} + +#[test] +fn test_meson_setup_args_include_linker_override() { + let flags = BuildFlags { + ld: "ld.lld".to_string(), + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert!(args.iter().any(|a| a == "-Dc_ld=ld.lld")); + assert!(args.iter().any(|a| a == "-Dcpp_ld=ld.lld")); +} + +#[test] +fn test_meson_setup_args_honor_explicit_linker_override() { + let flags = BuildFlags { + ld: "ld.lld".to_string(), + configure: vec!["-Dc_ld=gold".to_string(), "-Dcpp_ld=gold".to_string()], + ..BuildFlags::default() + }; + + let args = meson_setup_args(&flags, None, &[]); + assert_eq!(args.iter().filter(|a| *a == "-Dc_ld=gold").count(), 1); + assert_eq!(args.iter().filter(|a| *a == "-Dcpp_ld=gold").count(), 1); + assert!(!args.iter().any(|a| a == "-Dc_ld=ld.lld")); + assert!(!args.iter().any(|a| a == "-Dcpp_ld=ld.lld")); +} + +#[test] +fn test_compiler_command_with_lib32_target_adds_clang_target() { + let parts = compiler_command_with_lib32_target("clang -m32", "i686-sfg-linux-gnu"); + assert_eq!( + parts, + vec![ + "clang".to_string(), + "-m32".to_string(), + "--target=i686-sfg-linux-gnu".to_string() + ] + ); +} + +#[test] +fn test_compiler_command_with_lib32_target_skips_non_clang_compilers() { + let parts = compiler_command_with_lib32_target("gcc -m32", "i686-sfg-linux-gnu"); + assert_eq!(parts, vec!["gcc".to_string(), "-m32".to_string()]); +} + +#[test] +fn test_generate_lib32_meson_cross_file_sets_x86_host_machine() -> Result<()> { + let tmp = tempdir()?; + let flags = BuildFlags { + lib32_variant: true, + chost: "x86_64-sfg-linux-gnu".to_string(), + cc: "clang -m32".to_string(), + cxx: "clang++ -m32".to_string(), + ar: "llvm-ar".to_string(), + strip: "llvm-strip".to_string(), + ld: "ld.lld".to_string(), + nm: "llvm-nm".to_string(), + objcopy: "llvm-objcopy".to_string(), + objdump: "llvm-objdump".to_string(), + readelf: "llvm-readelf".to_string(), + ..BuildFlags::default() + }; + + let path = generate_lib32_meson_cross_file(&flags, tmp.path())?; + let content = fs::read_to_string(path)?; + assert!(content.contains("Generated by depot for target: i686-sfg-linux-gnu")); + assert!(content.contains("c = ['clang', '-m32', '--target=i686-sfg-linux-gnu']")); + assert!(content.contains("cpp = ['clang++', '-m32', '--target=i686-sfg-linux-gnu']")); + assert!(content.contains("strip = 'llvm-strip'")); + assert!(content.contains("ld = 'ld.lld'")); + assert!(content.contains("nm = 'llvm-nm'")); + assert!(content.contains("objcopy = 'llvm-objcopy'")); + assert!(content.contains("objdump = 'llvm-objdump'")); + assert!(content.contains("readelf = 'llvm-readelf'")); + assert!(content.contains("cpu_family = 'x86'")); + assert!(content.contains("cpu = 'i686'")); + Ok(()) +} + +#[test] +fn test_resolve_build_dir_uses_flag() { + let flags = BuildFlags { + build_dir: Some("build".to_string()), + ..BuildFlags::default() + }; + let src = Path::new("/tmp/src"); + assert_eq!( + resolve_build_dir(src, &flags), + PathBuf::from("/tmp/src/build") + ); +} + +#[test] +fn test_meson_test_suites_uses_single_and_multiple_targets() { + let flags = BuildFlags { + make_test_target: "unit".to_string(), + make_test_targets: vec!["integration".to_string(), " smoke ".to_string()], + ..BuildFlags::default() + }; + assert_eq!( + meson_test_suites(&flags), + vec![ + "unit".to_string(), + "integration".to_string(), + "smoke".to_string() + ] + ); +} + +#[test] +fn test_meson_test_suites_empty_without_targets() { + assert!(meson_test_suites(&BuildFlags::default()).is_empty()); +} + +#[test] +fn test_resolve_actual_src_uses_source_subdir_under_source() -> Result<()> { + let src = tempdir()?; + let spec_dir = tempdir()?; + fs::create_dir_all(src.path().join("sub"))?; + + let spec = PackageSpec { + package: PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "u".into(), + sha256: "s".into(), + extract_dir: "e".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Meson, + flags: BuildFlags { + source_subdir: "sub".into(), + ..BuildFlags::default() + }, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: spec_dir.path().to_path_buf(), + }; + + let resolved = resolve_actual_src(&spec, src.path())?; + assert_eq!(resolved, src.path().join("sub")); + Ok(()) +} diff --git a/src/builder/mod.rs b/src/builder/mod.rs old mode 100755 new mode 100644 index a7f4099..f9dfba9 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -1141,917 +1141,4 @@ pub fn build( } } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; - use crate::test_support::TestEnv; - use std::collections::HashMap; - use std::ffi::OsStr; - use std::fs; - use std::path::PathBuf; - - fn mk_spec(cflags: Vec<&str>, ldflags: Vec<&str>) -> PackageSpec { - let flags = BuildFlags { - cflags: cflags.into_iter().map(String::from).collect(), - ldflags: ldflags.into_iter().map(String::from).collect(), - ..BuildFlags::default() - }; - PackageSpec { - package: PackageInfo { - name: "env-test".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: vec![crate::package::Source { - url: "https://example.test/src.tar.gz".into(), - sha256: "abc".into(), - extract_dir: "src".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - fn env_value<'a>(env: &'a EnvVars, key: &str) -> Option<&'a str> { - env.iter() - .find(|(candidate, _)| candidate == key) - .map(|(_, value)| value.as_str()) - } - - #[test] - fn test_prepare_command() { - let mut cmd = Command::new("ls"); - // Set an env var that should be cleared - cmd.env("FORBIDDEN", "value"); - // Set PATH manually in the current process to ensure it's picked up if it exists - let mut env = TestEnv::new(); - env.set_var("PATH", "/usr/bin"); - env.set_var("HOME", "/home/test"); - env.set_var("SHELL", "/bin/zsh"); - env.set_var("DEPOT_ROOTFS", "/my/rootfs"); - env.set_var("TERM", "xterm-256color"); - env.set_var("CLICOLOR_FORCE", "1"); - - prepare_command(&mut cmd, &vec![("MYVAR".to_string(), "myval".to_string())]); - - let envs: HashMap<_, _> = cmd.get_envs().collect(); - assert!(envs.contains_key(OsStr::new("PATH"))); - assert!(envs.contains_key(OsStr::new("HOME"))); - assert!(!envs.contains_key(OsStr::new("FORBIDDEN"))); - assert_eq!( - envs.get(OsStr::new("SHELL")), - Some(&Some(std::ffi::OsString::from("/bin/sh").as_os_str())) - ); - assert_eq!( - envs.get(OsStr::new("MYVAR")), - Some(&Some(std::ffi::OsString::from("myval").as_os_str())) - ); - // DEPOT_ROOTFS should be preserved from the parent environment - assert_eq!( - envs.get(OsStr::new("DEPOT_ROOTFS")), - Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str())) - ); - assert_eq!( - envs.get(OsStr::new("TERM")), - Some(&Some( - std::ffi::OsString::from("xterm-256color").as_os_str() - )) - ); - assert_eq!( - envs.get(OsStr::new("CLICOLOR_FORCE")), - Some(&Some(std::ffi::OsString::from("1").as_os_str())) - ); - } - - #[test] - fn test_prepare_command_preserves_destdir() { - let mut cmd = std::process::Command::new("ls"); - let mut env = TestEnv::new(); - env.set_var("DESTDIR", "/tmp/dest"); - prepare_command(&mut cmd, &Vec::new()); - let envs: HashMap<_, _> = cmd.get_envs().collect(); - assert_eq!( - envs.get(OsStr::new("DESTDIR")), - Some(&Some(std::ffi::OsString::from("/tmp/dest").as_os_str())) - ); - } - - #[test] - fn test_prepare_command_preserves_rust_toolchain_homes() { - let mut cmd = std::process::Command::new("ls"); - let mut env = TestEnv::new(); - env.set_var("CARGO_HOME", "/var/cache/cargo-home"); - env.set_var("RUSTUP_HOME", "/var/cache/rustup-home"); - prepare_command(&mut cmd, &Vec::new()); - let envs: HashMap<_, _> = cmd.get_envs().collect(); - assert_eq!( - envs.get(OsStr::new("CARGO_HOME")), - Some(&Some( - std::ffi::OsString::from("/var/cache/cargo-home").as_os_str() - )) - ); - assert_eq!( - envs.get(OsStr::new("RUSTUP_HOME")), - Some(&Some( - std::ffi::OsString::from("/var/cache/rustup-home").as_os_str() - )) - ); - } - - #[test] - fn test_build_tool_package_option_maps_supported_builders() { - assert_eq!( - build_tool_package_option(BuildType::Meson), - Some("DEPOT_MESON_PACKAGE") - ); - assert_eq!( - build_tool_package_option(BuildType::CMake), - Some("DEPOT_CMAKE_PACKAGE") - ); - assert_eq!(build_tool_package_option(BuildType::Bin), None); - } - - #[test] - fn test_static_build_args_skip_disable_static_when_no_delete_static_enabled() { - let args = static_build_args_for_request(BuildType::Autotools, Some(false), true); - assert!(args.is_empty()); - - let args = static_build_args_for_request(BuildType::CMake, Some(false), true); - assert!(args.is_empty()); - } - - #[test] - fn test_static_build_args_keep_other_requested_modes() { - assert_eq!( - static_build_args_for_request(BuildType::Autotools, Some(false), false), - vec![ - "--enable-shared".to_string(), - "--disable-static".to_string() - ] - ); - assert_eq!( - static_build_args_for_request(BuildType::CMake, Some(false), false), - vec!["-DBUILD_SHARED_LIBS=ON".to_string()] - ); - assert_eq!( - static_build_args_for_request(BuildType::Meson, Some(false), false), - vec!["-Ddefault_library=shared".to_string()] - ); - assert_eq!( - static_build_args_for_request(BuildType::Perl, Some(false), false), - vec!["LINKTYPE=dynamic".to_string()] - ); - assert_eq!( - static_build_args_for_request(BuildType::Meson, Some(true), true), - vec!["-Ddefault_library=static".to_string()] - ); - } - - #[test] - fn test_standard_build_env_exports_native_linker_and_cpp() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.ranlib = "llvm-ranlib".to_string(); - spec.build.flags.strip = "llvm-strip".to_string(); - spec.build.flags.ld = "ld.lld".to_string(); - spec.build.flags.nm = "llvm-nm".to_string(); - spec.build.flags.objcopy = "llvm-objcopy".to_string(); - spec.build.flags.objdump = "llvm-objdump".to_string(); - spec.build.flags.readelf = "llvm-readelf".to_string(); - spec.build.flags.cpp = "clang-cpp".to_string(); - - let env = standard_build_env(&spec, None, true, true); - assert!(env.iter().any(|(k, v)| k == "RANLIB" && v == "llvm-ranlib")); - assert!(env.iter().any(|(k, v)| k == "STRIP" && v == "llvm-strip")); - assert!(env.iter().any(|(k, v)| k == "LD" && v == "ld.lld")); - assert!(env.iter().any(|(k, v)| k == "NM" && v == "llvm-nm")); - assert!( - env.iter() - .any(|(k, v)| k == "OBJCOPY" && v == "llvm-objcopy") - ); - assert!( - env.iter() - .any(|(k, v)| k == "OBJDUMP" && v == "llvm-objdump") - ); - assert!( - env.iter() - .any(|(k, v)| k == "READELF" && v == "llvm-readelf") - ); - assert!(env.iter().any(|(k, v)| k == "CPP" && v == "clang-cpp")); - } - - #[test] - fn test_standard_build_env_exports_tool_dir_and_expands_tool_commands() { - let mut spec = mk_spec( - vec!["--gcc-toolchain=$TOOL_DIR"], - vec!["-B$TOOL_DIR", "-Wl,--as-needed"], - ); - spec.build.flags.tool_dir = "/opt/depot-tools/bin".to_string(); - spec.build.flags.cc = "$TOOL_DIR/clang".to_string(); - spec.build.flags.cxx = "$TOOL_DIR/clang++".to_string(); - spec.build.flags.ar = "$TOOL_DIR/llvm-ar".to_string(); - spec.build.flags.ranlib = "$TOOL_DIR/llvm-ranlib".to_string(); - spec.build.flags.ld = "$TOOL_DIR/ld.lld".to_string(); - spec.build.flags.env_vars = vec!["LLVM_CONFIG=$TOOL_DIR/llvm-config".to_string()]; - - let env = standard_build_env(&spec, None, true, true); - - assert_eq!(env_value(&env, "TOOL_DIR"), Some("/opt/depot-tools/bin")); - assert_eq!(env_value(&env, "CC"), Some("/opt/depot-tools/bin/clang")); - assert_eq!(env_value(&env, "CXX"), Some("/opt/depot-tools/bin/clang++")); - assert_eq!(env_value(&env, "AR"), Some("/opt/depot-tools/bin/llvm-ar")); - assert_eq!( - env_value(&env, "RANLIB"), - Some("/opt/depot-tools/bin/llvm-ranlib") - ); - assert_eq!(env_value(&env, "LD"), Some("/opt/depot-tools/bin/ld.lld")); - assert_eq!( - env_value(&env, "CFLAGS"), - Some("--gcc-toolchain=/opt/depot-tools/bin") - ); - assert_eq!( - env_value(&env, "LDFLAGS"), - Some("-B/opt/depot-tools/bin -Wl,--as-needed") - ); - assert_eq!( - env_value(&env, "LLVM_CONFIG"), - Some("/opt/depot-tools/bin/llvm-config") - ); - } - - #[test] - fn test_standard_build_env_cross_uses_package_tool_overrides() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.cc = "/tools/bin/clang".to_string(); - spec.build.flags.cxx = "/tools/bin/clang++".to_string(); - spec.build.flags.ar = "/tools/bin/llvm-ar".to_string(); - spec.build.flags.ranlib = "/tools/bin/llvm-ranlib".to_string(); - spec.build.flags.strip = "/tools/bin/llvm-strip".to_string(); - spec.build.flags.ld = "/tools/bin/ld.lld".to_string(); - spec.build.flags.nm = "/tools/bin/llvm-nm".to_string(); - spec.build.flags.objcopy = "/tools/bin/llvm-objcopy".to_string(); - spec.build.flags.objdump = "/tools/bin/llvm-objdump".to_string(); - spec.build.flags.readelf = "/tools/bin/llvm-readelf".to_string(); - spec.build.flags.cpp = "/tools/bin/clang-cpp".to_string(); - let cross = CrossConfig { - prefix: "x86_64-test-linux-gnu".into(), - cc: "x86_64-test-linux-gnu-gcc".into(), - cxx: "x86_64-test-linux-gnu-g++".into(), - ar: "x86_64-test-linux-gnu-ar".into(), - ranlib: "x86_64-test-linux-gnu-ranlib".into(), - strip: "x86_64-test-linux-gnu-strip".into(), - ld: "x86_64-test-linux-gnu-ld".into(), - nm: "x86_64-test-linux-gnu-nm".into(), - objcopy: "x86_64-test-linux-gnu-objcopy".into(), - objdump: "x86_64-test-linux-gnu-objdump".into(), - readelf: "x86_64-test-linux-gnu-readelf".into(), - }; - - let env = standard_build_env(&spec, Some(&cross), true, true); - - assert_eq!(env_value(&env, "CC"), Some("/tools/bin/clang")); - assert_eq!(env_value(&env, "CXX"), Some("/tools/bin/clang++")); - assert_eq!(env_value(&env, "AR"), Some("/tools/bin/llvm-ar")); - assert_eq!(env_value(&env, "RANLIB"), Some("/tools/bin/llvm-ranlib")); - assert_eq!(env_value(&env, "STRIP"), Some("/tools/bin/llvm-strip")); - assert_eq!(env_value(&env, "LD"), Some("/tools/bin/ld.lld")); - assert_eq!(env_value(&env, "NM"), Some("/tools/bin/llvm-nm")); - assert_eq!(env_value(&env, "OBJCOPY"), Some("/tools/bin/llvm-objcopy")); - assert_eq!(env_value(&env, "OBJDUMP"), Some("/tools/bin/llvm-objdump")); - assert_eq!(env_value(&env, "READELF"), Some("/tools/bin/llvm-readelf")); - assert_eq!(env_value(&env, "CPP"), Some("/tools/bin/clang-cpp")); - assert_eq!( - env_value(&env, "CROSS_PREFIX"), - Some("x86_64-test-linux-gnu") - ); - } - - #[test] - fn test_standard_build_env_exports_effective_carch_for_cross_and_lib32() { - let spec = mk_spec(Vec::new(), Vec::new()); - let cross = CrossConfig { - prefix: "aarch64-linux-gnu".into(), - cc: "aarch64-linux-gnu-gcc".into(), - cxx: "aarch64-linux-gnu-g++".into(), - ar: "aarch64-linux-gnu-ar".into(), - ranlib: "aarch64-linux-gnu-ranlib".into(), - strip: "aarch64-linux-gnu-strip".into(), - ld: "aarch64-linux-gnu-ld".into(), - nm: "aarch64-linux-gnu-nm".into(), - objcopy: "aarch64-linux-gnu-objcopy".into(), - objdump: "aarch64-linux-gnu-objdump".into(), - readelf: "aarch64-linux-gnu-readelf".into(), - }; - - let cross_env = standard_build_env(&spec, Some(&cross), true, true); - assert!( - cross_env - .iter() - .any(|(k, v)| k == "CARCH" && v == "aarch64"), - "expected cross builds to export target CARCH" - ); - assert!( - cross_env - .iter() - .any(|(k, v)| k == "OBJCOPY" && v == "aarch64-linux-gnu-objcopy"), - "expected cross builds to export OBJCOPY" - ); - assert!( - cross_env - .iter() - .any(|(k, v)| k == "OBJDUMP" && v == "aarch64-linux-gnu-objdump"), - "expected cross builds to export OBJDUMP" - ); - assert!( - cross_env - .iter() - .any(|(k, v)| k == "READELF" && v == "aarch64-linux-gnu-readelf"), - "expected cross builds to export READELF" - ); - - let mut lib32_spec = spec.clone(); - lib32_spec.build.flags.lib32_variant = true; - lib32_spec.build.flags.carch = "x86_64".into(); - let lib32_env = standard_build_env(&lib32_spec, None, true, true); - assert!( - lib32_env.iter().any(|(k, v)| k == "CARCH" && v == "i686"), - "expected lib32 builds to export i686 CARCH" - ); - } - - #[test] - fn test_spec_with_target_configure_appends_matching_arch_args() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.configure = vec!["--base".to_string()]; - spec.build - .flags - .configure_arch - .insert("aarch64".to_string(), vec!["--for-aarch64".to_string()]); - spec.build - .flags - .configure_arch - .insert("x86_64".to_string(), vec!["--for-x86".to_string()]); - let cross = CrossConfig { - prefix: "aarch64-linux-gnu".into(), - cc: "aarch64-linux-gnu-gcc".into(), - cxx: "aarch64-linux-gnu-g++".into(), - ar: "aarch64-linux-gnu-ar".into(), - ranlib: "aarch64-linux-gnu-ranlib".into(), - strip: "aarch64-linux-gnu-strip".into(), - ld: "aarch64-linux-gnu-ld".into(), - nm: "aarch64-linux-gnu-nm".into(), - objcopy: "aarch64-linux-gnu-objcopy".into(), - objdump: "aarch64-linux-gnu-objdump".into(), - readelf: "aarch64-linux-gnu-readelf".into(), - }; - - let adjusted = spec_with_target_configure(&spec, Some(&cross), TargetBuildKind::Primary) - .expect("expected arch-specific configure args"); - - assert_eq!( - adjusted.build.flags.configure, - vec!["--base".to_string(), "--for-aarch64".to_string()] - ); - } - - #[test] - fn test_spec_with_target_configure_uses_lib32_arch() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.lib32_variant = true; - spec.build.flags.carch = "x86_64".to_string(); - spec.build - .flags - .configure_arch - .insert("i686".to_string(), vec!["--for-lib32".to_string()]); - - let adjusted = spec_with_target_configure(&spec, None, TargetBuildKind::Lib32) - .expect("expected lib32 configure args"); - - assert_eq!( - adjusted.build.flags.configure, - vec!["--for-lib32".to_string()] - ); - } - - #[test] - fn test_standard_build_env_respects_export_compiler_flags_toggle() { - let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); - spec.build.flags.cxxflags = vec!["-O2".into(), "-fno-exceptions".into()]; - - let enabled = standard_build_env(&spec, None, true, true); - assert!( - enabled.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), - "expected CFLAGS to be exported when enabled" - ); - assert!( - enabled - .iter() - .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions"), - "expected CXXFLAGS to be exported when enabled" - ); - assert!( - enabled - .iter() - .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed"), - "expected LDFLAGS to be exported when enabled" - ); - - let disabled = standard_build_env(&spec, None, true, false); - assert!( - !disabled.iter().any(|(k, _)| k == "CFLAGS"), - "expected CFLAGS to be omitted when disabled" - ); - assert!( - !disabled.iter().any(|(k, _)| k == "CXXFLAGS"), - "expected CXXFLAGS to be omitted when disabled" - ); - assert!( - !disabled.iter().any(|(k, _)| k == "LDFLAGS"), - "expected LDFLAGS to be omitted when disabled" - ); - - let mut disabled_by_spec = spec.clone(); - disabled_by_spec.build.flags.no_flags = true; - let disabled_env = standard_build_env(&disabled_by_spec, None, true, true); - assert!( - !disabled_env.iter().any(|(k, _)| k == "CFLAGS"), - "expected CFLAGS to be omitted when no_flags is set in spec" - ); - assert!( - !disabled_env.iter().any(|(k, _)| k == "CXXFLAGS"), - "expected CXXFLAGS to be omitted when no_flags is set in spec" - ); - assert!( - !disabled_env.iter().any(|(k, _)| k == "LDFLAGS"), - "expected LDFLAGS to be omitted when no_flags is set in spec" - ); - } - - #[test] - fn test_standard_build_env_injects_ltoflags_into_compiler_and_linker_flags() { - let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); - spec.build.flags.cxxflags = vec!["-O2".into()]; - spec.build.flags.ltoflags = vec!["-flto=auto".into(), "-fuse-linker-plugin".into()]; - spec.build.flags.use_lto = true; - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter() - .any(|(k, v)| { k == "CFLAGS" && v == "-O2 -flto=auto -fuse-linker-plugin" }), - "expected LTOFLAGS to be appended to CFLAGS" - ); - assert!( - env.iter() - .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -flto=auto -fuse-linker-plugin"), - "expected LTOFLAGS to be appended to CXXFLAGS" - ); - assert!( - env.iter().any(|(k, v)| { - k == "LDFLAGS" && v == "-Wl,--as-needed -flto=auto -fuse-linker-plugin" - }), - "expected LTOFLAGS to be appended to LDFLAGS" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=auto -fuse-linker-plugin"), - "expected LTOFLAGS variable to be exported" - ); - } - - #[test] - fn test_standard_build_env_injects_fuse_ld_into_ldflags() { - let mut spec = mk_spec(Vec::new(), vec!["-Wl,--as-needed"]); - spec.build.flags.fuse_ld = "/usr/bin/ld.lld".into(); - - let env = standard_build_env(&spec, None, true, true); - - assert_eq!( - env_value(&env, "LDFLAGS"), - Some("-fuse-ld=/usr/bin/ld.lld -Wl,--as-needed") - ); - } - - #[test] - fn test_standard_build_env_normalizes_fuse_ld_tool_names() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.fuse_ld = "ld.lld".into(); - - let env = standard_build_env(&spec, None, true, true); - - assert_eq!(env_value(&env, "LDFLAGS"), Some("-fuse-ld=lld")); - } - - #[test] - fn test_standard_build_env_applies_replace_flag_rules() { - let mut spec = mk_spec(vec!["-D_FORTIFY_SOURCE=3", "-O2"], vec!["-Wl,-O3"]); - spec.build.flags.cxxflags = vec!["-O2".into(), "-stdlib=libc++".into()]; - spec.build.flags.replace_cflags = vec!["_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".into()]; - spec.build.flags.replace_cxxflags = vec!["-stdlib=libc++=>-stdlib=libstdc++".into()]; - spec.build.flags.replace_ldflags = vec!["-O3=>-O2".into()]; - spec.build.flags.ltoflags = vec!["-flto=auto".into()]; - spec.build.flags.replace_ltoflags = vec!["auto=>thin".into()]; - spec.build.flags.use_lto = true; - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter() - .any(|(k, v)| k == "CFLAGS" && v == "-D_FORTIFY_SOURCE=2 -O2 -flto=thin"), - "expected replace_cflags and replace_ltoflags to be applied" - ); - assert!( - env.iter() - .any(|(k, v)| { k == "CXXFLAGS" && v == "-O2 -stdlib=libstdc++ -flto=thin" }), - "expected replace_cxxflags to be applied" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,-O2 -flto=thin"), - "expected replace_ldflags to be applied" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=thin"), - "expected replace_ltoflags to affect exported LTOFLAGS" - ); - } - - #[test] - fn test_standard_build_env_drops_bare_dash_flags() { - let mut spec = mk_spec(vec!["-O2", "-", ""], vec!["-Wl,--as-needed", " "]); - spec.build.flags.cxxflags = vec!["-O2".into(), "-".into(), "-fno-exceptions".into()]; - spec.build.flags.ltoflags = vec!["-".into(), "-flto=thin".into()]; - spec.build.flags.use_lto = true; - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter() - .any(|(k, v)| k == "CFLAGS" && v == "-O2 -flto=thin"), - "expected bare dash entries to be removed from CFLAGS" - ); - assert!( - env.iter() - .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions -flto=thin"), - "expected bare dash entries to be removed from CXXFLAGS" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed -flto=thin"), - "expected blank and bare dash entries to be removed from LDFLAGS" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=thin"), - "expected bare dash entries to be removed from LTOFLAGS" - ); - } - - #[test] - fn test_standard_build_env_skips_lto_injection_when_disabled() { - let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); - spec.build.flags.cxxflags = vec!["-O2".into()]; - spec.build.flags.ltoflags = vec!["-flto=auto".into()]; - spec.build.flags.rustltoflags = vec!["-Clinker-plugin-lto".into()]; - spec.build.flags.use_lto = false; - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), - "expected CFLAGS to remain unchanged when use_lto is false" - ); - assert!( - env.iter().any(|(k, v)| k == "CXXFLAGS" && v == "-O2"), - "expected CXXFLAGS to remain unchanged when use_lto is false" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed"), - "expected LDFLAGS to remain unchanged when use_lto is false" - ); - assert!( - env.iter() - .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=auto"), - "expected LTOFLAGS variable to be exported even when use_lto is false" - ); - assert!( - env.iter() - .any(|(k, v)| k == "RUSTLTOFLAGS" && v == "-Clinker-plugin-lto"), - "expected RUSTLTOFLAGS variable to be exported even when use_lto is false" - ); - assert_eq!(effective_rustflags(&spec.build.flags), Vec::::new()); - } - - #[test] - fn test_standard_build_env_exports_makeflags() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.makeflags = "-j12 --output-sync=target".to_string(); - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter() - .any(|(k, v)| k == "MAKEFLAGS" && v == "-j12 --output-sync=target"), - "expected MAKEFLAGS to be exported from build flags" - ); - } - - #[test] - fn test_standard_build_env_exports_install_dir_vars() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.prefix = "/opt/vertex".into(); - spec.build.flags.bindir = "/opt/vertex/bin".into(); - spec.build.flags.sbindir = "/opt/vertex/sbin".into(); - spec.build.flags.libdir = "/opt/vertex/lib".into(); - spec.build.flags.libexecdir = "/opt/vertex/libexec".into(); - spec.build.flags.sysconfdir = "/etc/vertex".into(); - spec.build.flags.localstatedir = "/var".into(); - spec.build.flags.sharedstatedir = "/var/lib".into(); - spec.build.flags.includedir = "/opt/vertex/include".into(); - spec.build.flags.datarootdir = "/opt/vertex/share".into(); - spec.build.flags.datadir = "/opt/vertex/share/data".into(); - spec.build.flags.mandir = "/opt/vertex/share/man".into(); - spec.build.flags.infodir = "/opt/vertex/share/info".into(); - - let env = standard_build_env(&spec, None, false, true); - - assert_eq!(env_value(&env, "PREFIX"), Some("/opt/vertex")); - assert_eq!(env_value(&env, "BINDIR"), Some("/opt/vertex/bin")); - assert_eq!(env_value(&env, "SBINDIR"), Some("/opt/vertex/sbin")); - assert_eq!(env_value(&env, "LIBDIR"), Some("/opt/vertex/lib")); - assert_eq!(env_value(&env, "LIBEXECDIR"), Some("/opt/vertex/libexec")); - assert_eq!(env_value(&env, "SYSCONFDIR"), Some("/etc/vertex")); - assert_eq!(env_value(&env, "LOCALSTATEDIR"), Some("/var")); - assert_eq!(env_value(&env, "SHAREDSTATEDIR"), Some("/var/lib")); - assert_eq!(env_value(&env, "INCLUDEDIR"), Some("/opt/vertex/include")); - assert_eq!(env_value(&env, "DATAROOTDIR"), Some("/opt/vertex/share")); - assert_eq!(env_value(&env, "DATADIR"), Some("/opt/vertex/share/data")); - assert_eq!(env_value(&env, "MANDIR"), Some("/opt/vertex/share/man")); - assert_eq!(env_value(&env, "INFODIR"), Some("/opt/vertex/share/info")); - } - - #[test] - fn test_standard_build_env_install_dir_vars_use_effective_defaults() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.lib32_variant = true; - - let env = standard_build_env(&spec, None, false, true); - - assert_eq!(env_value(&env, "LIBDIR"), Some("/usr/lib32")); - assert_eq!(env_value(&env, "LIBEXECDIR"), Some("/usr/lib32")); - assert_eq!(env_value(&env, "DATAROOTDIR"), Some("/usr/share")); - assert_eq!(env_value(&env, "DATADIR"), Some("/usr/share")); - } - - #[test] - fn test_standard_build_env_exports_passthrough_env() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.passthrough_env = vec!["RUSTFLAGS".into()]; - - let mut env = TestEnv::new(); - env.set_var("RUSTFLAGS", "-C target-cpu=native"); - - let env = standard_build_env(&spec, None, false, true); - assert!( - env.iter() - .any(|(k, v)| k == "RUSTFLAGS" && v == "-C target-cpu=native"), - "expected RUSTFLAGS to be copied from parent environment" - ); - } - - #[test] - fn test_standard_build_env_exports_declared_env_vars() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.package.version = "2.4.1".to_string(); - spec.spec_dir = PathBuf::from("/tmp/specs/demo"); - spec.build.flags.env_vars = vec![ - "SETUPTOOLS_SCM_PRETEND_VERSION=$version".into(), - "PYO3_CONFIG_FILE=$specdir/pyo3.toml".into(), - "PKG_CONFIG_PATH=$LIBDIR/pkgconfig".into(), - ]; - - let env = standard_build_env(&spec, None, false, true); - assert!( - env.iter() - .any(|(k, v)| k == "SETUPTOOLS_SCM_PRETEND_VERSION" && v == "2.4.1"), - "expected env_vars values to expand package variables" - ); - assert!( - env.iter() - .any(|(k, v)| k == "PYO3_CONFIG_FILE" && v == "/tmp/specs/demo/pyo3.toml"), - "expected env_vars values to expand specdir variables" - ); - assert!( - env.iter() - .any(|(k, v)| k == "PKG_CONFIG_PATH" && v == "/usr/lib/pkgconfig"), - "expected env_vars values to expand install directory variables" - ); - } - - #[test] - fn test_standard_build_env_declared_env_vars_override_defaults_and_passthrough() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.cc = "spec-cc".to_string(); - spec.build.flags.passthrough_env = vec!["CC".into()]; - spec.build.flags.env_vars = vec!["CC=custom-cc".into()]; - - let mut env = TestEnv::new(); - env.set_var("CC", "host-cc"); - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter().any(|(k, v)| k == "CC" && v == "custom-cc"), - "expected explicit env_vars assignments to override default and passthrough values" - ); - } - - #[test] - fn test_effective_rustflags_applies_replace_rules() { - let flags = BuildFlags { - rustflags: vec!["-C".into(), "debuginfo=2".into()], - replace_rustflags: vec!["debuginfo=2=>opt-level=2".into()], - ..BuildFlags::default() - }; - - assert_eq!(effective_rustflags(&flags), vec!["-C", "opt-level=2"]); - } - - #[test] - fn test_effective_rustflags_appends_rustltoflags_when_enabled() { - let flags = BuildFlags { - rustflags: vec!["-C".into(), "opt-level=3".into()], - rustltoflags: vec!["-Clinker-plugin-lto".into(), "-Cembed-bitcode=yes".into()], - use_lto: true, - ..BuildFlags::default() - }; - - assert_eq!( - effective_rustflags(&flags), - vec![ - "-C", - "opt-level=3", - "-Clinker-plugin-lto", - "-Cembed-bitcode=yes" - ] - ); - } - - #[test] - fn test_standard_build_env_passthrough_does_not_override_default_vars() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.cc = "spec-cc".to_string(); - spec.build.flags.passthrough_env = vec!["CC".into()]; - - let mut env = TestEnv::new(); - env.set_var("CC", "host-cc"); - - let env = standard_build_env(&spec, None, true, true); - assert!( - env.iter().any(|(k, v)| k == "CC" && v == "spec-cc"), - "expected default CC to take precedence over passthrough CC" - ); - } - - #[test] - fn test_install_dirs_use_defaults_and_lib32_fallbacks() { - let default_dirs = install_dirs(&BuildFlags::default()); - assert_eq!(default_dirs.bindir, "/usr/bin"); - assert_eq!(default_dirs.sbindir, "/usr/bin"); - assert_eq!(default_dirs.libdir, "/usr/lib"); - assert_eq!(default_dirs.libexecdir, "/usr/lib"); - assert_eq!(default_dirs.datarootdir, "/usr/share"); - assert_eq!(default_dirs.datadir, "/usr/share"); - - let lib32_dirs = install_dirs(&BuildFlags { - lib32_variant: true, - ..BuildFlags::default() - }); - assert_eq!(lib32_dirs.libdir, "/usr/lib32"); - assert_eq!(lib32_dirs.libexecdir, "/usr/lib32"); - } - - #[test] - fn test_build_helper_context_restores_runtime_build_flags() { - let mut spec = mk_spec(Vec::new(), Vec::new()); - spec.build.flags.lib32_variant = true; - spec.build.flags.host_build_dir = Some("/tmp/build-host".into()); - - let restored = BuildHelperContext::from_spec(&spec).build_flags(); - assert!(restored.lib32_variant); - assert_eq!(restored.host_build_dir.as_deref(), Some("/tmp/build-host")); - } - - #[test] - fn test_install_dirs_respect_explicit_overrides_and_derived_defaults() { - let dirs = install_dirs(&BuildFlags { - bindir: "/opt/bin".into(), - libdir: "/opt/lib64".into(), - datarootdir: "/opt/share-root".into(), - ..BuildFlags::default() - }); - - assert_eq!(dirs.bindir, "/opt/bin"); - assert_eq!(dirs.libdir, "/opt/lib64"); - assert_eq!(dirs.libexecdir, "/opt/lib64"); - assert_eq!(dirs.datarootdir, "/opt/share-root"); - assert_eq!(dirs.datadir, "/opt/share-root"); - } - - #[test] - fn test_install_destdir_path_uses_build_dir_for_lib32() { - let build_dir = Path::new("/tmp/build"); - let destdir = Path::new("/tmp/pkg"); - assert_eq!(install_destdir_path(build_dir, destdir, false), destdir); - assert_eq!( - install_destdir_path(build_dir, destdir, true), - build_dir.join("destdir") - ); - } - - #[test] - fn test_stage_lib32_install_tree_uses_usr_lib32_when_present() -> Result<()> { - let temp = tempfile::tempdir()?; - let staging = temp.path().join("staging"); - let dest = temp.path().join("dest"); - fs::create_dir_all(staging.join("usr/lib32"))?; - fs::create_dir_all(staging.join("usr/bin"))?; - fs::write(staging.join("usr/lib32/libfoo.so.1"), "lib32")?; - fs::write(staging.join("usr/bin/foo"), "bin")?; - - stage_lib32_install_tree(&staging, &dest)?; - - assert_eq!( - fs::read_to_string(dest.join("usr/lib32/libfoo.so.1"))?, - "lib32" - ); - assert!(!dest.join("usr/bin/foo").exists()); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn test_stage_lib32_install_tree_relocates_usr_lib_when_needed() -> Result<()> { - use std::os::unix::fs as unix_fs; - - let temp = tempfile::tempdir()?; - let staging = temp.path().join("staging"); - let dest = temp.path().join("dest"); - fs::create_dir_all(staging.join("usr/lib"))?; - fs::create_dir_all(staging.join("usr/share/man/man1"))?; - fs::write(staging.join("usr/lib/libfoo.so.1"), "relocated")?; - fs::write(staging.join("usr/share/man/man1/foo.1"), "manpage")?; - unix_fs::symlink("libfoo.so.1", staging.join("usr/lib/libfoo.so"))?; - - stage_lib32_install_tree(&staging, &dest)?; - - assert_eq!( - fs::read_to_string(dest.join("usr/lib32/libfoo.so.1"))?, - "relocated" - ); - assert_eq!( - fs::read_link(dest.join("usr/lib32/libfoo.so"))?, - PathBuf::from("libfoo.so.1") - ); - assert!(!dest.join("usr/share/man/man1/foo.1").exists()); - assert!(!dest.join("usr/lib").exists()); - Ok(()) - } - - #[test] - fn test_stage_lib32_install_tree_preserves_hardlinks() -> Result<()> { - use std::os::unix::fs::MetadataExt; - - let temp = tempfile::tempdir()?; - let staging = temp.path().join("staging"); - let dest = temp.path().join("dest"); - fs::create_dir_all(staging.join("usr/lib32"))?; - fs::write(staging.join("usr/lib32/libfoo.so.1"), "lib32")?; - fs::hard_link( - staging.join("usr/lib32/libfoo.so.1"), - staging.join("usr/lib32/libfoo-current.so"), - )?; - - stage_lib32_install_tree(&staging, &dest)?; - - let first = dest.join("usr/lib32/libfoo.so.1").metadata()?; - let second = dest.join("usr/lib32/libfoo-current.so").metadata()?; - assert_eq!(first.ino(), second.ino()); - assert_eq!(first.nlink(), 2); - assert_eq!(second.nlink(), 2); - Ok(()) - } -} +mod tests; diff --git a/src/builder/tests.rs b/src/builder/tests.rs new file mode 100644 index 0000000..b980e61 --- /dev/null +++ b/src/builder/tests.rs @@ -0,0 +1,912 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; +use crate::test_support::TestEnv; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; + +fn mk_spec(cflags: Vec<&str>, ldflags: Vec<&str>) -> PackageSpec { + let flags = BuildFlags { + cflags: cflags.into_iter().map(String::from).collect(), + ldflags: ldflags.into_iter().map(String::from).collect(), + ..BuildFlags::default() + }; + PackageSpec { + package: PackageInfo { + name: "env-test".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: vec![crate::package::Source { + url: "https://example.test/src.tar.gz".into(), + sha256: "abc".into(), + extract_dir: "src".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +fn env_value<'a>(env: &'a EnvVars, key: &str) -> Option<&'a str> { + env.iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value.as_str()) +} + +#[test] +fn test_prepare_command() { + let mut cmd = Command::new("ls"); + // Set an env var that should be cleared + cmd.env("FORBIDDEN", "value"); + // Set PATH manually in the current process to ensure it's picked up if it exists + let mut env = TestEnv::new(); + env.set_var("PATH", "/usr/bin"); + env.set_var("HOME", "/home/test"); + env.set_var("SHELL", "/bin/zsh"); + env.set_var("DEPOT_ROOTFS", "/my/rootfs"); + env.set_var("TERM", "xterm-256color"); + env.set_var("CLICOLOR_FORCE", "1"); + + prepare_command(&mut cmd, &vec![("MYVAR".to_string(), "myval".to_string())]); + + let envs: HashMap<_, _> = cmd.get_envs().collect(); + assert!(envs.contains_key(OsStr::new("PATH"))); + assert!(envs.contains_key(OsStr::new("HOME"))); + assert!(!envs.contains_key(OsStr::new("FORBIDDEN"))); + assert_eq!( + envs.get(OsStr::new("SHELL")), + Some(&Some(std::ffi::OsString::from("/bin/sh").as_os_str())) + ); + assert_eq!( + envs.get(OsStr::new("MYVAR")), + Some(&Some(std::ffi::OsString::from("myval").as_os_str())) + ); + // DEPOT_ROOTFS should be preserved from the parent environment + assert_eq!( + envs.get(OsStr::new("DEPOT_ROOTFS")), + Some(&Some(std::ffi::OsString::from("/my/rootfs").as_os_str())) + ); + assert_eq!( + envs.get(OsStr::new("TERM")), + Some(&Some( + std::ffi::OsString::from("xterm-256color").as_os_str() + )) + ); + assert_eq!( + envs.get(OsStr::new("CLICOLOR_FORCE")), + Some(&Some(std::ffi::OsString::from("1").as_os_str())) + ); +} + +#[test] +fn test_prepare_command_preserves_destdir() { + let mut cmd = std::process::Command::new("ls"); + let mut env = TestEnv::new(); + env.set_var("DESTDIR", "/tmp/dest"); + prepare_command(&mut cmd, &Vec::new()); + let envs: HashMap<_, _> = cmd.get_envs().collect(); + assert_eq!( + envs.get(OsStr::new("DESTDIR")), + Some(&Some(std::ffi::OsString::from("/tmp/dest").as_os_str())) + ); +} + +#[test] +fn test_prepare_command_preserves_rust_toolchain_homes() { + let mut cmd = std::process::Command::new("ls"); + let mut env = TestEnv::new(); + env.set_var("CARGO_HOME", "/var/cache/cargo-home"); + env.set_var("RUSTUP_HOME", "/var/cache/rustup-home"); + prepare_command(&mut cmd, &Vec::new()); + let envs: HashMap<_, _> = cmd.get_envs().collect(); + assert_eq!( + envs.get(OsStr::new("CARGO_HOME")), + Some(&Some( + std::ffi::OsString::from("/var/cache/cargo-home").as_os_str() + )) + ); + assert_eq!( + envs.get(OsStr::new("RUSTUP_HOME")), + Some(&Some( + std::ffi::OsString::from("/var/cache/rustup-home").as_os_str() + )) + ); +} + +#[test] +fn test_build_tool_package_option_maps_supported_builders() { + assert_eq!( + build_tool_package_option(BuildType::Meson), + Some("DEPOT_MESON_PACKAGE") + ); + assert_eq!( + build_tool_package_option(BuildType::CMake), + Some("DEPOT_CMAKE_PACKAGE") + ); + assert_eq!(build_tool_package_option(BuildType::Bin), None); +} + +#[test] +fn test_static_build_args_skip_disable_static_when_no_delete_static_enabled() { + let args = static_build_args_for_request(BuildType::Autotools, Some(false), true); + assert!(args.is_empty()); + + let args = static_build_args_for_request(BuildType::CMake, Some(false), true); + assert!(args.is_empty()); +} + +#[test] +fn test_static_build_args_keep_other_requested_modes() { + assert_eq!( + static_build_args_for_request(BuildType::Autotools, Some(false), false), + vec![ + "--enable-shared".to_string(), + "--disable-static".to_string() + ] + ); + assert_eq!( + static_build_args_for_request(BuildType::CMake, Some(false), false), + vec!["-DBUILD_SHARED_LIBS=ON".to_string()] + ); + assert_eq!( + static_build_args_for_request(BuildType::Meson, Some(false), false), + vec!["-Ddefault_library=shared".to_string()] + ); + assert_eq!( + static_build_args_for_request(BuildType::Perl, Some(false), false), + vec!["LINKTYPE=dynamic".to_string()] + ); + assert_eq!( + static_build_args_for_request(BuildType::Meson, Some(true), true), + vec!["-Ddefault_library=static".to_string()] + ); +} + +#[test] +fn test_standard_build_env_exports_native_linker_and_cpp() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.ranlib = "llvm-ranlib".to_string(); + spec.build.flags.strip = "llvm-strip".to_string(); + spec.build.flags.ld = "ld.lld".to_string(); + spec.build.flags.nm = "llvm-nm".to_string(); + spec.build.flags.objcopy = "llvm-objcopy".to_string(); + spec.build.flags.objdump = "llvm-objdump".to_string(); + spec.build.flags.readelf = "llvm-readelf".to_string(); + spec.build.flags.cpp = "clang-cpp".to_string(); + + let env = standard_build_env(&spec, None, true, true); + assert!(env.iter().any(|(k, v)| k == "RANLIB" && v == "llvm-ranlib")); + assert!(env.iter().any(|(k, v)| k == "STRIP" && v == "llvm-strip")); + assert!(env.iter().any(|(k, v)| k == "LD" && v == "ld.lld")); + assert!(env.iter().any(|(k, v)| k == "NM" && v == "llvm-nm")); + assert!( + env.iter() + .any(|(k, v)| k == "OBJCOPY" && v == "llvm-objcopy") + ); + assert!( + env.iter() + .any(|(k, v)| k == "OBJDUMP" && v == "llvm-objdump") + ); + assert!( + env.iter() + .any(|(k, v)| k == "READELF" && v == "llvm-readelf") + ); + assert!(env.iter().any(|(k, v)| k == "CPP" && v == "clang-cpp")); +} + +#[test] +fn test_standard_build_env_exports_tool_dir_and_expands_tool_commands() { + let mut spec = mk_spec( + vec!["--gcc-toolchain=$TOOL_DIR"], + vec!["-B$TOOL_DIR", "-Wl,--as-needed"], + ); + spec.build.flags.tool_dir = "/opt/depot-tools/bin".to_string(); + spec.build.flags.cc = "$TOOL_DIR/clang".to_string(); + spec.build.flags.cxx = "$TOOL_DIR/clang++".to_string(); + spec.build.flags.ar = "$TOOL_DIR/llvm-ar".to_string(); + spec.build.flags.ranlib = "$TOOL_DIR/llvm-ranlib".to_string(); + spec.build.flags.ld = "$TOOL_DIR/ld.lld".to_string(); + spec.build.flags.env_vars = vec!["LLVM_CONFIG=$TOOL_DIR/llvm-config".to_string()]; + + let env = standard_build_env(&spec, None, true, true); + + assert_eq!(env_value(&env, "TOOL_DIR"), Some("/opt/depot-tools/bin")); + assert_eq!(env_value(&env, "CC"), Some("/opt/depot-tools/bin/clang")); + assert_eq!(env_value(&env, "CXX"), Some("/opt/depot-tools/bin/clang++")); + assert_eq!(env_value(&env, "AR"), Some("/opt/depot-tools/bin/llvm-ar")); + assert_eq!( + env_value(&env, "RANLIB"), + Some("/opt/depot-tools/bin/llvm-ranlib") + ); + assert_eq!(env_value(&env, "LD"), Some("/opt/depot-tools/bin/ld.lld")); + assert_eq!( + env_value(&env, "CFLAGS"), + Some("--gcc-toolchain=/opt/depot-tools/bin") + ); + assert_eq!( + env_value(&env, "LDFLAGS"), + Some("-B/opt/depot-tools/bin -Wl,--as-needed") + ); + assert_eq!( + env_value(&env, "LLVM_CONFIG"), + Some("/opt/depot-tools/bin/llvm-config") + ); +} + +#[test] +fn test_standard_build_env_cross_uses_package_tool_overrides() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.cc = "/tools/bin/clang".to_string(); + spec.build.flags.cxx = "/tools/bin/clang++".to_string(); + spec.build.flags.ar = "/tools/bin/llvm-ar".to_string(); + spec.build.flags.ranlib = "/tools/bin/llvm-ranlib".to_string(); + spec.build.flags.strip = "/tools/bin/llvm-strip".to_string(); + spec.build.flags.ld = "/tools/bin/ld.lld".to_string(); + spec.build.flags.nm = "/tools/bin/llvm-nm".to_string(); + spec.build.flags.objcopy = "/tools/bin/llvm-objcopy".to_string(); + spec.build.flags.objdump = "/tools/bin/llvm-objdump".to_string(); + spec.build.flags.readelf = "/tools/bin/llvm-readelf".to_string(); + spec.build.flags.cpp = "/tools/bin/clang-cpp".to_string(); + let cross = CrossConfig { + prefix: "x86_64-test-linux-gnu".into(), + cc: "x86_64-test-linux-gnu-gcc".into(), + cxx: "x86_64-test-linux-gnu-g++".into(), + ar: "x86_64-test-linux-gnu-ar".into(), + ranlib: "x86_64-test-linux-gnu-ranlib".into(), + strip: "x86_64-test-linux-gnu-strip".into(), + ld: "x86_64-test-linux-gnu-ld".into(), + nm: "x86_64-test-linux-gnu-nm".into(), + objcopy: "x86_64-test-linux-gnu-objcopy".into(), + objdump: "x86_64-test-linux-gnu-objdump".into(), + readelf: "x86_64-test-linux-gnu-readelf".into(), + }; + + let env = standard_build_env(&spec, Some(&cross), true, true); + + assert_eq!(env_value(&env, "CC"), Some("/tools/bin/clang")); + assert_eq!(env_value(&env, "CXX"), Some("/tools/bin/clang++")); + assert_eq!(env_value(&env, "AR"), Some("/tools/bin/llvm-ar")); + assert_eq!(env_value(&env, "RANLIB"), Some("/tools/bin/llvm-ranlib")); + assert_eq!(env_value(&env, "STRIP"), Some("/tools/bin/llvm-strip")); + assert_eq!(env_value(&env, "LD"), Some("/tools/bin/ld.lld")); + assert_eq!(env_value(&env, "NM"), Some("/tools/bin/llvm-nm")); + assert_eq!(env_value(&env, "OBJCOPY"), Some("/tools/bin/llvm-objcopy")); + assert_eq!(env_value(&env, "OBJDUMP"), Some("/tools/bin/llvm-objdump")); + assert_eq!(env_value(&env, "READELF"), Some("/tools/bin/llvm-readelf")); + assert_eq!(env_value(&env, "CPP"), Some("/tools/bin/clang-cpp")); + assert_eq!( + env_value(&env, "CROSS_PREFIX"), + Some("x86_64-test-linux-gnu") + ); +} + +#[test] +fn test_standard_build_env_exports_effective_carch_for_cross_and_lib32() { + let spec = mk_spec(Vec::new(), Vec::new()); + let cross = CrossConfig { + prefix: "aarch64-linux-gnu".into(), + cc: "aarch64-linux-gnu-gcc".into(), + cxx: "aarch64-linux-gnu-g++".into(), + ar: "aarch64-linux-gnu-ar".into(), + ranlib: "aarch64-linux-gnu-ranlib".into(), + strip: "aarch64-linux-gnu-strip".into(), + ld: "aarch64-linux-gnu-ld".into(), + nm: "aarch64-linux-gnu-nm".into(), + objcopy: "aarch64-linux-gnu-objcopy".into(), + objdump: "aarch64-linux-gnu-objdump".into(), + readelf: "aarch64-linux-gnu-readelf".into(), + }; + + let cross_env = standard_build_env(&spec, Some(&cross), true, true); + assert!( + cross_env + .iter() + .any(|(k, v)| k == "CARCH" && v == "aarch64"), + "expected cross builds to export target CARCH" + ); + assert!( + cross_env + .iter() + .any(|(k, v)| k == "OBJCOPY" && v == "aarch64-linux-gnu-objcopy"), + "expected cross builds to export OBJCOPY" + ); + assert!( + cross_env + .iter() + .any(|(k, v)| k == "OBJDUMP" && v == "aarch64-linux-gnu-objdump"), + "expected cross builds to export OBJDUMP" + ); + assert!( + cross_env + .iter() + .any(|(k, v)| k == "READELF" && v == "aarch64-linux-gnu-readelf"), + "expected cross builds to export READELF" + ); + + let mut lib32_spec = spec.clone(); + lib32_spec.build.flags.lib32_variant = true; + lib32_spec.build.flags.carch = "x86_64".into(); + let lib32_env = standard_build_env(&lib32_spec, None, true, true); + assert!( + lib32_env.iter().any(|(k, v)| k == "CARCH" && v == "i686"), + "expected lib32 builds to export i686 CARCH" + ); +} + +#[test] +fn test_spec_with_target_configure_appends_matching_arch_args() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.configure = vec!["--base".to_string()]; + spec.build + .flags + .configure_arch + .insert("aarch64".to_string(), vec!["--for-aarch64".to_string()]); + spec.build + .flags + .configure_arch + .insert("x86_64".to_string(), vec!["--for-x86".to_string()]); + let cross = CrossConfig { + prefix: "aarch64-linux-gnu".into(), + cc: "aarch64-linux-gnu-gcc".into(), + cxx: "aarch64-linux-gnu-g++".into(), + ar: "aarch64-linux-gnu-ar".into(), + ranlib: "aarch64-linux-gnu-ranlib".into(), + strip: "aarch64-linux-gnu-strip".into(), + ld: "aarch64-linux-gnu-ld".into(), + nm: "aarch64-linux-gnu-nm".into(), + objcopy: "aarch64-linux-gnu-objcopy".into(), + objdump: "aarch64-linux-gnu-objdump".into(), + readelf: "aarch64-linux-gnu-readelf".into(), + }; + + let adjusted = spec_with_target_configure(&spec, Some(&cross), TargetBuildKind::Primary) + .expect("expected arch-specific configure args"); + + assert_eq!( + adjusted.build.flags.configure, + vec!["--base".to_string(), "--for-aarch64".to_string()] + ); +} + +#[test] +fn test_spec_with_target_configure_uses_lib32_arch() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.lib32_variant = true; + spec.build.flags.carch = "x86_64".to_string(); + spec.build + .flags + .configure_arch + .insert("i686".to_string(), vec!["--for-lib32".to_string()]); + + let adjusted = spec_with_target_configure(&spec, None, TargetBuildKind::Lib32) + .expect("expected lib32 configure args"); + + assert_eq!( + adjusted.build.flags.configure, + vec!["--for-lib32".to_string()] + ); +} + +#[test] +fn test_standard_build_env_respects_export_compiler_flags_toggle() { + let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + spec.build.flags.cxxflags = vec!["-O2".into(), "-fno-exceptions".into()]; + + let enabled = standard_build_env(&spec, None, true, true); + assert!( + enabled.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), + "expected CFLAGS to be exported when enabled" + ); + assert!( + enabled + .iter() + .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions"), + "expected CXXFLAGS to be exported when enabled" + ); + assert!( + enabled + .iter() + .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed"), + "expected LDFLAGS to be exported when enabled" + ); + + let disabled = standard_build_env(&spec, None, true, false); + assert!( + !disabled.iter().any(|(k, _)| k == "CFLAGS"), + "expected CFLAGS to be omitted when disabled" + ); + assert!( + !disabled.iter().any(|(k, _)| k == "CXXFLAGS"), + "expected CXXFLAGS to be omitted when disabled" + ); + assert!( + !disabled.iter().any(|(k, _)| k == "LDFLAGS"), + "expected LDFLAGS to be omitted when disabled" + ); + + let mut disabled_by_spec = spec.clone(); + disabled_by_spec.build.flags.no_flags = true; + let disabled_env = standard_build_env(&disabled_by_spec, None, true, true); + assert!( + !disabled_env.iter().any(|(k, _)| k == "CFLAGS"), + "expected CFLAGS to be omitted when no_flags is set in spec" + ); + assert!( + !disabled_env.iter().any(|(k, _)| k == "CXXFLAGS"), + "expected CXXFLAGS to be omitted when no_flags is set in spec" + ); + assert!( + !disabled_env.iter().any(|(k, _)| k == "LDFLAGS"), + "expected LDFLAGS to be omitted when no_flags is set in spec" + ); +} + +#[test] +fn test_standard_build_env_injects_ltoflags_into_compiler_and_linker_flags() { + let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + spec.build.flags.cxxflags = vec!["-O2".into()]; + spec.build.flags.ltoflags = vec!["-flto=auto".into(), "-fuse-linker-plugin".into()]; + spec.build.flags.use_lto = true; + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter() + .any(|(k, v)| { k == "CFLAGS" && v == "-O2 -flto=auto -fuse-linker-plugin" }), + "expected LTOFLAGS to be appended to CFLAGS" + ); + assert!( + env.iter() + .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -flto=auto -fuse-linker-plugin"), + "expected LTOFLAGS to be appended to CXXFLAGS" + ); + assert!( + env.iter().any(|(k, v)| { + k == "LDFLAGS" && v == "-Wl,--as-needed -flto=auto -fuse-linker-plugin" + }), + "expected LTOFLAGS to be appended to LDFLAGS" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=auto -fuse-linker-plugin"), + "expected LTOFLAGS variable to be exported" + ); +} + +#[test] +fn test_standard_build_env_injects_fuse_ld_into_ldflags() { + let mut spec = mk_spec(Vec::new(), vec!["-Wl,--as-needed"]); + spec.build.flags.fuse_ld = "/usr/bin/ld.lld".into(); + + let env = standard_build_env(&spec, None, true, true); + + assert_eq!( + env_value(&env, "LDFLAGS"), + Some("-fuse-ld=/usr/bin/ld.lld -Wl,--as-needed") + ); +} + +#[test] +fn test_standard_build_env_normalizes_fuse_ld_tool_names() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.fuse_ld = "ld.lld".into(); + + let env = standard_build_env(&spec, None, true, true); + + assert_eq!(env_value(&env, "LDFLAGS"), Some("-fuse-ld=lld")); +} + +#[test] +fn test_standard_build_env_applies_replace_flag_rules() { + let mut spec = mk_spec(vec!["-D_FORTIFY_SOURCE=3", "-O2"], vec!["-Wl,-O3"]); + spec.build.flags.cxxflags = vec!["-O2".into(), "-stdlib=libc++".into()]; + spec.build.flags.replace_cflags = vec!["_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".into()]; + spec.build.flags.replace_cxxflags = vec!["-stdlib=libc++=>-stdlib=libstdc++".into()]; + spec.build.flags.replace_ldflags = vec!["-O3=>-O2".into()]; + spec.build.flags.ltoflags = vec!["-flto=auto".into()]; + spec.build.flags.replace_ltoflags = vec!["auto=>thin".into()]; + spec.build.flags.use_lto = true; + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter() + .any(|(k, v)| k == "CFLAGS" && v == "-D_FORTIFY_SOURCE=2 -O2 -flto=thin"), + "expected replace_cflags and replace_ltoflags to be applied" + ); + assert!( + env.iter() + .any(|(k, v)| { k == "CXXFLAGS" && v == "-O2 -stdlib=libstdc++ -flto=thin" }), + "expected replace_cxxflags to be applied" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,-O2 -flto=thin"), + "expected replace_ldflags to be applied" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=thin"), + "expected replace_ltoflags to affect exported LTOFLAGS" + ); +} + +#[test] +fn test_standard_build_env_drops_bare_dash_flags() { + let mut spec = mk_spec(vec!["-O2", "-", ""], vec!["-Wl,--as-needed", " "]); + spec.build.flags.cxxflags = vec!["-O2".into(), "-".into(), "-fno-exceptions".into()]; + spec.build.flags.ltoflags = vec!["-".into(), "-flto=thin".into()]; + spec.build.flags.use_lto = true; + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter() + .any(|(k, v)| k == "CFLAGS" && v == "-O2 -flto=thin"), + "expected bare dash entries to be removed from CFLAGS" + ); + assert!( + env.iter() + .any(|(k, v)| k == "CXXFLAGS" && v == "-O2 -fno-exceptions -flto=thin"), + "expected bare dash entries to be removed from CXXFLAGS" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed -flto=thin"), + "expected blank and bare dash entries to be removed from LDFLAGS" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=thin"), + "expected bare dash entries to be removed from LTOFLAGS" + ); +} + +#[test] +fn test_standard_build_env_skips_lto_injection_when_disabled() { + let mut spec = mk_spec(vec!["-O2"], vec!["-Wl,--as-needed"]); + spec.build.flags.cxxflags = vec!["-O2".into()]; + spec.build.flags.ltoflags = vec!["-flto=auto".into()]; + spec.build.flags.rustltoflags = vec!["-Clinker-plugin-lto".into()]; + spec.build.flags.use_lto = false; + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter().any(|(k, v)| k == "CFLAGS" && v == "-O2"), + "expected CFLAGS to remain unchanged when use_lto is false" + ); + assert!( + env.iter().any(|(k, v)| k == "CXXFLAGS" && v == "-O2"), + "expected CXXFLAGS to remain unchanged when use_lto is false" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LDFLAGS" && v == "-Wl,--as-needed"), + "expected LDFLAGS to remain unchanged when use_lto is false" + ); + assert!( + env.iter() + .any(|(k, v)| k == "LTOFLAGS" && v == "-flto=auto"), + "expected LTOFLAGS variable to be exported even when use_lto is false" + ); + assert!( + env.iter() + .any(|(k, v)| k == "RUSTLTOFLAGS" && v == "-Clinker-plugin-lto"), + "expected RUSTLTOFLAGS variable to be exported even when use_lto is false" + ); + assert_eq!(effective_rustflags(&spec.build.flags), Vec::::new()); +} + +#[test] +fn test_standard_build_env_exports_makeflags() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.makeflags = "-j12 --output-sync=target".to_string(); + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter() + .any(|(k, v)| k == "MAKEFLAGS" && v == "-j12 --output-sync=target"), + "expected MAKEFLAGS to be exported from build flags" + ); +} + +#[test] +fn test_standard_build_env_exports_install_dir_vars() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.prefix = "/opt/vertex".into(); + spec.build.flags.bindir = "/opt/vertex/bin".into(); + spec.build.flags.sbindir = "/opt/vertex/sbin".into(); + spec.build.flags.libdir = "/opt/vertex/lib".into(); + spec.build.flags.libexecdir = "/opt/vertex/libexec".into(); + spec.build.flags.sysconfdir = "/etc/vertex".into(); + spec.build.flags.localstatedir = "/var".into(); + spec.build.flags.sharedstatedir = "/var/lib".into(); + spec.build.flags.includedir = "/opt/vertex/include".into(); + spec.build.flags.datarootdir = "/opt/vertex/share".into(); + spec.build.flags.datadir = "/opt/vertex/share/data".into(); + spec.build.flags.mandir = "/opt/vertex/share/man".into(); + spec.build.flags.infodir = "/opt/vertex/share/info".into(); + + let env = standard_build_env(&spec, None, false, true); + + assert_eq!(env_value(&env, "PREFIX"), Some("/opt/vertex")); + assert_eq!(env_value(&env, "BINDIR"), Some("/opt/vertex/bin")); + assert_eq!(env_value(&env, "SBINDIR"), Some("/opt/vertex/sbin")); + assert_eq!(env_value(&env, "LIBDIR"), Some("/opt/vertex/lib")); + assert_eq!(env_value(&env, "LIBEXECDIR"), Some("/opt/vertex/libexec")); + assert_eq!(env_value(&env, "SYSCONFDIR"), Some("/etc/vertex")); + assert_eq!(env_value(&env, "LOCALSTATEDIR"), Some("/var")); + assert_eq!(env_value(&env, "SHAREDSTATEDIR"), Some("/var/lib")); + assert_eq!(env_value(&env, "INCLUDEDIR"), Some("/opt/vertex/include")); + assert_eq!(env_value(&env, "DATAROOTDIR"), Some("/opt/vertex/share")); + assert_eq!(env_value(&env, "DATADIR"), Some("/opt/vertex/share/data")); + assert_eq!(env_value(&env, "MANDIR"), Some("/opt/vertex/share/man")); + assert_eq!(env_value(&env, "INFODIR"), Some("/opt/vertex/share/info")); +} + +#[test] +fn test_standard_build_env_install_dir_vars_use_effective_defaults() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.lib32_variant = true; + + let env = standard_build_env(&spec, None, false, true); + + assert_eq!(env_value(&env, "LIBDIR"), Some("/usr/lib32")); + assert_eq!(env_value(&env, "LIBEXECDIR"), Some("/usr/lib32")); + assert_eq!(env_value(&env, "DATAROOTDIR"), Some("/usr/share")); + assert_eq!(env_value(&env, "DATADIR"), Some("/usr/share")); +} + +#[test] +fn test_standard_build_env_exports_passthrough_env() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.passthrough_env = vec!["RUSTFLAGS".into()]; + + let mut env = TestEnv::new(); + env.set_var("RUSTFLAGS", "-C target-cpu=native"); + + let env = standard_build_env(&spec, None, false, true); + assert!( + env.iter() + .any(|(k, v)| k == "RUSTFLAGS" && v == "-C target-cpu=native"), + "expected RUSTFLAGS to be copied from parent environment" + ); +} + +#[test] +fn test_standard_build_env_exports_declared_env_vars() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.package.version = "2.4.1".to_string(); + spec.spec_dir = PathBuf::from("/tmp/specs/demo"); + spec.build.flags.env_vars = vec![ + "SETUPTOOLS_SCM_PRETEND_VERSION=$version".into(), + "PYO3_CONFIG_FILE=$specdir/pyo3.toml".into(), + "PKG_CONFIG_PATH=$LIBDIR/pkgconfig".into(), + ]; + + let env = standard_build_env(&spec, None, false, true); + assert!( + env.iter() + .any(|(k, v)| k == "SETUPTOOLS_SCM_PRETEND_VERSION" && v == "2.4.1"), + "expected env_vars values to expand package variables" + ); + assert!( + env.iter() + .any(|(k, v)| k == "PYO3_CONFIG_FILE" && v == "/tmp/specs/demo/pyo3.toml"), + "expected env_vars values to expand specdir variables" + ); + assert!( + env.iter() + .any(|(k, v)| k == "PKG_CONFIG_PATH" && v == "/usr/lib/pkgconfig"), + "expected env_vars values to expand install directory variables" + ); +} + +#[test] +fn test_standard_build_env_declared_env_vars_override_defaults_and_passthrough() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.cc = "spec-cc".to_string(); + spec.build.flags.passthrough_env = vec!["CC".into()]; + spec.build.flags.env_vars = vec!["CC=custom-cc".into()]; + + let mut env = TestEnv::new(); + env.set_var("CC", "host-cc"); + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter().any(|(k, v)| k == "CC" && v == "custom-cc"), + "expected explicit env_vars assignments to override default and passthrough values" + ); +} + +#[test] +fn test_effective_rustflags_applies_replace_rules() { + let flags = BuildFlags { + rustflags: vec!["-C".into(), "debuginfo=2".into()], + replace_rustflags: vec!["debuginfo=2=>opt-level=2".into()], + ..BuildFlags::default() + }; + + assert_eq!(effective_rustflags(&flags), vec!["-C", "opt-level=2"]); +} + +#[test] +fn test_effective_rustflags_appends_rustltoflags_when_enabled() { + let flags = BuildFlags { + rustflags: vec!["-C".into(), "opt-level=3".into()], + rustltoflags: vec!["-Clinker-plugin-lto".into(), "-Cembed-bitcode=yes".into()], + use_lto: true, + ..BuildFlags::default() + }; + + assert_eq!( + effective_rustflags(&flags), + vec![ + "-C", + "opt-level=3", + "-Clinker-plugin-lto", + "-Cembed-bitcode=yes" + ] + ); +} + +#[test] +fn test_standard_build_env_passthrough_does_not_override_default_vars() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.cc = "spec-cc".to_string(); + spec.build.flags.passthrough_env = vec!["CC".into()]; + + let mut env = TestEnv::new(); + env.set_var("CC", "host-cc"); + + let env = standard_build_env(&spec, None, true, true); + assert!( + env.iter().any(|(k, v)| k == "CC" && v == "spec-cc"), + "expected default CC to take precedence over passthrough CC" + ); +} + +#[test] +fn test_install_dirs_use_defaults_and_lib32_fallbacks() { + let default_dirs = install_dirs(&BuildFlags::default()); + assert_eq!(default_dirs.bindir, "/usr/bin"); + assert_eq!(default_dirs.sbindir, "/usr/bin"); + assert_eq!(default_dirs.libdir, "/usr/lib"); + assert_eq!(default_dirs.libexecdir, "/usr/lib"); + assert_eq!(default_dirs.datarootdir, "/usr/share"); + assert_eq!(default_dirs.datadir, "/usr/share"); + + let lib32_dirs = install_dirs(&BuildFlags { + lib32_variant: true, + ..BuildFlags::default() + }); + assert_eq!(lib32_dirs.libdir, "/usr/lib32"); + assert_eq!(lib32_dirs.libexecdir, "/usr/lib32"); +} + +#[test] +fn test_build_helper_context_restores_runtime_build_flags() { + let mut spec = mk_spec(Vec::new(), Vec::new()); + spec.build.flags.lib32_variant = true; + spec.build.flags.host_build_dir = Some("/tmp/build-host".into()); + + let restored = BuildHelperContext::from_spec(&spec).build_flags(); + assert!(restored.lib32_variant); + assert_eq!(restored.host_build_dir.as_deref(), Some("/tmp/build-host")); +} + +#[test] +fn test_install_dirs_respect_explicit_overrides_and_derived_defaults() { + let dirs = install_dirs(&BuildFlags { + bindir: "/opt/bin".into(), + libdir: "/opt/lib64".into(), + datarootdir: "/opt/share-root".into(), + ..BuildFlags::default() + }); + + assert_eq!(dirs.bindir, "/opt/bin"); + assert_eq!(dirs.libdir, "/opt/lib64"); + assert_eq!(dirs.libexecdir, "/opt/lib64"); + assert_eq!(dirs.datarootdir, "/opt/share-root"); + assert_eq!(dirs.datadir, "/opt/share-root"); +} + +#[test] +fn test_install_destdir_path_uses_build_dir_for_lib32() { + let build_dir = Path::new("/tmp/build"); + let destdir = Path::new("/tmp/pkg"); + assert_eq!(install_destdir_path(build_dir, destdir, false), destdir); + assert_eq!( + install_destdir_path(build_dir, destdir, true), + build_dir.join("destdir") + ); +} + +#[test] +fn test_stage_lib32_install_tree_uses_usr_lib32_when_present() -> Result<()> { + let temp = tempfile::tempdir()?; + let staging = temp.path().join("staging"); + let dest = temp.path().join("dest"); + fs::create_dir_all(staging.join("usr/lib32"))?; + fs::create_dir_all(staging.join("usr/bin"))?; + fs::write(staging.join("usr/lib32/libfoo.so.1"), "lib32")?; + fs::write(staging.join("usr/bin/foo"), "bin")?; + + stage_lib32_install_tree(&staging, &dest)?; + + assert_eq!( + fs::read_to_string(dest.join("usr/lib32/libfoo.so.1"))?, + "lib32" + ); + assert!(!dest.join("usr/bin/foo").exists()); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn test_stage_lib32_install_tree_relocates_usr_lib_when_needed() -> Result<()> { + use std::os::unix::fs as unix_fs; + + let temp = tempfile::tempdir()?; + let staging = temp.path().join("staging"); + let dest = temp.path().join("dest"); + fs::create_dir_all(staging.join("usr/lib"))?; + fs::create_dir_all(staging.join("usr/share/man/man1"))?; + fs::write(staging.join("usr/lib/libfoo.so.1"), "relocated")?; + fs::write(staging.join("usr/share/man/man1/foo.1"), "manpage")?; + unix_fs::symlink("libfoo.so.1", staging.join("usr/lib/libfoo.so"))?; + + stage_lib32_install_tree(&staging, &dest)?; + + assert_eq!( + fs::read_to_string(dest.join("usr/lib32/libfoo.so.1"))?, + "relocated" + ); + assert_eq!( + fs::read_link(dest.join("usr/lib32/libfoo.so"))?, + PathBuf::from("libfoo.so.1") + ); + assert!(!dest.join("usr/share/man/man1/foo.1").exists()); + assert!(!dest.join("usr/lib").exists()); + Ok(()) +} + +#[test] +fn test_stage_lib32_install_tree_preserves_hardlinks() -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let temp = tempfile::tempdir()?; + let staging = temp.path().join("staging"); + let dest = temp.path().join("dest"); + fs::create_dir_all(staging.join("usr/lib32"))?; + fs::write(staging.join("usr/lib32/libfoo.so.1"), "lib32")?; + fs::hard_link( + staging.join("usr/lib32/libfoo.so.1"), + staging.join("usr/lib32/libfoo-current.so"), + )?; + + stage_lib32_install_tree(&staging, &dest)?; + + let first = dest.join("usr/lib32/libfoo.so.1").metadata()?; + let second = dest.join("usr/lib32/libfoo-current.so").metadata()?; + assert_eq!(first.ino(), second.ino()); + assert_eq!(first.nlink(), 2); + assert_eq!(second.nlink(), 2); + Ok(()) +} diff --git a/src/commands.rs b/src/commands.rs index cc61e24..e9b2b5a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -704,2412 +704,13 @@ fn staged_output_specs( Ok(outputs) } -#[derive(Debug, Clone)] -struct PlannedStagedInstall { - is_update: bool, - remove_paths: Vec, - replacement_removals: Vec, - renamed_transition: Option, - hook_context: install::hooks::HookExecutionContextOwned, -} +mod direct_install; +mod install_plan; +mod install_transaction; -#[derive(Debug, Clone)] -struct RenamedPackageTransition { - replaced: db::InstalledPackageRecord, - retained_files: Vec, - retained_directories: Vec, -} - -impl RenamedPackageTransition { - fn replacement(&self) -> db::PackageReplacement { - db::PackageReplacement { - old_name: self.replaced.name.clone(), - retained_files: self.retained_files.clone(), - retained_directories: self.retained_directories.clone(), - } - } - - fn retains_old_package(&self) -> bool { - !self.retained_files.is_empty() || !self.retained_directories.is_empty() - } -} - -#[derive(Debug, Clone)] -struct PlannedPackageInstall { - spec: package::PackageSpec, - destdir: PathBuf, - staged: PlannedStagedInstall, -} - -#[derive(Debug, Clone)] -struct PlannedInstalledRemoval { - package: String, - affected_paths: Vec, -} - -struct PreparedDirectInstallResources<'a> { - _staging_dir: Option, - _source_cleanup_guard: SourceBuildCleanupGuard<'a>, -} - -struct PreparedDirectInstall<'a> { - plans: Vec, - resources: PreparedDirectInstallResources<'a>, -} - -struct DirectInstallPreparationOptions<'a> { - build_dir: &'a Path, - clean_sources_before_build: bool, - suppress_output: bool, - confirm_installation: bool, - resolve_installed_conflicts: bool, -} - -#[derive(Clone, Copy)] -struct PendingLifecycleHook { - hook: install::scripts::Hook, -} - -#[cfg(test)] -#[derive(Debug, Clone)] -struct InstalledPackageOutcome { - package: package::PackageInfo, - is_update: bool, -} - -#[derive(Debug, Clone)] -struct InstallConflictSubject { - package: String, - provides: Vec, - conflicts: Vec, -} - -#[derive(Debug, Clone)] -struct InstalledConflictPackage { - name: String, - provides: Vec, -} - -fn install_conflict_subjects_for_output_spec( - spec: &package::PackageSpec, -) -> Vec { - spec.outputs() - .into_iter() - .map(|output| { - let alternatives = spec.alternatives_for_output(&output.name); - InstallConflictSubject { - package: output.name, - provides: alternatives.provides, - conflicts: alternatives.conflicts, - } - }) - .collect() -} - -fn install_conflict_subjects_for_spec( - spec: &package::PackageSpec, - include_primary: bool, - include_lib32: bool, -) -> Vec { - let mut subjects = Vec::new(); - if include_primary { - subjects.extend(install_conflict_subjects_for_output_spec(spec)); - } - if include_lib32 { - subjects.extend(install_conflict_subjects_for_output_spec( - &make_lib32_package_spec(spec), - )); - } - subjects -} - -fn install_conflict_subject_for_binary_record( - record: &db::repo::BinaryRepoPackageRecord, -) -> InstallConflictSubject { - InstallConflictSubject { - package: record.name.clone(), - provides: record.provides.clone(), - conflicts: record.conflicts.clone(), - } -} - -fn matching_conflict_names( - conflicts: &[String], - package_name: &str, - provides: &[String], -) -> Vec { - let mut matches = Vec::new(); - for conflict in conflicts { - if conflict == package_name || provides.iter().any(|provided| provided == conflict) { - matches.push(conflict.clone()); - } - } - matches.sort(); - matches.dedup(); - matches -} - -fn validate_no_transaction_conflicts(subjects: &[InstallConflictSubject]) -> Result<()> { - let mut violations = BTreeSet::new(); - for (idx, left) in subjects.iter().enumerate() { - for right in subjects.iter().skip(idx + 1) { - let left_hits = - matching_conflict_names(&left.conflicts, &right.package, &right.provides); - if !left_hits.is_empty() { - violations.insert(format!( - "{} conflicts with {} via {}", - left.package, - right.package, - left_hits.join(", ") - )); - } - let right_hits = - matching_conflict_names(&right.conflicts, &left.package, &left.provides); - if !right_hits.is_empty() { - violations.insert(format!( - "{} conflicts with {} via {}", - right.package, - left.package, - right_hits.join(", ") - )); - } - } - } - - if violations.is_empty() { - return Ok(()); - } - - let mut message = - String::from("Cannot install conflicting packages in the same transaction:\n"); - for violation in violations { - message.push_str(" "); - message.push_str(&violation); - message.push('\n'); - } - anyhow::bail!(message.trim_end().to_string()); -} - -fn collect_installed_conflict_packages(db_path: &Path) -> Result> { - let mut installed = Vec::new(); - for record in db::list_installed_package_records(db_path)? { - installed.push(InstalledConflictPackage { - provides: db::get_package_provides(db_path, &record.name)?, - name: record.name, - }); - } - Ok(installed) -} - -fn collect_conflicting_installed_packages( - subjects: &[InstallConflictSubject], - installed: &[InstalledConflictPackage], -) -> Result>> { - validate_no_transaction_conflicts(subjects)?; - let planned_packages: HashSet<_> = subjects - .iter() - .map(|subject| subject.package.clone()) - .collect(); - let mut removals: BTreeMap> = BTreeMap::new(); - - for subject in subjects { - for installed_pkg in installed { - if installed_pkg.name == subject.package { - continue; - } - let matched = matching_conflict_names( - &subject.conflicts, - &installed_pkg.name, - &installed_pkg.provides, - ); - if matched.is_empty() { - continue; - } - if planned_packages.contains(&installed_pkg.name) { - anyhow::bail!( - "Cannot install conflicting packages in the same transaction: {} conflicts with {}", - subject.package, - installed_pkg.name - ); - } - removals - .entry(installed_pkg.name.clone()) - .or_default() - .insert(subject.package.clone()); - } - } - - Ok(removals) -} - -fn collect_installed_replacement_packages( - db_path: &Path, - pkg_spec: &package::PackageSpec, -) -> Result> { - let installed = db::get_installed_packages(db_path)?; - let mut replacements: Vec = pkg_spec - .alternatives - .replaces - .iter() - .filter(|name| *name != &pkg_spec.package.name) - .filter(|name| installed.contains(*name)) - .cloned() - .collect(); - replacements.sort(); - replacements.dedup(); - Ok(replacements) -} - -pub(crate) fn remove_installed_package_with_hooks( - package: &str, - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - let db_path = config.installed_db_path(rootfs); - let affected_paths = db::get_package_files(&db_path, package)?; - install::hooks::run_transaction_hooks( - rootfs, - &install::hooks::HookExecutionContext { - phase: install::hooks::HookPhase::Pre, - operation: install::hooks::HookOperation::Remove, - package, - affected_paths: &affected_paths, - }, - )?; - remove_installed_package_without_transaction_hooks(package, rootfs, config, &affected_paths)?; - install::hooks::run_transaction_hooks( - rootfs, - &install::hooks::HookExecutionContext { - phase: install::hooks::HookPhase::Post, - operation: install::hooks::HookOperation::Remove, - package, - affected_paths: &affected_paths, - }, - )?; - Ok(()) -} - -fn remove_installed_package_without_transaction_hooks( - package: &str, - rootfs: &Path, - config: &config::Config, - _affected_paths: &[String], -) -> Result<()> { - let db_path = config.installed_db_path(rootfs); - let script_dir = install::scripts::installed_scripts_dir(rootfs, package); - let _ = install::scripts::run_hook_if_present( - &script_dir, - install::scripts::Hook::PreRemove, - rootfs, - package, - )?; - db::remove_package(&db_path, package, rootfs)?; - let post_remove = install::scripts::run_hook_if_present( - &script_dir, - install::scripts::Hook::PostRemove, - rootfs, - package, - ); - let cleanup_scripts = install::scripts::remove_installed_scripts(rootfs, package); - post_remove?; - cleanup_scripts?; - ui::success(format!("Successfully removed {}", package)); - Ok(()) -} - -fn prompt_installed_conflict_removals_for_subjects( - subjects: &[InstallConflictSubject], - rootfs: &Path, - config: &config::Config, - dry_run: bool, -) -> Result> { - if subjects.is_empty() { - return Ok(Vec::new()); - } - - let db_path = config.installed_db_path(rootfs); - let installed = collect_installed_conflict_packages(&db_path)?; - let removals = collect_conflicting_installed_packages(subjects, &installed)?; - if removals.is_empty() { - return Ok(Vec::new()); - } - - let prompt_entries: Vec = removals - .iter() - .map(|(package, conflicted_by)| { - format!( - "{} (conflicts with {})", - package, - conflicted_by.iter().cloned().collect::>().join(", ") - ) - }) - .collect(); - - if dry_run { - ui::info(format!( - "Dry run: would remove conflicting installed package(s): {}", - prompt_entries.join(", ") - )); - return Ok(Vec::new()); - } - - if !ui::prompt_package_action("conflict removal", &prompt_entries, true)? { - anyhow::bail!("Aborted"); - } - - Ok(removals.keys().cloned().collect()) -} - -fn resolve_installed_conflicts_for_subjects( - subjects: &[InstallConflictSubject], - rootfs: &Path, - config: &config::Config, - dry_run: bool, -) -> Result<()> { - for package in - prompt_installed_conflict_removals_for_subjects(subjects, rootfs, config, dry_run)? - { - ui::info(format!("Removing conflicting package: {}", package)); - remove_installed_package_with_hooks(&package, rootfs, config)?; - } - - Ok(()) -} - -fn is_versioned_shared_library_path(path: &str) -> bool { - let Some(file_name) = Path::new(path).file_name().and_then(|name| name.to_str()) else { - return false; - }; - let Some(version_suffix) = file_name.split(".so.").nth(1) else { - return false; - }; - !version_suffix.is_empty() - && version_suffix - .chars() - .all(|ch| ch.is_ascii_digit() || ch == '.') - && version_suffix.chars().any(|ch| ch.is_ascii_digit()) -} - -fn retained_abi_files_for_replacement( - old_files: &[String], - new_manifest: &staging::Manifest, -) -> Vec { - let new_files: HashSet<&str> = new_manifest.files.iter().map(String::as_str).collect(); - let mut retained: Vec = old_files - .iter() - .filter(|path| is_versioned_shared_library_path(path)) - .filter(|path| !new_files.contains(path.as_str())) - .cloned() - .collect(); - retained.sort(); - retained -} - -fn retained_directories_for_files( - old_directories: &[String], - retained_files: &[String], -) -> Vec { - let retained_files: HashSet<&str> = retained_files.iter().map(String::as_str).collect(); - let mut directories: Vec = old_directories - .iter() - .filter(|directory| { - let prefix = format!("{}/", directory); - retained_files - .iter() - .any(|file| *file == directory.as_str() || file.starts_with(&prefix)) - }) - .cloned() - .collect(); - directories.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); - directories -} - -fn compare_installed_records_for_stream( - left: &db::InstalledPackageRecord, - right: &db::InstalledPackageRecord, -) -> Ordering { - compare_package_release(&left.version, left.revision, &right.version, right.revision) - .then_with(|| compare_completed_at(left.completed_at, right.completed_at)) - .then_with(|| left.name.cmp(&right.name)) -} - -fn select_primary_installed_record<'a>( - records: impl IntoIterator, -) -> Option<&'a db::InstalledPackageRecord> { - let mut best: Option<&db::InstalledPackageRecord> = None; - for record in records { - if best.as_ref().is_none_or(|current| { - compare_installed_records_for_stream(record, current) == Ordering::Greater - }) { - best = Some(record); - } - } - best -} - -fn build_renamed_package_transition( - db_path: &Path, - pkg_spec: &package::PackageSpec, - new_manifest: &staging::Manifest, -) -> Result> { - let installed = db::list_installed_package_records(db_path)?; - if installed - .iter() - .any(|record| record.name == pkg_spec.package.name) - { - return Ok(None); - } - - let stream_name = pkg_spec.package.effective_real_name(); - let Some(replaced) = select_primary_installed_record( - installed - .iter() - .filter(|record| record.effective_real_name() == stream_name) - .filter(|record| record.name != pkg_spec.package.name), - ) - .cloned() else { - return Ok(None); - }; - - let old_files = db::get_package_files(db_path, &replaced.name)?; - let old_directories = db::get_package_directories(db_path, &replaced.name)?; - let retained_files = if replaced.abi_breaking { - retained_abi_files_for_replacement(&old_files, new_manifest) - } else { - Vec::new() - }; - let retained_directories = if retained_files.is_empty() { - Vec::new() - } else { - retained_directories_for_files(&old_directories, &retained_files) - }; - - Ok(Some(RenamedPackageTransition { - replaced, - retained_files, - retained_directories, - })) -} - -fn plan_staged_install( - pkg_spec: &package::PackageSpec, - destdir: &Path, - rootfs: &Path, - config: &config::Config, -) -> Result { - std::fs::create_dir_all(&config.db_dir).with_context(|| { - format!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - let db_path = config.installed_db_path(rootfs); - - let new_manifest = staging::generate_manifest_with_dirs(destdir)?; - let replacement_removals = collect_installed_replacement_packages(&db_path, pkg_spec)?; - let renamed_transition = build_renamed_package_transition(&db_path, pkg_spec, &new_manifest)?; - let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some() - || renamed_transition.is_some() - || !replacement_removals.is_empty(); - let mut remove_paths = - db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_manifest)?; - if let Some(transition) = &renamed_transition { - let old_files = db::get_package_files(&db_path, &transition.replaced.name)?; - let old_directories = db::get_package_directories(&db_path, &transition.replaced.name)?; - let retained_files: HashSet<&str> = transition - .retained_files - .iter() - .map(String::as_str) - .collect(); - let retained_directories: HashSet<&str> = transition - .retained_directories - .iter() - .map(String::as_str) - .collect(); - remove_paths.extend( - old_files - .into_iter() - .filter(|path| !retained_files.contains(path.as_str())), - ); - remove_paths.extend( - old_directories - .into_iter() - .filter(|path| !retained_directories.contains(path.as_str())), - ); - remove_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); - remove_paths.dedup(); - } - let operation = if is_update { - install::hooks::HookOperation::Update - } else { - install::hooks::HookOperation::Install - }; - let mut affected_paths = new_manifest.files.clone(); - affected_paths.extend(remove_paths.iter().cloned()); - affected_paths.sort(); - affected_paths.dedup(); - - Ok(PlannedStagedInstall { - is_update, - remove_paths, - replacement_removals, - renamed_transition, - hook_context: install::hooks::HookExecutionContextOwned { - operation, - package: pkg_spec.package.name.clone(), - affected_paths, - }, - }) -} - -fn plan_package_outputs_for_install( - pkg_spec: &package::PackageSpec, - destdir: &Path, - rootfs: &Path, - config: &config::Config, -) -> Result> { - let mut plans = Vec::new(); - for (spec_for_out, out_destdir) in staged_output_specs(pkg_spec, destdir)? { - let staged = plan_staged_install(&spec_for_out, &out_destdir, rootfs, config)?; - plans.push(PlannedPackageInstall { - spec: spec_for_out, - destdir: out_destdir, - staged, - }); - } - Ok(plans) -} - -fn run_transaction_hooks_for_plans( - rootfs: &Path, - phase: install::hooks::HookPhase, - plans: &[PlannedPackageInstall], -) -> Result { - let contexts: Vec<_> = plans - .iter() - .map(|plan| plan.staged.hook_context.clone()) - .collect(); - install::hooks::run_transaction_hooks_batch(rootfs, phase, &contexts) -} - -fn preflight_file_ownership_and_order( - plans: &[PlannedPackageInstall], - pre_removed_packages: &HashSet, - rootfs: &Path, - config: &config::Config, -) -> Result> { - let db_path = config.installed_db_path(rootfs); - let installed_ownership = db::get_file_ownership(&db_path)?; - let mut manifests = Vec::with_capacity(plans.len()); - let mut plan_by_package = BTreeMap::new(); - let mut replacement_plan_by_package = BTreeMap::new(); - let mut violations = BTreeSet::new(); - - for (idx, plan) in plans.iter().enumerate() { - let package = &plan.spec.package.name; - if plan_by_package.insert(package.clone(), idx).is_some() { - violations.insert(format!( - "package '{}' appears more than once in the transaction", - package - )); - } - for replaced in &plan.staged.replacement_removals { - if replacement_plan_by_package - .insert(replaced.clone(), idx) - .is_some() - { - violations.insert(format!( - "installed package '{}' is replaced by more than one transaction package", - replaced - )); - } - } - if let Some(transition) = &plan.staged.renamed_transition - && replacement_plan_by_package - .insert(transition.replaced.name.clone(), idx) - .is_some() - { - violations.insert(format!( - "installed package '{}' is replaced by more than one transaction package", - transition.replaced.name - )); - } - - let manifest = staging::generate_manifest_with_dirs(&plan.destdir).with_context(|| { - format!( - "Failed to inspect staged files for package '{}'", - plan.spec.package.name - ) - })?; - manifests.push(manifest.files.into_iter().collect::>()); - } - - let mut planned_owner_by_path: BTreeMap<&str, usize> = BTreeMap::new(); - for (idx, manifest) in manifests.iter().enumerate() { - for path in manifest { - if let Some(previous_idx) = planned_owner_by_path.insert(path, idx) - && plans[previous_idx].spec.package.name != plans[idx].spec.package.name - && !db::should_auto_clear_conflict(&plans[previous_idx].spec.package.name, path) - { - violations.insert(format!( - "{} -> provided by both {} and {}", - path, plans[previous_idx].spec.package.name, plans[idx].spec.package.name - )); - } - } - } - - let mut edges = vec![BTreeSet::new(); plans.len()]; - let mut indegree = vec![0_usize; plans.len()]; - for (taker_idx, manifest) in manifests.iter().enumerate() { - let taker = &plans[taker_idx].spec.package.name; - for path in manifest { - let Some(owner) = installed_ownership.get(path) else { - continue; - }; - if owner == taker - || pre_removed_packages.contains(owner) - || db::should_auto_clear_conflict(owner, path) - { - continue; - } - - let owner_plan_idx = if let Some(owner_idx) = plan_by_package.get(owner) { - if manifests[*owner_idx].contains(path) { - violations.insert(format!( - "{} -> owned by {} and still provided by its transaction update (wanted by {})", - path, owner, taker - )); - continue; - } - Some(*owner_idx) - } else if let Some(owner_idx) = replacement_plan_by_package.get(owner) { - let retained_by_rename = plans[*owner_idx] - .staged - .renamed_transition - .as_ref() - .is_some_and(|transition| { - transition.replaced.name == *owner - && transition.retained_files.contains(path) - }); - if retained_by_rename { - violations.insert(format!( - "{} -> retained by renamed package {} (wanted by {})", - path, owner, taker - )); - continue; - } - Some(*owner_idx) - } else { - violations.insert(format!( - "{} -> owned by {} (wanted by {})", - path, owner, taker - )); - None - }; - - if let Some(owner_idx) = owner_plan_idx - && owner_idx != taker_idx - && edges[owner_idx].insert(taker_idx) - { - indegree[taker_idx] += 1; - } - } - } - - if !violations.is_empty() { - let mut message = String::from("File ownership conflict detected before transaction:\n"); - for violation in violations { - message.push_str(&format!(" {violation}\n")); - } - anyhow::bail!(message); - } - - let mut order = Vec::with_capacity(plans.len()); - let mut emitted = vec![false; plans.len()]; - while order.len() < plans.len() { - let Some(next) = (0..plans.len()).find(|idx| !emitted[*idx] && indegree[*idx] == 0) else { - let packages = (0..plans.len()) - .filter(|idx| !emitted[*idx]) - .map(|idx| plans[idx].spec.package.name.as_str()) - .collect::>() - .join(", "); - anyhow::bail!( - "File ownership handoff cycle detected before transaction among: {}", - packages - ); - }; - emitted[next] = true; - order.push(next); - for dependent in &edges[next] { - indegree[*dependent] -= 1; - } - } - - Ok(order.into_iter().map(|idx| plans[idx].clone()).collect()) -} - -fn install_staged_to_rootfs( - pkg_spec: &package::PackageSpec, - destdir: &Path, - rootfs: &Path, - config: &config::Config, - plan: &PlannedStagedInstall, -) -> 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); - - if plan.is_update { - let has_staged_pre = install::scripts::run_hook_if_present( - &staged_scripts_dir, - install::scripts::Hook::PreUpdate, - rootfs, - &pkg_spec.package.name, - )?; - if !has_staged_pre { - let _ = install::scripts::run_hook_if_present( - &installed_scripts_dir, - install::scripts::Hook::PreUpdate, - rootfs, - &pkg_spec.package.name, - )?; - } - } else { - let _ = install::scripts::run_hook_if_present( - &staged_scripts_dir, - install::scripts::Hook::PreInstall, - rootfs, - &pkg_spec.package.name, - )?; - } - - let tx_base = config.build_dir.join("tx"); - let tx = staging::install_atomic( - destdir, - rootfs, - &tx_base, - &plan.remove_paths, - &pkg_spec.build.flags.keep, - )?; - - let db_path = config.installed_db_path(rootfs); - let replacement = plan - .renamed_transition - .as_ref() - .map(RenamedPackageTransition::replacement); - let register_result = if let Some(replacement) = replacement.as_ref() { - db::register_package_with_replacement(&db_path, pkg_spec, destdir, Some(replacement)) - } else { - db::register_package(&db_path, pkg_spec, destdir) - }; - if let Err(e) = register_result { - let _ = tx.rollback(); - return Err(e); - } - tx.commit()?; - - if let Some(transition) = &plan.renamed_transition - && !transition.retains_old_package() - { - install::scripts::remove_installed_scripts(rootfs, &transition.replaced.name)?; - } - - install::scripts::sync_staged_scripts_to_rootfs( - &staged_scripts_dir, - rootfs, - &pkg_spec.package.name, - )?; - - Ok(Some(PendingLifecycleHook { - hook: if plan.is_update { - install::scripts::Hook::PostUpdate - } else { - install::scripts::Hook::PostInstall - }, - })) -} - -fn install_planned_packages_to_rootfs( - plans: &[PlannedPackageInstall], - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - install_planned_packages_to_rootfs_with_pre_removed( - plans, - rootfs, - config, - &HashSet::new(), - true, - ) -} - -fn install_planned_packages_to_rootfs_with_pre_removed( - plans: &[PlannedPackageInstall], - rootfs: &Path, - config: &config::Config, - pre_removed_packages: &HashSet, - show_progress: bool, -) -> Result<()> { - let ordered_plans = - preflight_file_ownership_and_order(plans, pre_removed_packages, rootfs, config)?; - install_preflighted_planned_packages_to_rootfs_with_pre_removed( - &ordered_plans, - rootfs, - config, - pre_removed_packages, - show_progress, - ) -} - -fn install_preflighted_planned_packages_to_rootfs_with_pre_removed( - plans: &[PlannedPackageInstall], - rootfs: &Path, - config: &config::Config, - pre_removed_packages: &HashSet, - show_progress: bool, -) -> Result<()> { - let mut removed_replacements = HashSet::new(); - let mut pending_post_hooks = Vec::new(); - for (idx, plan) in plans.iter().enumerate() { - if show_progress { - ui::info(format!( - "{}/{} Installing package {}-{}-{}", - idx + 1, - plans.len(), - plan.spec.package.name, - plan.spec.package.version, - plan.spec.package.revision - )); - } - for package in &plan.staged.replacement_removals { - if pre_removed_packages.contains(package) { - continue; - } - if removed_replacements.insert(package.clone()) { - remove_installed_package_with_hooks(package, rootfs, config)?; - } - } - 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)); - } - } - // Lifecycle hooks may invoke sh, cc, or ld. Select a sole provider before - // any post-install hook runs so the aliases are usable within this transaction. - set::auto_select_sole_tool_providers(rootfs, config)?; - 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(()) -} - -fn run_parallel_tasks(items: &[T], worker_count: usize, task: F) -> Result> -where - T: Sync, - U: Send, - F: Fn(usize, &T) -> Result + Sync, -{ - if items.is_empty() { - return Ok(Vec::new()); - } - - let worker_count = worker_count.max(1).min(items.len()); - let next_index = AtomicUsize::new(0); - let (sender, receiver) = mpsc::channel(); - - std::thread::scope(|scope| -> Result> { - for _ in 0..worker_count { - let sender = sender.clone(); - let task = &task; - let next_index = &next_index; - scope.spawn(move || { - loop { - let index = next_index.fetch_add(1, AtomicOrdering::Relaxed); - if index >= items.len() { - break; - } - let result = task(index, &items[index]); - if sender.send((index, result)).is_err() { - break; - } - } - }); - } - drop(sender); - - let mut results: Vec>> = (0..items.len()).map(|_| None).collect(); - for _ in 0..items.len() { - let (index, result) = receiver - .recv() - .context("Parallel worker exited before reporting a result")?; - results[index] = Some(result); - } - - results - .into_iter() - .map(|result| result.expect("every parallel item must report a result")) - .collect() - }) -} - -fn run_parallel_verification(items: &[T], progress: &ProgressBar, verify: F) -> Result<()> -where - T: Sync, - F: Fn(&T) -> Result<()> + Sync, -{ - let worker_count = std::thread::available_parallelism() - .map(|count| count.get()) - .unwrap_or(1); - run_parallel_tasks(items, worker_count, |_, item| { - let result = verify(item); - progress.inc(1); - result - })?; - Ok(()) -} - -#[cfg(test)] -fn install_package_outputs_to_rootfs( - pkg_spec: &package::PackageSpec, - destdir: &Path, - rootfs: &Path, - config: &config::Config, -) -> Result> { - let plans = plan_package_outputs_for_install(pkg_spec, destdir, rootfs, config)?; - let ordered_plans = - preflight_file_ownership_and_order(&plans, &HashSet::new(), rootfs, config)?; - let installed = plans - .iter() - .map(|plan| InstalledPackageOutcome { - package: plan.spec.package.clone(), - is_update: plan.staged.is_update, - }) - .collect(); - run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Pre, &ordered_plans)?; - install_preflighted_planned_packages_to_rootfs_with_pre_removed( - &ordered_plans, - rootfs, - config, - &HashSet::new(), - true, - )?; - run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Post, &ordered_plans)?; - Ok(installed) -} - -fn print_plan_summary(plan: &planner::ExecutionPlan) { - if std::env::var_os("DEPOT_VERBOSE_PLAN").is_none() { - return; - } - for step in &plan.steps { - let (action, origin) = match &step.action { - planner::PlanAction::SkipInstalled => ("skip", "installed".to_string()), - planner::PlanAction::BuildAndInstall => match &step.origin { - planner::PlanOrigin::Source { - path, - local_sibling, - } => ( - "build+install", - if *local_sibling { - format!("source:local-sibling ({})", path.display()) - } else { - format!("source ({})", path.display()) - }, - ), - _ => ("build+install", "source".to_string()), - }, - planner::PlanAction::InstallBinary => match &step.origin { - planner::PlanOrigin::Binary { repo_name, record } => ( - "install", - format!( - "binary:{} {}-{} size={}", - repo_name, - record.version, - record.revision, - human_bytes(record.size) - ), - ), - _ => ("install", "binary".to_string()), - }, - }; - ui::info(format!(" {} [{}] {}", step.package, action, origin)); - } -} - -fn actionable_plan_packages(plan: &planner::ExecutionPlan) -> Vec { - plan.actionable_steps() - .map(|step| step.package.clone()) - .collect() -} - -fn source_build_reason(reason: &str) -> String { - if let Some(dep) = reason.strip_prefix("dependency ") { - format!("requested dependency '{dep}'") - } else if let Some((requester, _)) = reason.split_once(" needs ") { - format!("needed by '{requester}'") - } else if reason == "requested spec" { - "requested spec".to_string() - } else if reason == "requested package" { - "requested package".to_string() - } else { - reason.to_string() - } -} - -fn source_build_warning_messages(plan: &planner::ExecutionPlan) -> Vec { - let mut lines = Vec::new(); - for step in plan.actionable_steps() { - if !matches!(step.action, planner::PlanAction::BuildAndInstall) { - continue; - } - - let mut reasons = Vec::new(); - for reason in &step.requested_by { - let label = source_build_reason(reason); - if !reasons.contains(&label) { - reasons.push(label); - } - } - - if reasons.is_empty() { - lines.push(step.package.clone()); - } else { - lines.push(format!("{} ({})", step.package, reasons.join(", "))); - } - } - lines -} - -fn warn_source_build_plan(plan: &planner::ExecutionPlan) { - let lines = source_build_warning_messages(plan); - if lines.is_empty() { - return; - } - - ui::warn(format!( - "{} package(s) will be built from source before installation.", - lines.len() - )); - for line in lines { - ui::warn(format!(" {line}")); - } -} - -fn validate_source_build_prereqs_for_plan( - plan: &planner::ExecutionPlan, - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - let db_path = config.installed_db_path(rootfs); - let mut checked_development_package = false; - - for step in plan.actionable_steps() { - let planner::PlanOrigin::Source { path, .. } = &step.origin else { - continue; - }; - if !matches!(step.action, planner::PlanAction::BuildAndInstall) { - continue; - } - - let mut spec = package::PackageSpec::from_file(path) - .with_context(|| format!("Failed to parse spec {}", path.display()))?; - spec.apply_config(config); - source::preflight_local_manual_sources(&spec)?; - if !checked_development_package && !spec.is_metapackage() { - ensure_requested_development_package_installed(&db_path)?; - checked_development_package = true; - } - } - - Ok(()) -} - -#[derive(Clone, Copy)] -struct InstallPlanExecutionOptions<'a> { - no_flags: bool, - cross_prefix: Option<&'a str>, - clean: bool, - dry_run: bool, - confirm_installation: bool, - lib32_only_requested_specs: bool, - install_test_deps: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ChildInstallBatch { - requests: Vec, - lib32_only: bool, -} - -fn step_requests_only_lib32( - step: &planner::PlannedStep, - options: &InstallPlanExecutionOptions<'_>, -) -> bool { - options.lib32_only_requested_specs - && step - .requested_by - .iter() - .any(|reason| reason.starts_with("requested ")) -} - -fn build_live_rootfs_child_install_batches( - steps: &[&planner::PlannedStep], - options: &InstallPlanExecutionOptions<'_>, - binary_archives: &HashMap<(String, String), db::repo::BinaryRepoCachedArchive>, -) -> Result> { - let mut batches = Vec::new(); - let mut pending_binary_requests = Vec::new(); - - for step in steps { - match &step.origin { - planner::PlanOrigin::Source { path, .. } => { - if !pending_binary_requests.is_empty() { - batches.push(ChildInstallBatch { - requests: std::mem::take(&mut pending_binary_requests), - lib32_only: false, - }); - } - batches.push(ChildInstallBatch { - requests: vec![path.clone()], - lib32_only: step_requests_only_lib32(step, options), - }); - } - planner::PlanOrigin::Binary { repo_name, record } => { - let cached = binary_archives - .get(&(repo_name.clone(), record.filename.clone())) - .with_context(|| { - format!( - "Cached archive missing for planned binary step '{}' from repo '{}'", - record.filename, repo_name - ) - })?; - pending_binary_requests.push(cached.package_path.clone()); - } - planner::PlanOrigin::Installed => {} - } - } - - if !pending_binary_requests.is_empty() { - batches.push(ChildInstallBatch { - requests: pending_binary_requests, - lib32_only: false, - }); - } - - Ok(batches) -} - -fn flush_binary_install_batch( - pending_plans: &mut Vec, - pending_staging_dirs: &mut Vec, - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - if pending_plans.is_empty() { - return Ok(()); - } - - install_planned_packages_to_rootfs(pending_plans, rootfs, config)?; - pending_plans.clear(); - pending_staging_dirs.clear(); - Ok(()) -} - -fn execute_install_plan_with_child_commands( - plan: &planner::ExecutionPlan, - rootfs: &Path, - config: &config::Config, - options: InstallPlanExecutionOptions<'_>, -) -> Result<()> { - #[derive(Clone)] - struct BinaryPhaseItem { - repo_name: String, - record: db::repo::BinaryRepoPackageRecord, - } - - let actionable_steps: Vec<_> = plan.actionable_steps().collect(); - if actionable_steps.is_empty() { - ui::info("Nothing to do."); - return Ok(()); - } - - validate_source_build_prereqs_for_plan(plan, rootfs, config)?; - warn_source_build_plan(plan); - let planned_packages = actionable_plan_packages(plan); - if options.confirm_installation - && !ui::prompt_package_action("installation", &planned_packages, true)? - { - anyhow::bail!("Aborted"); - } - - let mut conflict_subjects = Vec::new(); - for step in &actionable_steps { - match &step.origin { - planner::PlanOrigin::Source { path, .. } => { - let mut spec = package::PackageSpec::from_file(path) - .with_context(|| format!("Failed to parse spec {}", path.display()))?; - spec.apply_config(config); - let lib32_only = - effective_lib32_only(&spec, step_requests_only_lib32(step, &options)); - conflict_subjects.extend(install_conflict_subjects_for_spec( - &spec, - !lib32_only, - spec.builds_lib32_output() || lib32_only, - )); - } - planner::PlanOrigin::Binary { record, .. } => { - conflict_subjects.push(install_conflict_subject_for_binary_record(record)); - } - planner::PlanOrigin::Installed => {} - } - } - resolve_installed_conflicts_for_subjects(&conflict_subjects, rootfs, config, options.dry_run)?; - - if options.dry_run { - ui::info("Dry run enabled, no install/build actions executed."); - return Ok(()); - } - - let mut binary_archives: HashMap<(String, String), db::repo::BinaryRepoCachedArchive> = - HashMap::new(); - let mut binary_phase_items = Vec::new(); - let mut seen_binary_archives = HashSet::new(); - for step in &actionable_steps { - if let planner::PlanOrigin::Binary { repo_name, record } = &step.origin - && seen_binary_archives.insert((repo_name.clone(), record.filename.clone())) - { - binary_phase_items.push(BinaryPhaseItem { - repo_name: repo_name.clone(), - record: (**record).clone(), - }); - } - } - - if !binary_phase_items.is_empty() { - ui::info(format!( - "Downloading {} binary package(s) and detached signatures...", - binary_phase_items.len() - )); - let use_tty_progress = std::io::stderr().is_terminal(); - let download_progress = MultiProgress::with_draw_target(if use_tty_progress { - ProgressDrawTarget::stderr() - } else { - ProgressDrawTarget::hidden() - }); - let download_bars = binary_phase_items - .iter() - .map(|item| { - let label = format!( - "{}-{}-{}", - item.record.name, - item.record.version, - binary_arch_from_filename(&item.record.filename) - ); - let pb = download_progress.add(ProgressBar::new(item.record.size.max(1))); - pb.set_style( - ProgressStyle::default_bar() - .template("{prefix:.bold} [{bar:40.cyan/blue}] {eta}") - .unwrap_or_else(|_| ProgressStyle::default_bar()) - .progress_chars("#>-"), - ); - pb.set_prefix(label); - pb - }) - .collect::>(); - let download_client = db::repo::binary_package_http_client()?; - let download_results = run_parallel_tasks( - &binary_phase_items, - MAX_PARALLEL_DOWNLOADS, - |index, item| { - let pb = &download_bars[index]; - let mut progress_cb = |downloaded: u64, total: Option| { - if let Some(t) = total - && t > 0 - { - pb.set_length(t); - } - pb.set_position(downloaded); - }; - let result = (|| { - let repo_cfg = config.binary_repos.get(&item.repo_name).with_context(|| { - format!("Binary repo '{}' not found in config", item.repo_name) - })?; - db::repo::cache_binary_package_archive_with_client_and_progress( - &item.repo_name, - repo_cfg, - &item.record, - &config.package_cache_dir, - &download_client, - Some(&mut progress_cb), - ) - .with_context(|| { - format!( - "Failed to cache binary package '{}' from repo '{}'", - item.record.filename, item.repo_name - ) - }) - })(); - pb.finish_and_clear(); - result - }, - ); - download_progress - .clear() - .context("Failed to clear binary download progress")?; - for (item, cached) in binary_phase_items.iter().zip(download_results?) { - binary_archives.insert( - (item.repo_name.clone(), item.record.filename.clone()), - cached, - ); - } - - ui::info(format!( - "Verifying checksums and detached signatures for {} binary package(s)...", - binary_phase_items.len() - )); - let integrity_pb = ProgressBar::new(binary_phase_items.len() as u64); - integrity_pb.set_draw_target(if use_tty_progress { - ProgressDrawTarget::stderr() - } else { - ProgressDrawTarget::hidden() - }); - integrity_pb.set_style( - ProgressStyle::default_bar() - .template("{prefix:.bold} [{bar:40.cyan/blue}] {pos}/{len} {eta}") - .unwrap_or_else(|_| ProgressStyle::default_bar()) - .progress_chars("#>-"), - ); - integrity_pb.set_prefix("integrity"); - let has_detached_signatures = binary_phase_items.iter().any(|item| { - binary_archives - .get(&(item.repo_name.clone(), item.record.filename.clone())) - .is_some_and(|cached| cached.signature_path.exists()) - }); - let trusted_keys = if has_detached_signatures { - signing::load_trusted_public_keys(rootfs) - .context("Failed to load trusted Minisign public keys")? - } else { - Vec::new() - }; - run_parallel_verification(&binary_phase_items, &integrity_pb, |item| { - let repo_cfg = config - .binary_repos - .get(&item.repo_name) - .with_context(|| format!("Binary repo '{}' not found in config", item.repo_name))?; - let cached = binary_archives - .get(&(item.repo_name.clone(), item.record.filename.clone())) - .with_context(|| { - format!( - "Cached archive missing for {} from repo '{}'", - item.record.filename, item.repo_name - ) - })?; - db::repo::verify_binary_package_archive_integrity_with_trusted_keys( - &item.repo_name, - repo_cfg, - &item.record, - &cached.package_path, - &cached.signature_path, - &trusted_keys, - ) - .with_context(|| { - format!( - "Integrity verification failed for {} from repo '{}'", - item.record.filename, item.repo_name - ) - }) - })?; - integrity_pb.finish_and_clear(); - } - - if should_delegate_live_rootfs_installs(rootfs) { - let exe = std::env::current_exe().context("Failed to locate depot executable")?; - let batches = - build_live_rootfs_child_install_batches(&actionable_steps, &options, &binary_archives)?; - for batch in batches { - run_install_command_with_program( - &exe, - &batch.requests, - rootfs, - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - lib32_only: batch.lib32_only, - install_test_deps: options.install_test_deps, - install_context: Some(INSTALL_CONTEXT_PLANNED), - dep_chain: None, - }, - )?; - } - return Ok(()); - } - - let mut binary_pre_hook_plans = Vec::new(); - for step in &actionable_steps { - if let planner::PlanOrigin::Binary { repo_name, record } = &step.origin { - let cached = binary_archives - .get(&(repo_name.clone(), record.filename.clone())) - .with_context(|| { - format!( - "Cached archive missing for planned binary step '{}' from repo '{}'", - record.filename, repo_name - ) - })?; - let staged = extract_package_archive_to_staging(config, &cached.package_path)?; - let spec = load_package_spec_from_staging_or_repo_record(staged.path(), record)?; - let plans = plan_package_outputs_for_install(&spec, staged.path(), rootfs, config)?; - binary_pre_hook_plans.extend(plans); - } - } - run_transaction_hooks_for_plans( - rootfs, - install::hooks::HookPhase::Pre, - &binary_pre_hook_plans, - )?; - - let exe = std::env::current_exe().context("Failed to locate depot executable")?; - let total_steps = actionable_steps.len(); - let mut binary_post_hook_plans = Vec::new(); - let mut pending_binary_install_plans = Vec::new(); - let mut pending_binary_install_staging_dirs = Vec::new(); - for (idx, step) in actionable_steps.into_iter().enumerate() { - match &step.origin { - planner::PlanOrigin::Source { path, .. } => { - flush_binary_install_batch( - &mut pending_binary_install_plans, - &mut pending_binary_install_staging_dirs, - rootfs, - config, - )?; - ui::info(format!( - "[{}/{}] building+installing {} from source", - idx + 1, - total_steps, - step.package - )); - - run_install_command_with_program( - &exe, - std::slice::from_ref(path), - rootfs, - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - lib32_only: step_requests_only_lib32(step, &options), - install_test_deps: options.install_test_deps, - install_context: Some(INSTALL_CONTEXT_PLANNED), - dep_chain: None, - }, - ) - .with_context(|| { - format!("Failed to spawn planned install step '{}'", step.package) - })?; - } - planner::PlanOrigin::Binary { repo_name, record } => { - let cached = binary_archives - .get(&(repo_name.clone(), record.filename.clone())) - .with_context(|| { - format!( - "Cached archive missing for planned binary step '{}' from repo '{}'", - record.filename, repo_name - ) - })?; - let staged = extract_package_archive_to_staging(config, &cached.package_path)?; - let spec = load_package_spec_from_staging_or_repo_record(staged.path(), record)?; - let plans = plan_package_outputs_for_install(&spec, staged.path(), rootfs, config)?; - binary_post_hook_plans.extend(plans.iter().cloned()); - pending_binary_install_plans.extend(plans); - pending_binary_install_staging_dirs.push(staged); - } - planner::PlanOrigin::Installed => {} - } - } - - flush_binary_install_batch( - &mut pending_binary_install_plans, - &mut pending_binary_install_staging_dirs, - rootfs, - config, - )?; - run_transaction_hooks_for_plans( - rootfs, - install::hooks::HookPhase::Post, - &binary_post_hook_plans, - )?; - install::scripts::run_deferred_hooks_if_possible(rootfs)?; - Ok(()) -} - -fn is_archive_install_request(spec_path: &Path) -> bool { - spec_path.exists() - && spec_path - .to_string_lossy() - .to_ascii_lowercase() - .ends_with(".tar.zst") -} - -fn shared_local_sibling_root(spec_paths: &[PathBuf]) -> Option { - let mut roots = spec_paths.iter().filter_map(|path| { - path.parent() - .and_then(|p| p.parent()) - .map(Path::to_path_buf) - }); - let first = roots.next()?; - if roots.all(|path| path == first) { - Some(first) - } else { - None - } -} - -#[derive(Clone, Copy)] -struct DirectInstallOptions<'a> { - rootfs: &'a Path, - no_deps: bool, - no_flags: bool, - cross_prefix: Option<&'a str>, - clean: bool, - dry_run: bool, - lib32_only: bool, - install_test_deps: bool, -} - -fn run_direct_archive_install_requests( - options: DirectInstallOptions<'_>, - config: &config::Config, - archive_paths: &[PathBuf], - confirm_installation: bool, -) -> Result { - if archive_paths.is_empty() { - return Ok(false); - } - - let mut install_lock = locking::open_lock(config)?; - let install_lock_path = locking::lock_path(config); - let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "install")?; - - let mut staged_dirs = Vec::with_capacity(archive_paths.len()); - let mut pkg_specs = Vec::with_capacity(archive_paths.len()); - let mut install_targets = Vec::with_capacity(archive_paths.len()); - let suppress_output = suppress_nested_install_output(); - - for archive_path in archive_paths { - if !suppress_output { - ui::info(format!( - "Installing package from: {}", - archive_path.display() - )); - } - - let (pkg_spec, staging_dir) = load_package_archive_into_staging(config, archive_path)?; - if options.lib32_only { - anyhow::bail!("--lib32-only is only supported when installing from a package spec"); - } - - install_targets.push(format!( - "{} v{}-{}", - pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision - )); - pkg_specs.push(pkg_spec); - staged_dirs.push(staging_dir); - } - - let mut conflict_subjects = Vec::new(); - for pkg_spec in &pkg_specs { - conflict_subjects.extend(install_conflict_subjects_for_spec(pkg_spec, true, false)); - } - resolve_installed_conflicts_for_subjects( - &conflict_subjects, - options.rootfs, - config, - options.dry_run, - )?; - - if options.dry_run { - ui::info("Dry run enabled, stopping before install/build work."); - return Ok(false); - } - - if confirm_installation - && !suppress_output - && !ui::prompt_package_action("installation", &install_targets, true)? - { - anyhow::bail!("Aborted"); - } - - if !suppress_output { - ui::info(format!( - "Installing {} binary archive payload(s)", - archive_paths.len() - )); - } - - let mut transaction_plans = Vec::new(); - for (pkg_spec, staging_dir) in pkg_specs.iter().zip(staged_dirs.iter()) { - let output_plans = - plan_package_outputs_for_install(pkg_spec, staging_dir.path(), options.rootfs, config)?; - transaction_plans.extend(output_plans); - } - - install_direct_transaction(&transaction_plans, options.rootfs, config)?; - - Ok(true) -} - -struct SourceBuildCleanupGuard<'a> { - config: &'a config::Config, - enabled: bool, -} - -impl<'a> SourceBuildCleanupGuard<'a> { - fn new(config: &'a config::Config, enabled: bool) -> Self { - Self { config, enabled } - } -} - -impl Drop for SourceBuildCleanupGuard<'_> { - fn drop(&mut self) { - if self.enabled - && let Err(err) = clean_build_source_dirs(self.config) - { - crate::log_warn!("Failed to clean build source dirs: {}", err); - } - } -} - -fn prepare_direct_install_request<'a>( - options: DirectInstallOptions<'_>, - config: &'a config::Config, - spec_path: &Path, - preparation: DirectInstallPreparationOptions<'_>, -) -> Result> { - let (mut pkg_spec, staging_dir): (package::PackageSpec, Option) = - if spec_path.to_string_lossy().ends_with(".tar.zst") { - let (spec, tmp_dir) = load_package_archive_into_staging(config, spec_path)?; - (spec, Some(tmp_dir)) - } else { - let mut pkg_spec = package::PackageSpec::from_file(spec_path)?; - pkg_spec.apply_config(config); - pkg_spec.build.flags.rootfs = build_cmd::build_env_rootfs(options.rootfs); - (pkg_spec, None) - }; - let built_from_source = staging_dir.is_none(); - let source_cleanup_guard = SourceBuildCleanupGuard::new(config, built_from_source); - - if options.lib32_only && staging_dir.is_some() { - anyhow::bail!("--lib32-only is only supported when installing from a package spec"); - } - let lib32_only = effective_lib32_only(&pkg_spec, options.lib32_only); - - if staging_dir.is_none() && !preparation.suppress_output { - ui::info(format!( - "Package: {} v{}-{}", - pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision - )); - } - - let requested_outputs = requested_outputs(&pkg_spec, lib32_only); - let db_path = config.installed_db_path(options.rootfs); - - if staging_dir.is_none() { - source::preflight_local_manual_sources(&pkg_spec)?; - if !pkg_spec.is_metapackage() { - ensure_requested_development_package_installed(&db_path)?; - } - } - - let mut conflict_subjects = install_conflict_subjects_for_spec( - &pkg_spec, - !lib32_only, - staging_dir.is_none() && (lib32_only || pkg_spec.builds_lib32_output()), - ); - if staging_dir.is_some() { - conflict_subjects = install_conflict_subjects_for_spec(&pkg_spec, true, false); - } - if preparation.resolve_installed_conflicts { - resolve_installed_conflicts_for_subjects( - &conflict_subjects, - options.rootfs, - config, - options.dry_run, - )?; - } - - if options.dry_run { - ui::info("Dry run enabled, stopping before install/build work."); - return Ok(PreparedDirectInstall { - plans: Vec::new(), - resources: PreparedDirectInstallResources { - _staging_dir: staging_dir, - _source_cleanup_guard: source_cleanup_guard, - }, - }); - } - - let install_targets = vec![format!( - "{} v{}-{}", - pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision - )]; - if preparation.confirm_installation - && !preparation.suppress_output - && !ui::prompt_package_action("installation", &install_targets, true)? - { - anyhow::bail!("Aborted"); - } - - std::fs::create_dir_all(&config.db_dir).with_context(|| { - format!( - "Failed to create database directory: {}", - config.db_dir.display() - ) - })?; - - if staging_dir.is_none() { - if options.no_deps - && should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) - { - let missing_test = - deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if !missing_test.is_empty() - && !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies are missing", - )? - { - anyhow::bail!("Missing test dependencies: {}", missing_test.join(", ")); - } - } else if options.no_deps - || !should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) - { - maybe_disable_tests_for_missing_deps(&mut pkg_spec, &db_path, requested_outputs)?; - } - } - - if !options.no_deps { - let missing_required = merge_missing_dependencies( - deps::check_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?, - deps::check_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?, - ); - if !missing_required.is_empty() { - let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default(); - let chain_set: std::collections::HashSet<&str> = - dep_chain.split(',').filter(|s| !s.is_empty()).collect(); - - if chain_set.contains(pkg_spec.package.name.as_str()) { - anyhow::bail!( - "Dependency cycle detected! {} is already in chain: {}", - pkg_spec.package.name, - dep_chain - ); - } - - ui::warn(format!( - "Missing dependencies: {}", - missing_required.join(", ") - )); - let local_sibling_root = spec_path.parent().and_then(|path| path.parent()); - let dep_plan = planner::build_dependency_install_plan( - config, - options.rootfs, - &missing_required, - planner::PlannerOptions { - assume_yes: ui::assume_yes_enabled(), - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root: local_sibling_root.map(Path::to_path_buf), - include_test_deps: options.install_test_deps, - lib32_only_requested_specs: false, - }, - )?; - let dep_plan_packages = actionable_plan_packages(&dep_plan); - warn_source_build_plan(&dep_plan); - let dep_prompt_packages = if dep_plan_packages.is_empty() { - missing_required.clone() - } else { - dep_plan_packages - }; - if ui::prompt_package_action("dependency installation", &dep_prompt_packages, true)? { - let pkg_index = - index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); - - let new_chain = if dep_chain.is_empty() { - pkg_spec.package.name.clone() - } else { - format!("{},{}", dep_chain, pkg_spec.package.name) - }; - - let mut dep_spec_paths = Vec::new(); - for dep in missing_required { - let candidate = pkg_index.find(&dep); - - if let Some(dep_spec_path) = candidate { - dep_spec_paths.push(dep_spec_path); - } else { - anyhow::bail!("Could not find package spec for dependency: {}", dep); - } - } - ui::info(format!( - "Installing dependencies: {}", - install_request_display(&dep_spec_paths) - )); - let exe = std::env::current_exe().context("Failed to locate depot executable")?; - run_install_command_with_program( - &exe, - &dep_spec_paths, - options.rootfs, - ChildInstallCommandOptions { - no_deps: options.no_deps, - assume_yes: true, - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - lib32_only: false, - install_test_deps: options.install_test_deps, - install_context: Some(INSTALL_CONTEXT_PLANNED), - dep_chain: Some(&new_chain), - }, - )?; - } - } - - deps::require_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - deps::require_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) { - let missing_test = - deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if !missing_test.is_empty() { - let pkg_index = - index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); - let mut dep_spec_paths = Vec::new(); - let mut unavailable_test = Vec::new(); - for dep in &missing_test { - if let Some(dep_spec_path) = pkg_index.find(dep) { - dep_spec_paths.push(dep_spec_path); - } else { - unavailable_test.push(dep.clone()); - } - } - - if !unavailable_test.is_empty() - && !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &unavailable_test, - "Requested test dependencies could not be resolved", - )? - { - anyhow::bail!("Missing test dependencies: {}", unavailable_test.join(", ")); - } - - if !automatic_tests_disabled_for_outputs(&pkg_spec, requested_outputs) - && !dep_spec_paths.is_empty() - { - ui::warn(format!( - "Missing test dependencies: {}", - missing_test.join(", ") - )); - let local_sibling_root = spec_path.parent().and_then(|path| path.parent()); - let dep_plan = planner::build_dependency_install_plan( - config, - options.rootfs, - &missing_test, - planner::PlannerOptions { - assume_yes: ui::assume_yes_enabled(), - prefer_binary: config.repo_settings.prefer_binary, - local_sibling_root: local_sibling_root.map(Path::to_path_buf), - include_test_deps: options.install_test_deps, - lib32_only_requested_specs: false, - }, - )?; - let dep_plan_packages = actionable_plan_packages(&dep_plan); - warn_source_build_plan(&dep_plan); - let dep_prompt_packages = if dep_plan_packages.is_empty() { - missing_test.clone() - } else { - dep_plan_packages - }; - if ui::prompt_package_action( - "dependency installation", - &dep_prompt_packages, - true, - )? { - ui::info(format!( - "Installing test dependencies: {}", - install_request_display(&dep_spec_paths) - )); - let exe = - std::env::current_exe().context("Failed to locate depot executable")?; - run_install_command_with_program( - &exe, - &dep_spec_paths, - options.rootfs, - ChildInstallCommandOptions { - no_deps: options.no_deps, - assume_yes: true, - no_flags: options.no_flags, - cross_prefix: options.cross_prefix, - clean: options.clean, - lib32_only: false, - install_test_deps: options.install_test_deps, - install_context: Some(INSTALL_CONTEXT_PLANNED), - dep_chain: None, - }, - )?; - } else if !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies were not installed", - )? { - anyhow::bail!("Aborted"); - } - } - } - } - - if should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) { - let missing_test = - deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - if !missing_test.is_empty() - && !maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut pkg_spec, - &missing_test, - "Requested test dependencies are still missing", - )? - { - deps::require_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; - } - } - } - - let cross_config = options - .cross_prefix - .map(cross::CrossConfig::from_prefix) - .transpose()?; - let mut built_src_dir: Option = None; - - let destdir = if let Some(dir) = &staging_dir { - dir.path().to_path_buf() - } else { - if preparation.clean_sources_before_build { - clean_build_source_dirs(config)?; - } - source::preflight_manual_sources(&pkg_spec, &config.cache_dir)?; - let src_dir = source::prepare(&pkg_spec, &config.cache_dir, preparation.build_dir)?; - built_src_dir = Some(src_dir.clone()); - let host_build_dir = builder::ensure_host_build( - &pkg_spec, - &src_dir, - cross_config.as_ref(), - !options.no_flags, - builder::TargetBuildKind::Primary, - )?; - if let Some(host_dir) = host_build_dir.as_ref() { - pkg_spec.build.flags.host_build_dir = Some(host_dir.to_string_lossy().into_owned()); - } - - let destdir = preparation - .build_dir - .join("destdir") - .join(&pkg_spec.package.name); - if destdir.exists() { - fs::remove_dir_all(&destdir) - .with_context(|| format!("Failed to clean destdir: {}", destdir.display()))?; - } - - if !lib32_only { - builder::build( - &pkg_spec, - &src_dir, - &destdir, - cross_config.as_ref(), - !options.no_flags, - host_build_dir.as_deref(), - )?; - - staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; - install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; - builder::stage_generated_lifecycle_scripts(&pkg_spec, &destdir)?; - } - - destdir - }; - - let mut transaction_plans = Vec::new(); - - if !lib32_only { - if staging_dir.is_none() { - staging::process(&destdir, &pkg_spec)?; - if let Some(src_dir) = built_src_dir.as_deref() { - staging::stage_split_package_licenses(src_dir, &destdir, &pkg_spec)?; - } - } else if !preparation.suppress_output { - ui::info("Installing binary archive payload"); - } - - let output_plans = - plan_package_outputs_for_install(&pkg_spec, &destdir, options.rootfs, config)?; - transaction_plans.extend(output_plans); - } - - if let Some(src_dir) = built_src_dir.as_deref() - && let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( - &pkg_spec, - src_dir, - config, - cross_config.as_ref(), - !options.no_flags, - lib32_only, - )? - { - let staged = plan_staged_install(&lib32_spec, &lib32_destdir, options.rootfs, config)?; - transaction_plans.push(PlannedPackageInstall { - spec: lib32_spec, - destdir: lib32_destdir, - staged, - }); - } - - Ok(PreparedDirectInstall { - plans: transaction_plans, - resources: PreparedDirectInstallResources { - _staging_dir: staging_dir, - _source_cleanup_guard: source_cleanup_guard, - }, - }) -} - -fn install_direct_transaction( - plans: &[PlannedPackageInstall], - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - let ordered_plans = preflight_file_ownership_and_order(plans, &HashSet::new(), rootfs, config)?; - run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Pre, &ordered_plans)?; - install_preflighted_planned_packages_to_rootfs_with_pre_removed( - &ordered_plans, - rootfs, - config, - &HashSet::new(), - true, - )?; - run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Post, &ordered_plans)?; - Ok(()) -} - -fn install_requests_for_plan( - plan: &planner::ExecutionPlan, - config: &config::Config, - rootfs: &Path, -) -> Result> { - let mut requests = Vec::new(); - for step in plan.actionable_steps() { - match &step.origin { - planner::PlanOrigin::Source { path, .. } => { - requests.push(path.clone()); - } - planner::PlanOrigin::Binary { repo_name, record } => { - let repo_cfg = config - .binary_repos - .get(repo_name) - .with_context(|| format!("Binary repo '{}' not found in config", repo_name))?; - let archive = db::repo::fetch_binary_package_archive( - repo_name, - repo_cfg, - rootfs, - record, - &config.package_cache_dir, - ) - .with_context(|| { - format!( - "Failed to fetch binary package '{}' from repo '{}'", - record.filename, repo_name - ) - })?; - requests.push(archive); - } - planner::PlanOrigin::Installed => {} - } - } - Ok(requests) -} - -fn planned_installed_removals( - rootfs: &Path, - config: &config::Config, - packages: impl IntoIterator, -) -> Result> { - let db_path = config.installed_db_path(rootfs); - let installed = db::get_installed_packages(&db_path)?; - let mut unique = BTreeSet::new(); - for package in packages { - if installed.contains(&package) { - unique.insert(package); - } - } - - unique - .into_iter() - .map(|package| { - let affected_paths = db::get_package_files(&db_path, &package)?; - Ok(PlannedInstalledRemoval { - package, - affected_paths, - }) - }) - .collect() -} - -fn transaction_contexts_for_update( - removals: &[PlannedInstalledRemoval], - plans: &[PlannedPackageInstall], -) -> Vec { - let mut contexts = Vec::with_capacity(removals.len() + plans.len()); - contexts.extend( - removals - .iter() - .map(|removal| install::hooks::HookExecutionContextOwned { - operation: install::hooks::HookOperation::Remove, - package: removal.package.clone(), - affected_paths: removal.affected_paths.clone(), - }), - ); - contexts.extend(plans.iter().map(|plan| plan.staged.hook_context.clone())); - contexts -} - -fn install_update_transaction( - plans: &[PlannedPackageInstall], - removals: &[PlannedInstalledRemoval], - rootfs: &Path, - config: &config::Config, -) -> Result<()> { - let pre_removed_packages: HashSet = removals - .iter() - .map(|removal| removal.package.clone()) - .collect(); - let ordered_plans = - preflight_file_ownership_and_order(plans, &pre_removed_packages, rootfs, config)?; - let contexts = transaction_contexts_for_update(removals, &ordered_plans); - install::hooks::run_transaction_hooks_batch(rootfs, install::hooks::HookPhase::Pre, &contexts)?; - - for removal in removals { - remove_installed_package_without_transaction_hooks( - &removal.package, - rootfs, - config, - &removal.affected_paths, - )?; - } - - install_preflighted_planned_packages_to_rootfs_with_pre_removed( - &ordered_plans, - rootfs, - config, - &pre_removed_packages, - false, - )?; - install::hooks::run_transaction_hooks_batch( - rootfs, - install::hooks::HookPhase::Post, - &contexts, - )?; - Ok(()) -} - -fn run_direct_install_request( - options: DirectInstallOptions<'_>, - config: &config::Config, - mut spec_path: PathBuf, -) -> Result { - let mut install_lock = locking::open_lock(config)?; - let install_lock_path = locking::lock_path(config); - let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "install")?; - - // Repo clone dir is available via `config.repo_clone_dir` and - // is passed explicitly to index builders below. - - // If the provided path doesn't exist, treat it as a package name and - // try to locate a spec under configured repo dir or local packages/. - if !spec_path.exists() { - let name = spec_path.to_string_lossy().to_string(); - ui::info(format!("Looking up package '{}' in local indexes...", name)); - let pkg_index = - index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); - if let Some(found) = pkg_index.find(&name) { - spec_path = found; - } else { - let host_arch = std::env::consts::ARCH; - let mut binary_repos: Vec<_> = config - .binary_repos - .iter() - .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) - .collect(); - binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); - - for (repo_name, repo_cfg) in binary_repos { - match db::repo::find_binary_repo_package( - repo_name, - repo_cfg, - options.rootfs, - &config.package_cache_dir, - &name, - ) { - Ok(Some(rec)) => { - let archive = db::repo::fetch_binary_package_archive( - repo_name, - repo_cfg, - options.rootfs, - &rec, - &config.package_cache_dir, - )?; - ui::info(format!( - "Resolved '{}' from binary repo '{}' as {}-{} (package {}) ({} bytes){} -> {}", - name, - repo_name, - rec.version, - rec.revision, - rec.name, - rec.size, - rec.description - .as_ref() - .map(|d| format!(" [{}]", d)) - .unwrap_or_default(), - archive.display() - )); - spec_path = archive; - break; - } - Ok(None) => {} - Err(e) => { - crate::log_warn!("Binary repo '{}': {}", repo_name, e); - } - } - } - } - } - - let suppress_output = suppress_nested_install_output(); - if !suppress_output { - ui::info(format!("Installing package from: {}", spec_path.display())); - } - - let _snapper_pre_install_snapshot_todo: fn() -> ! = - || todo!("snapper: create pre-install snapshot before install work starts"); - let _snapper_post_install_snapshot_todo: fn() -> ! = - || todo!("snapper: create post-install snapshot after install commit succeeds"); - - let prepared = prepare_direct_install_request( - options, - config, - &spec_path, - DirectInstallPreparationOptions { - build_dir: &config.build_dir, - clean_sources_before_build: true, - suppress_output, - confirm_installation: true, - resolve_installed_conflicts: true, - }, - )?; - if options.dry_run { - return Ok(false); - } - let _resources = prepared.resources; - install_direct_transaction(&prepared.plans, options.rootfs, config)?; - - Ok(true) -} - -fn isolated_update_build_dir(config: &config::Config, idx: usize) -> PathBuf { - config - .build_dir - .join("update-tx") - .join(format!("{:04}", idx + 1)) -} - -fn run_update_transaction_install_requests( - options: DirectInstallOptions<'_>, - config: &config::Config, - requests: &[PathBuf], -) -> Result { - if requests.is_empty() { - return Ok(false); - } - - let mut install_lock = locking::open_lock(config)?; - let install_lock_path = locking::lock_path(config); - let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "update")?; - - if requests - .iter() - .any(|request| !is_archive_install_request(request)) - { - clean_build_source_dirs(config)?; - } - - let mut transaction_plans = Vec::new(); - let mut resources = Vec::with_capacity(requests.len()); - for (idx, request) in requests.iter().enumerate() { - let build_dir = isolated_update_build_dir(config, idx); - let prepared = prepare_direct_install_request( - options, - config, - request, - DirectInstallPreparationOptions { - build_dir: &build_dir, - clean_sources_before_build: false, - suppress_output: true, - confirm_installation: false, - resolve_installed_conflicts: false, - }, - ) - .with_context(|| { - format!( - "Failed to prepare update payload from {}", - request.display() - ) - })?; - transaction_plans.extend(prepared.plans); - resources.push(prepared.resources); - } - - if options.dry_run { - return Ok(false); - } - - let conflict_subjects: Vec<_> = transaction_plans - .iter() - .flat_map(|plan| install_conflict_subjects_for_output_spec(&plan.spec)) - .collect(); - validate_no_transaction_conflicts(&conflict_subjects)?; - let mut removal_packages = prompt_installed_conflict_removals_for_subjects( - &conflict_subjects, - options.rootfs, - config, - false, - )?; - for plan in &transaction_plans { - removal_packages.extend(plan.staged.replacement_removals.iter().cloned()); - } - let removals = planned_installed_removals(options.rootfs, config, removal_packages)?; - - install_update_transaction(&transaction_plans, &removals, options.rootfs, config)?; - drop(resources); - Ok(true) -} +use direct_install::*; +use install_plan::*; +use install_transaction::*; mod build_cmd; mod check; diff --git a/src/commands/direct_install.rs b/src/commands/direct_install.rs new file mode 100644 index 0000000..a58cc04 --- /dev/null +++ b/src/commands/direct_install.rs @@ -0,0 +1,857 @@ +use super::*; + +pub(super) fn is_archive_install_request(spec_path: &Path) -> bool { + spec_path.exists() + && spec_path + .to_string_lossy() + .to_ascii_lowercase() + .ends_with(".tar.zst") +} + +pub(super) fn shared_local_sibling_root(spec_paths: &[PathBuf]) -> Option { + let mut roots = spec_paths.iter().filter_map(|path| { + path.parent() + .and_then(|p| p.parent()) + .map(Path::to_path_buf) + }); + let first = roots.next()?; + if roots.all(|path| path == first) { + Some(first) + } else { + None + } +} + +#[derive(Clone, Copy)] +pub(super) struct DirectInstallOptions<'a> { + pub(super) rootfs: &'a Path, + pub(super) no_deps: bool, + pub(super) no_flags: bool, + pub(super) cross_prefix: Option<&'a str>, + pub(super) clean: bool, + pub(super) dry_run: bool, + pub(super) lib32_only: bool, + pub(super) install_test_deps: bool, +} + +pub(super) fn run_direct_archive_install_requests( + options: DirectInstallOptions<'_>, + config: &config::Config, + archive_paths: &[PathBuf], + confirm_installation: bool, +) -> Result { + if archive_paths.is_empty() { + return Ok(false); + } + + let mut install_lock = locking::open_lock(config)?; + let install_lock_path = locking::lock_path(config); + let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "install")?; + + let mut staged_dirs = Vec::with_capacity(archive_paths.len()); + let mut pkg_specs = Vec::with_capacity(archive_paths.len()); + let mut install_targets = Vec::with_capacity(archive_paths.len()); + let suppress_output = suppress_nested_install_output(); + + for archive_path in archive_paths { + if !suppress_output { + ui::info(format!( + "Installing package from: {}", + archive_path.display() + )); + } + + let (pkg_spec, staging_dir) = load_package_archive_into_staging(config, archive_path)?; + if options.lib32_only { + anyhow::bail!("--lib32-only is only supported when installing from a package spec"); + } + + install_targets.push(format!( + "{} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )); + pkg_specs.push(pkg_spec); + staged_dirs.push(staging_dir); + } + + let mut conflict_subjects = Vec::new(); + for pkg_spec in &pkg_specs { + conflict_subjects.extend(install_conflict_subjects_for_spec(pkg_spec, true, false)); + } + resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + + if options.dry_run { + ui::info("Dry run enabled, stopping before install/build work."); + return Ok(false); + } + + if confirm_installation + && !suppress_output + && !ui::prompt_package_action("installation", &install_targets, true)? + { + anyhow::bail!("Aborted"); + } + + if !suppress_output { + ui::info(format!( + "Installing {} binary archive payload(s)", + archive_paths.len() + )); + } + + let mut transaction_plans = Vec::new(); + for (pkg_spec, staging_dir) in pkg_specs.iter().zip(staged_dirs.iter()) { + let output_plans = + plan_package_outputs_for_install(pkg_spec, staging_dir.path(), options.rootfs, config)?; + transaction_plans.extend(output_plans); + } + + install_direct_transaction(&transaction_plans, options.rootfs, config)?; + + Ok(true) +} + +pub(super) struct SourceBuildCleanupGuard<'a> { + pub(super) config: &'a config::Config, + pub(super) enabled: bool, +} + +impl<'a> SourceBuildCleanupGuard<'a> { + fn new(config: &'a config::Config, enabled: bool) -> Self { + Self { config, enabled } + } +} + +impl Drop for SourceBuildCleanupGuard<'_> { + fn drop(&mut self) { + if self.enabled + && let Err(err) = clean_build_source_dirs(self.config) + { + crate::log_warn!("Failed to clean build source dirs: {}", err); + } + } +} + +pub(super) fn prepare_direct_install_request<'a>( + options: DirectInstallOptions<'_>, + config: &'a config::Config, + spec_path: &Path, + preparation: DirectInstallPreparationOptions<'_>, +) -> Result> { + let (mut pkg_spec, staging_dir): (package::PackageSpec, Option) = + if spec_path.to_string_lossy().ends_with(".tar.zst") { + let (spec, tmp_dir) = load_package_archive_into_staging(config, spec_path)?; + (spec, Some(tmp_dir)) + } else { + let mut pkg_spec = package::PackageSpec::from_file(spec_path)?; + pkg_spec.apply_config(config); + pkg_spec.build.flags.rootfs = build_cmd::build_env_rootfs(options.rootfs); + (pkg_spec, None) + }; + let built_from_source = staging_dir.is_none(); + let source_cleanup_guard = SourceBuildCleanupGuard::new(config, built_from_source); + + if options.lib32_only && staging_dir.is_some() { + anyhow::bail!("--lib32-only is only supported when installing from a package spec"); + } + let lib32_only = effective_lib32_only(&pkg_spec, options.lib32_only); + + if staging_dir.is_none() && !preparation.suppress_output { + ui::info(format!( + "Package: {} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )); + } + + let requested_outputs = requested_outputs(&pkg_spec, lib32_only); + let db_path = config.installed_db_path(options.rootfs); + + if staging_dir.is_none() { + source::preflight_local_manual_sources(&pkg_spec)?; + if !pkg_spec.is_metapackage() { + ensure_requested_development_package_installed(&db_path)?; + } + } + + let mut conflict_subjects = install_conflict_subjects_for_spec( + &pkg_spec, + !lib32_only, + staging_dir.is_none() && (lib32_only || pkg_spec.builds_lib32_output()), + ); + if staging_dir.is_some() { + conflict_subjects = install_conflict_subjects_for_spec(&pkg_spec, true, false); + } + if preparation.resolve_installed_conflicts { + resolve_installed_conflicts_for_subjects( + &conflict_subjects, + options.rootfs, + config, + options.dry_run, + )?; + } + + if options.dry_run { + ui::info("Dry run enabled, stopping before install/build work."); + return Ok(PreparedDirectInstall { + plans: Vec::new(), + resources: PreparedDirectInstallResources { + _staging_dir: staging_dir, + _source_cleanup_guard: source_cleanup_guard, + }, + }); + } + + let install_targets = vec![format!( + "{} v{}-{}", + pkg_spec.package.name, pkg_spec.package.version, pkg_spec.package.revision + )]; + if preparation.confirm_installation + && !preparation.suppress_output + && !ui::prompt_package_action("installation", &install_targets, true)? + { + anyhow::bail!("Aborted"); + } + + std::fs::create_dir_all(&config.db_dir).with_context(|| { + format!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + + if staging_dir.is_none() { + if options.no_deps + && should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) + { + let missing_test = + deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if !missing_test.is_empty() + && !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies are missing", + )? + { + anyhow::bail!("Missing test dependencies: {}", missing_test.join(", ")); + } + } else if options.no_deps + || !should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) + { + maybe_disable_tests_for_missing_deps(&mut pkg_spec, &db_path, requested_outputs)?; + } + } + + if !options.no_deps { + let missing_required = merge_missing_dependencies( + deps::check_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?, + deps::check_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?, + ); + if !missing_required.is_empty() { + let dep_chain = std::env::var("DEPOT_DEPCHAIN").unwrap_or_default(); + let chain_set: std::collections::HashSet<&str> = + dep_chain.split(',').filter(|s| !s.is_empty()).collect(); + + if chain_set.contains(pkg_spec.package.name.as_str()) { + anyhow::bail!( + "Dependency cycle detected! {} is already in chain: {}", + pkg_spec.package.name, + dep_chain + ); + } + + ui::warn(format!( + "Missing dependencies: {}", + missing_required.join(", ") + )); + let local_sibling_root = spec_path.parent().and_then(|path| path.parent()); + let dep_plan = planner::build_dependency_install_plan( + config, + options.rootfs, + &missing_required, + planner::PlannerOptions { + assume_yes: ui::assume_yes_enabled(), + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root: local_sibling_root.map(Path::to_path_buf), + include_test_deps: options.install_test_deps, + lib32_only_requested_specs: false, + }, + )?; + let dep_plan_packages = actionable_plan_packages(&dep_plan); + warn_source_build_plan(&dep_plan); + let dep_prompt_packages = if dep_plan_packages.is_empty() { + missing_required.clone() + } else { + dep_plan_packages + }; + if ui::prompt_package_action("dependency installation", &dep_prompt_packages, true)? { + let pkg_index = + index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + + let new_chain = if dep_chain.is_empty() { + pkg_spec.package.name.clone() + } else { + format!("{},{}", dep_chain, pkg_spec.package.name) + }; + + let mut dep_spec_paths = Vec::new(); + for dep in missing_required { + let candidate = pkg_index.find(&dep); + + if let Some(dep_spec_path) = candidate { + dep_spec_paths.push(dep_spec_path); + } else { + anyhow::bail!("Could not find package spec for dependency: {}", dep); + } + } + ui::info(format!( + "Installing dependencies: {}", + install_request_display(&dep_spec_paths) + )); + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + run_install_command_with_program( + &exe, + &dep_spec_paths, + options.rootfs, + ChildInstallCommandOptions { + no_deps: options.no_deps, + assume_yes: true, + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + lib32_only: false, + install_test_deps: options.install_test_deps, + install_context: Some(INSTALL_CONTEXT_PLANNED), + dep_chain: Some(&new_chain), + }, + )?; + } + } + + deps::require_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + deps::require_runtime_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) { + let missing_test = + deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if !missing_test.is_empty() { + let pkg_index = + index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + let mut dep_spec_paths = Vec::new(); + let mut unavailable_test = Vec::new(); + for dep in &missing_test { + if let Some(dep_spec_path) = pkg_index.find(dep) { + dep_spec_paths.push(dep_spec_path); + } else { + unavailable_test.push(dep.clone()); + } + } + + if !unavailable_test.is_empty() + && !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &unavailable_test, + "Requested test dependencies could not be resolved", + )? + { + anyhow::bail!("Missing test dependencies: {}", unavailable_test.join(", ")); + } + + if !automatic_tests_disabled_for_outputs(&pkg_spec, requested_outputs) + && !dep_spec_paths.is_empty() + { + ui::warn(format!( + "Missing test dependencies: {}", + missing_test.join(", ") + )); + let local_sibling_root = spec_path.parent().and_then(|path| path.parent()); + let dep_plan = planner::build_dependency_install_plan( + config, + options.rootfs, + &missing_test, + planner::PlannerOptions { + assume_yes: ui::assume_yes_enabled(), + prefer_binary: config.repo_settings.prefer_binary, + local_sibling_root: local_sibling_root.map(Path::to_path_buf), + include_test_deps: options.install_test_deps, + lib32_only_requested_specs: false, + }, + )?; + let dep_plan_packages = actionable_plan_packages(&dep_plan); + warn_source_build_plan(&dep_plan); + let dep_prompt_packages = if dep_plan_packages.is_empty() { + missing_test.clone() + } else { + dep_plan_packages + }; + if ui::prompt_package_action( + "dependency installation", + &dep_prompt_packages, + true, + )? { + ui::info(format!( + "Installing test dependencies: {}", + install_request_display(&dep_spec_paths) + )); + let exe = + std::env::current_exe().context("Failed to locate depot executable")?; + run_install_command_with_program( + &exe, + &dep_spec_paths, + options.rootfs, + ChildInstallCommandOptions { + no_deps: options.no_deps, + assume_yes: true, + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + lib32_only: false, + install_test_deps: options.install_test_deps, + install_context: Some(INSTALL_CONTEXT_PLANNED), + dep_chain: None, + }, + )?; + } else if !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies were not installed", + )? { + anyhow::bail!("Aborted"); + } + } + } + } + + if should_install_test_deps(&pkg_spec, options.install_test_deps, requested_outputs) { + let missing_test = + deps::check_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + if !missing_test.is_empty() + && !maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut pkg_spec, + &missing_test, + "Requested test dependencies are still missing", + )? + { + deps::require_test_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; + } + } + } + + let cross_config = options + .cross_prefix + .map(cross::CrossConfig::from_prefix) + .transpose()?; + let mut built_src_dir: Option = None; + + let destdir = if let Some(dir) = &staging_dir { + dir.path().to_path_buf() + } else { + if preparation.clean_sources_before_build { + clean_build_source_dirs(config)?; + } + source::preflight_manual_sources(&pkg_spec, &config.cache_dir)?; + let src_dir = source::prepare(&pkg_spec, &config.cache_dir, preparation.build_dir)?; + built_src_dir = Some(src_dir.clone()); + let host_build_dir = builder::ensure_host_build( + &pkg_spec, + &src_dir, + cross_config.as_ref(), + !options.no_flags, + builder::TargetBuildKind::Primary, + )?; + if let Some(host_dir) = host_build_dir.as_ref() { + pkg_spec.build.flags.host_build_dir = Some(host_dir.to_string_lossy().into_owned()); + } + + let destdir = preparation + .build_dir + .join("destdir") + .join(&pkg_spec.package.name); + if destdir.exists() { + fs::remove_dir_all(&destdir) + .with_context(|| format!("Failed to clean destdir: {}", destdir.display()))?; + } + + if !lib32_only { + builder::build( + &pkg_spec, + &src_dir, + &destdir, + cross_config.as_ref(), + !options.no_flags, + host_build_dir.as_deref(), + )?; + + staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; + install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + builder::stage_generated_lifecycle_scripts(&pkg_spec, &destdir)?; + } + + destdir + }; + + let mut transaction_plans = Vec::new(); + + if !lib32_only { + if staging_dir.is_none() { + staging::process(&destdir, &pkg_spec)?; + if let Some(src_dir) = built_src_dir.as_deref() { + staging::stage_split_package_licenses(src_dir, &destdir, &pkg_spec)?; + } + } else if !preparation.suppress_output { + ui::info("Installing binary archive payload"); + } + + let output_plans = + plan_package_outputs_for_install(&pkg_spec, &destdir, options.rootfs, config)?; + transaction_plans.extend(output_plans); + } + + if let Some(src_dir) = built_src_dir.as_deref() + && let Some((lib32_spec, lib32_destdir)) = build_lib32_companion_package( + &pkg_spec, + src_dir, + config, + cross_config.as_ref(), + !options.no_flags, + lib32_only, + )? + { + let staged = plan_staged_install(&lib32_spec, &lib32_destdir, options.rootfs, config)?; + transaction_plans.push(PlannedPackageInstall { + spec: lib32_spec, + destdir: lib32_destdir, + staged, + }); + } + + Ok(PreparedDirectInstall { + plans: transaction_plans, + resources: PreparedDirectInstallResources { + _staging_dir: staging_dir, + _source_cleanup_guard: source_cleanup_guard, + }, + }) +} + +pub(super) fn install_direct_transaction( + plans: &[PlannedPackageInstall], + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + let ordered_plans = preflight_file_ownership_and_order(plans, &HashSet::new(), rootfs, config)?; + run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Pre, &ordered_plans)?; + install_preflighted_planned_packages_to_rootfs_with_pre_removed( + &ordered_plans, + rootfs, + config, + &HashSet::new(), + true, + )?; + run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Post, &ordered_plans)?; + Ok(()) +} + +pub(super) fn install_requests_for_plan( + plan: &planner::ExecutionPlan, + config: &config::Config, + rootfs: &Path, +) -> Result> { + let mut requests = Vec::new(); + for step in plan.actionable_steps() { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + requests.push(path.clone()); + } + planner::PlanOrigin::Binary { repo_name, record } => { + let repo_cfg = config + .binary_repos + .get(repo_name) + .with_context(|| format!("Binary repo '{}' not found in config", repo_name))?; + let archive = db::repo::fetch_binary_package_archive( + repo_name, + repo_cfg, + rootfs, + record, + &config.package_cache_dir, + ) + .with_context(|| { + format!( + "Failed to fetch binary package '{}' from repo '{}'", + record.filename, repo_name + ) + })?; + requests.push(archive); + } + planner::PlanOrigin::Installed => {} + } + } + Ok(requests) +} + +pub(super) fn planned_installed_removals( + rootfs: &Path, + config: &config::Config, + packages: impl IntoIterator, +) -> Result> { + let db_path = config.installed_db_path(rootfs); + let installed = db::get_installed_packages(&db_path)?; + let mut unique = BTreeSet::new(); + for package in packages { + if installed.contains(&package) { + unique.insert(package); + } + } + + unique + .into_iter() + .map(|package| { + let affected_paths = db::get_package_files(&db_path, &package)?; + Ok(PlannedInstalledRemoval { + package, + affected_paths, + }) + }) + .collect() +} + +pub(super) fn transaction_contexts_for_update( + removals: &[PlannedInstalledRemoval], + plans: &[PlannedPackageInstall], +) -> Vec { + let mut contexts = Vec::with_capacity(removals.len() + plans.len()); + contexts.extend( + removals + .iter() + .map(|removal| install::hooks::HookExecutionContextOwned { + operation: install::hooks::HookOperation::Remove, + package: removal.package.clone(), + affected_paths: removal.affected_paths.clone(), + }), + ); + contexts.extend(plans.iter().map(|plan| plan.staged.hook_context.clone())); + contexts +} + +pub(super) fn install_update_transaction( + plans: &[PlannedPackageInstall], + removals: &[PlannedInstalledRemoval], + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + let pre_removed_packages: HashSet = removals + .iter() + .map(|removal| removal.package.clone()) + .collect(); + let ordered_plans = + preflight_file_ownership_and_order(plans, &pre_removed_packages, rootfs, config)?; + let contexts = transaction_contexts_for_update(removals, &ordered_plans); + install::hooks::run_transaction_hooks_batch(rootfs, install::hooks::HookPhase::Pre, &contexts)?; + + for removal in removals { + remove_installed_package_without_transaction_hooks( + &removal.package, + rootfs, + config, + &removal.affected_paths, + )?; + } + + install_preflighted_planned_packages_to_rootfs_with_pre_removed( + &ordered_plans, + rootfs, + config, + &pre_removed_packages, + false, + )?; + install::hooks::run_transaction_hooks_batch( + rootfs, + install::hooks::HookPhase::Post, + &contexts, + )?; + Ok(()) +} + +pub(super) fn run_direct_install_request( + options: DirectInstallOptions<'_>, + config: &config::Config, + mut spec_path: PathBuf, +) -> Result { + let mut install_lock = locking::open_lock(config)?; + let install_lock_path = locking::lock_path(config); + let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "install")?; + + // Repo clone dir is available via `config.repo_clone_dir` and + // is passed explicitly to index builders below. + + // If the provided path doesn't exist, treat it as a package name and + // try to locate a spec under configured repo dir or local packages/. + if !spec_path.exists() { + let name = spec_path.to_string_lossy().to_string(); + ui::info(format!("Looking up package '{}' in local indexes...", name)); + let pkg_index = + index::PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); + if let Some(found) = pkg_index.find(&name) { + spec_path = found; + } else { + let host_arch = std::env::consts::ARCH; + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + for (repo_name, repo_cfg) in binary_repos { + match db::repo::find_binary_repo_package( + repo_name, + repo_cfg, + options.rootfs, + &config.package_cache_dir, + &name, + ) { + Ok(Some(rec)) => { + let archive = db::repo::fetch_binary_package_archive( + repo_name, + repo_cfg, + options.rootfs, + &rec, + &config.package_cache_dir, + )?; + ui::info(format!( + "Resolved '{}' from binary repo '{}' as {}-{} (package {}) ({} bytes){} -> {}", + name, + repo_name, + rec.version, + rec.revision, + rec.name, + rec.size, + rec.description + .as_ref() + .map(|d| format!(" [{}]", d)) + .unwrap_or_default(), + archive.display() + )); + spec_path = archive; + break; + } + Ok(None) => {} + Err(e) => { + crate::log_warn!("Binary repo '{}': {}", repo_name, e); + } + } + } + } + } + + let suppress_output = suppress_nested_install_output(); + if !suppress_output { + ui::info(format!("Installing package from: {}", spec_path.display())); + } + + let _snapper_pre_install_snapshot_todo: fn() -> ! = + || todo!("snapper: create pre-install snapshot before install work starts"); + let _snapper_post_install_snapshot_todo: fn() -> ! = + || todo!("snapper: create post-install snapshot after install commit succeeds"); + + let prepared = prepare_direct_install_request( + options, + config, + &spec_path, + DirectInstallPreparationOptions { + build_dir: &config.build_dir, + clean_sources_before_build: true, + suppress_output, + confirm_installation: true, + resolve_installed_conflicts: true, + }, + )?; + if options.dry_run { + return Ok(false); + } + let _resources = prepared.resources; + install_direct_transaction(&prepared.plans, options.rootfs, config)?; + + Ok(true) +} + +pub(super) fn isolated_update_build_dir(config: &config::Config, idx: usize) -> PathBuf { + config + .build_dir + .join("update-tx") + .join(format!("{:04}", idx + 1)) +} + +pub(super) fn run_update_transaction_install_requests( + options: DirectInstallOptions<'_>, + config: &config::Config, + requests: &[PathBuf], +) -> Result { + if requests.is_empty() { + return Ok(false); + } + + let mut install_lock = locking::open_lock(config)?; + let install_lock_path = locking::lock_path(config); + let _install_lock_guard = locking::try_write(&mut install_lock, &install_lock_path, "update")?; + + if requests + .iter() + .any(|request| !is_archive_install_request(request)) + { + clean_build_source_dirs(config)?; + } + + let mut transaction_plans = Vec::new(); + let mut resources = Vec::with_capacity(requests.len()); + for (idx, request) in requests.iter().enumerate() { + let build_dir = isolated_update_build_dir(config, idx); + let prepared = prepare_direct_install_request( + options, + config, + request, + DirectInstallPreparationOptions { + build_dir: &build_dir, + clean_sources_before_build: false, + suppress_output: true, + confirm_installation: false, + resolve_installed_conflicts: false, + }, + ) + .with_context(|| { + format!( + "Failed to prepare update payload from {}", + request.display() + ) + })?; + transaction_plans.extend(prepared.plans); + resources.push(prepared.resources); + } + + if options.dry_run { + return Ok(false); + } + + let conflict_subjects: Vec<_> = transaction_plans + .iter() + .flat_map(|plan| install_conflict_subjects_for_output_spec(&plan.spec)) + .collect(); + validate_no_transaction_conflicts(&conflict_subjects)?; + let mut removal_packages = prompt_installed_conflict_removals_for_subjects( + &conflict_subjects, + options.rootfs, + config, + false, + )?; + for plan in &transaction_plans { + removal_packages.extend(plan.staged.replacement_removals.iter().cloned()); + } + let removals = planned_installed_removals(options.rootfs, config, removal_packages)?; + + install_update_transaction(&transaction_plans, &removals, options.rootfs, config)?; + drop(resources); + Ok(true) +} diff --git a/src/commands/install_plan.rs b/src/commands/install_plan.rs new file mode 100644 index 0000000..560e052 --- /dev/null +++ b/src/commands/install_plan.rs @@ -0,0 +1,550 @@ +use super::*; + +pub(super) fn print_plan_summary(plan: &planner::ExecutionPlan) { + if std::env::var_os("DEPOT_VERBOSE_PLAN").is_none() { + return; + } + for step in &plan.steps { + let (action, origin) = match &step.action { + planner::PlanAction::SkipInstalled => ("skip", "installed".to_string()), + planner::PlanAction::BuildAndInstall => match &step.origin { + planner::PlanOrigin::Source { + path, + local_sibling, + } => ( + "build+install", + if *local_sibling { + format!("source:local-sibling ({})", path.display()) + } else { + format!("source ({})", path.display()) + }, + ), + _ => ("build+install", "source".to_string()), + }, + planner::PlanAction::InstallBinary => match &step.origin { + planner::PlanOrigin::Binary { repo_name, record } => ( + "install", + format!( + "binary:{} {}-{} size={}", + repo_name, + record.version, + record.revision, + human_bytes(record.size) + ), + ), + _ => ("install", "binary".to_string()), + }, + }; + ui::info(format!(" {} [{}] {}", step.package, action, origin)); + } +} + +pub(super) fn actionable_plan_packages(plan: &planner::ExecutionPlan) -> Vec { + plan.actionable_steps() + .map(|step| step.package.clone()) + .collect() +} + +pub(super) fn source_build_reason(reason: &str) -> String { + if let Some(dep) = reason.strip_prefix("dependency ") { + format!("requested dependency '{dep}'") + } else if let Some((requester, _)) = reason.split_once(" needs ") { + format!("needed by '{requester}'") + } else if reason == "requested spec" { + "requested spec".to_string() + } else if reason == "requested package" { + "requested package".to_string() + } else { + reason.to_string() + } +} + +pub(super) fn source_build_warning_messages(plan: &planner::ExecutionPlan) -> Vec { + let mut lines = Vec::new(); + for step in plan.actionable_steps() { + if !matches!(step.action, planner::PlanAction::BuildAndInstall) { + continue; + } + + let mut reasons = Vec::new(); + for reason in &step.requested_by { + let label = source_build_reason(reason); + if !reasons.contains(&label) { + reasons.push(label); + } + } + + if reasons.is_empty() { + lines.push(step.package.clone()); + } else { + lines.push(format!("{} ({})", step.package, reasons.join(", "))); + } + } + lines +} + +pub(super) fn warn_source_build_plan(plan: &planner::ExecutionPlan) { + let lines = source_build_warning_messages(plan); + if lines.is_empty() { + return; + } + + ui::warn(format!( + "{} package(s) will be built from source before installation.", + lines.len() + )); + for line in lines { + ui::warn(format!(" {line}")); + } +} + +pub(super) fn validate_source_build_prereqs_for_plan( + plan: &planner::ExecutionPlan, + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + let db_path = config.installed_db_path(rootfs); + let mut checked_development_package = false; + + for step in plan.actionable_steps() { + let planner::PlanOrigin::Source { path, .. } = &step.origin else { + continue; + }; + if !matches!(step.action, planner::PlanAction::BuildAndInstall) { + continue; + } + + let mut spec = package::PackageSpec::from_file(path) + .with_context(|| format!("Failed to parse spec {}", path.display()))?; + spec.apply_config(config); + source::preflight_local_manual_sources(&spec)?; + if !checked_development_package && !spec.is_metapackage() { + ensure_requested_development_package_installed(&db_path)?; + checked_development_package = true; + } + } + + Ok(()) +} + +#[derive(Clone, Copy)] +pub(super) struct InstallPlanExecutionOptions<'a> { + pub(super) no_flags: bool, + pub(super) cross_prefix: Option<&'a str>, + pub(super) clean: bool, + pub(super) dry_run: bool, + pub(super) confirm_installation: bool, + pub(super) lib32_only_requested_specs: bool, + pub(super) install_test_deps: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ChildInstallBatch { + pub(super) requests: Vec, + pub(super) lib32_only: bool, +} + +pub(super) fn step_requests_only_lib32( + step: &planner::PlannedStep, + options: &InstallPlanExecutionOptions<'_>, +) -> bool { + options.lib32_only_requested_specs + && step + .requested_by + .iter() + .any(|reason| reason.starts_with("requested ")) +} + +pub(super) fn build_live_rootfs_child_install_batches( + steps: &[&planner::PlannedStep], + options: &InstallPlanExecutionOptions<'_>, + binary_archives: &HashMap<(String, String), db::repo::BinaryRepoCachedArchive>, +) -> Result> { + let mut batches = Vec::new(); + let mut pending_binary_requests = Vec::new(); + + for step in steps { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + if !pending_binary_requests.is_empty() { + batches.push(ChildInstallBatch { + requests: std::mem::take(&mut pending_binary_requests), + lib32_only: false, + }); + } + batches.push(ChildInstallBatch { + requests: vec![path.clone()], + lib32_only: step_requests_only_lib32(step, options), + }); + } + planner::PlanOrigin::Binary { repo_name, record } => { + let cached = binary_archives + .get(&(repo_name.clone(), record.filename.clone())) + .with_context(|| { + format!( + "Cached archive missing for planned binary step '{}' from repo '{}'", + record.filename, repo_name + ) + })?; + pending_binary_requests.push(cached.package_path.clone()); + } + planner::PlanOrigin::Installed => {} + } + } + + if !pending_binary_requests.is_empty() { + batches.push(ChildInstallBatch { + requests: pending_binary_requests, + lib32_only: false, + }); + } + + Ok(batches) +} + +pub(super) fn flush_binary_install_batch( + pending_plans: &mut Vec, + pending_staging_dirs: &mut Vec, + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + if pending_plans.is_empty() { + return Ok(()); + } + + install_planned_packages_to_rootfs(pending_plans, rootfs, config)?; + pending_plans.clear(); + pending_staging_dirs.clear(); + Ok(()) +} + +pub(super) fn execute_install_plan_with_child_commands( + plan: &planner::ExecutionPlan, + rootfs: &Path, + config: &config::Config, + options: InstallPlanExecutionOptions<'_>, +) -> Result<()> { + #[derive(Clone)] + struct BinaryPhaseItem { + repo_name: String, + record: db::repo::BinaryRepoPackageRecord, + } + + let actionable_steps: Vec<_> = plan.actionable_steps().collect(); + if actionable_steps.is_empty() { + ui::info("Nothing to do."); + return Ok(()); + } + + validate_source_build_prereqs_for_plan(plan, rootfs, config)?; + warn_source_build_plan(plan); + let planned_packages = actionable_plan_packages(plan); + if options.confirm_installation + && !ui::prompt_package_action("installation", &planned_packages, true)? + { + anyhow::bail!("Aborted"); + } + + let mut conflict_subjects = Vec::new(); + for step in &actionable_steps { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + let mut spec = package::PackageSpec::from_file(path) + .with_context(|| format!("Failed to parse spec {}", path.display()))?; + spec.apply_config(config); + let lib32_only = + effective_lib32_only(&spec, step_requests_only_lib32(step, &options)); + conflict_subjects.extend(install_conflict_subjects_for_spec( + &spec, + !lib32_only, + spec.builds_lib32_output() || lib32_only, + )); + } + planner::PlanOrigin::Binary { record, .. } => { + conflict_subjects.push(install_conflict_subject_for_binary_record(record)); + } + planner::PlanOrigin::Installed => {} + } + } + resolve_installed_conflicts_for_subjects(&conflict_subjects, rootfs, config, options.dry_run)?; + + if options.dry_run { + ui::info("Dry run enabled, no install/build actions executed."); + return Ok(()); + } + + let mut binary_archives: HashMap<(String, String), db::repo::BinaryRepoCachedArchive> = + HashMap::new(); + let mut binary_phase_items = Vec::new(); + let mut seen_binary_archives = HashSet::new(); + for step in &actionable_steps { + if let planner::PlanOrigin::Binary { repo_name, record } = &step.origin + && seen_binary_archives.insert((repo_name.clone(), record.filename.clone())) + { + binary_phase_items.push(BinaryPhaseItem { + repo_name: repo_name.clone(), + record: (**record).clone(), + }); + } + } + + if !binary_phase_items.is_empty() { + ui::info(format!( + "Downloading {} binary package(s) and detached signatures...", + binary_phase_items.len() + )); + let use_tty_progress = std::io::stderr().is_terminal(); + let download_progress = MultiProgress::with_draw_target(if use_tty_progress { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + let download_bars = binary_phase_items + .iter() + .map(|item| { + let label = format!( + "{}-{}-{}", + item.record.name, + item.record.version, + binary_arch_from_filename(&item.record.filename) + ); + let pb = download_progress.add(ProgressBar::new(item.record.size.max(1))); + pb.set_style( + ProgressStyle::default_bar() + .template("{prefix:.bold} [{bar:40.cyan/blue}] {eta}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + pb.set_prefix(label); + pb + }) + .collect::>(); + let download_client = db::repo::binary_package_http_client()?; + let download_results = run_parallel_tasks( + &binary_phase_items, + MAX_PARALLEL_DOWNLOADS, + |index, item| { + let pb = &download_bars[index]; + let mut progress_cb = |downloaded: u64, total: Option| { + if let Some(t) = total + && t > 0 + { + pb.set_length(t); + } + pb.set_position(downloaded); + }; + let result = (|| { + let repo_cfg = config.binary_repos.get(&item.repo_name).with_context(|| { + format!("Binary repo '{}' not found in config", item.repo_name) + })?; + db::repo::cache_binary_package_archive_with_client_and_progress( + &item.repo_name, + repo_cfg, + &item.record, + &config.package_cache_dir, + &download_client, + Some(&mut progress_cb), + ) + .with_context(|| { + format!( + "Failed to cache binary package '{}' from repo '{}'", + item.record.filename, item.repo_name + ) + }) + })(); + pb.finish_and_clear(); + result + }, + ); + download_progress + .clear() + .context("Failed to clear binary download progress")?; + for (item, cached) in binary_phase_items.iter().zip(download_results?) { + binary_archives.insert( + (item.repo_name.clone(), item.record.filename.clone()), + cached, + ); + } + + ui::info(format!( + "Verifying checksums and detached signatures for {} binary package(s)...", + binary_phase_items.len() + )); + let integrity_pb = ProgressBar::new(binary_phase_items.len() as u64); + integrity_pb.set_draw_target(if use_tty_progress { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + integrity_pb.set_style( + ProgressStyle::default_bar() + .template("{prefix:.bold} [{bar:40.cyan/blue}] {pos}/{len} {eta}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + integrity_pb.set_prefix("integrity"); + let has_detached_signatures = binary_phase_items.iter().any(|item| { + binary_archives + .get(&(item.repo_name.clone(), item.record.filename.clone())) + .is_some_and(|cached| cached.signature_path.exists()) + }); + let trusted_keys = if has_detached_signatures { + signing::load_trusted_public_keys(rootfs) + .context("Failed to load trusted Minisign public keys")? + } else { + Vec::new() + }; + run_parallel_verification(&binary_phase_items, &integrity_pb, |item| { + let repo_cfg = config + .binary_repos + .get(&item.repo_name) + .with_context(|| format!("Binary repo '{}' not found in config", item.repo_name))?; + let cached = binary_archives + .get(&(item.repo_name.clone(), item.record.filename.clone())) + .with_context(|| { + format!( + "Cached archive missing for {} from repo '{}'", + item.record.filename, item.repo_name + ) + })?; + db::repo::verify_binary_package_archive_integrity_with_trusted_keys( + &item.repo_name, + repo_cfg, + &item.record, + &cached.package_path, + &cached.signature_path, + &trusted_keys, + ) + .with_context(|| { + format!( + "Integrity verification failed for {} from repo '{}'", + item.record.filename, item.repo_name + ) + }) + })?; + integrity_pb.finish_and_clear(); + } + + if should_delegate_live_rootfs_installs(rootfs) { + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + let batches = + build_live_rootfs_child_install_batches(&actionable_steps, &options, &binary_archives)?; + for batch in batches { + run_install_command_with_program( + &exe, + &batch.requests, + rootfs, + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + lib32_only: batch.lib32_only, + install_test_deps: options.install_test_deps, + install_context: Some(INSTALL_CONTEXT_PLANNED), + dep_chain: None, + }, + )?; + } + return Ok(()); + } + + let mut binary_pre_hook_plans = Vec::new(); + for step in &actionable_steps { + if let planner::PlanOrigin::Binary { repo_name, record } = &step.origin { + let cached = binary_archives + .get(&(repo_name.clone(), record.filename.clone())) + .with_context(|| { + format!( + "Cached archive missing for planned binary step '{}' from repo '{}'", + record.filename, repo_name + ) + })?; + let staged = extract_package_archive_to_staging(config, &cached.package_path)?; + let spec = load_package_spec_from_staging_or_repo_record(staged.path(), record)?; + let plans = plan_package_outputs_for_install(&spec, staged.path(), rootfs, config)?; + binary_pre_hook_plans.extend(plans); + } + } + run_transaction_hooks_for_plans( + rootfs, + install::hooks::HookPhase::Pre, + &binary_pre_hook_plans, + )?; + + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + let total_steps = actionable_steps.len(); + let mut binary_post_hook_plans = Vec::new(); + let mut pending_binary_install_plans = Vec::new(); + let mut pending_binary_install_staging_dirs = Vec::new(); + for (idx, step) in actionable_steps.into_iter().enumerate() { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + flush_binary_install_batch( + &mut pending_binary_install_plans, + &mut pending_binary_install_staging_dirs, + rootfs, + config, + )?; + ui::info(format!( + "[{}/{}] building+installing {} from source", + idx + 1, + total_steps, + step.package + )); + + run_install_command_with_program( + &exe, + std::slice::from_ref(path), + rootfs, + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: options.no_flags, + cross_prefix: options.cross_prefix, + clean: options.clean, + lib32_only: step_requests_only_lib32(step, &options), + install_test_deps: options.install_test_deps, + install_context: Some(INSTALL_CONTEXT_PLANNED), + dep_chain: None, + }, + ) + .with_context(|| { + format!("Failed to spawn planned install step '{}'", step.package) + })?; + } + planner::PlanOrigin::Binary { repo_name, record } => { + let cached = binary_archives + .get(&(repo_name.clone(), record.filename.clone())) + .with_context(|| { + format!( + "Cached archive missing for planned binary step '{}' from repo '{}'", + record.filename, repo_name + ) + })?; + let staged = extract_package_archive_to_staging(config, &cached.package_path)?; + let spec = load_package_spec_from_staging_or_repo_record(staged.path(), record)?; + let plans = plan_package_outputs_for_install(&spec, staged.path(), rootfs, config)?; + binary_post_hook_plans.extend(plans.iter().cloned()); + pending_binary_install_plans.extend(plans); + pending_binary_install_staging_dirs.push(staged); + } + planner::PlanOrigin::Installed => {} + } + } + + flush_binary_install_batch( + &mut pending_binary_install_plans, + &mut pending_binary_install_staging_dirs, + rootfs, + config, + )?; + run_transaction_hooks_for_plans( + rootfs, + install::hooks::HookPhase::Post, + &binary_post_hook_plans, + )?; + install::scripts::run_deferred_hooks_if_possible(rootfs)?; + Ok(()) +} diff --git a/src/commands/install_transaction.rs b/src/commands/install_transaction.rs new file mode 100644 index 0000000..b10b14c --- /dev/null +++ b/src/commands/install_transaction.rs @@ -0,0 +1,1013 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(super) struct PlannedStagedInstall { + pub(super) is_update: bool, + pub(super) remove_paths: Vec, + pub(super) replacement_removals: Vec, + pub(super) renamed_transition: Option, + pub(super) hook_context: install::hooks::HookExecutionContextOwned, +} + +#[derive(Debug, Clone)] +pub(super) struct RenamedPackageTransition { + pub(super) replaced: db::InstalledPackageRecord, + pub(super) retained_files: Vec, + pub(super) retained_directories: Vec, +} + +impl RenamedPackageTransition { + fn replacement(&self) -> db::PackageReplacement { + db::PackageReplacement { + old_name: self.replaced.name.clone(), + retained_files: self.retained_files.clone(), + retained_directories: self.retained_directories.clone(), + } + } + + fn retains_old_package(&self) -> bool { + !self.retained_files.is_empty() || !self.retained_directories.is_empty() + } +} + +#[derive(Debug, Clone)] +pub(super) struct PlannedPackageInstall { + pub(super) spec: package::PackageSpec, + pub(super) destdir: PathBuf, + pub(super) staged: PlannedStagedInstall, +} + +#[derive(Debug, Clone)] +pub(super) struct PlannedInstalledRemoval { + pub(super) package: String, + pub(super) affected_paths: Vec, +} + +pub(super) struct PreparedDirectInstallResources<'a> { + pub(super) _staging_dir: Option, + pub(super) _source_cleanup_guard: SourceBuildCleanupGuard<'a>, +} + +pub(super) struct PreparedDirectInstall<'a> { + pub(super) plans: Vec, + pub(super) resources: PreparedDirectInstallResources<'a>, +} + +pub(super) struct DirectInstallPreparationOptions<'a> { + pub(super) build_dir: &'a Path, + pub(super) clean_sources_before_build: bool, + pub(super) suppress_output: bool, + pub(super) confirm_installation: bool, + pub(super) resolve_installed_conflicts: bool, +} + +#[derive(Clone, Copy)] +pub(super) struct PendingLifecycleHook { + pub(super) hook: install::scripts::Hook, +} + +#[cfg(test)] +#[derive(Debug, Clone)] +pub(super) struct InstalledPackageOutcome { + pub(super) package: package::PackageInfo, + pub(super) is_update: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct InstallConflictSubject { + pub(super) package: String, + pub(super) provides: Vec, + pub(super) conflicts: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct InstalledConflictPackage { + pub(super) name: String, + pub(super) provides: Vec, +} + +pub(super) fn install_conflict_subjects_for_output_spec( + spec: &package::PackageSpec, +) -> Vec { + spec.outputs() + .into_iter() + .map(|output| { + let alternatives = spec.alternatives_for_output(&output.name); + InstallConflictSubject { + package: output.name, + provides: alternatives.provides, + conflicts: alternatives.conflicts, + } + }) + .collect() +} + +pub(super) fn install_conflict_subjects_for_spec( + spec: &package::PackageSpec, + include_primary: bool, + include_lib32: bool, +) -> Vec { + let mut subjects = Vec::new(); + if include_primary { + subjects.extend(install_conflict_subjects_for_output_spec(spec)); + } + if include_lib32 { + subjects.extend(install_conflict_subjects_for_output_spec( + &make_lib32_package_spec(spec), + )); + } + subjects +} + +pub(super) fn install_conflict_subject_for_binary_record( + record: &db::repo::BinaryRepoPackageRecord, +) -> InstallConflictSubject { + InstallConflictSubject { + package: record.name.clone(), + provides: record.provides.clone(), + conflicts: record.conflicts.clone(), + } +} + +pub(super) fn matching_conflict_names( + conflicts: &[String], + package_name: &str, + provides: &[String], +) -> Vec { + let mut matches = Vec::new(); + for conflict in conflicts { + if conflict == package_name || provides.iter().any(|provided| provided == conflict) { + matches.push(conflict.clone()); + } + } + matches.sort(); + matches.dedup(); + matches +} + +pub(super) fn validate_no_transaction_conflicts(subjects: &[InstallConflictSubject]) -> Result<()> { + let mut violations = BTreeSet::new(); + for (idx, left) in subjects.iter().enumerate() { + for right in subjects.iter().skip(idx + 1) { + let left_hits = + matching_conflict_names(&left.conflicts, &right.package, &right.provides); + if !left_hits.is_empty() { + violations.insert(format!( + "{} conflicts with {} via {}", + left.package, + right.package, + left_hits.join(", ") + )); + } + let right_hits = + matching_conflict_names(&right.conflicts, &left.package, &left.provides); + if !right_hits.is_empty() { + violations.insert(format!( + "{} conflicts with {} via {}", + right.package, + left.package, + right_hits.join(", ") + )); + } + } + } + + if violations.is_empty() { + return Ok(()); + } + + let mut message = + String::from("Cannot install conflicting packages in the same transaction:\n"); + for violation in violations { + message.push_str(" "); + message.push_str(&violation); + message.push('\n'); + } + anyhow::bail!(message.trim_end().to_string()); +} + +pub(super) fn collect_installed_conflict_packages( + db_path: &Path, +) -> Result> { + let mut installed = Vec::new(); + for record in db::list_installed_package_records(db_path)? { + installed.push(InstalledConflictPackage { + provides: db::get_package_provides(db_path, &record.name)?, + name: record.name, + }); + } + Ok(installed) +} + +pub(super) fn collect_conflicting_installed_packages( + subjects: &[InstallConflictSubject], + installed: &[InstalledConflictPackage], +) -> Result>> { + validate_no_transaction_conflicts(subjects)?; + let planned_packages: HashSet<_> = subjects + .iter() + .map(|subject| subject.package.clone()) + .collect(); + let mut removals: BTreeMap> = BTreeMap::new(); + + for subject in subjects { + for installed_pkg in installed { + if installed_pkg.name == subject.package { + continue; + } + let matched = matching_conflict_names( + &subject.conflicts, + &installed_pkg.name, + &installed_pkg.provides, + ); + if matched.is_empty() { + continue; + } + if planned_packages.contains(&installed_pkg.name) { + anyhow::bail!( + "Cannot install conflicting packages in the same transaction: {} conflicts with {}", + subject.package, + installed_pkg.name + ); + } + removals + .entry(installed_pkg.name.clone()) + .or_default() + .insert(subject.package.clone()); + } + } + + Ok(removals) +} + +pub(super) fn collect_installed_replacement_packages( + db_path: &Path, + pkg_spec: &package::PackageSpec, +) -> Result> { + let installed = db::get_installed_packages(db_path)?; + let mut replacements: Vec = pkg_spec + .alternatives + .replaces + .iter() + .filter(|name| *name != &pkg_spec.package.name) + .filter(|name| installed.contains(*name)) + .cloned() + .collect(); + replacements.sort(); + replacements.dedup(); + Ok(replacements) +} + +pub(crate) fn remove_installed_package_with_hooks( + package: &str, + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + let db_path = config.installed_db_path(rootfs); + let affected_paths = db::get_package_files(&db_path, package)?; + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Pre, + operation: install::hooks::HookOperation::Remove, + package, + affected_paths: &affected_paths, + }, + )?; + remove_installed_package_without_transaction_hooks(package, rootfs, config, &affected_paths)?; + install::hooks::run_transaction_hooks( + rootfs, + &install::hooks::HookExecutionContext { + phase: install::hooks::HookPhase::Post, + operation: install::hooks::HookOperation::Remove, + package, + affected_paths: &affected_paths, + }, + )?; + Ok(()) +} + +pub(super) fn remove_installed_package_without_transaction_hooks( + package: &str, + rootfs: &Path, + config: &config::Config, + _affected_paths: &[String], +) -> Result<()> { + let db_path = config.installed_db_path(rootfs); + let script_dir = install::scripts::installed_scripts_dir(rootfs, package); + let _ = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PreRemove, + rootfs, + package, + )?; + db::remove_package(&db_path, package, rootfs)?; + let post_remove = install::scripts::run_hook_if_present( + &script_dir, + install::scripts::Hook::PostRemove, + rootfs, + package, + ); + let cleanup_scripts = install::scripts::remove_installed_scripts(rootfs, package); + post_remove?; + cleanup_scripts?; + ui::success(format!("Successfully removed {}", package)); + Ok(()) +} + +pub(super) fn prompt_installed_conflict_removals_for_subjects( + subjects: &[InstallConflictSubject], + rootfs: &Path, + config: &config::Config, + dry_run: bool, +) -> Result> { + if subjects.is_empty() { + return Ok(Vec::new()); + } + + let db_path = config.installed_db_path(rootfs); + let installed = collect_installed_conflict_packages(&db_path)?; + let removals = collect_conflicting_installed_packages(subjects, &installed)?; + if removals.is_empty() { + return Ok(Vec::new()); + } + + let prompt_entries: Vec = removals + .iter() + .map(|(package, conflicted_by)| { + format!( + "{} (conflicts with {})", + package, + conflicted_by.iter().cloned().collect::>().join(", ") + ) + }) + .collect(); + + if dry_run { + ui::info(format!( + "Dry run: would remove conflicting installed package(s): {}", + prompt_entries.join(", ") + )); + return Ok(Vec::new()); + } + + if !ui::prompt_package_action("conflict removal", &prompt_entries, true)? { + anyhow::bail!("Aborted"); + } + + Ok(removals.keys().cloned().collect()) +} + +pub(super) fn resolve_installed_conflicts_for_subjects( + subjects: &[InstallConflictSubject], + rootfs: &Path, + config: &config::Config, + dry_run: bool, +) -> Result<()> { + for package in + prompt_installed_conflict_removals_for_subjects(subjects, rootfs, config, dry_run)? + { + ui::info(format!("Removing conflicting package: {}", package)); + remove_installed_package_with_hooks(&package, rootfs, config)?; + } + + Ok(()) +} + +pub(super) fn is_versioned_shared_library_path(path: &str) -> bool { + let Some(file_name) = Path::new(path).file_name().and_then(|name| name.to_str()) else { + return false; + }; + let Some(version_suffix) = file_name.split(".so.").nth(1) else { + return false; + }; + !version_suffix.is_empty() + && version_suffix + .chars() + .all(|ch| ch.is_ascii_digit() || ch == '.') + && version_suffix.chars().any(|ch| ch.is_ascii_digit()) +} + +pub(super) fn retained_abi_files_for_replacement( + old_files: &[String], + new_manifest: &staging::Manifest, +) -> Vec { + let new_files: HashSet<&str> = new_manifest.files.iter().map(String::as_str).collect(); + let mut retained: Vec = old_files + .iter() + .filter(|path| is_versioned_shared_library_path(path)) + .filter(|path| !new_files.contains(path.as_str())) + .cloned() + .collect(); + retained.sort(); + retained +} + +pub(super) fn retained_directories_for_files( + old_directories: &[String], + retained_files: &[String], +) -> Vec { + let retained_files: HashSet<&str> = retained_files.iter().map(String::as_str).collect(); + let mut directories: Vec = old_directories + .iter() + .filter(|directory| { + let prefix = format!("{}/", directory); + retained_files + .iter() + .any(|file| *file == directory.as_str() || file.starts_with(&prefix)) + }) + .cloned() + .collect(); + directories.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); + directories +} + +pub(super) fn compare_installed_records_for_stream( + left: &db::InstalledPackageRecord, + right: &db::InstalledPackageRecord, +) -> Ordering { + compare_package_release(&left.version, left.revision, &right.version, right.revision) + .then_with(|| compare_completed_at(left.completed_at, right.completed_at)) + .then_with(|| left.name.cmp(&right.name)) +} + +pub(super) fn select_primary_installed_record<'a>( + records: impl IntoIterator, +) -> Option<&'a db::InstalledPackageRecord> { + let mut best: Option<&db::InstalledPackageRecord> = None; + for record in records { + if best.as_ref().is_none_or(|current| { + compare_installed_records_for_stream(record, current) == Ordering::Greater + }) { + best = Some(record); + } + } + best +} + +pub(super) fn build_renamed_package_transition( + db_path: &Path, + pkg_spec: &package::PackageSpec, + new_manifest: &staging::Manifest, +) -> Result> { + let installed = db::list_installed_package_records(db_path)?; + if installed + .iter() + .any(|record| record.name == pkg_spec.package.name) + { + return Ok(None); + } + + let stream_name = pkg_spec.package.effective_real_name(); + let Some(replaced) = select_primary_installed_record( + installed + .iter() + .filter(|record| record.effective_real_name() == stream_name) + .filter(|record| record.name != pkg_spec.package.name), + ) + .cloned() else { + return Ok(None); + }; + + let old_files = db::get_package_files(db_path, &replaced.name)?; + let old_directories = db::get_package_directories(db_path, &replaced.name)?; + let retained_files = if replaced.abi_breaking { + retained_abi_files_for_replacement(&old_files, new_manifest) + } else { + Vec::new() + }; + let retained_directories = if retained_files.is_empty() { + Vec::new() + } else { + retained_directories_for_files(&old_directories, &retained_files) + }; + + Ok(Some(RenamedPackageTransition { + replaced, + retained_files, + retained_directories, + })) +} + +pub(super) fn plan_staged_install( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, +) -> Result { + std::fs::create_dir_all(&config.db_dir).with_context(|| { + format!( + "Failed to create database directory: {}", + config.db_dir.display() + ) + })?; + let db_path = config.installed_db_path(rootfs); + + let new_manifest = staging::generate_manifest_with_dirs(destdir)?; + let replacement_removals = collect_installed_replacement_packages(&db_path, pkg_spec)?; + let renamed_transition = build_renamed_package_transition(&db_path, pkg_spec, &new_manifest)?; + let is_update = db::get_package_version(&db_path, &pkg_spec.package.name)?.is_some() + || renamed_transition.is_some() + || !replacement_removals.is_empty(); + let mut remove_paths = + db::calculate_upgrade_paths(&db_path, &pkg_spec.package.name, &new_manifest)?; + if let Some(transition) = &renamed_transition { + let old_files = db::get_package_files(&db_path, &transition.replaced.name)?; + let old_directories = db::get_package_directories(&db_path, &transition.replaced.name)?; + let retained_files: HashSet<&str> = transition + .retained_files + .iter() + .map(String::as_str) + .collect(); + let retained_directories: HashSet<&str> = transition + .retained_directories + .iter() + .map(String::as_str) + .collect(); + remove_paths.extend( + old_files + .into_iter() + .filter(|path| !retained_files.contains(path.as_str())), + ); + remove_paths.extend( + old_directories + .into_iter() + .filter(|path| !retained_directories.contains(path.as_str())), + ); + remove_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); + remove_paths.dedup(); + } + let operation = if is_update { + install::hooks::HookOperation::Update + } else { + install::hooks::HookOperation::Install + }; + let mut affected_paths = new_manifest.files.clone(); + affected_paths.extend(remove_paths.iter().cloned()); + affected_paths.sort(); + affected_paths.dedup(); + + Ok(PlannedStagedInstall { + is_update, + remove_paths, + replacement_removals, + renamed_transition, + hook_context: install::hooks::HookExecutionContextOwned { + operation, + package: pkg_spec.package.name.clone(), + affected_paths, + }, + }) +} + +pub(super) fn plan_package_outputs_for_install( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, +) -> Result> { + let mut plans = Vec::new(); + for (spec_for_out, out_destdir) in staged_output_specs(pkg_spec, destdir)? { + let staged = plan_staged_install(&spec_for_out, &out_destdir, rootfs, config)?; + plans.push(PlannedPackageInstall { + spec: spec_for_out, + destdir: out_destdir, + staged, + }); + } + Ok(plans) +} + +pub(super) fn run_transaction_hooks_for_plans( + rootfs: &Path, + phase: install::hooks::HookPhase, + plans: &[PlannedPackageInstall], +) -> Result { + let contexts: Vec<_> = plans + .iter() + .map(|plan| plan.staged.hook_context.clone()) + .collect(); + install::hooks::run_transaction_hooks_batch(rootfs, phase, &contexts) +} + +pub(super) fn preflight_file_ownership_and_order( + plans: &[PlannedPackageInstall], + pre_removed_packages: &HashSet, + rootfs: &Path, + config: &config::Config, +) -> Result> { + let db_path = config.installed_db_path(rootfs); + let installed_ownership = db::get_file_ownership(&db_path)?; + let mut manifests = Vec::with_capacity(plans.len()); + let mut plan_by_package = BTreeMap::new(); + let mut replacement_plan_by_package = BTreeMap::new(); + let mut violations = BTreeSet::new(); + + for (idx, plan) in plans.iter().enumerate() { + let package = &plan.spec.package.name; + if plan_by_package.insert(package.clone(), idx).is_some() { + violations.insert(format!( + "package '{}' appears more than once in the transaction", + package + )); + } + for replaced in &plan.staged.replacement_removals { + if replacement_plan_by_package + .insert(replaced.clone(), idx) + .is_some() + { + violations.insert(format!( + "installed package '{}' is replaced by more than one transaction package", + replaced + )); + } + } + if let Some(transition) = &plan.staged.renamed_transition + && replacement_plan_by_package + .insert(transition.replaced.name.clone(), idx) + .is_some() + { + violations.insert(format!( + "installed package '{}' is replaced by more than one transaction package", + transition.replaced.name + )); + } + + let manifest = staging::generate_manifest_with_dirs(&plan.destdir).with_context(|| { + format!( + "Failed to inspect staged files for package '{}'", + plan.spec.package.name + ) + })?; + manifests.push(manifest.files.into_iter().collect::>()); + } + + let mut planned_owner_by_path: BTreeMap<&str, usize> = BTreeMap::new(); + for (idx, manifest) in manifests.iter().enumerate() { + for path in manifest { + if let Some(previous_idx) = planned_owner_by_path.insert(path, idx) + && plans[previous_idx].spec.package.name != plans[idx].spec.package.name + && !db::should_auto_clear_conflict(&plans[previous_idx].spec.package.name, path) + { + violations.insert(format!( + "{} -> provided by both {} and {}", + path, plans[previous_idx].spec.package.name, plans[idx].spec.package.name + )); + } + } + } + + let mut edges = vec![BTreeSet::new(); plans.len()]; + let mut indegree = vec![0_usize; plans.len()]; + for (taker_idx, manifest) in manifests.iter().enumerate() { + let taker = &plans[taker_idx].spec.package.name; + for path in manifest { + let Some(owner) = installed_ownership.get(path) else { + continue; + }; + if owner == taker + || pre_removed_packages.contains(owner) + || db::should_auto_clear_conflict(owner, path) + { + continue; + } + + let owner_plan_idx = if let Some(owner_idx) = plan_by_package.get(owner) { + if manifests[*owner_idx].contains(path) { + violations.insert(format!( + "{} -> owned by {} and still provided by its transaction update (wanted by {})", + path, owner, taker + )); + continue; + } + Some(*owner_idx) + } else if let Some(owner_idx) = replacement_plan_by_package.get(owner) { + let retained_by_rename = plans[*owner_idx] + .staged + .renamed_transition + .as_ref() + .is_some_and(|transition| { + transition.replaced.name == *owner + && transition.retained_files.contains(path) + }); + if retained_by_rename { + violations.insert(format!( + "{} -> retained by renamed package {} (wanted by {})", + path, owner, taker + )); + continue; + } + Some(*owner_idx) + } else { + violations.insert(format!( + "{} -> owned by {} (wanted by {})", + path, owner, taker + )); + None + }; + + if let Some(owner_idx) = owner_plan_idx + && owner_idx != taker_idx + && edges[owner_idx].insert(taker_idx) + { + indegree[taker_idx] += 1; + } + } + } + + if !violations.is_empty() { + let mut message = String::from("File ownership conflict detected before transaction:\n"); + for violation in violations { + message.push_str(&format!(" {violation}\n")); + } + anyhow::bail!(message); + } + + let mut order = Vec::with_capacity(plans.len()); + let mut emitted = vec![false; plans.len()]; + while order.len() < plans.len() { + let Some(next) = (0..plans.len()).find(|idx| !emitted[*idx] && indegree[*idx] == 0) else { + let packages = (0..plans.len()) + .filter(|idx| !emitted[*idx]) + .map(|idx| plans[idx].spec.package.name.as_str()) + .collect::>() + .join(", "); + anyhow::bail!( + "File ownership handoff cycle detected before transaction among: {}", + packages + ); + }; + emitted[next] = true; + order.push(next); + for dependent in &edges[next] { + indegree[*dependent] -= 1; + } + } + + Ok(order.into_iter().map(|idx| plans[idx].clone()).collect()) +} + +pub(super) fn install_staged_to_rootfs( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, + plan: &PlannedStagedInstall, +) -> 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); + + if plan.is_update { + let has_staged_pre = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + if !has_staged_pre { + let _ = install::scripts::run_hook_if_present( + &installed_scripts_dir, + install::scripts::Hook::PreUpdate, + rootfs, + &pkg_spec.package.name, + )?; + } + } else { + let _ = install::scripts::run_hook_if_present( + &staged_scripts_dir, + install::scripts::Hook::PreInstall, + rootfs, + &pkg_spec.package.name, + )?; + } + + let tx_base = config.build_dir.join("tx"); + let tx = staging::install_atomic( + destdir, + rootfs, + &tx_base, + &plan.remove_paths, + &pkg_spec.build.flags.keep, + )?; + + let db_path = config.installed_db_path(rootfs); + let replacement = plan + .renamed_transition + .as_ref() + .map(RenamedPackageTransition::replacement); + let register_result = if let Some(replacement) = replacement.as_ref() { + db::register_package_with_replacement(&db_path, pkg_spec, destdir, Some(replacement)) + } else { + db::register_package(&db_path, pkg_spec, destdir) + }; + if let Err(e) = register_result { + let _ = tx.rollback(); + return Err(e); + } + tx.commit()?; + + if let Some(transition) = &plan.renamed_transition + && !transition.retains_old_package() + { + install::scripts::remove_installed_scripts(rootfs, &transition.replaced.name)?; + } + + install::scripts::sync_staged_scripts_to_rootfs( + &staged_scripts_dir, + rootfs, + &pkg_spec.package.name, + )?; + + Ok(Some(PendingLifecycleHook { + hook: if plan.is_update { + install::scripts::Hook::PostUpdate + } else { + install::scripts::Hook::PostInstall + }, + })) +} + +pub(super) fn install_planned_packages_to_rootfs( + plans: &[PlannedPackageInstall], + rootfs: &Path, + config: &config::Config, +) -> Result<()> { + install_planned_packages_to_rootfs_with_pre_removed( + plans, + rootfs, + config, + &HashSet::new(), + true, + ) +} + +pub(super) fn install_planned_packages_to_rootfs_with_pre_removed( + plans: &[PlannedPackageInstall], + rootfs: &Path, + config: &config::Config, + pre_removed_packages: &HashSet, + show_progress: bool, +) -> Result<()> { + let ordered_plans = + preflight_file_ownership_and_order(plans, pre_removed_packages, rootfs, config)?; + install_preflighted_planned_packages_to_rootfs_with_pre_removed( + &ordered_plans, + rootfs, + config, + pre_removed_packages, + show_progress, + ) +} + +pub(super) fn install_preflighted_planned_packages_to_rootfs_with_pre_removed( + plans: &[PlannedPackageInstall], + rootfs: &Path, + config: &config::Config, + pre_removed_packages: &HashSet, + show_progress: bool, +) -> Result<()> { + let mut removed_replacements = HashSet::new(); + let mut pending_post_hooks = Vec::new(); + for (idx, plan) in plans.iter().enumerate() { + if show_progress { + ui::info(format!( + "{}/{} Installing package {}-{}-{}", + idx + 1, + plans.len(), + plan.spec.package.name, + plan.spec.package.version, + plan.spec.package.revision + )); + } + for package in &plan.staged.replacement_removals { + if pre_removed_packages.contains(package) { + continue; + } + if removed_replacements.insert(package.clone()) { + remove_installed_package_with_hooks(package, rootfs, config)?; + } + } + 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)); + } + } + // Lifecycle hooks may invoke sh, cc, or ld. Select a sole provider before + // any post-install hook runs so the aliases are usable within this transaction. + set::auto_select_sole_tool_providers(rootfs, config)?; + 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(()) +} + +pub(super) fn run_parallel_tasks( + items: &[T], + worker_count: usize, + task: F, +) -> Result> +where + T: Sync, + U: Send, + F: Fn(usize, &T) -> Result + Sync, +{ + if items.is_empty() { + return Ok(Vec::new()); + } + + let worker_count = worker_count.max(1).min(items.len()); + let next_index = AtomicUsize::new(0); + let (sender, receiver) = mpsc::channel(); + + std::thread::scope(|scope| -> Result> { + for _ in 0..worker_count { + let sender = sender.clone(); + let task = &task; + let next_index = &next_index; + scope.spawn(move || { + loop { + let index = next_index.fetch_add(1, AtomicOrdering::Relaxed); + if index >= items.len() { + break; + } + let result = task(index, &items[index]); + if sender.send((index, result)).is_err() { + break; + } + } + }); + } + drop(sender); + + let mut results: Vec>> = (0..items.len()).map(|_| None).collect(); + for _ in 0..items.len() { + let (index, result) = receiver + .recv() + .context("Parallel worker exited before reporting a result")?; + results[index] = Some(result); + } + + results + .into_iter() + .map(|result| result.expect("every parallel item must report a result")) + .collect() + }) +} + +pub(super) fn run_parallel_verification( + items: &[T], + progress: &ProgressBar, + verify: F, +) -> Result<()> +where + T: Sync, + F: Fn(&T) -> Result<()> + Sync, +{ + let worker_count = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1); + run_parallel_tasks(items, worker_count, |_, item| { + let result = verify(item); + progress.inc(1); + result + })?; + Ok(()) +} + +#[cfg(test)] +pub(super) fn install_package_outputs_to_rootfs( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, +) -> Result> { + let plans = plan_package_outputs_for_install(pkg_spec, destdir, rootfs, config)?; + let ordered_plans = + preflight_file_ownership_and_order(&plans, &HashSet::new(), rootfs, config)?; + let installed = plans + .iter() + .map(|plan| InstalledPackageOutcome { + package: plan.spec.package.clone(), + is_update: plan.staged.is_update, + }) + .collect(); + run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Pre, &ordered_plans)?; + install_preflighted_planned_packages_to_rootfs_with_pre_removed( + &ordered_plans, + rootfs, + config, + &HashSet::new(), + true, + )?; + run_transaction_hooks_for_plans(rootfs, install::hooks::HookPhase::Post, &ordered_plans)?; + Ok(installed) +} diff --git a/src/commands/tests.rs b/src/commands/tests.rs index c0adfe7..27294c6 100644 --- a/src/commands/tests.rs +++ b/src/commands/tests.rs @@ -80,119 +80,6 @@ fn write_basic_binary_archive( Ok(()) } -#[test] -fn build_env_rootfs_uses_selected_non_live_rootfs() { - let tmp = tempfile::tempdir().unwrap(); - let expected = tmp.path().canonicalize().unwrap(); - - assert_eq!( - build_cmd::build_env_rootfs(tmp.path()), - expected.to_string_lossy() - ); - assert_eq!(build_cmd::build_env_rootfs(Path::new("/")), "/"); -} - -#[test] -fn parallel_verification_processes_every_item() -> Result<()> { - let items = vec![0_u8; 32]; - let completed = AtomicUsize::new(0); - let progress = ProgressBar::hidden(); - - run_parallel_verification(&items, &progress, |_| { - completed.fetch_add(1, AtomicOrdering::Relaxed); - Ok(()) - })?; - - assert_eq!(completed.load(AtomicOrdering::Relaxed), items.len()); - Ok(()) -} - -#[test] -fn parallel_tasks_run_concurrently_and_preserve_input_order() -> Result<()> { - let items = vec![3_u8, 1, 4, 2]; - let barrier = Barrier::new(items.len()); - - let results = run_parallel_tasks(&items, items.len(), |_, item| { - barrier.wait(); - Ok(item * 2) - })?; - - assert_eq!(results, vec![6, 2, 8, 4]); - Ok(()) -} - -#[test] -fn install_post_extract_env_uses_selected_non_live_rootfs() -> Result<()> { - let _guard = assume_yes_test_lock(); - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let spec_dir = temp.path().join("packages").join("demo"); - let source_dir = temp.path().join("source").join("demo-1.0.0"); - let observed_env = temp.path().join("post-extract-rootfs.txt"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&spec_dir)?; - fs::create_dir_all(&source_dir)?; - fs::write(source_dir.join("README"), "demo source")?; - fs::write( - spec_dir.join("build.sh"), - "mkdir -p \"$DESTDIR/usr/bin\"\nprintf demo > \"$DESTDIR/usr/bin/demo\"\n", - )?; - - let spec_path = spec_dir.join("demo.toml"); - fs::write( - &spec_path, - format!( - r#"[package] -name = "demo" -version = "1.0.0" -revision = 1 -description = "demo" -homepage = "https://example.test/demo" -license = "MIT" - -[[source]] -url = "file://{}" -sha256 = "skip" -extract_dir = "demo-1.0.0" -post_extract = ["printf '%s' \"$DEPOT_ROOTFS\" > '{}'"] - -[build] -type = "custom" - -[dependencies] -build = [] -runtime = [] -optional = [] -"#, - source_dir.display(), - observed_env.display() - ), - )?; - - let config = config::Config::for_rootfs(&rootfs); - register_required_development_package_if_configured(&config, &rootfs)?; - - run(Cli { - command: Commands::Install(InstallArgs { - rootfs_args: rootfs_args(rootfs.clone()), - prompt_args: prompt_args(true), - build_exec_args: BuildExecArgs { - no_deps: true, - ..build_exec_args() - }, - lib32_args: lib32_args(), - spec_or_archive: vec![spec_path], - spec: None, - }), - })?; - - assert_eq!( - fs::read_to_string(&observed_env)?, - build_cmd::build_env_rootfs(&rootfs) - ); - Ok(()) -} - fn test_binary_repo_record(name: &str, filename: &str) -> db::repo::BinaryRepoPackageRecord { db::repo::BinaryRepoPackageRecord { repo_name: "core".into(), @@ -311,26 +198,6 @@ fn make_remote_git_repo() -> (tempfile::TempDir, String, Oid) { (tmp, remote_url, tagged) } -#[test] -fn run_internal_clone_checks_out_git_revision() { - let (_tmp, remote_url, tagged) = make_remote_git_repo(); - let clone_root = tempfile::tempdir().unwrap(); - let dest = clone_root.path().join("cloned-src"); - - run_internal_command(InternalCommands::Clone { - repo: format!("{remote_url}#v1.0.0"), - dest: Some(dest.clone()), - }) - .unwrap(); - - let repo = Repository::open(&dest).unwrap(); - assert_eq!(repo.head().unwrap().target().unwrap(), tagged); - assert_eq!( - std::fs::read_to_string(dest.join("README")).unwrap(), - "tagged\n" - ); -} - fn register_installed_test_package( config: &config::Config, rootfs: &Path, @@ -411,753 +278,6 @@ optional = [] use anyhow::Context; use std::io::Write; -#[test] -fn clean_build_workspace_removes_build_and_source_cache_dirs() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - fs::create_dir_all(&cfg.build_dir) - .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; - fs::create_dir_all(&cfg.cache_dir) - .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; - - let mut build_file = fs::File::create(cfg.build_dir.join("artifact.txt"))?; - build_file.write_all(b"build data")?; - build_file.flush()?; - - let mut source_file = fs::File::create(cfg.cache_dir.join("source.tar.zst"))?; - source_file.write_all(b"source data")?; - source_file.flush()?; - - clean_build_workspace(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(!cfg.cache_dir.exists()); - Ok(()) -} - -#[test] -fn clean_build_workspace_noops_when_dirs_are_missing() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - clean_build_workspace(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(!cfg.cache_dir.exists()); - Ok(()) -} - -#[test] -fn clean_build_source_dirs_removes_build_dir_only() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - fs::create_dir_all(&cfg.build_dir) - .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; - fs::create_dir_all(&cfg.cache_dir) - .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; - - clean_build_source_dirs(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(cfg.cache_dir.exists()); - Ok(()) -} - -#[test] -fn clean_build_source_dirs_noops_when_build_dir_missing() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("tmp/build"); - cfg.cache_dir = rootfs.path().join("tmp/sources"); - - fs::create_dir_all(&cfg.cache_dir) - .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; - - clean_build_source_dirs(&cfg)?; - - assert!(!cfg.build_dir.exists()); - assert!(cfg.cache_dir.exists()); - Ok(()) -} - -#[test] -fn binary_install_path_uses_repo_record_metadata_without_archive_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); - - // Build an archive that intentionally does not contain .metadata.toml. - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"hello"; - let mut header = tar::Header::new_gnu(); - header.set_path("usr/bin/hello").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - let record = db::repo::BinaryRepoPackageRecord { - repo_name: "core".into(), - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: archive_path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or_default() - .to_string(), - size: payload.len() as u64, - sha512: String::new(), - description: Some("test package".into()), - homepage: Some("https://example.test".into()), - license: Some("MIT".into()), - provides: vec!["pkg-virtual".into()], - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: vec!["glibc".into()], - optional_dependencies: vec!["manpages".into()], - groups: vec!["base".into()], - }; - let spec = package_spec_from_repo_record(&record); - let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - assert_eq!(installed[0].package.name, "pkg"); - assert!(rootfs.path().join("usr/bin/hello").exists()); - - let db_path = cfg.installed_db_path(rootfs.path()); - assert_eq!( - db::get_package_version(&db_path, "pkg")?, - Some("1.0".into()) - ); - Ok(()) -} - -#[test] -fn binary_archive_staging_uses_config_build_dir_instead_of_process_tmpdir() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"hello"; - let mut header = tar::Header::new_gnu(); - header.set_path("usr/bin/hello").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - - assert!(staged.path().starts_with(staging_temp_root(&cfg))); - assert!(staged.path().join("usr/bin/hello").exists()); - Ok(()) -} - -#[test] -fn direct_archive_install_requests_batch_multiple_archives() -> Result<()> { - fn write_archive( - archive_path: &Path, - package_name: &str, - conflicts: &[&str], - payload_path: &str, - payload: &[u8], - ) -> Result<()> { - let file = fs::File::create(archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path(payload_path)?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, payload)?; - - let conflicts_toml = if conflicts.is_empty() { - String::new() - } else { - format!( - "conflicts = [{}]\n", - conflicts - .iter() - .map(|conflict| format!("\"{conflict}\"")) - .collect::>() - .join(", ") - ) - }; - let metadata = format!( - "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" - ); - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, metadata.as_bytes())?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - Ok(()) - } - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); - let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?; - write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?; - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let installed = run_direct_archive_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[archive_a, archive_b], - false, - )?; - - assert!(installed); - assert!(rootfs.path().join("usr/bin/alpha").exists()); - assert!(rootfs.path().join("usr/bin/beta").exists()); - let db_path = cfg.installed_db_path(rootfs.path()); - assert_eq!( - db::get_package_version(&db_path, "alpha")?, - Some("1.0".into()) - ); - assert_eq!( - db::get_package_version(&db_path, "beta")?, - Some("1.0".into()) - ); - Ok(()) -} - -#[test] -fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> { - fn write_archive(archive_path: &Path, package_name: &str, conflicts: &[&str]) -> Result<()> { - let file = fs::File::create(archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = package_name.as_bytes(); - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path(format!("usr/bin/{package_name}"))?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, payload)?; - - let conflicts_toml = if conflicts.is_empty() { - String::new() - } else { - format!( - "conflicts = [{}]\n", - conflicts - .iter() - .map(|conflict| format!("\"{conflict}\"")) - .collect::>() - .join(", ") - ) - }; - let metadata = format!( - "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" - ); - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, metadata.as_bytes())?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - Ok(()) - } - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); - let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - write_archive(&archive_a, "alpha", &["beta"])?; - write_archive(&archive_b, "beta", &[])?; - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let err = run_direct_archive_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[archive_a, archive_b], - false, - ) - .expect_err("conflicting archives should be rejected"); - - assert!( - err.to_string() - .contains("Cannot install conflicting packages in the same transaction") - ); - Ok(()) -} - -#[test] -fn update_transaction_runs_matching_transaction_hook_once_for_batch() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let old_alpha = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); - let old_beta = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); - let new_alpha = pkg_dir.path().join("alpha-2.0-1-x86_64.depot.pkg.tar.zst"); - let new_beta = pkg_dir.path().join("beta-2.0-1-x86_64.depot.pkg.tar.zst"); - write_basic_binary_archive(&old_alpha, "alpha", "1.0", 1, "usr/bin/alpha", b"alpha-old")?; - write_basic_binary_archive(&old_beta, "beta", "1.0", 1, "usr/bin/beta", b"beta-old")?; - write_basic_binary_archive(&new_alpha, "alpha", "2.0", 1, "usr/bin/alpha", b"alpha-new")?; - write_basic_binary_archive(&new_beta, "beta", "2.0", 1, "usr/bin/beta", b"beta-new")?; - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - run_direct_archive_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[old_alpha, old_beta], - false, - )?; - - let hooks_dir = install::hooks::transaction_hooks_dir(rootfs.path()); - fs::create_dir_all(&hooks_dir)?; - fs::write( - hooks_dir.join("90-update-batch.toml"), - r#" -[hook] -name = "update batch recorder" - -[when] -phase = "post" -operation = ["update"] -paths = ["usr/bin/*"] - -[exec] -command = "printf '%s:%s\n' \"$DEPOT_ACTION\" \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/hook-runs\"; cat >> \"$DEPOT_ROOTFS/hook-targets\"" -needs_paths = true -"#, - )?; - - let updated = run_update_transaction_install_requests( - DirectInstallOptions { - rootfs: rootfs.path(), - no_deps: true, - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - lib32_only: false, - install_test_deps: false, - }, - &cfg, - &[new_alpha, new_beta], - )?; - - assert!(updated); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/alpha"))?, - "alpha-new" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/beta"))?, - "beta-new" - ); - let hook_runs = fs::read_to_string(rootfs.path().join("hook-runs"))?; - assert_eq!(hook_runs.lines().collect::>(), vec!["update:alpha"]); - let hook_targets: BTreeSet<_> = fs::read_to_string(rootfs.path().join("hook-targets"))? - .lines() - .map(str::to_string) - .collect(); - assert_eq!( - hook_targets, - BTreeSet::from(["usr/bin/alpha".to_string(), "usr/bin/beta".to_string()]) - ); - Ok(()) -} - -#[test] -fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> { - let removals = collect_conflicting_installed_packages( - &[InstallConflictSubject { - package: "beta".into(), - provides: Vec::new(), - conflicts: vec!["alpha".into(), "editor".into()], - }], - &[InstalledConflictPackage { - name: "alpha".into(), - provides: vec!["editor".into()], - }], - )?; - - assert_eq!( - removals.get("alpha"), - Some(&BTreeSet::from(["beta".to_string()])) - ); - Ok(()) -} - -#[test] -#[cfg(unix)] -fn binary_archive_install_preserves_setuid_permissions() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("sudo-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - let payload = b"sudo"; - let mut header = tar::Header::new_gnu(); - header.set_path("bin/sudo").unwrap(); - header.set_size(payload.len() as u64); - header.set_mode(0o4755); - header.set_cksum(); - tar.append(&header, &payload[..]).unwrap(); - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; - let staged_mode = fs::metadata(staged.path().join("bin/sudo"))? - .permissions() - .mode() - & 0o7777; - assert_eq!(staged_mode, 0o4755); - - let record = db::repo::BinaryRepoPackageRecord { - repo_name: "core".into(), - name: "sudo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: archive_path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or_default() - .to_string(), - size: payload.len() as u64, - sha512: String::new(), - description: Some("sudo".into()), - homepage: Some("https://example.test".into()), - license: Some("ISC".into()), - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }; - let spec = package_spec_from_repo_record(&record); - let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - let root_mode = fs::metadata(rootfs.path().join("bin/sudo"))? - .permissions() - .mode() - & 0o7777; - assert_eq!(root_mode, 0o4755); - Ok(()) -} - -#[test] -fn binary_archive_install_honors_keep_paths_from_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir - .path() - .join("filesystem-1.0-3-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = b"package-fstab"; - let mut fstab_header = tar::Header::new_gnu(); - fstab_header.set_path("etc/fstab").unwrap(); - fstab_header.set_size(payload.len() as u64); - fstab_header.set_mode(0o644); - fstab_header.set_cksum(); - tar.append(&fstab_header, &payload[..]).unwrap(); - - let metadata = br#"name = "filesystem" -version = "1.0.1" -revision = 3 -description = "Base filesystem" -homepage = "https://example.test" -license = "Unlicense" -keep = ["etc/fstab"] - -[dependencies] -runtime = [] -optional = [] -"#; - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml").unwrap(); - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, &metadata[..]).unwrap(); - - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - fs::create_dir_all(rootfs.path().join("etc"))?; - fs::write(rootfs.path().join("etc/fstab"), "existing-fstab")?; - - let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; - assert_eq!(spec.build.flags.keep, vec!["etc/fstab".to_string()]); - - let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - assert_eq!(installed.len(), 1); - assert_eq!( - fs::read_to_string(rootfs.path().join("etc/fstab"))?, - "existing-fstab" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("etc/fstab.depotnew"))?, - "package-fstab" - ); - Ok(()) -} - -#[test] -fn binary_archive_install_honors_replaces_from_metadata() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; - let archive_path = pkg_dir.path().join("vx-0.1.0-1-x86_64.depot.pkg.tar.zst"); - - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "diffutils".into(), - real_name: None, - version: "3.12".into(), - revision: 1, - description: "diffutils".into(), - homepage: "https://example.test/diffutils".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["GPL-3.0-or-later".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/bin"))?; - fs::write(old_dest.join("usr/bin/diff"), "old-diff")?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let file = fs::File::create(&archive_path) - .with_context(|| format!("Failed to create {}", archive_path.display()))?; - let encoder = - zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; - let mut tar = tar::Builder::new(encoder); - - let payload = b"vx-diff"; - let mut payload_header = tar::Header::new_gnu(); - payload_header.set_path("usr/bin/diff")?; - payload_header.set_size(payload.len() as u64); - payload_header.set_mode(0o755); - payload_header.set_cksum(); - tar.append(&payload_header, &payload[..])?; - - let metadata = br#"name = "vx" -version = "0.1.0" -revision = 1 -description = "vertex utils" -homepage = "https://example.test/vx" -license = "MIT" -replaces = ["diffutils"] - -[dependencies] -runtime = [] -optional = [] -"#; - let mut meta_header = tar::Header::new_gnu(); - meta_header.set_path(".metadata.toml")?; - meta_header.set_size(metadata.len() as u64); - meta_header.set_mode(0o644); - meta_header.set_cksum(); - tar.append(&meta_header, &metadata[..])?; - - let encoder = tar.into_inner()?; - encoder.finish()?; - - let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; - assert_eq!(spec.alternatives.replaces, vec!["diffutils".to_string()]); - - let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; - - assert_eq!(installed.len(), 1); - assert!(installed[0].is_update); - assert_eq!(installed[0].package.name, "vx"); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/diff"))?, - "vx-diff" - ); - - let db_path = cfg.installed_db_path(rootfs.path()); - assert_eq!(db::get_package_version(&db_path, "diffutils")?, None); - assert_eq!( - db::get_package_version(&db_path, "vx")?, - Some("0.1.0".into()) - ); - Ok(()) -} - -#[test] -fn plan_staged_install_reads_updates_from_rootfs_installed_db() -> 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("home/vertex/.local/share/depot"); - - let installed_db = cfg.installed_db_path(rootfs.path()); - fs::create_dir_all( - installed_db - .parent() - .context("Installed DB path should have a parent")?, - )?; - - let existing_dest = rootfs.path().join("installed"); - fs::create_dir_all(existing_dest.join("usr/bin"))?; - fs::write(existing_dest.join("usr/bin/tool"), "old")?; - - let spec = package::PackageSpec { - package: package::PackageInfo { - name: "filesystem".into(), - real_name: None, - version: "1.0.1".into(), - revision: 3, - description: "Base filesystem".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["Unlicense".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - db::register_package(&installed_db, &spec, &existing_dest)?; - - let staged_dest = rootfs.path().join("staged"); - fs::create_dir_all(staged_dest.join("usr/bin"))?; - fs::write(staged_dest.join("usr/bin/tool"), "new")?; - - let plan = plan_staged_install(&spec, &staged_dest, rootfs.path(), &cfg)?; - assert!(plan.is_update); - Ok(()) -} - fn file_ownership_test_spec(name: &str, version: &str) -> package::PackageSpec { let mut spec = test_package_spec(package::BuildType::Bin, None, &[]); spec.package.name = name.to_string(); @@ -1176,2345 +296,8 @@ fn stage_file(destdir: &Path, path: &str, contents: &str) -> Result<()> { Ok(()) } -#[test] -fn transaction_orders_relinquishing_update_before_new_file_owner() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let payloads = tempfile::tempdir().context("Failed to create payload dir")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - let db_path = cfg.installed_db_path(rootfs.path()); - - let old_alpha = file_ownership_test_spec("alpha", "1.0"); - let old_alpha_dest = payloads.path().join("old-alpha"); - stage_file(&old_alpha_dest, "usr/bin/shared", "alpha-old")?; - stage_file(rootfs.path(), "usr/bin/shared", "alpha-old")?; - db::register_package(&db_path, &old_alpha, &old_alpha_dest)?; - - let new_alpha = file_ownership_test_spec("alpha", "2.0"); - let new_alpha_dest = payloads.path().join("new-alpha"); - stage_file(&new_alpha_dest, "usr/bin/alpha", "alpha-new")?; - let beta = file_ownership_test_spec("beta", "1.0"); - let beta_dest = payloads.path().join("beta"); - stage_file(&beta_dest, "usr/bin/shared", "beta")?; - - let mut plans = plan_package_outputs_for_install(&beta, &beta_dest, rootfs.path(), &cfg)?; - plans.extend(plan_package_outputs_for_install( - &new_alpha, - &new_alpha_dest, - rootfs.path(), - &cfg, - )?); - - let ordered = preflight_file_ownership_and_order(&plans, &HashSet::new(), rootfs.path(), &cfg)?; - assert_eq!(ordered[0].spec.package.name, "alpha"); - assert_eq!(ordered[1].spec.package.name, "beta"); - - install_direct_transaction(&plans, rootfs.path(), &cfg)?; - - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/shared"))?, - "beta" - ); - assert_eq!( - db::owns_path(&db_path, Path::new("usr/bin/shared"))?, - Some("beta".into()) - ); - assert_eq!( - db::get_package_version(&db_path, "alpha")?, - Some("2.0".into()) - ); - Ok(()) -} - -#[test] -fn transaction_rejects_file_still_owned_after_planned_update_before_mutation() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let payloads = tempfile::tempdir().context("Failed to create payload dir")?; - let mut cfg = config::Config::for_rootfs(rootfs.path()); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - cfg.db_dir = rootfs.path().join("var/lib/depot"); - let db_path = cfg.installed_db_path(rootfs.path()); - - let old_alpha = file_ownership_test_spec("alpha", "1.0"); - let old_alpha_dest = payloads.path().join("old-alpha"); - stage_file(&old_alpha_dest, "usr/bin/shared", "alpha-old")?; - stage_file(rootfs.path(), "usr/bin/shared", "alpha-old")?; - db::register_package(&db_path, &old_alpha, &old_alpha_dest)?; - - let new_alpha = file_ownership_test_spec("alpha", "2.0"); - let new_alpha_dest = payloads.path().join("new-alpha"); - stage_file(&new_alpha_dest, "usr/bin/shared", "alpha-new")?; - let beta = file_ownership_test_spec("beta", "1.0"); - let beta_dest = payloads.path().join("beta"); - stage_file(&beta_dest, "usr/bin/shared", "beta")?; - - let mut plans = plan_package_outputs_for_install(&beta, &beta_dest, rootfs.path(), &cfg)?; - plans.extend(plan_package_outputs_for_install( - &new_alpha, - &new_alpha_dest, - rootfs.path(), - &cfg, - )?); - - let err = install_direct_transaction(&plans, rootfs.path(), &cfg) - .expect_err("retained ownership conflict should fail preflight"); - assert!( - err.to_string() - .contains("still provided by its transaction update") - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/shared"))?, - "alpha-old" - ); - assert_eq!( - db::get_package_version(&db_path, "alpha")?, - Some("1.0".into()) - ); - assert_eq!(db::get_package_version(&db_path, "beta")?, None); - Ok(()) -} - -#[test] -fn renamed_abi_updates_keep_versioned_shared_libraries() -> 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"); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "libxml214".into(), - real_name: Some("libxml2".into()), - version: "2.14.9".into(), - revision: 1, - description: "libxml2 2.14".into(), - homepage: "https://example.test/libxml2".into(), - abi_breaking: true, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/lib/pkgconfig"))?; - fs::write(old_dest.join("usr/lib/libxml2.so.14.9.0"), "old-real")?; - std::os::unix::fs::symlink("libxml2.so.14.9.0", old_dest.join("usr/lib/libxml2.so.14"))?; - std::os::unix::fs::symlink("libxml2.so.14", old_dest.join("usr/lib/libxml2.so"))?; - fs::write( - old_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), - "old-pkgconfig", - )?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let new_spec = package::PackageSpec { - package: package::PackageInfo { - name: "libxml215".into(), - real_name: Some("libxml2".into()), - version: "2.15.1".into(), - revision: 1, - description: "libxml2 2.15".into(), - homepage: "https://example.test/libxml2".into(), - abi_breaking: true, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let new_dest = rootfs.path().join("new-dest"); - fs::create_dir_all(new_dest.join("usr/lib/pkgconfig"))?; - fs::write(new_dest.join("usr/lib/libxml2.so.15.1.0"), "new-real")?; - std::os::unix::fs::symlink("libxml2.so.15.1.0", new_dest.join("usr/lib/libxml2.so.15"))?; - std::os::unix::fs::symlink("libxml2.so.15", new_dest.join("usr/lib/libxml2.so"))?; - fs::write( - new_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), - "new-pkgconfig", - )?; - - let installed = install_package_outputs_to_rootfs(&new_spec, &new_dest, rootfs.path(), &cfg)?; - assert_eq!(installed.len(), 1); - assert!(installed[0].is_update); - assert_eq!(installed[0].package.name, "libxml215"); - - assert!(rootfs.path().join("usr/lib/libxml2.so.14.9.0").exists()); - assert!(rootfs.path().join("usr/lib/libxml2.so.14").exists()); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/lib/libxml2.so.15.1.0"))?, - "new-real" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/lib/pkgconfig/libxml-2.0.pc"))?, - "new-pkgconfig" - ); - - let db_path = cfg.installed_db_path(rootfs.path()); - let old_files = db::get_package_files(&db_path, "libxml214")?; - assert_eq!( - old_files, - vec![ - "usr/lib/libxml2.so.14".to_string(), - "usr/lib/libxml2.so.14.9.0".to_string(), - ] - ); - - let new_files = db::get_package_files(&db_path, "libxml215")?; - assert!(new_files.contains(&"usr/lib/libxml2.so".to_string())); - assert!(new_files.contains(&"usr/lib/libxml2.so.15".to_string())); - assert!(new_files.contains(&"usr/lib/libxml2.so.15.1.0".to_string())); - assert!(new_files.contains(&"usr/lib/pkgconfig/libxml-2.0.pc".to_string())); - Ok(()) -} - -#[test] -fn merge_missing_dependencies_preserves_order_and_uniqueness() { - let merged = merge_missing_dependencies( - vec!["make".into(), "pkgconf".into(), "glibc".into()], - vec![ - "glibc".into(), - "openssl".into(), - "pkgconf".into(), - "zlib".into(), - ], - ); - assert_eq!(merged, vec!["make", "pkgconf", "glibc", "openssl", "zlib"]); -} - -#[test] -fn collect_update_candidates_matches_renamed_packages_by_real_name() -> Result<()> { - let temp = tempfile::tempdir()?; - let rootfs = temp.path().join("rootfs"); - let repo_clones = temp.path().join("repos"); - let build_dir = temp.path().join("build"); - let db_dir = rootfs.join("var/lib/depot"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - fs::create_dir_all(&db_dir)?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_clones.clone(); - config.build_dir = build_dir; - config.db_dir = db_dir.clone(); - config.repo_settings.prefer_binary = false; - config.binary_repos.clear(); - config.source_repos.clear(); - config.source_repos.insert( - "private".into(), - config::SourceRepo { - url: "https://example.test/private.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - let installed_spec = package::PackageSpec { - package: package::PackageInfo { - name: "icu78".into(), - real_name: Some("icu".into()), - version: "78.2".into(), - revision: 1, - description: "icu78".into(), - homepage: "https://example.test/icu".into(), - abi_breaking: true, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/lib"))?; - fs::write(dest.join("usr/lib/libicuuc.so.78.2"), "icu78")?; - db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; - - let repo_root = repo_clones.join("private"); - fs::create_dir_all(&repo_root)?; - fs::write( - repo_root.join("icu79.toml"), - r#"[package] -name = "icu79" -real_name = "icu" -version = "79.1" -revision = 1 -description = "icu79" -homepage = "https://example.test/icu" -abi_breaking = true -license = "MIT" - -[build] -type = "meta" - -[dependencies] -runtime = [] -optional = [] -"#, - )?; - - let installed_records = db::list_installed_package_records(&config.installed_db_path(&rootfs))?; - assert_eq!(installed_records.len(), 1); - assert_eq!(installed_records[0].real_name.as_deref(), Some("icu")); - - let source_candidates = - collect_best_source_update_candidates(&config, &HashSet::from([String::from("icu")]))?; - assert!(source_candidates.contains_key("icu")); - let selected = select_update_candidate( - &installed_records[0], - installed_records[0].completed_at, - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &HashMap::new(), - false, - ); - assert!(selected.is_some()); - - let updates = collect_update_candidates(&config, &rootfs, &["icu78".into()])?; - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].installed_package, "icu78"); - assert_eq!(updates[0].candidate_package, "icu79"); - assert_eq!(updates[0].candidate_version, "79.1"); - Ok(()) -} - -#[test] -fn update_candidate_prefers_binary_when_versions_match_and_config_does() { - let installed = db::InstalledPackageRecord { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - }; - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.1.0".into(), - revision: 1, - description: "test".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: vec![package::Source { - url: "https://example.test/pkg-$version.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "pkg-$version".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let source_candidates = HashMap::from([( - "pkg".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/pkg.toml"), - completed_at: None, - spec: source_spec, - }, - )]); - let binary_candidates = HashMap::from([( - "pkg".to_string(), - ( - 0, - db::repo::BinaryRepoPackageRecord { - repo_name: "binary".into(), - name: "pkg".into(), - real_name: None, - version: "1.1.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: "pkg-1.1.0-1-x86_64.depot.pkg.tar.zst".into(), - size: 1, - sha512: String::new(), - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }, - ), - )]); - - let selected = select_update_candidate( - &installed, - None, - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &binary_candidates, - true, - ) - .expect("expected update candidate"); - assert!(matches!(selected.origin, UpdateOrigin::Binary { .. })); -} - -#[test] -fn select_update_candidate_uses_newer_timestamp_when_versions_match() { - let installed = db::InstalledPackageRecord { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: Some(100), - }; - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - description: "test".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let source_candidates = HashMap::from([( - "pkg".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/pkg.toml"), - completed_at: Some(200), - spec: source_spec, - }, - )]); - - let selected = select_update_candidate( - &installed, - Some(100), - &HashMap::new(), - &HashMap::new(), - &source_candidates, - &HashMap::new(), - true, - ) - .expect("expected update candidate"); - assert_eq!(selected.candidate_version, "1.0.0"); - assert_eq!(selected.candidate_completed_at, Some(200)); -} - -#[test] -fn select_update_candidate_prefers_replacement_candidate() { - let installed = db::InstalledPackageRecord { - name: "findutils".into(), - real_name: None, - version: "4.9.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: Some(100), - }; - - let source_spec = package::PackageSpec { - package: package::PackageInfo { - name: "findutils".into(), - real_name: None, - version: "5.0.0".into(), - revision: 1, - description: "findutils".into(), - homepage: "https://example.test/findutils".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let replacement_spec = package::PackageSpec { - package: package::PackageInfo { - name: "busybox".into(), - real_name: None, - version: "1.36.1".into(), - revision: 1, - description: "busybox".into(), - homepage: "https://example.test/busybox".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["GPL-2.0-only".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives { - provides: Vec::new(), - conflicts: Vec::new(), - replaces: vec!["findutils".into()], - lib32: None, - }, - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Custom, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let source_candidates = HashMap::from([( - "findutils".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 5, - path: PathBuf::from("/tmp/findutils.toml"), - completed_at: Some(200), - spec: source_spec, - }, - )]); - let source_replacement_candidates = HashMap::from([( - "findutils".to_string(), - SourceUpdateCandidate { - repo_name: "source".into(), - repo_priority: 0, - path: PathBuf::from("/tmp/busybox.toml"), - completed_at: Some(150), - spec: replacement_spec, - }, - )]); - - let selected = select_update_candidate( - &installed, - installed.completed_at, - &source_replacement_candidates, - &HashMap::new(), - &source_candidates, - &HashMap::new(), - false, - ) - .expect("expected replacement update candidate"); - - assert!(selected.replaces_installed); - assert_eq!(selected.installed_package, "findutils"); - assert_eq!(selected.candidate_package, "busybox"); -} - -#[test] -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"); - cfg.build_dir = rootfs.path().join("var/cache/depot/build"); - - let old_spec = package::PackageSpec { - package: package::PackageInfo { - name: "findutils".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "findutils".into(), - homepage: "https://example.test/findutils".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let old_dest = rootfs.path().join("old-dest"); - fs::create_dir_all(old_dest.join("usr/bin"))?; - fs::write(old_dest.join("usr/bin/find"), "old-find")?; - install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; - - let alpha_spec = package::PackageSpec { - package: package::PackageInfo { - name: "alpha".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "alpha".into(), - homepage: "https://example.test/alpha".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let alpha_dest = rootfs.path().join("alpha-dest"); - fs::create_dir_all(alpha_dest.join("usr/bin"))?; - fs::create_dir_all(alpha_dest.join("scripts"))?; - fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?; - fs::write( - alpha_dest.join("scripts/post_install"), - "cat \"$DEPOT_ROOTFS/usr/bin/find\" > \"$DEPOT_ROOTFS/alpha-marker\"\n", - )?; - - let replacement_spec = package::PackageSpec { - package: package::PackageInfo { - name: "busybox".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "busybox".into(), - homepage: "https://example.test/busybox".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["GPL-2.0-only".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives { - provides: Vec::new(), - conflicts: Vec::new(), - replaces: vec!["findutils".into()], - lib32: None, - }, - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let replacement_dest = rootfs.path().join("replacement-dest"); - fs::create_dir_all(replacement_dest.join("usr/bin"))?; - fs::write(replacement_dest.join("usr/bin/find"), "new-find")?; - - let mut plans = Vec::new(); - plans.extend(plan_package_outputs_for_install( - &alpha_spec, - &alpha_dest, - rootfs.path(), - &cfg, - )?); - plans.extend(plan_package_outputs_for_install( - &replacement_spec, - &replacement_dest, - rootfs.path(), - &cfg, - )?); - - install_planned_packages_to_rootfs(&plans, rootfs.path(), &cfg)?; - - assert_eq!( - fs::read_to_string(rootfs.path().join("alpha-marker"))?, - "new-find" - ); - assert_eq!( - fs::read_to_string(rootfs.path().join("usr/bin/find"))?, - "new-find" - ); - assert!(db::get_package_version(&cfg.installed_db_path(rootfs.path()), "findutils")?.is_none()); - assert_eq!( - db::get_package_version(&cfg.installed_db_path(rootfs.path()), "busybox")?, - Some("1.0".into()) - ); - Ok(()) -} - -#[test] -fn install_planned_packages_sets_sole_tool_provider_before_post_hooks() -> Result<()> { - let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; - let mut config = config::Config::for_rootfs(rootfs.path()); - config.db_dir = rootfs.path().join("var/lib/depot"); - config.build_dir = rootfs.path().join("var/cache/depot/build"); - - let mut dash_spec = test_package_spec(package::BuildType::Bin, None, &[]); - dash_spec.package.name = "dash".into(); - let dash_dest = rootfs.path().join("dash-dest"); - fs::create_dir_all(dash_dest.join("usr/bin"))?; - fs::create_dir_all(dash_dest.join("scripts"))?; - fs::write(dash_dest.join("usr/bin/dash"), "dash")?; - fs::write( - dash_dest.join("scripts/post_install"), - "[ -L \"$DEPOT_ROOTFS/usr/bin/sh\" ] && [ \"$(readlink \"$DEPOT_ROOTFS/usr/bin/sh\")\" = dash ]\n", - )?; - - let plans = plan_package_outputs_for_install(&dash_spec, &dash_dest, rootfs.path(), &config)?; - install_planned_packages_to_rootfs(&plans, rootfs.path(), &config)?; - - assert_eq!( - fs::read_link(rootfs.path().join("usr/bin/sh"))?, - PathBuf::from("dash") - ); - Ok(()) -} - -#[test] -fn collect_update_candidates_skips_source_when_prefer_binary_is_enabled() -> Result<()> { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_clones = temp.path().join("repos"); - let build_dir = temp.path().join("build"); - let db_dir = rootfs.join("var/lib/depot"); - fs::create_dir_all(&db_dir)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_clones.clone(); - config.build_dir = build_dir; - config.db_dir = db_dir.clone(); - config.repo_settings.prefer_binary = true; - config.binary_repos.clear(); - config.source_repos.clear(); - config.source_repos.insert( - "private".into(), - config::SourceRepo { - url: "https://example.test/private.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - let installed_spec = package::PackageSpec { - package: package::PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - description: "pkg".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/bin"))?; - fs::write(dest.join("usr/bin/pkg"), "pkg")?; - db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; - - let updates = collect_update_candidates(&config, &rootfs, &[])?; - assert!(updates.is_empty()); - Ok(()) -} - -#[test] -fn explicit_depot_self_update_request_requires_only_depot() { - assert!(is_explicit_depot_self_update_request(&[ - DEPOT_PACKAGE_NAME.to_string() - ])); - assert!(!is_explicit_depot_self_update_request(&[])); - assert!(!is_explicit_depot_self_update_request(&["pkg".to_string()])); - assert!(!is_explicit_depot_self_update_request(&[ - DEPOT_PACKAGE_NAME.to_string(), - "pkg".to_string() - ])); -} - -#[test] -fn depot_self_update_check_blocks_when_update_is_available() -> Result<()> { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_clones = temp.path().join("repos"); - let build_dir = temp.path().join("build"); - let db_dir = rootfs.join("var/lib/depot"); - fs::create_dir_all(&db_dir)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_clones.clone(); - config.build_dir = build_dir; - config.db_dir = db_dir; - config.repo_settings.prefer_binary = false; - config.binary_repos.clear(); - config.source_repos.clear(); - config.source_repos.insert( - "core".into(), - config::SourceRepo { - url: "https://example.test/core.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.0.0")?; - write_test_repo_spec( - &repo_clones.join("core").join("depot.toml"), - DEPOT_PACKAGE_NAME, - "1.1.0", - )?; - - let err = ensure_depot_self_update_not_required(&config, &rootfs) - .expect_err("outdated depot should block command execution"); - assert!(err.to_string().contains("update depot")); - Ok(()) -} - -#[test] -fn depot_self_update_check_allows_when_depot_is_current() -> Result<()> { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_clones = temp.path().join("repos"); - let build_dir = temp.path().join("build"); - let db_dir = rootfs.join("var/lib/depot"); - fs::create_dir_all(&db_dir)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_clones.clone(); - config.build_dir = build_dir; - config.db_dir = db_dir; - config.repo_settings.prefer_binary = false; - config.binary_repos.clear(); - config.source_repos.clear(); - config.source_repos.insert( - "core".into(), - config::SourceRepo { - url: "https://example.test/core.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - let repo_spec = repo_clones.join("core").join("depot.toml"); - register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.1.0")?; - write_test_repo_spec(&repo_spec, DEPOT_PACKAGE_NAME, "1.1.0")?; - let repo_completed_at = - crate::metadata_time::system_time_to_unix(fs::metadata(&repo_spec)?.modified()?)?; - set_installed_test_package_completed_at( - &config, - &rootfs, - DEPOT_PACKAGE_NAME, - repo_completed_at + 1, - )?; - - ensure_depot_self_update_not_required(&config, &rootfs)?; - Ok(()) -} - -#[test] -fn depot_self_update_check_is_skipped_for_nested_update_install_context() -> Result<()> { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_clones = temp.path().join("repos"); - let build_dir = temp.path().join("build"); - let db_dir = rootfs.join("var/lib/depot"); - fs::create_dir_all(&db_dir)?; - fs::create_dir_all(&repo_clones)?; - fs::create_dir_all(&build_dir)?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_clones.clone(); - config.build_dir = build_dir; - config.db_dir = db_dir; - config.repo_settings.prefer_binary = false; - config.binary_repos.clear(); - config.source_repos.clear(); - config.source_repos.insert( - "core".into(), - config::SourceRepo { - url: "https://example.test/core.git".into(), - enabled: true, - priority: 0, - subdirs: Vec::new(), - }, - ); - - register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.0.0")?; - write_test_repo_spec( - &repo_clones.join("core").join("depot.toml"), - DEPOT_PACKAGE_NAME, - "1.1.0", - )?; - - let mut env = TestEnv::new(); - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_UPDATE); - - ensure_depot_self_update_not_required(&config, &rootfs)?; - Ok(()) -} - -#[test] -fn collect_missing_update_dependencies_skips_planned_provides_and_installed_deps() -> Result<()> { - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let db_path = temp.path().join("packages.db"); - - let libc_spec = package::PackageSpec { - package: package::PackageInfo { - name: "glibc".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "glibc".into(), - homepage: "https://example.test".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: package::Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: package::Build { - build_type: package::BuildType::Bin, - flags: package::BuildFlags::default(), - }, - dependencies: package::Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/lib"))?; - fs::write(dest.join("usr/lib/libc.so"), "glibc")?; - db::register_package(&db_path, &libc_spec, &dest)?; - - let missing = collect_missing_update_dependencies( - &[ - UpdateCandidate { - installed_package: "pkg".into(), - candidate_package: "pkg".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()], - provides: Vec::new(), - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/pkg.toml"), - }, - }, - UpdateCandidate { - installed_package: "helper".into(), - candidate_package: "helper".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: Vec::new(), - provides: vec!["helper-virtual".into()], - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/helper.toml"), - }, - }, - UpdateCandidate { - installed_package: "tool".into(), - candidate_package: "tool".into(), - replaces_installed: false, - installed_version: "1.0".into(), - installed_revision: 1, - installed_completed_at: None, - candidate_version: "2.0".into(), - candidate_revision: 1, - candidate_completed_at: None, - runtime_dependencies: vec!["newdep".into()], - provides: Vec::new(), - conflicts: Vec::new(), - repo_priority: 0, - origin: UpdateOrigin::Source { - repo_name: "source".into(), - path: PathBuf::from("/tmp/tool.toml"), - }, - }, - ], - &db_path, - )?; - - assert_eq!(missing, vec!["newdep".to_string()]); - Ok(()) -} - -#[test] -fn validate_no_transaction_conflicts_rejects_conflicting_updates() { - let err = validate_no_transaction_conflicts(&[ - InstallConflictSubject { - package: "alpha".into(), - provides: Vec::new(), - conflicts: vec!["beta".into()], - }, - InstallConflictSubject { - package: "beta".into(), - provides: Vec::new(), - conflicts: Vec::new(), - }, - ]) - .expect_err("conflicting update set should be rejected"); - - assert!( - err.to_string() - .contains("Cannot install conflicting packages in the same transaction") - ); -} - -#[test] -fn compare_versions_for_updates_handles_semver_and_date_versions() { - assert_eq!( - compare_versions_for_updates("10.8.4", "10.8.3"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("20260202", "20251231"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("1.10", "1.9"), - Ordering::Greater - ); - assert_eq!( - compare_versions_for_updates("v1.0.0", "1.0.0"), - Ordering::Equal - ); - assert_eq!( - compare_versions_for_updates("lts_2027_01_01", "20260107.1"), - Ordering::Greater - ); -} - -#[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] -fn extract_version_patterns_handles_git_and_release_urls() { - let git_patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); - assert!(git_patterns.contains(&VersionPattern { - prefix: "v".into(), - suffix: String::new(), - })); - - let release_patterns = extract_version_patterns( - "https://github.com/Mic92/iana-etc/releases/download/$version/iana-etc-$version.tar.gz", - ); - assert!(release_patterns.contains(&VersionPattern { - prefix: String::new(), - suffix: String::new(), - })); -} - -#[test] -fn candidate_versions_from_refs_matches_version_patterns() { - let refs = vec![ - "refs/tags/v10.8.3".to_string(), - "refs/tags/v10.8.4".to_string(), - "refs/heads/main".to_string(), - ]; - let patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); - let candidates = candidate_versions_from_refs(&refs, &patterns); - - assert_eq!(candidates, vec!["10.8.3".to_string(), "10.8.4".to_string()]); - assert_eq!( - best_newer_version("10.8.3", candidates.iter().map(String::as_str)), - Some("10.8.4".to_string()) - ); -} - -#[test] -fn best_newer_version_skips_branches_and_prereleases() { - let candidates = ["2", "1.10.0rc1", "1.10.0", "release-0.13"]; - assert_eq!( - best_newer_version("1.9.5", candidates.into_iter()), - Some("1.10.0".to_string()) - ); -} - -#[test] -fn best_newer_version_normalizes_date_style_tags() { - let candidates = ["lts_2026_01_07", "lts_2027_02_03"]; - assert_eq!( - best_newer_version("20260107.1", candidates.into_iter()), - Some("20270203".to_string()) - ); -} - -#[test] -fn remote_git_repository_from_github_release_url_maps_to_repo_git_url() { - let repo_url = remote_git_repository_from_source_url( - "https://github.com/Mic92/iana-etc/releases/download/20260202/iana-etc-20260202.tar.gz", - ); - assert_eq!( - repo_url, - Some("https://github.com/Mic92/iana-etc.git".to_string()) - ); -} - -#[test] -fn remote_git_repository_from_gitlab_archive_url_maps_to_repo_git_url() { - let repo_url = remote_git_repository_from_source_url( - "https://gitlab.com/graphviz/graphviz/-/archive/14.1.4/graphviz-14.1.4.tar.gz", - ); - assert_eq!( - repo_url, - Some("https://gitlab.com/graphviz/graphviz.git".to_string()) - ); -} - -#[test] -fn archive_listing_probe_uses_parent_of_first_version_segment() { - let probe = archive_listing_probe( - "https://downloads.example.test/dav1d/$version/dav1d-$version.tar.xz", - "https://downloads.example.test/dav1d/1.5.3/dav1d-1.5.3.tar.xz", - ) - .expect("archive probe"); - assert_eq!(probe.listing_url, "https://downloads.example.test/dav1d/"); - assert_eq!( - probe.patterns, - vec![VersionPattern { - prefix: String::new(), - suffix: String::new(), - }] - ); -} - -#[test] -fn candidate_versions_from_listing_matches_archive_entries() { - let patterns = vec![VersionPattern { - prefix: "alsa-lib-".into(), - suffix: ".tar.bz2".into(), - }]; - let html = r#" - alsa-lib-1.2.15.3.tar.bz2 - alsa-lib-1.2.16.tar.bz2 - "#; - assert_eq!( - candidate_versions_from_listing(html, &patterns), - vec!["1.2.15.3".to_string(), "1.2.16".to_string()] - ); -} - -#[test] -fn list_archive_versions_reads_simple_http_index() -> Result<()> { - use std::io::{BufRead, BufReader, Write}; - use std::net::TcpListener; - use std::thread; - - let listener = TcpListener::bind("127.0.0.1:0").context("bind test listener")?; - let addr = listener.local_addr().context("listener addr")?; - let server = thread::spawn(move || -> Result<()> { - let (mut stream, _) = listener.accept().context("accept request")?; - let mut reader = BufReader::new(stream.try_clone().context("clone stream")?); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .context("read request line")?; - loop { - let mut line = String::new(); - reader.read_line(&mut line).context("read header line")?; - if line == "\r\n" || line.is_empty() { - break; - } - } - assert!(request_line.starts_with("GET /pub/lib/ HTTP/1.1")); - let body = r#" - - alsa-lib-1.2.15.3.tar.bz2 - alsa-lib-1.2.16.tar.bz2 - - "#; - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .context("write response")?; - stream.flush().context("flush response")?; - Ok(()) - }); - - let probe = ArchiveListingProbe { - listing_url: format!("http://{addr}/pub/lib/"), - patterns: vec![VersionPattern { - prefix: "alsa-lib-".into(), - suffix: ".tar.bz2".into(), - }], - }; - let versions = list_archive_versions(&probe)?; - server.join().expect("join server")?; - assert_eq!(versions, vec!["1.2.15.3".to_string(), "1.2.16".to_string()]); - Ok(()) -} - -#[test] -#[cfg(unix)] -fn child_install_command_batches_multiple_requests_in_one_invocation() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-child-install.sh"); - let args_path = temp.path().join("args.txt"); - let env_path = temp.path().join("env.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s' \"${{DEPOT_DEPCHAIN:-}}\" > \"{}\"\n", - args_path.display(), - env_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - let requests = vec![ - PathBuf::from("/tmp/pkg-a.toml"), - PathBuf::from("/tmp/pkg-b.toml"), - ]; - let rootfs = Path::new("/"); - run_install_command_with_program( - &script_path, - &requests, - rootfs, - ChildInstallCommandOptions { - no_deps: false, - assume_yes: false, - no_flags: true, - cross_prefix: Some("x86_64-linux-musl"), - clean: true, - lib32_only: false, - install_test_deps: true, - install_context: None, - dep_chain: Some("parent"), - }, - )?; - - let captured_args = fs::read_to_string(&args_path) - .with_context(|| format!("Failed to read {}", args_path.display()))?; - assert_eq!( - captured_args.lines().collect::>(), - vec![ - "install", - "-r", - "/", - "--no-flags", - "--cross-prefix", - "x86_64-linux-musl", - "--clean", - "--test-deps", - "/tmp/pkg-a.toml", - "/tmp/pkg-b.toml", - ] - ); - assert_eq!(fs::read_to_string(&env_path)?, "parent"); - Ok(()) -} - -#[test] -#[cfg(unix)] -fn child_install_command_includes_lib32_only_flag_when_requested() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-lib32-child-install.sh"); - let args_path = temp.path().join("args.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\n", - args_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - run_install_command_with_program( - &script_path, - &[PathBuf::from("/tmp/pkg.toml")], - Path::new("/"), - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: false, - cross_prefix: None, - clean: false, - lib32_only: true, - install_test_deps: false, - install_context: None, - dep_chain: None, - }, - )?; - - let captured_args = fs::read_to_string(&args_path) - .with_context(|| format!("Failed to read {}", args_path.display()))?; - assert!(captured_args.lines().any(|line| line == "--lib32-only")); - Ok(()) -} - -#[test] -#[cfg(unix)] -fn child_install_command_propagates_install_context_env() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let script_path = temp.path().join("capture-child-install-context.sh"); - let env_path = temp.path().join("context.txt"); - let script = format!( - "#!/bin/sh\nprintf '%s' \"${{{}:-}}\" > \"{}\"\n", - DEPOT_INSTALL_CONTEXT_ENV, - env_path.display() - ); - fs::write(&script_path, script) - .with_context(|| format!("Failed to write {}", script_path.display()))?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to chmod {}", script_path.display()))?; - - run_install_command_with_program( - &script_path, - &[PathBuf::from("/tmp/pkg.toml")], - Path::new("/"), - ChildInstallCommandOptions { - no_deps: true, - assume_yes: true, - no_flags: false, - cross_prefix: None, - clean: false, - lib32_only: false, - install_test_deps: false, - install_context: Some(INSTALL_CONTEXT_UPDATE), - dep_chain: None, - }, - )?; - - assert_eq!(fs::read_to_string(&env_path)?, INSTALL_CONTEXT_UPDATE); - 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 build_command_checks_manual_sources_before_dependency_resolution() -> Result<()> { - let _guard = assume_yes_test_lock(); - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let spec_dir = temp.path().join("packages").join("demo"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&spec_dir)?; - - let spec_path = spec_dir.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" - -[[source]] -url = "https://example.test/demo-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "demo-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = ["definitely-missing-dep"] -runtime = [] -optional = [] - -[[manual_sources]] -file = "missing.patch" -"#, - )?; - - let result = run(Cli { - command: Commands::Build(BuildArgs { - rootfs_args: rootfs_args(rootfs), - prompt_args: prompt_args(true), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_pos: Some(spec_path), - spec: None, - install: false, - install_deps: true, - cleanup_deps: 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("Failed to resolve required build tool package") - ); - Ok(()) -} - -#[test] -fn source_build_warning_messages_include_dependency_context() { - let plan = planner::ExecutionPlan { - steps: vec![ - planner::PlannedStep { - package: "dep-src".into(), - action: planner::PlanAction::BuildAndInstall, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("/tmp/dep-src.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency dep-src".into(), "app needs dep-src".into()], - }, - planner::PlannedStep { - package: "dep-bin".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(test_binary_repo_record( - "dep-bin", - "dep-bin-1.0-1-x86_64.tar.zst", - )), - }, - requested_by: vec!["app needs dep-bin".into()], - }, - ], - }; - - assert_eq!( - source_build_warning_messages(&plan), - vec!["dep-src (requested dependency 'dep-src', needed by 'app')".to_string()] - ); -} - -#[test] -fn planned_source_build_prereqs_check_manual_sources_before_confirmation() -> 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" - -[[source]] -url = "https://example.test/demo-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "demo-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = [] -runtime = [] -optional = [] - -[[manual_sources]] -file = "missing.patch" -"#, - )?; - - let config = config::Config::for_rootfs(rootfs.path()); - let plan = planner::ExecutionPlan { - steps: vec![planner::PlannedStep { - package: "demo".into(), - action: planner::PlanAction::BuildAndInstall, - origin: planner::PlanOrigin::Source { - path: spec_path, - local_sibling: true, - }, - requested_by: vec!["requested spec".into()], - }], - }; - - let err = validate_source_build_prereqs_for_plan(&plan, rootfs.path(), &config) - .expect_err("missing local manual source should fail before confirmation"); - assert!( - err.to_string() - .contains("Manual source not found: missing.patch") - ); - Ok(()) -} - -#[test] -fn suppress_nested_install_output_for_planned_context() { - let mut env = TestEnv::new(); - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); - - assert!(suppress_nested_install_output()); - assert_eq!( - current_install_invocation_context(), - InstallInvocationContext::Planned - ); -} - -#[test] -fn sudo_preserve_env_arg_only_includes_present_depot_env_vars() { - let mut env = TestEnv::new(); - assert_eq!(sudo_preserve_env_arg(), None); - - env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); - assert_eq!( - sudo_preserve_env_arg(), - Some(format!("--preserve-env={}", DEPOT_INSTALL_CONTEXT_ENV)) - ); - - env.set_var("DEPOT_DEPCHAIN", "parent"); - assert_eq!( - sudo_preserve_env_arg(), - Some(format!( - "--preserve-env={},DEPOT_DEPCHAIN", - DEPOT_INSTALL_CONTEXT_ENV - )) - ); -} - -#[test] -fn plan_dependency_closure_tracks_requested_dependency_roots() { - let plan = planner::ExecutionPlan { - steps: vec![ - planner::PlannedStep { - package: "zlib".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/zlib/zlib.toml"), - local_sibling: false, - }, - requested_by: vec!["cmake needs zlib".into()], - }, - planner::PlannedStep { - package: "cmake".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/cmake/cmake.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency cmake".into()], - }, - planner::PlannedStep { - package: "libffi".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/libffi/libffi.toml"), - local_sibling: false, - }, - requested_by: vec!["python needs libffi".into()], - }, - planner::PlannedStep { - package: "python".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/python/python.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency python".into()], - }, - ], - }; - - let cmake_closure = plan_dependency_closure_for_requested_deps(&plan, &["cmake".into()]); - assert_eq!( - cmake_closure, - HashSet::from(["cmake".to_string(), "zlib".to_string()]) - ); - - let python_closure = plan_dependency_closure_for_requested_deps(&plan, &["python".into()]); - assert_eq!( - python_closure, - HashSet::from(["python".to_string(), "libffi".to_string()]) - ); -} - -#[test] -fn cleanup_targets_keep_runtime_dependencies_for_build_install() { - let plan = planner::ExecutionPlan { - steps: vec![ - planner::PlannedStep { - package: "zlib".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/zlib/zlib.toml"), - local_sibling: false, - }, - requested_by: vec!["cmake needs zlib".into(), "llvm-runtime needs zlib".into()], - }, - planner::PlannedStep { - package: "cmake".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/cmake/cmake.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency cmake".into()], - }, - planner::PlannedStep { - package: "llvm-runtime".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Source { - path: PathBuf::from("packages/core/llvm-runtime/llvm-runtime.toml"), - local_sibling: false, - }, - requested_by: vec!["dependency llvm-runtime".into()], - }, - ], - }; - let mut tracker = AutoInstalledDependencyTracker::default(); - tracker.record_plan(&plan, &["cmake".into()], AutoInstalledDependencyKind::Build); - tracker.record_plan( - &plan, - &["llvm-runtime".into()], - AutoInstalledDependencyKind::Runtime, - ); - - assert_eq!(tracker.cleanup_targets(false), vec!["cmake".to_string()]); - assert_eq!( - tracker.cleanup_targets(true), - vec![ - "llvm-runtime".to_string(), - "cmake".to_string(), - "zlib".to_string() - ] - ); -} - -#[test] -fn build_type_runs_automatic_tests_matches_builder_behavior() { - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Autotools, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Perl, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::Meson, - None, - &[] - ))); - assert!(build_type_runs_automatic_tests(&test_package_spec( - package::BuildType::CMake, - None, - &[] - ))); -} - -#[test] -fn requested_test_deps_prompt_can_disable_tests() -> Result<()> { - let _guard = assume_yes_test_lock(); - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(prompted); - assert!(spec.build.flags.skip_tests); - Ok(()) -} - -#[test] -fn requested_test_deps_prompt_is_ignored_for_non_automatic_test_builders() -> Result<()> { - let _guard = assume_yes_test_lock(); - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(!prompted); - assert!(!spec.build.flags.skip_tests); - Ok(()) -} - -#[test] -fn requested_test_deps_prompt_is_ignored_for_multilib_builds() -> Result<()> { - let _guard = assume_yes_test_lock(); - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.build.flags.build_32 = true; - spec.dependencies.test = vec!["pytest".into()]; - - ui::set_assume_yes(true); - let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( - &mut spec, - &["pytest".into()], - "Requested test dependencies are missing", - )?; - ui::set_assume_yes(false); - - assert!(!prompted); - assert!(!spec.build.flags.skip_tests); - Ok(()) -} - -#[test] -fn should_not_install_test_deps_for_cli_lib32_only_builds() { - let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); - spec.dependencies.lib32 = Some(package::DependencyGroup { - build: Vec::new(), - runtime: Vec::new(), - test: vec!["lib32-pytest".into()], - optional: Vec::new(), - groups: Vec::new(), - }); - - assert!(!should_install_test_deps( - &spec, - true, - deps::RequestedOutputs::Lib32Only - )); -} - -#[test] -fn rootfs_is_system_root_detects_live_rootfs() { - assert!(rootfs_is_system_root(Path::new("/"))); - assert!(!rootfs_is_system_root(Path::new("/tmp/depot-test-rootfs"))); -} - -#[test] -fn command_requires_live_root_for_install_remove_and_update() { - assert!(command_requires_live_root(&Commands::Install( - InstallArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_or_archive: vec![PathBuf::from("foo")], - spec: None, - } - ))); - assert!(command_requires_live_root(&Commands::Remove(RemoveArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - package: "foo".to_string(), - }))); - assert!(command_requires_live_root(&Commands::Update(UpdateArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - packages: vec!["foo".to_string()], - }))); - assert!(!command_requires_live_root(&Commands::Build(BuildArgs { - rootfs_args: rootfs_args("/"), - prompt_args: prompt_args(false), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_pos: Some(PathBuf::from("foo.toml")), - spec: None, - install: false, - install_deps: false, - cleanup_deps: false, - }))); - assert!(!command_requires_live_root(&Commands::Search(SearchArgs { - rootfs_args: rootfs_args("/"), - query: "foo".to_string(), - files: false, - }))); -} - -#[test] -fn should_delegate_live_rootfs_installs_only_for_live_root_when_non_root() { - assert_eq!( - should_delegate_live_rootfs_installs(Path::new("/")), - !crate::fakeroot::is_root() - ); - assert!(!should_delegate_live_rootfs_installs(Path::new( - "/tmp/depot-test-rootfs" - ))); -} - -#[test] -fn live_rootfs_child_install_batches_group_consecutive_binary_steps() -> Result<()> { - let source_path = PathBuf::from("/tmp/requested.toml"); - let expat_archive = PathBuf::from("/tmp/expat.pkg.tar.zst"); - let python_archive = PathBuf::from("/tmp/python.pkg.tar.zst"); - let compiler_rt_archive = PathBuf::from("/tmp/lib32-compiler-rt.pkg.tar.zst"); - - let expat_record = test_binary_repo_record("expat", "expat-1.0-1-x86_64.depot.pkg.tar.zst"); - let python_record = test_binary_repo_record("python", "python-1.0-1-x86_64.depot.pkg.tar.zst"); - let compiler_rt_record = test_binary_repo_record( - "lib32-compiler-rt", - "lib32-compiler-rt-1.0-1-x86_64.depot.pkg.tar.zst", - ); - - let steps = [ - planner::PlannedStep { - package: "expat".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(expat_record.clone()), - }, - requested_by: vec!["pkg needs expat".into()], - }, - planner::PlannedStep { - package: "python".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(python_record.clone()), - }, - requested_by: vec!["pkg needs python".into()], - }, - planner::PlannedStep { - package: "pkg".into(), - action: planner::PlanAction::BuildAndInstall, - origin: planner::PlanOrigin::Source { - path: source_path.clone(), - local_sibling: false, - }, - requested_by: vec!["requested spec".into()], - }, - planner::PlannedStep { - package: "lib32-compiler-rt".into(), - action: planner::PlanAction::InstallBinary, - origin: planner::PlanOrigin::Binary { - repo_name: "core".into(), - record: Box::new(compiler_rt_record.clone()), - }, - requested_by: vec!["pkg needs lib32-compiler-rt".into()], - }, - ]; - let step_refs = steps.iter().collect::>(); - - let mut binary_archives = HashMap::new(); - binary_archives.insert( - ("core".to_string(), expat_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: expat_archive.clone(), - signature_path: PathBuf::from("/tmp/expat.sig"), - }, - ); - binary_archives.insert( - ("core".to_string(), python_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: python_archive.clone(), - signature_path: PathBuf::from("/tmp/python.sig"), - }, - ); - binary_archives.insert( - ("core".to_string(), compiler_rt_record.filename.clone()), - db::repo::BinaryRepoCachedArchive { - package_path: compiler_rt_archive.clone(), - signature_path: PathBuf::from("/tmp/lib32-compiler-rt.sig"), - }, - ); - - let options = InstallPlanExecutionOptions { - no_flags: false, - cross_prefix: None, - clean: false, - dry_run: false, - confirm_installation: false, - lib32_only_requested_specs: true, - install_test_deps: false, - }; - - let batches = build_live_rootfs_child_install_batches(&step_refs, &options, &binary_archives)?; - - assert_eq!( - batches, - vec![ - ChildInstallBatch { - requests: vec![expat_archive, python_archive], - lib32_only: false, - }, - ChildInstallBatch { - requests: vec![source_path], - lib32_only: true, - }, - ChildInstallBatch { - requests: vec![compiler_rt_archive], - lib32_only: false, - }, - ] - ); - Ok(()) -} - -#[test] -fn build_command_requires_install_deps_flag_for_missing_dependencies() -> Result<()> { - let _guard = assume_yes_test_lock(); - let temp = tempfile::tempdir().context("Failed to create temp dir")?; - let rootfs = temp.path().join("rootfs"); - let repo_root = temp.path().join("packages"); - let app_dir = repo_root.join("app"); - let dep_dir = repo_root.join("dep"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&app_dir)?; - fs::create_dir_all(&dep_dir)?; - - let app_spec = app_dir.join("app.toml"); - fs::write( - &app_spec, - r#"[package] -name = "app" -version = "1.0.0" -revision = 1 -description = "app" -homepage = "https://example.test/app" -license = "MIT" - -[[source]] -url = "https://example.test/app-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "app-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = ["dep"] -runtime = [] -optional = [] -"#, - ) - .with_context(|| format!("Failed to write {}", app_spec.display()))?; - - let dep_spec = dep_dir.join("dep.toml"); - fs::write( - &dep_spec, - r#"[package] -name = "dep" -version = "1.0.0" -revision = 1 -description = "dep" -homepage = "https://example.test/dep" -license = "MIT" - -[[source]] -url = "https://example.test/dep-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "dep-1.0.0" - -[build] -type = "custom" - -[dependencies] -build = [] -runtime = [] -optional = [] -"#, - ) - .with_context(|| format!("Failed to write {}", dep_spec.display()))?; - - let config = config::Config::for_rootfs(&rootfs); - register_required_development_package_if_configured(&config, &rootfs)?; - - let result = run(Cli { - command: Commands::Build(BuildArgs { - rootfs_args: rootfs_args(rootfs.clone()), - prompt_args: prompt_args(true), - build_exec_args: build_exec_args(), - lib32_args: lib32_args(), - spec_pos: Some(app_spec), - spec: None, - install: false, - install_deps: false, - cleanup_deps: false, - }), - }); - ui::set_assume_yes(false); - - let err = result.expect_err("build should require --install-deps when deps are missing"); - assert!(err.to_string().contains("Re-run with --install-deps")); - Ok(()) -} - -#[test] -fn make_lib32_build_spec_uses_only_lib32_flag_rules() { - let mut base = test_package_spec(package::BuildType::Custom, None, &[]); - base.build.flags.cflags = vec!["-O2".into()]; - base.build.flags.replace_cflags = vec!["-O2=>-O3".into()]; - base.build.flags.cflags_lib32 = vec!["-m32".into()]; - base.build.flags.replace_cflags_lib32 = vec!["-m32=>-mstackrealign".into()]; - base.build.flags.cxxflags = vec!["-O2".into()]; - base.build.flags.replace_cxxflags = vec!["-O2=>-O3".into()]; - base.build.flags.cxxflags_lib32 = vec!["-fno-rtti".into()]; - base.build.flags.replace_cxxflags_lib32 = vec!["-fno-rtti=>-fno-exceptions".into()]; - - let lib32 = make_lib32_build_spec(&base); - - assert!(lib32.build.flags.lib32_variant); - assert_eq!(lib32.build.flags.cflags, vec!["-m32"]); - assert_eq!( - lib32.build.flags.replace_cflags, - vec!["-m32=>-mstackrealign"] - ); - assert_eq!(lib32.build.flags.cxxflags, vec!["-fno-rtti"]); - assert_eq!( - lib32.build.flags.replace_cxxflags, - vec!["-fno-rtti=>-fno-exceptions"] - ); -} - -#[test] -fn make_lib32_package_spec_uses_lib32_dependency_override() { - let mut base = test_package_spec(package::BuildType::Custom, None, &[]); - base.dependencies.runtime = vec!["zlib".into()]; - base.dependencies.lib32 = Some(package::DependencyGroup { - build: vec!["gcc-multilib".into()], - runtime: vec!["lib32-zlib".into()], - test: Vec::new(), - optional: vec!["lib32-gtk-doc".into()], - groups: Vec::new(), - }); - - let lib32 = make_lib32_package_spec(&base); - - assert_eq!(lib32.package.name, "lib32-pkg"); - assert_eq!(lib32.dependencies.build, vec!["gcc-multilib"]); - assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib", "pkg"]); - assert_eq!(lib32.dependencies.optional, vec!["lib32-gtk-doc"]); -} - -#[test] -fn make_lib32_package_spec_does_not_inherit_primary_alternatives() { - let mut base = test_package_spec(package::BuildType::Custom, None, &[]); - base.alternatives.provides = vec!["editor".into()]; - base.alternatives.conflicts = vec!["nano".into()]; - base.alternatives.replaces = vec!["vi".into()]; - - let lib32 = make_lib32_package_spec(&base); - - assert_eq!(lib32.package.name, "lib32-pkg"); - assert!(lib32.alternatives.provides.is_empty()); - assert!(lib32.alternatives.conflicts.is_empty()); - assert!(lib32.alternatives.replaces.is_empty()); -} - -#[test] -fn requested_outputs_prefers_lib32_only_spec_flag() { - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.build.flags.lib32_only = true; - - assert_eq!( - requested_outputs(&spec, false), - deps::RequestedOutputs::Lib32Only - ); -} - -#[test] -fn expand_install_requests_for_groups_uses_source_specs() -> Result<()> { - let temp = tempfile::tempdir()?; - let rootfs = temp.path().join("rootfs"); - let repo_root = temp.path().join("repos"); - let core = repo_root.join("core").join("foo"); - let desktop = repo_root.join("desktop").join("bar"); - fs::create_dir_all(&rootfs)?; - fs::create_dir_all(&core)?; - fs::create_dir_all(&desktop)?; - - let foo_spec = core.join("foo.toml"); - fs::write( - &foo_spec, - r#"[package] -name = "foo" -version = "1.0.0" -revision = 1 -description = "foo" -homepage = "https://example.test/foo" -license = "MIT" - -[[source]] -url = "https://example.test/foo-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "foo-1.0.0" - -[build] -type = "custom" - -[dependencies] -groups = ["base"] -runtime = [] -optional = [] -"#, - )?; - - let bar_spec = desktop.join("bar.toml"); - fs::write( - &bar_spec, - r#"[package] -name = "bar" -version = "1.0.0" -revision = 1 -description = "bar" -homepage = "https://example.test/bar" -license = "MIT" - -[[source]] -url = "https://example.test/bar-1.0.0.tar.gz" -sha256 = "skip" -extract_dir = "bar-1.0.0" - -[build] -type = "custom" - -[dependencies] -groups = ["base", "desktop"] -runtime = [] -optional = [] -"#, - )?; - - let mut config = config::Config::for_rootfs(&rootfs); - config.repo_clone_dir = repo_root; - config.binary_repos.clear(); - - let (expanded, groups) = - expand_install_requests_for_groups(&config, &rootfs, &[PathBuf::from("base")])?; - - assert_eq!(groups, vec!["base".to_string()]); - assert_eq!(expanded, vec![PathBuf::from("bar"), PathBuf::from("foo")]); - Ok(()) -} - -#[test] -fn expand_installed_group_targets_uses_installed_group_membership() -> Result<()> { - let temp = tempfile::tempdir()?; - let rootfs = temp.path().join("rootfs"); - fs::create_dir_all(&rootfs)?; - let config = config::Config::for_rootfs(&rootfs); - let db_path = config.installed_db_path(&rootfs); - - let dest = temp.path().join("dest"); - fs::create_dir_all(dest.join("usr/bin"))?; - fs::write(dest.join("usr/bin/foo"), "foo")?; - - let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); - spec.package.name = "foo".into(); - spec.dependencies.groups = vec!["base".into()]; - db::register_package(&db_path, &spec, &dest)?; - db::record_installed_groups(&db_path, &[String::from("base")])?; - - let (expanded, groups) = expand_installed_group_targets(&db_path, &[String::from("base")])?; - assert_eq!(groups, vec!["base".to_string()]); - assert_eq!(expanded, vec!["foo".to_string()]); - Ok(()) -} +mod build_cases; +mod cli_cases; +mod install_cases; +mod update_cases; +mod version_cases; diff --git a/src/commands/tests/build_cases.rs b/src/commands/tests/build_cases.rs new file mode 100644 index 0000000..d33d645 --- /dev/null +++ b/src/commands/tests/build_cases.rs @@ -0,0 +1,764 @@ +use super::*; + +#[test] +fn build_env_rootfs_uses_selected_non_live_rootfs() { + let tmp = tempfile::tempdir().unwrap(); + let expected = tmp.path().canonicalize().unwrap(); + + assert_eq!( + build_cmd::build_env_rootfs(tmp.path()), + expected.to_string_lossy() + ); + assert_eq!(build_cmd::build_env_rootfs(Path::new("/")), "/"); +} + +#[test] +fn clean_build_workspace_removes_build_and_source_cache_dirs() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + fs::create_dir_all(&cfg.build_dir) + .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; + fs::create_dir_all(&cfg.cache_dir) + .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; + + let mut build_file = fs::File::create(cfg.build_dir.join("artifact.txt"))?; + build_file.write_all(b"build data")?; + build_file.flush()?; + + let mut source_file = fs::File::create(cfg.cache_dir.join("source.tar.zst"))?; + source_file.write_all(b"source data")?; + source_file.flush()?; + + clean_build_workspace(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(!cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn clean_build_workspace_noops_when_dirs_are_missing() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + clean_build_workspace(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(!cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn clean_build_source_dirs_removes_build_dir_only() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + fs::create_dir_all(&cfg.build_dir) + .with_context(|| format!("Failed to create {}", cfg.build_dir.display()))?; + fs::create_dir_all(&cfg.cache_dir) + .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; + + clean_build_source_dirs(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn clean_build_source_dirs_noops_when_build_dir_missing() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("tmp/build"); + cfg.cache_dir = rootfs.path().join("tmp/sources"); + + fs::create_dir_all(&cfg.cache_dir) + .with_context(|| format!("Failed to create {}", cfg.cache_dir.display()))?; + + clean_build_source_dirs(&cfg)?; + + assert!(!cfg.build_dir.exists()); + assert!(cfg.cache_dir.exists()); + Ok(()) +} + +#[test] +fn binary_archive_staging_uses_config_build_dir_instead_of_process_tmpdir() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"hello"; + let mut header = tar::Header::new_gnu(); + header.set_path("usr/bin/hello").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + + assert!(staged.path().starts_with(staging_temp_root(&cfg))); + assert!(staged.path().join("usr/bin/hello").exists()); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_includes_lib32_only_flag_when_requested() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-lib32-child-install.sh"); + let args_path = temp.path().join("args.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\n", + args_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + run_install_command_with_program( + &script_path, + &[PathBuf::from("/tmp/pkg.toml")], + Path::new("/"), + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: false, + cross_prefix: None, + clean: false, + lib32_only: true, + install_test_deps: false, + install_context: None, + dep_chain: None, + }, + )?; + + let captured_args = fs::read_to_string(&args_path) + .with_context(|| format!("Failed to read {}", args_path.display()))?; + assert!(captured_args.lines().any(|line| line == "--lib32-only")); + 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 build_command_checks_manual_sources_before_dependency_resolution() -> Result<()> { + let _guard = assume_yes_test_lock(); + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let spec_dir = temp.path().join("packages").join("demo"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&spec_dir)?; + + let spec_path = spec_dir.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" + +[[source]] +url = "https://example.test/demo-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "demo-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = ["definitely-missing-dep"] +runtime = [] +optional = [] + +[[manual_sources]] +file = "missing.patch" +"#, + )?; + + let result = run(Cli { + command: Commands::Build(BuildArgs { + rootfs_args: rootfs_args(rootfs), + prompt_args: prompt_args(true), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_pos: Some(spec_path), + spec: None, + install: false, + install_deps: true, + cleanup_deps: 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("Failed to resolve required build tool package") + ); + Ok(()) +} + +#[test] +fn source_build_warning_messages_include_dependency_context() { + let plan = planner::ExecutionPlan { + steps: vec![ + planner::PlannedStep { + package: "dep-src".into(), + action: planner::PlanAction::BuildAndInstall, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("/tmp/dep-src.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency dep-src".into(), "app needs dep-src".into()], + }, + planner::PlannedStep { + package: "dep-bin".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(test_binary_repo_record( + "dep-bin", + "dep-bin-1.0-1-x86_64.tar.zst", + )), + }, + requested_by: vec!["app needs dep-bin".into()], + }, + ], + }; + + assert_eq!( + source_build_warning_messages(&plan), + vec!["dep-src (requested dependency 'dep-src', needed by 'app')".to_string()] + ); +} + +#[test] +fn planned_source_build_prereqs_check_manual_sources_before_confirmation() -> 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" + +[[source]] +url = "https://example.test/demo-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "demo-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = [] +runtime = [] +optional = [] + +[[manual_sources]] +file = "missing.patch" +"#, + )?; + + let config = config::Config::for_rootfs(rootfs.path()); + let plan = planner::ExecutionPlan { + steps: vec![planner::PlannedStep { + package: "demo".into(), + action: planner::PlanAction::BuildAndInstall, + origin: planner::PlanOrigin::Source { + path: spec_path, + local_sibling: true, + }, + requested_by: vec!["requested spec".into()], + }], + }; + + let err = validate_source_build_prereqs_for_plan(&plan, rootfs.path(), &config) + .expect_err("missing local manual source should fail before confirmation"); + assert!( + err.to_string() + .contains("Manual source not found: missing.patch") + ); + Ok(()) +} + +#[test] +fn cleanup_targets_keep_runtime_dependencies_for_build_install() { + let plan = planner::ExecutionPlan { + steps: vec![ + planner::PlannedStep { + package: "zlib".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/zlib/zlib.toml"), + local_sibling: false, + }, + requested_by: vec!["cmake needs zlib".into(), "llvm-runtime needs zlib".into()], + }, + planner::PlannedStep { + package: "cmake".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/cmake/cmake.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency cmake".into()], + }, + planner::PlannedStep { + package: "llvm-runtime".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/llvm-runtime/llvm-runtime.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency llvm-runtime".into()], + }, + ], + }; + let mut tracker = AutoInstalledDependencyTracker::default(); + tracker.record_plan(&plan, &["cmake".into()], AutoInstalledDependencyKind::Build); + tracker.record_plan( + &plan, + &["llvm-runtime".into()], + AutoInstalledDependencyKind::Runtime, + ); + + assert_eq!(tracker.cleanup_targets(false), vec!["cmake".to_string()]); + assert_eq!( + tracker.cleanup_targets(true), + vec![ + "llvm-runtime".to_string(), + "cmake".to_string(), + "zlib".to_string() + ] + ); +} + +#[test] +fn build_type_runs_automatic_tests_matches_builder_behavior() { + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Autotools, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Perl, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::Meson, + None, + &[] + ))); + assert!(build_type_runs_automatic_tests(&test_package_spec( + package::BuildType::CMake, + None, + &[] + ))); +} + +#[test] +fn requested_test_deps_prompt_can_disable_tests() -> Result<()> { + let _guard = assume_yes_test_lock(); + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(prompted); + assert!(spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn requested_test_deps_prompt_is_ignored_for_non_automatic_test_builders() -> Result<()> { + let _guard = assume_yes_test_lock(); + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(!prompted); + assert!(!spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn requested_test_deps_prompt_is_ignored_for_multilib_builds() -> Result<()> { + let _guard = assume_yes_test_lock(); + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.build.flags.build_32 = true; + spec.dependencies.test = vec!["pytest".into()]; + + ui::set_assume_yes(true); + let prompted = maybe_prompt_to_skip_tests_for_missing_requested_deps( + &mut spec, + &["pytest".into()], + "Requested test dependencies are missing", + )?; + ui::set_assume_yes(false); + + assert!(!prompted); + assert!(!spec.build.flags.skip_tests); + Ok(()) +} + +#[test] +fn should_not_install_test_deps_for_cli_lib32_only_builds() { + let mut spec = test_package_spec(package::BuildType::Meson, None, &[]); + spec.dependencies.lib32 = Some(package::DependencyGroup { + build: Vec::new(), + runtime: Vec::new(), + test: vec!["lib32-pytest".into()], + optional: Vec::new(), + groups: Vec::new(), + }); + + assert!(!should_install_test_deps( + &spec, + true, + deps::RequestedOutputs::Lib32Only + )); +} + +#[test] +fn build_command_requires_install_deps_flag_for_missing_dependencies() -> Result<()> { + let _guard = assume_yes_test_lock(); + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_root = temp.path().join("packages"); + let app_dir = repo_root.join("app"); + let dep_dir = repo_root.join("dep"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&app_dir)?; + fs::create_dir_all(&dep_dir)?; + + let app_spec = app_dir.join("app.toml"); + fs::write( + &app_spec, + r#"[package] +name = "app" +version = "1.0.0" +revision = 1 +description = "app" +homepage = "https://example.test/app" +license = "MIT" + +[[source]] +url = "https://example.test/app-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "app-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = ["dep"] +runtime = [] +optional = [] +"#, + ) + .with_context(|| format!("Failed to write {}", app_spec.display()))?; + + let dep_spec = dep_dir.join("dep.toml"); + fs::write( + &dep_spec, + r#"[package] +name = "dep" +version = "1.0.0" +revision = 1 +description = "dep" +homepage = "https://example.test/dep" +license = "MIT" + +[[source]] +url = "https://example.test/dep-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "dep-1.0.0" + +[build] +type = "custom" + +[dependencies] +build = [] +runtime = [] +optional = [] +"#, + ) + .with_context(|| format!("Failed to write {}", dep_spec.display()))?; + + let config = config::Config::for_rootfs(&rootfs); + register_required_development_package_if_configured(&config, &rootfs)?; + + let result = run(Cli { + command: Commands::Build(BuildArgs { + rootfs_args: rootfs_args(rootfs.clone()), + prompt_args: prompt_args(true), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_pos: Some(app_spec), + spec: None, + install: false, + install_deps: false, + cleanup_deps: false, + }), + }); + ui::set_assume_yes(false); + + let err = result.expect_err("build should require --install-deps when deps are missing"); + assert!(err.to_string().contains("Re-run with --install-deps")); + Ok(()) +} + +#[test] +fn make_lib32_build_spec_uses_only_lib32_flag_rules() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.build.flags.cflags = vec!["-O2".into()]; + base.build.flags.replace_cflags = vec!["-O2=>-O3".into()]; + base.build.flags.cflags_lib32 = vec!["-m32".into()]; + base.build.flags.replace_cflags_lib32 = vec!["-m32=>-mstackrealign".into()]; + base.build.flags.cxxflags = vec!["-O2".into()]; + base.build.flags.replace_cxxflags = vec!["-O2=>-O3".into()]; + base.build.flags.cxxflags_lib32 = vec!["-fno-rtti".into()]; + base.build.flags.replace_cxxflags_lib32 = vec!["-fno-rtti=>-fno-exceptions".into()]; + + let lib32 = make_lib32_build_spec(&base); + + assert!(lib32.build.flags.lib32_variant); + assert_eq!(lib32.build.flags.cflags, vec!["-m32"]); + assert_eq!( + lib32.build.flags.replace_cflags, + vec!["-m32=>-mstackrealign"] + ); + assert_eq!(lib32.build.flags.cxxflags, vec!["-fno-rtti"]); + assert_eq!( + lib32.build.flags.replace_cxxflags, + vec!["-fno-rtti=>-fno-exceptions"] + ); +} + +#[test] +fn make_lib32_package_spec_uses_lib32_dependency_override() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.dependencies.runtime = vec!["zlib".into()]; + base.dependencies.lib32 = Some(package::DependencyGroup { + build: vec!["gcc-multilib".into()], + runtime: vec!["lib32-zlib".into()], + test: Vec::new(), + optional: vec!["lib32-gtk-doc".into()], + groups: Vec::new(), + }); + + let lib32 = make_lib32_package_spec(&base); + + assert_eq!(lib32.package.name, "lib32-pkg"); + assert_eq!(lib32.dependencies.build, vec!["gcc-multilib"]); + assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib", "pkg"]); + assert_eq!(lib32.dependencies.optional, vec!["lib32-gtk-doc"]); +} + +#[test] +fn make_lib32_package_spec_does_not_inherit_primary_alternatives() { + let mut base = test_package_spec(package::BuildType::Custom, None, &[]); + base.alternatives.provides = vec!["editor".into()]; + base.alternatives.conflicts = vec!["nano".into()]; + base.alternatives.replaces = vec!["vi".into()]; + + let lib32 = make_lib32_package_spec(&base); + + assert_eq!(lib32.package.name, "lib32-pkg"); + assert!(lib32.alternatives.provides.is_empty()); + assert!(lib32.alternatives.conflicts.is_empty()); + assert!(lib32.alternatives.replaces.is_empty()); +} + +#[test] +fn requested_outputs_prefers_lib32_only_spec_flag() { + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.build.flags.lib32_only = true; + + assert_eq!( + requested_outputs(&spec, false), + deps::RequestedOutputs::Lib32Only + ); +} + +#[test] +fn expand_install_requests_for_groups_uses_source_specs() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + let repo_root = temp.path().join("repos"); + let core = repo_root.join("core").join("foo"); + let desktop = repo_root.join("desktop").join("bar"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&core)?; + fs::create_dir_all(&desktop)?; + + let foo_spec = core.join("foo.toml"); + fs::write( + &foo_spec, + r#"[package] +name = "foo" +version = "1.0.0" +revision = 1 +description = "foo" +homepage = "https://example.test/foo" +license = "MIT" + +[[source]] +url = "https://example.test/foo-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "foo-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base"] +runtime = [] +optional = [] +"#, + )?; + + let bar_spec = desktop.join("bar.toml"); + fs::write( + &bar_spec, + r#"[package] +name = "bar" +version = "1.0.0" +revision = 1 +description = "bar" +homepage = "https://example.test/bar" +license = "MIT" + +[[source]] +url = "https://example.test/bar-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "bar-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base", "desktop"] +runtime = [] +optional = [] +"#, + )?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_root; + config.binary_repos.clear(); + + let (expanded, groups) = + expand_install_requests_for_groups(&config, &rootfs, &[PathBuf::from("base")])?; + + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec![PathBuf::from("bar"), PathBuf::from("foo")]); + Ok(()) +} diff --git a/src/commands/tests/cli_cases.rs b/src/commands/tests/cli_cases.rs new file mode 100644 index 0000000..670baa5 --- /dev/null +++ b/src/commands/tests/cli_cases.rs @@ -0,0 +1,502 @@ +use super::*; + +#[test] +fn install_post_extract_env_uses_selected_non_live_rootfs() -> Result<()> { + let _guard = assume_yes_test_lock(); + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let spec_dir = temp.path().join("packages").join("demo"); + let source_dir = temp.path().join("source").join("demo-1.0.0"); + let observed_env = temp.path().join("post-extract-rootfs.txt"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&spec_dir)?; + fs::create_dir_all(&source_dir)?; + fs::write(source_dir.join("README"), "demo source")?; + fs::write( + spec_dir.join("build.sh"), + "mkdir -p \"$DESTDIR/usr/bin\"\nprintf demo > \"$DESTDIR/usr/bin/demo\"\n", + )?; + + let spec_path = spec_dir.join("demo.toml"); + fs::write( + &spec_path, + format!( + r#"[package] +name = "demo" +version = "1.0.0" +revision = 1 +description = "demo" +homepage = "https://example.test/demo" +license = "MIT" + +[[source]] +url = "file://{}" +sha256 = "skip" +extract_dir = "demo-1.0.0" +post_extract = ["printf '%s' \"$DEPOT_ROOTFS\" > '{}'"] + +[build] +type = "custom" + +[dependencies] +build = [] +runtime = [] +optional = [] +"#, + source_dir.display(), + observed_env.display() + ), + )?; + + let config = config::Config::for_rootfs(&rootfs); + register_required_development_package_if_configured(&config, &rootfs)?; + + run(Cli { + command: Commands::Install(InstallArgs { + rootfs_args: rootfs_args(rootfs.clone()), + prompt_args: prompt_args(true), + build_exec_args: BuildExecArgs { + no_deps: true, + ..build_exec_args() + }, + lib32_args: lib32_args(), + spec_or_archive: vec![spec_path], + spec: None, + }), + })?; + + assert_eq!( + fs::read_to_string(&observed_env)?, + build_cmd::build_env_rootfs(&rootfs) + ); + Ok(()) +} + +#[test] +fn run_internal_clone_checks_out_git_revision() { + let (_tmp, remote_url, tagged) = make_remote_git_repo(); + let clone_root = tempfile::tempdir().unwrap(); + let dest = clone_root.path().join("cloned-src"); + + run_internal_command(InternalCommands::Clone { + repo: format!("{remote_url}#v1.0.0"), + dest: Some(dest.clone()), + }) + .unwrap(); + + let repo = Repository::open(&dest).unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap(), tagged); + assert_eq!( + std::fs::read_to_string(dest.join("README")).unwrap(), + "tagged\n" + ); +} + +#[test] +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"); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/bin"))?; + fs::write(old_dest.join("usr/bin/find"), "old-find")?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let alpha_spec = package::PackageSpec { + package: package::PackageInfo { + name: "alpha".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "alpha".into(), + homepage: "https://example.test/alpha".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let alpha_dest = rootfs.path().join("alpha-dest"); + fs::create_dir_all(alpha_dest.join("usr/bin"))?; + fs::create_dir_all(alpha_dest.join("scripts"))?; + fs::write(alpha_dest.join("usr/bin/alpha"), "alpha")?; + fs::write( + alpha_dest.join("scripts/post_install"), + "cat \"$DEPOT_ROOTFS/usr/bin/find\" > \"$DEPOT_ROOTFS/alpha-marker\"\n", + )?; + + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_dest = rootfs.path().join("replacement-dest"); + fs::create_dir_all(replacement_dest.join("usr/bin"))?; + fs::write(replacement_dest.join("usr/bin/find"), "new-find")?; + + let mut plans = Vec::new(); + plans.extend(plan_package_outputs_for_install( + &alpha_spec, + &alpha_dest, + rootfs.path(), + &cfg, + )?); + plans.extend(plan_package_outputs_for_install( + &replacement_spec, + &replacement_dest, + rootfs.path(), + &cfg, + )?); + + install_planned_packages_to_rootfs(&plans, rootfs.path(), &cfg)?; + + assert_eq!( + fs::read_to_string(rootfs.path().join("alpha-marker"))?, + "new-find" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/find"))?, + "new-find" + ); + assert!(db::get_package_version(&cfg.installed_db_path(rootfs.path()), "findutils")?.is_none()); + assert_eq!( + db::get_package_version(&cfg.installed_db_path(rootfs.path()), "busybox")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_batches_multiple_requests_in_one_invocation() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-child-install.sh"); + let args_path = temp.path().join("args.txt"); + let env_path = temp.path().join("env.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s' \"${{DEPOT_DEPCHAIN:-}}\" > \"{}\"\n", + args_path.display(), + env_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + let requests = vec![ + PathBuf::from("/tmp/pkg-a.toml"), + PathBuf::from("/tmp/pkg-b.toml"), + ]; + let rootfs = Path::new("/"); + run_install_command_with_program( + &script_path, + &requests, + rootfs, + ChildInstallCommandOptions { + no_deps: false, + assume_yes: false, + no_flags: true, + cross_prefix: Some("x86_64-linux-musl"), + clean: true, + lib32_only: false, + install_test_deps: true, + install_context: None, + dep_chain: Some("parent"), + }, + )?; + + let captured_args = fs::read_to_string(&args_path) + .with_context(|| format!("Failed to read {}", args_path.display()))?; + assert_eq!( + captured_args.lines().collect::>(), + vec![ + "install", + "-r", + "/", + "--no-flags", + "--cross-prefix", + "x86_64-linux-musl", + "--clean", + "--test-deps", + "/tmp/pkg-a.toml", + "/tmp/pkg-b.toml", + ] + ); + assert_eq!(fs::read_to_string(&env_path)?, "parent"); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn child_install_command_propagates_install_context_env() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let script_path = temp.path().join("capture-child-install-context.sh"); + let env_path = temp.path().join("context.txt"); + let script = format!( + "#!/bin/sh\nprintf '%s' \"${{{}:-}}\" > \"{}\"\n", + DEPOT_INSTALL_CONTEXT_ENV, + env_path.display() + ); + fs::write(&script_path, script) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("Failed to chmod {}", script_path.display()))?; + + run_install_command_with_program( + &script_path, + &[PathBuf::from("/tmp/pkg.toml")], + Path::new("/"), + ChildInstallCommandOptions { + no_deps: true, + assume_yes: true, + no_flags: false, + cross_prefix: None, + clean: false, + lib32_only: false, + install_test_deps: false, + install_context: Some(INSTALL_CONTEXT_UPDATE), + dep_chain: None, + }, + )?; + + assert_eq!(fs::read_to_string(&env_path)?, INSTALL_CONTEXT_UPDATE); + Ok(()) +} + +#[test] +fn sudo_preserve_env_arg_only_includes_present_depot_env_vars() { + let mut env = TestEnv::new(); + assert_eq!(sudo_preserve_env_arg(), None); + + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); + assert_eq!( + sudo_preserve_env_arg(), + Some(format!("--preserve-env={}", DEPOT_INSTALL_CONTEXT_ENV)) + ); + + env.set_var("DEPOT_DEPCHAIN", "parent"); + assert_eq!( + sudo_preserve_env_arg(), + Some(format!( + "--preserve-env={},DEPOT_DEPCHAIN", + DEPOT_INSTALL_CONTEXT_ENV + )) + ); +} + +#[test] +fn rootfs_is_system_root_detects_live_rootfs() { + assert!(rootfs_is_system_root(Path::new("/"))); + assert!(!rootfs_is_system_root(Path::new("/tmp/depot-test-rootfs"))); +} + +#[test] +fn should_delegate_live_rootfs_installs_only_for_live_root_when_non_root() { + assert_eq!( + should_delegate_live_rootfs_installs(Path::new("/")), + !crate::fakeroot::is_root() + ); + assert!(!should_delegate_live_rootfs_installs(Path::new( + "/tmp/depot-test-rootfs" + ))); +} + +#[test] +fn live_rootfs_child_install_batches_group_consecutive_binary_steps() -> Result<()> { + let source_path = PathBuf::from("/tmp/requested.toml"); + let expat_archive = PathBuf::from("/tmp/expat.pkg.tar.zst"); + let python_archive = PathBuf::from("/tmp/python.pkg.tar.zst"); + let compiler_rt_archive = PathBuf::from("/tmp/lib32-compiler-rt.pkg.tar.zst"); + + let expat_record = test_binary_repo_record("expat", "expat-1.0-1-x86_64.depot.pkg.tar.zst"); + let python_record = test_binary_repo_record("python", "python-1.0-1-x86_64.depot.pkg.tar.zst"); + let compiler_rt_record = test_binary_repo_record( + "lib32-compiler-rt", + "lib32-compiler-rt-1.0-1-x86_64.depot.pkg.tar.zst", + ); + + let steps = [ + planner::PlannedStep { + package: "expat".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(expat_record.clone()), + }, + requested_by: vec!["pkg needs expat".into()], + }, + planner::PlannedStep { + package: "python".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(python_record.clone()), + }, + requested_by: vec!["pkg needs python".into()], + }, + planner::PlannedStep { + package: "pkg".into(), + action: planner::PlanAction::BuildAndInstall, + origin: planner::PlanOrigin::Source { + path: source_path.clone(), + local_sibling: false, + }, + requested_by: vec!["requested spec".into()], + }, + planner::PlannedStep { + package: "lib32-compiler-rt".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Binary { + repo_name: "core".into(), + record: Box::new(compiler_rt_record.clone()), + }, + requested_by: vec!["pkg needs lib32-compiler-rt".into()], + }, + ]; + let step_refs = steps.iter().collect::>(); + + let mut binary_archives = HashMap::new(); + binary_archives.insert( + ("core".to_string(), expat_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: expat_archive.clone(), + signature_path: PathBuf::from("/tmp/expat.sig"), + }, + ); + binary_archives.insert( + ("core".to_string(), python_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: python_archive.clone(), + signature_path: PathBuf::from("/tmp/python.sig"), + }, + ); + binary_archives.insert( + ("core".to_string(), compiler_rt_record.filename.clone()), + db::repo::BinaryRepoCachedArchive { + package_path: compiler_rt_archive.clone(), + signature_path: PathBuf::from("/tmp/lib32-compiler-rt.sig"), + }, + ); + + let options = InstallPlanExecutionOptions { + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + confirm_installation: false, + lib32_only_requested_specs: true, + install_test_deps: false, + }; + + let batches = build_live_rootfs_child_install_batches(&step_refs, &options, &binary_archives)?; + + assert_eq!( + batches, + vec![ + ChildInstallBatch { + requests: vec![expat_archive, python_archive], + lib32_only: false, + }, + ChildInstallBatch { + requests: vec![source_path], + lib32_only: true, + }, + ChildInstallBatch { + requests: vec![compiler_rt_archive], + lib32_only: false, + }, + ] + ); + Ok(()) +} + +#[test] +fn expand_installed_group_targets_uses_installed_group_membership() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + fs::create_dir_all(&rootfs)?; + let config = config::Config::for_rootfs(&rootfs); + let db_path = config.installed_db_path(&rootfs); + + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin/foo"), "foo")?; + + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.package.name = "foo".into(); + spec.dependencies.groups = vec!["base".into()]; + db::register_package(&db_path, &spec, &dest)?; + db::record_installed_groups(&db_path, &[String::from("base")])?; + + let (expanded, groups) = expand_installed_group_targets(&db_path, &[String::from("base")])?; + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec!["foo".to_string()]); + Ok(()) +} diff --git a/src/commands/tests/install_cases.rs b/src/commands/tests/install_cases.rs new file mode 100644 index 0000000..33447b6 --- /dev/null +++ b/src/commands/tests/install_cases.rs @@ -0,0 +1,637 @@ +use super::*; + +#[test] +fn parallel_verification_processes_every_item() -> Result<()> { + let items = vec![0_u8; 32]; + let completed = AtomicUsize::new(0); + let progress = ProgressBar::hidden(); + + run_parallel_verification(&items, &progress, |_| { + completed.fetch_add(1, AtomicOrdering::Relaxed); + Ok(()) + })?; + + assert_eq!(completed.load(AtomicOrdering::Relaxed), items.len()); + Ok(()) +} + +#[test] +fn parallel_tasks_run_concurrently_and_preserve_input_order() -> Result<()> { + let items = vec![3_u8, 1, 4, 2]; + let barrier = Barrier::new(items.len()); + + let results = run_parallel_tasks(&items, items.len(), |_, item| { + barrier.wait(); + Ok(item * 2) + })?; + + assert_eq!(results, vec![6, 2, 8, 4]); + Ok(()) +} + +#[test] +fn binary_install_path_uses_repo_record_metadata_without_archive_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("pkg-1.0-1-x86_64.depot.pkg.tar.zst"); + + // Build an archive that intentionally does not contain .metadata.toml. + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"hello"; + let mut header = tar::Header::new_gnu(); + header.set_path("usr/bin/hello").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + let record = db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: archive_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(), + size: payload.len() as u64, + sha512: String::new(), + description: Some("test package".into()), + homepage: Some("https://example.test".into()), + license: Some("MIT".into()), + provides: vec!["pkg-virtual".into()], + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: vec!["glibc".into()], + optional_dependencies: vec!["manpages".into()], + groups: vec!["base".into()], + }; + let spec = package_spec_from_repo_record(&record); + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].package.name, "pkg"); + assert!(rootfs.path().join("usr/bin/hello").exists()); + + let db_path = cfg.installed_db_path(rootfs.path()); + assert_eq!( + db::get_package_version(&db_path, "pkg")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +fn direct_archive_install_requests_batch_multiple_archives() -> Result<()> { + fn write_archive( + archive_path: &Path, + package_name: &str, + conflicts: &[&str], + payload_path: &str, + payload: &[u8], + ) -> Result<()> { + let file = fs::File::create(archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path(payload_path)?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, payload)?; + + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; + let metadata = format!( + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" + ); + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, metadata.as_bytes())?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + Ok(()) + } + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + write_archive(&archive_a, "alpha", &[], "usr/bin/alpha", b"alpha")?; + write_archive(&archive_b, "beta", &[], "usr/bin/beta", b"beta")?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let installed = run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[archive_a, archive_b], + false, + )?; + + assert!(installed); + assert!(rootfs.path().join("usr/bin/alpha").exists()); + assert!(rootfs.path().join("usr/bin/beta").exists()); + let db_path = cfg.installed_db_path(rootfs.path()); + assert_eq!( + db::get_package_version(&db_path, "alpha")?, + Some("1.0".into()) + ); + assert_eq!( + db::get_package_version(&db_path, "beta")?, + Some("1.0".into()) + ); + Ok(()) +} + +#[test] +fn direct_archive_install_rejects_conflicting_archives_in_same_batch() -> Result<()> { + fn write_archive(archive_path: &Path, package_name: &str, conflicts: &[&str]) -> Result<()> { + let file = fs::File::create(archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = package_name.as_bytes(); + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path(format!("usr/bin/{package_name}"))?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, payload)?; + + let conflicts_toml = if conflicts.is_empty() { + String::new() + } else { + format!( + "conflicts = [{}]\n", + conflicts + .iter() + .map(|conflict| format!("\"{conflict}\"")) + .collect::>() + .join(", ") + ) + }; + let metadata = format!( + "name = \"{package_name}\"\nversion = \"1.0\"\nrevision = 1\ndescription = \"test\"\nhomepage = \"https://example.test\"\nlicense = \"MIT\"\n{conflicts_toml}\n[dependencies]\nruntime = []\noptional = []\n" + ); + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, metadata.as_bytes())?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + Ok(()) + } + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_a = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let archive_b = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + write_archive(&archive_a, "alpha", &["beta"])?; + write_archive(&archive_b, "beta", &[])?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let err = run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[archive_a, archive_b], + false, + ) + .expect_err("conflicting archives should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); + Ok(()) +} + +#[test] +fn collect_conflicting_installed_packages_matches_by_name_and_provide() -> Result<()> { + let removals = collect_conflicting_installed_packages( + &[InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: vec!["alpha".into(), "editor".into()], + }], + &[InstalledConflictPackage { + name: "alpha".into(), + provides: vec!["editor".into()], + }], + )?; + + assert_eq!( + removals.get("alpha"), + Some(&BTreeSet::from(["beta".to_string()])) + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn binary_archive_install_preserves_setuid_permissions() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("sudo-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + let payload = b"sudo"; + let mut header = tar::Header::new_gnu(); + header.set_path("bin/sudo").unwrap(); + header.set_size(payload.len() as u64); + header.set_mode(0o4755); + header.set_cksum(); + tar.append(&header, &payload[..]).unwrap(); + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let staged = extract_package_archive_to_staging(&cfg, &archive_path)?; + let staged_mode = fs::metadata(staged.path().join("bin/sudo"))? + .permissions() + .mode() + & 0o7777; + assert_eq!(staged_mode, 0o4755); + + let record = db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: "sudo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: archive_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(), + size: payload.len() as u64, + sha512: String::new(), + description: Some("sudo".into()), + homepage: Some("https://example.test".into()), + license: Some("ISC".into()), + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }; + let spec = package_spec_from_repo_record(&record); + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + let root_mode = fs::metadata(rootfs.path().join("bin/sudo"))? + .permissions() + .mode() + & 0o7777; + assert_eq!(root_mode, 0o4755); + Ok(()) +} + +#[test] +fn binary_archive_install_honors_keep_paths_from_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir + .path() + .join("filesystem-1.0-3-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = b"package-fstab"; + let mut fstab_header = tar::Header::new_gnu(); + fstab_header.set_path("etc/fstab").unwrap(); + fstab_header.set_size(payload.len() as u64); + fstab_header.set_mode(0o644); + fstab_header.set_cksum(); + tar.append(&fstab_header, &payload[..]).unwrap(); + + let metadata = br#"name = "filesystem" +version = "1.0.1" +revision = 3 +description = "Base filesystem" +homepage = "https://example.test" +license = "Unlicense" +keep = ["etc/fstab"] + +[dependencies] +runtime = [] +optional = [] +"#; + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml").unwrap(); + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, &metadata[..]).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + fs::create_dir_all(rootfs.path().join("etc"))?; + fs::write(rootfs.path().join("etc/fstab"), "existing-fstab")?; + + let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; + assert_eq!(spec.build.flags.keep, vec!["etc/fstab".to_string()]); + + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + assert_eq!(installed.len(), 1); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/fstab"))?, + "existing-fstab" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/fstab.depotnew"))?, + "package-fstab" + ); + Ok(()) +} + +#[test] +fn binary_archive_install_honors_replaces_from_metadata() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let archive_path = pkg_dir.path().join("vx-0.1.0-1-x86_64.depot.pkg.tar.zst"); + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "diffutils".into(), + real_name: None, + version: "3.12".into(), + revision: 1, + description: "diffutils".into(), + homepage: "https://example.test/diffutils".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["GPL-3.0-or-later".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/bin"))?; + fs::write(old_dest.join("usr/bin/diff"), "old-diff")?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let file = fs::File::create(&archive_path) + .with_context(|| format!("Failed to create {}", archive_path.display()))?; + let encoder = + zstd::stream::write::Encoder::new(file, 3).context("Failed to create zstd encoder")?; + let mut tar = tar::Builder::new(encoder); + + let payload = b"vx-diff"; + let mut payload_header = tar::Header::new_gnu(); + payload_header.set_path("usr/bin/diff")?; + payload_header.set_size(payload.len() as u64); + payload_header.set_mode(0o755); + payload_header.set_cksum(); + tar.append(&payload_header, &payload[..])?; + + let metadata = br#"name = "vx" +version = "0.1.0" +revision = 1 +description = "vertex utils" +homepage = "https://example.test/vx" +license = "MIT" +replaces = ["diffutils"] + +[dependencies] +runtime = [] +optional = [] +"#; + let mut meta_header = tar::Header::new_gnu(); + meta_header.set_path(".metadata.toml")?; + meta_header.set_size(metadata.len() as u64); + meta_header.set_mode(0o644); + meta_header.set_cksum(); + tar.append(&meta_header, &metadata[..])?; + + let encoder = tar.into_inner()?; + encoder.finish()?; + + let (spec, staged) = load_package_archive_into_staging(&cfg, &archive_path)?; + assert_eq!(spec.alternatives.replaces, vec!["diffutils".to_string()]); + + let installed = install_package_outputs_to_rootfs(&spec, staged.path(), rootfs.path(), &cfg)?; + + assert_eq!(installed.len(), 1); + assert!(installed[0].is_update); + assert_eq!(installed[0].package.name, "vx"); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/diff"))?, + "vx-diff" + ); + + let db_path = cfg.installed_db_path(rootfs.path()); + assert_eq!(db::get_package_version(&db_path, "diffutils")?, None); + assert_eq!( + db::get_package_version(&db_path, "vx")?, + Some("0.1.0".into()) + ); + Ok(()) +} + +#[test] +fn merge_missing_dependencies_preserves_order_and_uniqueness() { + let merged = merge_missing_dependencies( + vec!["make".into(), "pkgconf".into(), "glibc".into()], + vec![ + "glibc".into(), + "openssl".into(), + "pkgconf".into(), + "zlib".into(), + ], + ); + assert_eq!(merged, vec!["make", "pkgconf", "glibc", "openssl", "zlib"]); +} + +#[test] +fn install_planned_packages_sets_sole_tool_provider_before_post_hooks() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let mut config = config::Config::for_rootfs(rootfs.path()); + config.db_dir = rootfs.path().join("var/lib/depot"); + config.build_dir = rootfs.path().join("var/cache/depot/build"); + + let mut dash_spec = test_package_spec(package::BuildType::Bin, None, &[]); + dash_spec.package.name = "dash".into(); + let dash_dest = rootfs.path().join("dash-dest"); + fs::create_dir_all(dash_dest.join("usr/bin"))?; + fs::create_dir_all(dash_dest.join("scripts"))?; + fs::write(dash_dest.join("usr/bin/dash"), "dash")?; + fs::write( + dash_dest.join("scripts/post_install"), + "[ -L \"$DEPOT_ROOTFS/usr/bin/sh\" ] && [ \"$(readlink \"$DEPOT_ROOTFS/usr/bin/sh\")\" = dash ]\n", + )?; + + let plans = plan_package_outputs_for_install(&dash_spec, &dash_dest, rootfs.path(), &config)?; + install_planned_packages_to_rootfs(&plans, rootfs.path(), &config)?; + + assert_eq!( + fs::read_link(rootfs.path().join("usr/bin/sh"))?, + PathBuf::from("dash") + ); + Ok(()) +} + +#[test] +fn suppress_nested_install_output_for_planned_context() { + let mut env = TestEnv::new(); + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_PLANNED); + + assert!(suppress_nested_install_output()); + assert_eq!( + current_install_invocation_context(), + InstallInvocationContext::Planned + ); +} + +#[test] +fn plan_dependency_closure_tracks_requested_dependency_roots() { + let plan = planner::ExecutionPlan { + steps: vec![ + planner::PlannedStep { + package: "zlib".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/zlib/zlib.toml"), + local_sibling: false, + }, + requested_by: vec!["cmake needs zlib".into()], + }, + planner::PlannedStep { + package: "cmake".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/cmake/cmake.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency cmake".into()], + }, + planner::PlannedStep { + package: "libffi".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/libffi/libffi.toml"), + local_sibling: false, + }, + requested_by: vec!["python needs libffi".into()], + }, + planner::PlannedStep { + package: "python".into(), + action: planner::PlanAction::InstallBinary, + origin: planner::PlanOrigin::Source { + path: PathBuf::from("packages/core/python/python.toml"), + local_sibling: false, + }, + requested_by: vec!["dependency python".into()], + }, + ], + }; + + let cmake_closure = plan_dependency_closure_for_requested_deps(&plan, &["cmake".into()]); + assert_eq!( + cmake_closure, + HashSet::from(["cmake".to_string(), "zlib".to_string()]) + ); + + let python_closure = plan_dependency_closure_for_requested_deps(&plan, &["python".into()]); + assert_eq!( + python_closure, + HashSet::from(["python".to_string(), "libffi".to_string()]) + ); +} diff --git a/src/commands/tests/update_cases.rs b/src/commands/tests/update_cases.rs new file mode 100644 index 0000000..cb41aa4 --- /dev/null +++ b/src/commands/tests/update_cases.rs @@ -0,0 +1,1164 @@ +use super::*; + +#[test] +fn update_transaction_runs_matching_transaction_hook_once_for_batch() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let pkg_dir = tempfile::tempdir().context("Failed to create temp package dir")?; + let old_alpha = pkg_dir.path().join("alpha-1.0-1-x86_64.depot.pkg.tar.zst"); + let old_beta = pkg_dir.path().join("beta-1.0-1-x86_64.depot.pkg.tar.zst"); + let new_alpha = pkg_dir.path().join("alpha-2.0-1-x86_64.depot.pkg.tar.zst"); + let new_beta = pkg_dir.path().join("beta-2.0-1-x86_64.depot.pkg.tar.zst"); + write_basic_binary_archive(&old_alpha, "alpha", "1.0", 1, "usr/bin/alpha", b"alpha-old")?; + write_basic_binary_archive(&old_beta, "beta", "1.0", 1, "usr/bin/beta", b"beta-old")?; + write_basic_binary_archive(&new_alpha, "alpha", "2.0", 1, "usr/bin/alpha", b"alpha-new")?; + write_basic_binary_archive(&new_beta, "beta", "2.0", 1, "usr/bin/beta", b"beta-new")?; + + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + + run_direct_archive_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[old_alpha, old_beta], + false, + )?; + + let hooks_dir = install::hooks::transaction_hooks_dir(rootfs.path()); + fs::create_dir_all(&hooks_dir)?; + fs::write( + hooks_dir.join("90-update-batch.toml"), + r#" +[hook] +name = "update batch recorder" + +[when] +phase = "post" +operation = ["update"] +paths = ["usr/bin/*"] + +[exec] +command = "printf '%s:%s\n' \"$DEPOT_ACTION\" \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/hook-runs\"; cat >> \"$DEPOT_ROOTFS/hook-targets\"" +needs_paths = true +"#, + )?; + + let updated = run_update_transaction_install_requests( + DirectInstallOptions { + rootfs: rootfs.path(), + no_deps: true, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + lib32_only: false, + install_test_deps: false, + }, + &cfg, + &[new_alpha, new_beta], + )?; + + assert!(updated); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/alpha"))?, + "alpha-new" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/beta"))?, + "beta-new" + ); + let hook_runs = fs::read_to_string(rootfs.path().join("hook-runs"))?; + assert_eq!(hook_runs.lines().collect::>(), vec!["update:alpha"]); + let hook_targets: BTreeSet<_> = fs::read_to_string(rootfs.path().join("hook-targets"))? + .lines() + .map(str::to_string) + .collect(); + assert_eq!( + hook_targets, + BTreeSet::from(["usr/bin/alpha".to_string(), "usr/bin/beta".to_string()]) + ); + Ok(()) +} + +#[test] +fn plan_staged_install_reads_updates_from_rootfs_installed_db() -> 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("home/vertex/.local/share/depot"); + + let installed_db = cfg.installed_db_path(rootfs.path()); + fs::create_dir_all( + installed_db + .parent() + .context("Installed DB path should have a parent")?, + )?; + + let existing_dest = rootfs.path().join("installed"); + fs::create_dir_all(existing_dest.join("usr/bin"))?; + fs::write(existing_dest.join("usr/bin/tool"), "old")?; + + let spec = package::PackageSpec { + package: package::PackageInfo { + name: "filesystem".into(), + real_name: None, + version: "1.0.1".into(), + revision: 3, + description: "Base filesystem".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["Unlicense".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + db::register_package(&installed_db, &spec, &existing_dest)?; + + let staged_dest = rootfs.path().join("staged"); + fs::create_dir_all(staged_dest.join("usr/bin"))?; + fs::write(staged_dest.join("usr/bin/tool"), "new")?; + + let plan = plan_staged_install(&spec, &staged_dest, rootfs.path(), &cfg)?; + assert!(plan.is_update); + Ok(()) +} + +#[test] +fn transaction_orders_relinquishing_update_before_new_file_owner() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let payloads = tempfile::tempdir().context("Failed to create payload dir")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + let db_path = cfg.installed_db_path(rootfs.path()); + + let old_alpha = file_ownership_test_spec("alpha", "1.0"); + let old_alpha_dest = payloads.path().join("old-alpha"); + stage_file(&old_alpha_dest, "usr/bin/shared", "alpha-old")?; + stage_file(rootfs.path(), "usr/bin/shared", "alpha-old")?; + db::register_package(&db_path, &old_alpha, &old_alpha_dest)?; + + let new_alpha = file_ownership_test_spec("alpha", "2.0"); + let new_alpha_dest = payloads.path().join("new-alpha"); + stage_file(&new_alpha_dest, "usr/bin/alpha", "alpha-new")?; + let beta = file_ownership_test_spec("beta", "1.0"); + let beta_dest = payloads.path().join("beta"); + stage_file(&beta_dest, "usr/bin/shared", "beta")?; + + let mut plans = plan_package_outputs_for_install(&beta, &beta_dest, rootfs.path(), &cfg)?; + plans.extend(plan_package_outputs_for_install( + &new_alpha, + &new_alpha_dest, + rootfs.path(), + &cfg, + )?); + + let ordered = preflight_file_ownership_and_order(&plans, &HashSet::new(), rootfs.path(), &cfg)?; + assert_eq!(ordered[0].spec.package.name, "alpha"); + assert_eq!(ordered[1].spec.package.name, "beta"); + + install_direct_transaction(&plans, rootfs.path(), &cfg)?; + + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/shared"))?, + "beta" + ); + assert_eq!( + db::owns_path(&db_path, Path::new("usr/bin/shared"))?, + Some("beta".into()) + ); + assert_eq!( + db::get_package_version(&db_path, "alpha")?, + Some("2.0".into()) + ); + Ok(()) +} + +#[test] +fn transaction_rejects_file_still_owned_after_planned_update_before_mutation() -> Result<()> { + let rootfs = tempfile::tempdir().context("Failed to create temp rootfs")?; + let payloads = tempfile::tempdir().context("Failed to create payload dir")?; + let mut cfg = config::Config::for_rootfs(rootfs.path()); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + cfg.db_dir = rootfs.path().join("var/lib/depot"); + let db_path = cfg.installed_db_path(rootfs.path()); + + let old_alpha = file_ownership_test_spec("alpha", "1.0"); + let old_alpha_dest = payloads.path().join("old-alpha"); + stage_file(&old_alpha_dest, "usr/bin/shared", "alpha-old")?; + stage_file(rootfs.path(), "usr/bin/shared", "alpha-old")?; + db::register_package(&db_path, &old_alpha, &old_alpha_dest)?; + + let new_alpha = file_ownership_test_spec("alpha", "2.0"); + let new_alpha_dest = payloads.path().join("new-alpha"); + stage_file(&new_alpha_dest, "usr/bin/shared", "alpha-new")?; + let beta = file_ownership_test_spec("beta", "1.0"); + let beta_dest = payloads.path().join("beta"); + stage_file(&beta_dest, "usr/bin/shared", "beta")?; + + let mut plans = plan_package_outputs_for_install(&beta, &beta_dest, rootfs.path(), &cfg)?; + plans.extend(plan_package_outputs_for_install( + &new_alpha, + &new_alpha_dest, + rootfs.path(), + &cfg, + )?); + + let err = install_direct_transaction(&plans, rootfs.path(), &cfg) + .expect_err("retained ownership conflict should fail preflight"); + assert!( + err.to_string() + .contains("still provided by its transaction update") + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/bin/shared"))?, + "alpha-old" + ); + assert_eq!( + db::get_package_version(&db_path, "alpha")?, + Some("1.0".into()) + ); + assert_eq!(db::get_package_version(&db_path, "beta")?, None); + Ok(()) +} + +#[test] +fn renamed_abi_updates_keep_versioned_shared_libraries() -> 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"); + cfg.build_dir = rootfs.path().join("var/cache/depot/build"); + + let old_spec = package::PackageSpec { + package: package::PackageInfo { + name: "libxml214".into(), + real_name: Some("libxml2".into()), + version: "2.14.9".into(), + revision: 1, + description: "libxml2 2.14".into(), + homepage: "https://example.test/libxml2".into(), + abi_breaking: true, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let old_dest = rootfs.path().join("old-dest"); + fs::create_dir_all(old_dest.join("usr/lib/pkgconfig"))?; + fs::write(old_dest.join("usr/lib/libxml2.so.14.9.0"), "old-real")?; + std::os::unix::fs::symlink("libxml2.so.14.9.0", old_dest.join("usr/lib/libxml2.so.14"))?; + std::os::unix::fs::symlink("libxml2.so.14", old_dest.join("usr/lib/libxml2.so"))?; + fs::write( + old_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), + "old-pkgconfig", + )?; + install_package_outputs_to_rootfs(&old_spec, &old_dest, rootfs.path(), &cfg)?; + + let new_spec = package::PackageSpec { + package: package::PackageInfo { + name: "libxml215".into(), + real_name: Some("libxml2".into()), + version: "2.15.1".into(), + revision: 1, + description: "libxml2 2.15".into(), + homepage: "https://example.test/libxml2".into(), + abi_breaking: true, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let new_dest = rootfs.path().join("new-dest"); + fs::create_dir_all(new_dest.join("usr/lib/pkgconfig"))?; + fs::write(new_dest.join("usr/lib/libxml2.so.15.1.0"), "new-real")?; + std::os::unix::fs::symlink("libxml2.so.15.1.0", new_dest.join("usr/lib/libxml2.so.15"))?; + std::os::unix::fs::symlink("libxml2.so.15", new_dest.join("usr/lib/libxml2.so"))?; + fs::write( + new_dest.join("usr/lib/pkgconfig/libxml-2.0.pc"), + "new-pkgconfig", + )?; + + let installed = install_package_outputs_to_rootfs(&new_spec, &new_dest, rootfs.path(), &cfg)?; + assert_eq!(installed.len(), 1); + assert!(installed[0].is_update); + assert_eq!(installed[0].package.name, "libxml215"); + + assert!(rootfs.path().join("usr/lib/libxml2.so.14.9.0").exists()); + assert!(rootfs.path().join("usr/lib/libxml2.so.14").exists()); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/lib/libxml2.so.15.1.0"))?, + "new-real" + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("usr/lib/pkgconfig/libxml-2.0.pc"))?, + "new-pkgconfig" + ); + + let db_path = cfg.installed_db_path(rootfs.path()); + let old_files = db::get_package_files(&db_path, "libxml214")?; + assert_eq!( + old_files, + vec![ + "usr/lib/libxml2.so.14".to_string(), + "usr/lib/libxml2.so.14.9.0".to_string(), + ] + ); + + let new_files = db::get_package_files(&db_path, "libxml215")?; + assert!(new_files.contains(&"usr/lib/libxml2.so".to_string())); + assert!(new_files.contains(&"usr/lib/libxml2.so.15".to_string())); + assert!(new_files.contains(&"usr/lib/libxml2.so.15.1.0".to_string())); + assert!(new_files.contains(&"usr/lib/pkgconfig/libxml-2.0.pc".to_string())); + Ok(()) +} + +#[test] +fn collect_update_candidates_matches_renamed_packages_by_real_name() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + fs::create_dir_all(&db_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir.clone(); + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.clear(); + config.source_repos.insert( + "private".into(), + config::SourceRepo { + url: "https://example.test/private.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + let installed_spec = package::PackageSpec { + package: package::PackageInfo { + name: "icu78".into(), + real_name: Some("icu".into()), + version: "78.2".into(), + revision: 1, + description: "icu78".into(), + homepage: "https://example.test/icu".into(), + abi_breaking: true, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/lib"))?; + fs::write(dest.join("usr/lib/libicuuc.so.78.2"), "icu78")?; + db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; + + let repo_root = repo_clones.join("private"); + fs::create_dir_all(&repo_root)?; + fs::write( + repo_root.join("icu79.toml"), + r#"[package] +name = "icu79" +real_name = "icu" +version = "79.1" +revision = 1 +description = "icu79" +homepage = "https://example.test/icu" +abi_breaking = true +license = "MIT" + +[build] +type = "meta" + +[dependencies] +runtime = [] +optional = [] +"#, + )?; + + let installed_records = db::list_installed_package_records(&config.installed_db_path(&rootfs))?; + assert_eq!(installed_records.len(), 1); + assert_eq!(installed_records[0].real_name.as_deref(), Some("icu")); + + let source_candidates = + collect_best_source_update_candidates(&config, &HashSet::from([String::from("icu")]))?; + assert!(source_candidates.contains_key("icu")); + let selected = select_update_candidate( + &installed_records[0], + installed_records[0].completed_at, + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &HashMap::new(), + false, + ); + assert!(selected.is_some()); + + let updates = collect_update_candidates(&config, &rootfs, &["icu78".into()])?; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].installed_package, "icu78"); + assert_eq!(updates[0].candidate_package, "icu79"); + assert_eq!(updates[0].candidate_version, "79.1"); + Ok(()) +} + +#[test] +fn update_candidate_prefers_binary_when_versions_match_and_config_does() { + let installed = db::InstalledPackageRecord { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + }; + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.1.0".into(), + revision: 1, + description: "test".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: vec![package::Source { + url: "https://example.test/pkg-$version.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "pkg-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let source_candidates = HashMap::from([( + "pkg".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/pkg.toml"), + completed_at: None, + spec: source_spec, + }, + )]); + let binary_candidates = HashMap::from([( + "pkg".to_string(), + ( + 0, + db::repo::BinaryRepoPackageRecord { + repo_name: "binary".into(), + name: "pkg".into(), + real_name: None, + version: "1.1.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: "pkg-1.1.0-1-x86_64.depot.pkg.tar.zst".into(), + size: 1, + sha512: String::new(), + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + ), + )]); + + let selected = select_update_candidate( + &installed, + None, + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &binary_candidates, + true, + ) + .expect("expected update candidate"); + assert!(matches!(selected.origin, UpdateOrigin::Binary { .. })); +} + +#[test] +fn select_update_candidate_uses_newer_timestamp_when_versions_match() { + let installed = db::InstalledPackageRecord { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: Some(100), + }; + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + description: "test".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let source_candidates = HashMap::from([( + "pkg".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/pkg.toml"), + completed_at: Some(200), + spec: source_spec, + }, + )]); + + let selected = select_update_candidate( + &installed, + Some(100), + &HashMap::new(), + &HashMap::new(), + &source_candidates, + &HashMap::new(), + true, + ) + .expect("expected update candidate"); + assert_eq!(selected.candidate_version, "1.0.0"); + assert_eq!(selected.candidate_completed_at, Some(200)); +} + +#[test] +fn select_update_candidate_prefers_replacement_candidate() { + let installed = db::InstalledPackageRecord { + name: "findutils".into(), + real_name: None, + version: "4.9.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: Some(100), + }; + + let source_spec = package::PackageSpec { + package: package::PackageInfo { + name: "findutils".into(), + real_name: None, + version: "5.0.0".into(), + revision: 1, + description: "findutils".into(), + homepage: "https://example.test/findutils".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let replacement_spec = package::PackageSpec { + package: package::PackageInfo { + name: "busybox".into(), + real_name: None, + version: "1.36.1".into(), + revision: 1, + description: "busybox".into(), + homepage: "https://example.test/busybox".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["GPL-2.0-only".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["findutils".into()], + lib32: None, + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Custom, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let source_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 5, + path: PathBuf::from("/tmp/findutils.toml"), + completed_at: Some(200), + spec: source_spec, + }, + )]); + let source_replacement_candidates = HashMap::from([( + "findutils".to_string(), + SourceUpdateCandidate { + repo_name: "source".into(), + repo_priority: 0, + path: PathBuf::from("/tmp/busybox.toml"), + completed_at: Some(150), + spec: replacement_spec, + }, + )]); + + let selected = select_update_candidate( + &installed, + installed.completed_at, + &source_replacement_candidates, + &HashMap::new(), + &source_candidates, + &HashMap::new(), + false, + ) + .expect("expected replacement update candidate"); + + assert!(selected.replaces_installed); + assert_eq!(selected.installed_package, "findutils"); + assert_eq!(selected.candidate_package, "busybox"); +} + +#[test] +fn collect_update_candidates_skips_source_when_prefer_binary_is_enabled() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir.clone(); + config.repo_settings.prefer_binary = true; + config.binary_repos.clear(); + config.source_repos.clear(); + config.source_repos.insert( + "private".into(), + config::SourceRepo { + url: "https://example.test/private.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + let installed_spec = package::PackageSpec { + package: package::PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + description: "pkg".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin/pkg"), "pkg")?; + db::register_package(&config.installed_db_path(&rootfs), &installed_spec, &dest)?; + + let updates = collect_update_candidates(&config, &rootfs, &[])?; + assert!(updates.is_empty()); + Ok(()) +} + +#[test] +fn explicit_depot_self_update_request_requires_only_depot() { + assert!(is_explicit_depot_self_update_request(&[ + DEPOT_PACKAGE_NAME.to_string() + ])); + assert!(!is_explicit_depot_self_update_request(&[])); + assert!(!is_explicit_depot_self_update_request(&["pkg".to_string()])); + assert!(!is_explicit_depot_self_update_request(&[ + DEPOT_PACKAGE_NAME.to_string(), + "pkg".to_string() + ])); +} + +#[test] +fn depot_self_update_check_blocks_when_update_is_available() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir; + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.clear(); + config.source_repos.insert( + "core".into(), + config::SourceRepo { + url: "https://example.test/core.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.0.0")?; + write_test_repo_spec( + &repo_clones.join("core").join("depot.toml"), + DEPOT_PACKAGE_NAME, + "1.1.0", + )?; + + let err = ensure_depot_self_update_not_required(&config, &rootfs) + .expect_err("outdated depot should block command execution"); + assert!(err.to_string().contains("update depot")); + Ok(()) +} + +#[test] +fn depot_self_update_check_allows_when_depot_is_current() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir; + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.clear(); + config.source_repos.insert( + "core".into(), + config::SourceRepo { + url: "https://example.test/core.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + let repo_spec = repo_clones.join("core").join("depot.toml"); + register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.1.0")?; + write_test_repo_spec(&repo_spec, DEPOT_PACKAGE_NAME, "1.1.0")?; + let repo_completed_at = + crate::metadata_time::system_time_to_unix(fs::metadata(&repo_spec)?.modified()?)?; + set_installed_test_package_completed_at( + &config, + &rootfs, + DEPOT_PACKAGE_NAME, + repo_completed_at + 1, + )?; + + ensure_depot_self_update_not_required(&config, &rootfs)?; + Ok(()) +} + +#[test] +fn depot_self_update_check_is_skipped_for_nested_update_install_context() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let rootfs = temp.path().join("rootfs"); + let repo_clones = temp.path().join("repos"); + let build_dir = temp.path().join("build"); + let db_dir = rootfs.join("var/lib/depot"); + fs::create_dir_all(&db_dir)?; + fs::create_dir_all(&repo_clones)?; + fs::create_dir_all(&build_dir)?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_clones.clone(); + config.build_dir = build_dir; + config.db_dir = db_dir; + config.repo_settings.prefer_binary = false; + config.binary_repos.clear(); + config.source_repos.clear(); + config.source_repos.insert( + "core".into(), + config::SourceRepo { + url: "https://example.test/core.git".into(), + enabled: true, + priority: 0, + subdirs: Vec::new(), + }, + ); + + register_installed_test_package(&config, &rootfs, DEPOT_PACKAGE_NAME, "1.0.0")?; + write_test_repo_spec( + &repo_clones.join("core").join("depot.toml"), + DEPOT_PACKAGE_NAME, + "1.1.0", + )?; + + let mut env = TestEnv::new(); + env.set_var(DEPOT_INSTALL_CONTEXT_ENV, INSTALL_CONTEXT_UPDATE); + + ensure_depot_self_update_not_required(&config, &rootfs)?; + Ok(()) +} + +#[test] +fn collect_missing_update_dependencies_skips_planned_provides_and_installed_deps() -> Result<()> { + let temp = tempfile::tempdir().context("Failed to create temp dir")?; + let db_path = temp.path().join("packages.db"); + + let libc_spec = package::PackageSpec { + package: package::PackageInfo { + name: "glibc".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "glibc".into(), + homepage: "https://example.test".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: package::Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/lib"))?; + fs::write(dest.join("usr/lib/libc.so"), "glibc")?; + db::register_package(&db_path, &libc_spec, &dest)?; + + let missing = collect_missing_update_dependencies( + &[ + UpdateCandidate { + installed_package: "pkg".into(), + candidate_package: "pkg".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: vec!["glibc".into(), "helper-virtual".into()], + provides: Vec::new(), + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/pkg.toml"), + }, + }, + UpdateCandidate { + installed_package: "helper".into(), + candidate_package: "helper".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: Vec::new(), + provides: vec!["helper-virtual".into()], + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/helper.toml"), + }, + }, + UpdateCandidate { + installed_package: "tool".into(), + candidate_package: "tool".into(), + replaces_installed: false, + installed_version: "1.0".into(), + installed_revision: 1, + installed_completed_at: None, + candidate_version: "2.0".into(), + candidate_revision: 1, + candidate_completed_at: None, + runtime_dependencies: vec!["newdep".into()], + provides: Vec::new(), + conflicts: Vec::new(), + repo_priority: 0, + origin: UpdateOrigin::Source { + repo_name: "source".into(), + path: PathBuf::from("/tmp/tool.toml"), + }, + }, + ], + &db_path, + )?; + + assert_eq!(missing, vec!["newdep".to_string()]); + Ok(()) +} + +#[test] +fn validate_no_transaction_conflicts_rejects_conflicting_updates() { + let err = validate_no_transaction_conflicts(&[ + InstallConflictSubject { + package: "alpha".into(), + provides: Vec::new(), + conflicts: vec!["beta".into()], + }, + InstallConflictSubject { + package: "beta".into(), + provides: Vec::new(), + conflicts: Vec::new(), + }, + ]) + .expect_err("conflicting update set should be rejected"); + + assert!( + err.to_string() + .contains("Cannot install conflicting packages in the same transaction") + ); +} + +#[test] +fn compare_versions_for_updates_handles_semver_and_date_versions() { + assert_eq!( + compare_versions_for_updates("10.8.4", "10.8.3"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("20260202", "20251231"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("1.10", "1.9"), + Ordering::Greater + ); + assert_eq!( + compare_versions_for_updates("v1.0.0", "1.0.0"), + Ordering::Equal + ); + assert_eq!( + compare_versions_for_updates("lts_2027_01_01", "20260107.1"), + Ordering::Greater + ); +} + +#[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] +fn command_requires_live_root_for_install_remove_and_update() { + assert!(command_requires_live_root(&Commands::Install( + InstallArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_or_archive: vec![PathBuf::from("foo")], + spec: None, + } + ))); + assert!(command_requires_live_root(&Commands::Remove(RemoveArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + package: "foo".to_string(), + }))); + assert!(command_requires_live_root(&Commands::Update(UpdateArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + packages: vec!["foo".to_string()], + }))); + assert!(!command_requires_live_root(&Commands::Build(BuildArgs { + rootfs_args: rootfs_args("/"), + prompt_args: prompt_args(false), + build_exec_args: build_exec_args(), + lib32_args: lib32_args(), + spec_pos: Some(PathBuf::from("foo.toml")), + spec: None, + install: false, + install_deps: false, + cleanup_deps: false, + }))); + assert!(!command_requires_live_root(&Commands::Search(SearchArgs { + rootfs_args: rootfs_args("/"), + query: "foo".to_string(), + files: false, + }))); +} diff --git a/src/commands/tests/version_cases.rs b/src/commands/tests/version_cases.rs new file mode 100644 index 0000000..eae2b11 --- /dev/null +++ b/src/commands/tests/version_cases.rs @@ -0,0 +1,161 @@ +use super::*; + +#[test] +fn extract_version_patterns_handles_git_and_release_urls() { + let git_patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); + assert!(git_patterns.contains(&VersionPattern { + prefix: "v".into(), + suffix: String::new(), + })); + + let release_patterns = extract_version_patterns( + "https://github.com/Mic92/iana-etc/releases/download/$version/iana-etc-$version.tar.gz", + ); + assert!(release_patterns.contains(&VersionPattern { + prefix: String::new(), + suffix: String::new(), + })); +} + +#[test] +fn candidate_versions_from_refs_matches_version_patterns() { + let refs = vec![ + "refs/tags/v10.8.3".to_string(), + "refs/tags/v10.8.4".to_string(), + "refs/heads/main".to_string(), + ]; + let patterns = extract_version_patterns("https://codeberg.org/Limine/limine.git#v$version"); + let candidates = candidate_versions_from_refs(&refs, &patterns); + + assert_eq!(candidates, vec!["10.8.3".to_string(), "10.8.4".to_string()]); + assert_eq!( + best_newer_version("10.8.3", candidates.iter().map(String::as_str)), + Some("10.8.4".to_string()) + ); +} + +#[test] +fn best_newer_version_skips_branches_and_prereleases() { + let candidates = ["2", "1.10.0rc1", "1.10.0", "release-0.13"]; + assert_eq!( + best_newer_version("1.9.5", candidates.into_iter()), + Some("1.10.0".to_string()) + ); +} + +#[test] +fn best_newer_version_normalizes_date_style_tags() { + let candidates = ["lts_2026_01_07", "lts_2027_02_03"]; + assert_eq!( + best_newer_version("20260107.1", candidates.into_iter()), + Some("20270203".to_string()) + ); +} + +#[test] +fn remote_git_repository_from_github_release_url_maps_to_repo_git_url() { + let repo_url = remote_git_repository_from_source_url( + "https://github.com/Mic92/iana-etc/releases/download/20260202/iana-etc-20260202.tar.gz", + ); + assert_eq!( + repo_url, + Some("https://github.com/Mic92/iana-etc.git".to_string()) + ); +} + +#[test] +fn remote_git_repository_from_gitlab_archive_url_maps_to_repo_git_url() { + let repo_url = remote_git_repository_from_source_url( + "https://gitlab.com/graphviz/graphviz/-/archive/14.1.4/graphviz-14.1.4.tar.gz", + ); + assert_eq!( + repo_url, + Some("https://gitlab.com/graphviz/graphviz.git".to_string()) + ); +} + +#[test] +fn archive_listing_probe_uses_parent_of_first_version_segment() { + let probe = archive_listing_probe( + "https://downloads.example.test/dav1d/$version/dav1d-$version.tar.xz", + "https://downloads.example.test/dav1d/1.5.3/dav1d-1.5.3.tar.xz", + ) + .expect("archive probe"); + assert_eq!(probe.listing_url, "https://downloads.example.test/dav1d/"); + assert_eq!( + probe.patterns, + vec![VersionPattern { + prefix: String::new(), + suffix: String::new(), + }] + ); +} + +#[test] +fn candidate_versions_from_listing_matches_archive_entries() { + let patterns = vec![VersionPattern { + prefix: "alsa-lib-".into(), + suffix: ".tar.bz2".into(), + }]; + let html = r#" + alsa-lib-1.2.15.3.tar.bz2 + alsa-lib-1.2.16.tar.bz2 + "#; + assert_eq!( + candidate_versions_from_listing(html, &patterns), + vec!["1.2.15.3".to_string(), "1.2.16".to_string()] + ); +} + +#[test] +fn list_archive_versions_reads_simple_http_index() -> Result<()> { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").context("bind test listener")?; + let addr = listener.local_addr().context("listener addr")?; + let server = thread::spawn(move || -> Result<()> { + let (mut stream, _) = listener.accept().context("accept request")?; + let mut reader = BufReader::new(stream.try_clone().context("clone stream")?); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .context("read request line")?; + loop { + let mut line = String::new(); + reader.read_line(&mut line).context("read header line")?; + if line == "\r\n" || line.is_empty() { + break; + } + } + assert!(request_line.starts_with("GET /pub/lib/ HTTP/1.1")); + let body = r#" + + alsa-lib-1.2.15.3.tar.bz2 + alsa-lib-1.2.16.tar.bz2 + + "#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .context("write response")?; + stream.flush().context("flush response")?; + Ok(()) + }); + + let probe = ArchiveListingProbe { + listing_url: format!("http://{addr}/pub/lib/"), + patterns: vec![VersionPattern { + prefix: "alsa-lib-".into(), + suffix: ".tar.bz2".into(), + }], + }; + let versions = list_archive_versions(&probe)?; + server.join().expect("join server")?; + assert_eq!(versions, vec!["1.2.15.3".to_string(), "1.2.16".to_string()]); + Ok(()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs old mode 100755 new mode 100644 index 4e1799a..9054249 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1238,494 +1238,4 @@ pub fn owns_path(db_path: &Path, path: &Path) -> Result> { } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{ - Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, - }; - use crate::test_support::TestEnv; - use std::path::PathBuf; - - fn mk_spec(name: &str, version: &str) -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: name.into(), - real_name: None, - version: version.into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives { - provides: vec![format!("{}-virtual", name)], - conflicts: Vec::new(), - replaces: Vec::new(), - lib32: None, - }, - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - #[test] - fn register_package_updates_in_place_and_replaces_file_list() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - - let spec_v1 = mk_spec("foo", "1.0"); - let dest1 = tmp.path().join("dest1"); - std::fs::create_dir_all(dest1.join("usr/bin")).unwrap(); - std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); - - register_package(&db_path, &spec_v1, &dest1).unwrap(); - - // Capture package id - let conn = Connection::open(&db_path).unwrap(); - let id1: i64 = conn - .query_row( - "SELECT id FROM packages WHERE name = ?1", - params!["foo"], - |r| r.get(0), - ) - .unwrap(); - - // Update with different file set - let spec_v2 = mk_spec("foo", "2.0"); - let dest2 = tmp.path().join("dest2"); - std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); - std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); - std::fs::write(dest2.join("usr/bin/new_only"), "x").unwrap(); - - register_package(&db_path, &spec_v2, &dest2).unwrap(); - - let id2: i64 = conn - .query_row( - "SELECT id FROM packages WHERE name = ?1", - params!["foo"], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(id1, id2); - - let files = get_package_files(&db_path, "foo").unwrap(); - assert!(files.contains(&"usr/bin/foo".to_string())); - assert!(files.contains(&"usr/bin/new_only".to_string())); - - let version = get_package_version(&db_path, "foo").unwrap(); - assert_eq!(version.as_deref(), Some("2.0")); - } - - #[test] - fn installed_dependency_names_include_real_name_aliases() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&destdir).unwrap(); - - let mut spec = mk_spec("libressl43", "4.3.2"); - spec.package.real_name = Some("libressl".into()); - register_package(&db_path, &spec, &destdir).unwrap(); - - let names = get_installed_dependency_names(&db_path).unwrap(); - assert!(names.contains("libressl43")); - assert!(names.contains("libressl")); - assert_eq!( - get_dependency_version(&db_path, "libressl") - .unwrap() - .as_deref(), - Some("4.3.2") - ); - } - - #[test] - fn register_package_uses_metadata_completed_at_when_present() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let spec = mk_spec("foo", "1.0"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap(); - std::fs::write( - dest.join(".metadata.toml"), - "completed_at = \"2026-03-10T12:34:56Z\"\n", - ) - .unwrap(); - - register_package(&db_path, &spec, &dest).unwrap(); - - let records = list_installed_package_records(&db_path).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].completed_at, Some(1_773_146_096)); - } - - #[test] - fn register_package_falls_back_to_destdir_mtime_when_metadata_missing() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let spec = mk_spec("foo", "1.0"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - let file = dest.join("usr/bin/foo"); - std::fs::write(&file, "bin").unwrap(); - - let ts = filetime::FileTime::from_unix_time(1_700_000_000, 0); - filetime::set_file_mtime(&file, ts).unwrap(); - filetime::set_file_mtime(dest.join("usr"), ts).unwrap(); - filetime::set_file_mtime(dest.join("usr/bin"), ts).unwrap(); - filetime::set_file_mtime(&dest, ts).unwrap(); - - register_package(&db_path, &spec, &dest).unwrap(); - - let records = list_installed_package_records(&db_path).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].completed_at, Some(1_700_000_000)); - } - - #[test] - fn register_package_detects_conflicting_files() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - - // Install package 'alpha' owning usr/bin/shared - let spec_a = mk_spec("alpha", "1.0"); - let dest_a = tmp.path().join("dest_a"); - std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); - std::fs::write(dest_a.join("usr/bin/shared"), "a").unwrap(); - register_package(&db_path, &spec_a, &dest_a).unwrap(); - - // Try to install package 'beta' that also includes the same path -> should fail - let spec_b = mk_spec("beta", "1.0"); - let dest_b = tmp.path().join("dest_b"); - std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); - std::fs::write(dest_b.join("usr/bin/shared"), "b").unwrap(); - - let res = register_package(&db_path, &spec_b, &dest_b); - assert!(res.is_err()); - let err = format!("{}", res.err().unwrap()); - assert!(err.contains("File ownership conflict detected")); - assert!(err.contains("usr/bin/shared")); - assert!(err.contains("alpha")); - } - - #[test] - fn register_package_auto_clears_safe_conflicts() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - - // Install package 'alpha' owning a known shared Perl path - let spec_a = mk_spec("alpha", "1.0"); - let dest_a = tmp.path().join("dest_a"); - std::fs::create_dir_all(dest_a.join("usr/share/perl5")).unwrap(); - std::fs::write(dest_a.join("usr/share/perl5/shared.pm"), "package A;").unwrap(); - register_package(&db_path, &spec_a, &dest_a).unwrap(); - - // Now install package 'beta' that also provides the same shared path -> should auto-clear - let spec_b = mk_spec("beta", "1.0"); - let dest_b = tmp.path().join("dest_b"); - std::fs::create_dir_all(dest_b.join("usr/share/perl5")).unwrap(); - std::fs::write(dest_b.join("usr/share/perl5/shared.pm"), "package B;").unwrap(); - - // This should succeed and transfer ownership of the shared path to beta - register_package(&db_path, &spec_b, &dest_b).unwrap(); - - // Verify DB: alpha should no longer own the path, beta should - let files_a = get_package_files(&db_path, "alpha").unwrap(); - assert!(!files_a.contains(&"usr/share/perl5/shared.pm".to_string())); - let files_b = get_package_files(&db_path, "beta").unwrap(); - assert!(files_b.contains(&"usr/share/perl5/shared.pm".to_string())); - } - - #[test] - fn register_package_auto_clears_sbase_conflicts_when_requested() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("rootfs"); - let db_path = crate::config::Config::for_rootfs(&rootfs).installed_db_path(&rootfs); - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::write(rootfs.join("usr/bin/find"), "sbase find").unwrap(); - - let spec_a = mk_spec("sbase", "1.0"); - let dest_a = tmp.path().join("dest_a"); - std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); - std::fs::write(dest_a.join("usr/bin/find"), "sbase find").unwrap(); - register_package(&db_path, &spec_a, &dest_a).unwrap(); - - let spec_b = mk_spec("bfs", "4.1"); - let dest_b = tmp.path().join("dest_b"); - std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); - std::fs::write(dest_b.join("usr/bin/find"), "bfs find").unwrap(); - - let mut env = TestEnv::new(); - env.set_var(DEPOT_BOOTSTRAP_IGNORE_SBASE_CONFLICTS, "1"); - register_package(&db_path, &spec_b, &dest_b).unwrap(); - - let files_sbase = get_package_files(&db_path, "sbase").unwrap(); - assert!(!files_sbase.contains(&"usr/bin/find".to_string())); - let files_bfs = get_package_files(&db_path, "bfs").unwrap(); - assert!(files_bfs.contains(&"usr/bin/find".to_string())); - } - - #[test] - fn register_package_auto_clear_preserves_new_payload_on_disk() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("rootfs"); - let db_path = crate::config::Config::for_rootfs(&rootfs).installed_db_path(&rootfs); - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::write(rootfs.join("usr/bin/find"), "sbase find").unwrap(); - - let spec_a = mk_spec("sbase", "1.0"); - let dest_a = tmp.path().join("dest_a"); - std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); - std::fs::write(dest_a.join("usr/bin/find"), "sbase find").unwrap(); - register_package(&db_path, &spec_a, &dest_a).unwrap(); - - std::fs::write(rootfs.join("usr/bin/find"), "bfs find").unwrap(); - let spec_b = mk_spec("bfs", "4.1"); - let dest_b = tmp.path().join("dest_b"); - std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); - std::fs::write(dest_b.join("usr/bin/find"), "bfs find").unwrap(); - - let mut env = TestEnv::new(); - env.set_var(DEPOT_BOOTSTRAP_IGNORE_SBASE_CONFLICTS, "1"); - register_package(&db_path, &spec_b, &dest_b).unwrap(); - - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/find")).unwrap(), - "bfs find" - ); - let files_sbase = get_package_files(&db_path, "sbase").unwrap(); - assert!(!files_sbase.contains(&"usr/bin/find".to_string())); - let files_bfs = get_package_files(&db_path, "bfs").unwrap(); - assert!(files_bfs.contains(&"usr/bin/find".to_string())); - } - - #[test] - fn get_package_files_missing_package_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - - // Create an empty database file with schema but no packages - let conn = Connection::open(&db_path).unwrap(); - init_db(&conn).unwrap(); - drop(conn); - - // Querying files for a package that doesn't exist should return an empty list - let files = get_package_files(&db_path, "nonexistent").unwrap(); - assert!(files.is_empty()); - } - - #[test] - fn get_package_version_missing_db_returns_none_without_creating_db() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - - let version = get_package_version(&db_path, "nonexistent").unwrap(); - assert!(version.is_none()); - assert!(!db_path.exists()); - } - - #[test] - fn calculate_upgrade_paths_handles_existing_db_file_without_schema() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - std::fs::File::create(&db_path).unwrap(); - let manifest = staging::Manifest { - files: vec!["usr/bin/foo".to_string()], - directories: Vec::new(), - }; - - let remove_paths = calculate_upgrade_paths(&db_path, "nonexistent", &manifest).unwrap(); - assert!(remove_paths.is_empty()); - } - - #[test] - fn remove_package_tolerates_missing_files_and_cleans_db() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&rootfs).unwrap(); - - let spec = mk_spec("foo", "1.0"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap(); - register_package(&db_path, &spec, &dest).unwrap(); - - // Create the installed file in rootfs (one real) - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::write(rootfs.join("usr/bin/foo"), "bin").unwrap(); - - // Inject an extra missing file into DB to ensure we tolerate it. - let conn = Connection::open(&db_path).unwrap(); - let pkg_id: i64 = conn - .query_row( - "SELECT id FROM packages WHERE name = ?1", - params!["foo"], - |r| r.get(0), - ) - .unwrap(); - conn.execute( - "INSERT OR IGNORE INTO files (package_id, path) VALUES (?1, ?2)", - params![pkg_id, "usr/bin/does_not_exist"], - ) - .unwrap(); - - remove_package(&db_path, "foo", &rootfs).unwrap(); - assert!(get_package_version(&db_path, "foo").unwrap().is_none()); - } - - #[test] - fn test_package_upgrade_removes_orphaned_files() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let rootfs = tmp.path().join("root"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - - // 1. Install v1: usr/bin/foo, usr/bin/shared_dir/old_file - let spec_v1 = mk_spec("foo", "1.0"); - let dest1 = tmp.path().join("dest1"); - std::fs::create_dir_all(dest1.join("usr/bin/shared_dir")).unwrap(); - std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); - std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap(); - - register_package(&db_path, &spec_v1, &dest1).unwrap(); - let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!(rootfs.join("usr/bin/foo").exists()); - assert!(rootfs.join("usr/bin/shared_dir/old_file").exists()); - - // 2. Prepare v2: usr/bin/foo (updated), usr/bin/new_file - // (shared_dir/old_file is removed from spec) - let spec_v2 = mk_spec("foo", "2.0"); - let dest2 = tmp.path().join("dest2"); - std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); - std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); - std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap(); - - let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap(); - let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2).unwrap(); - - assert_eq!( - remove_paths, - vec![ - "usr/bin/shared_dir/old_file".to_string(), - "usr/bin/shared_dir".to_string() - ] - ); - - let tx = - crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); - register_package(&db_path, &spec_v2, &dest2).unwrap(); - tx.commit().unwrap(); - - // 3. Verify filesystem - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), - "v2" - ); - assert!(rootfs.join("usr/bin/new_file").exists()); - assert!(!rootfs.join("usr/bin/shared_dir/old_file").exists()); - - // Check DB - let files = get_package_files(&db_path, "foo").unwrap(); - assert!(files.contains(&"usr/bin/foo".to_string())); - assert!(files.contains(&"usr/bin/new_file".to_string())); - assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string())); - } - - #[test] - fn register_package_persists_replacements() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - std::fs::write(dest.join("usr/bin/vx"), "vx").unwrap(); - - let mut spec = mk_spec("vx", "1.0"); - spec.alternatives.replaces = vec!["grep".into(), "patch".into()]; - - register_package(&db_path, &spec, &dest).unwrap(); - - let replaces = get_all_replaces(&db_path).unwrap(); - assert!(replaces.contains("grep")); - assert!(replaces.contains("patch")); - } - - #[test] - fn register_package_persists_groups() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); - - let mut spec = mk_spec("foo", "1.0"); - spec.dependencies.groups = vec!["base".into(), "desktop".into()]; - - register_package(&db_path, &spec, &dest).unwrap(); - - assert_eq!( - get_package_groups(&db_path, "foo").unwrap(), - vec!["base".to_string(), "desktop".to_string()] - ); - } - - #[test] - fn register_package_records_built_against_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(&dest).unwrap(); - std::fs::write(dest.join("payload"), "x").unwrap(); - - let mut spec = mk_spec("app", "1.0"); - spec.package.built_against = vec!["icu78".into()]; - register_package(&db_path, &spec, &dest).unwrap(); - - let records = list_installed_package_records(&db_path).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].built_against, vec!["icu78".to_string()]); - } - - #[test] - fn installed_group_helpers_round_trip_membership() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("packages.db"); - let dest = tmp.path().join("dest"); - std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); - std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); - - let mut spec = mk_spec("foo", "1.0"); - spec.dependencies.groups = vec!["base".into()]; - register_package(&db_path, &spec, &dest).unwrap(); - - record_installed_groups(&db_path, &[String::from("base")]).unwrap(); - assert!(is_installed_group(&db_path, "base").unwrap()); - assert_eq!( - get_packages_in_installed_group(&db_path, "base").unwrap(), - vec!["foo".to_string()] - ); - - remove_installed_group(&db_path, "base").unwrap(); - assert!(!is_installed_group(&db_path, "base").unwrap()); - } -} +mod tests; diff --git a/src/db/repo.rs b/src/db/repo.rs index 83d2f03..b31948e 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -183,2558 +183,16 @@ pub struct BinaryRepoFileSearchHit { pub size: u64, } -impl RepoManager { - pub fn new(repo_dir: PathBuf) -> Self { - Self { repo_dir } - } - - /// Create a compressed SQLite repository database from a directory of packages - pub fn create_repo_db(&self) -> Result { - let db_path = self.repo_dir.join("repo.db"); - let compressed_db_path = self.repo_dir.join("repo.db.zst"); - - // Remove existing DB if it exists - if db_path.exists() { - fs::remove_file(&db_path)?; - } - - let mut conn = Connection::open(&db_path) - .with_context(|| format!("Failed to create repo database at {}", db_path.display()))?; - - self.configure_repo_build_pragmas(&mut conn)?; - self.init_repo_schema(&mut conn)?; - - let package_paths = self.collect_repo_package_paths()?; - let indexed_packages = self.collect_indexed_packages_parallel(&package_paths)?; - - conn.execute_batch("BEGIN IMMEDIATE TRANSACTION;") - .context("Failed to begin repo DB write transaction")?; - let insert_result: Result<()> = (|| { - for indexed in indexed_packages { - self.insert_indexed_package(&mut conn, indexed)?; - } - Ok(()) - })(); - match insert_result { - Ok(()) => { - conn.execute_batch("COMMIT;") - .context("Failed to commit repo DB write transaction")?; - } - Err(err) => { - let _ = conn.execute_batch("ROLLBACK;"); - return Err(err); - } - } - - self.create_repo_indexes(&mut conn)?; - - conn.close().map_err(|(_, e)| e)?; - - // Compress the database - self.compress_db(&db_path, &compressed_db_path)?; - - // Remove the uncompressed DB - fs::remove_file(&db_path)?; - - Ok(compressed_db_path) - } - - fn configure_repo_build_pragmas(&self, conn: &mut Connection) -> Result<()> { - // Speed-focused settings are scoped to this temporary repo DB build process. - conn.execute_batch( - "PRAGMA synchronous = OFF; - PRAGMA journal_mode = MEMORY; - PRAGMA temp_store = MEMORY; - PRAGMA locking_mode = EXCLUSIVE; - PRAGMA cache_size = -200000;", - ) - .context("Failed to apply SQLite build PRAGMAs for repo DB creation")?; - Ok(()) - } - - fn collect_repo_package_paths(&self) -> Result> { - let mut package_paths = Vec::new(); - for entry in fs::read_dir(&self.repo_dir) - .with_context(|| format!("Failed to read {}", self.repo_dir.display()))? - { - let entry = entry?; - let path = entry.path(); - if path.is_file() && path.to_string_lossy().ends_with(".depot.pkg.tar.zst") { - package_paths.push(path); - } - } - package_paths.sort(); - Ok(package_paths) - } - - fn collect_indexed_packages_parallel( - &self, - package_paths: &[PathBuf], - ) -> Result> { - if package_paths.is_empty() { - return Ok(Vec::new()); - } - - let worker_count = num_cpus().min(package_paths.len()); - crate::log_info!( - "Using {} thread(s) to index {} package(s)...", - worker_count, - package_paths.len() - ); - - let next_index = AtomicUsize::new(0); - let mut indexed = std::thread::scope(|scope| -> Result> { - let (tx, rx) = mpsc::channel::<(usize, Result)>(); - - for _ in 0..worker_count { - let tx = tx.clone(); - let next_index = &next_index; - scope.spawn(move || { - loop { - let idx = next_index.fetch_add(1, Ordering::Relaxed); - if idx >= package_paths.len() { - break; - } - let result = self.read_indexed_package(&package_paths[idx]); - if tx.send((idx, result)).is_err() { - break; - } - } - }); - } - drop(tx); - - let mut indexed = Vec::with_capacity(package_paths.len()); - for _ in 0..package_paths.len() { - let (idx, result) = rx - .recv() - .context("Failed to receive package indexing result from worker")?; - indexed.push((idx, result?)); - } - Ok(indexed) - })?; - - indexed.sort_by_key(|(idx, _)| *idx); - Ok(indexed.into_iter().map(|(_, pkg)| pkg).collect()) - } - - fn init_repo_schema(&self, conn: &mut Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE packages ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - real_name TEXT, - version TEXT NOT NULL, - revision INTEGER NOT NULL, - abi_breaking INTEGER NOT NULL DEFAULT 0, - built_against TEXT NOT NULL DEFAULT '', - completed_at INTEGER, - description TEXT, - homepage TEXT, - license TEXT, - filename TEXT NOT NULL, - size INTEGER NOT NULL, - sha256 TEXT NOT NULL, - sha512 TEXT NOT NULL - ); - CREATE TABLE provides ( - package_id INTEGER, - name TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - ); - CREATE TABLE conflicts ( - package_id INTEGER, - name TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - ); - CREATE TABLE replaces ( - package_id INTEGER, - name TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - ); - CREATE TABLE dependencies ( - package_id INTEGER, - kind TEXT NOT NULL, - name TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - ); - CREATE TABLE groups ( - package_id INTEGER, - name TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - ); - CREATE TABLE files ( - package_id INTEGER, - path TEXT NOT NULL, - FOREIGN KEY(package_id) REFERENCES packages(id) - );", - ) - .context("Failed to initialize repo schema")?; - Ok(()) - } - - fn create_repo_indexes(&self, conn: &mut Connection) -> Result<()> { - conn.execute_batch( - "CREATE INDEX idx_packages_name ON packages(name); - CREATE INDEX idx_provides_name ON provides(name); - CREATE INDEX idx_conflicts_name ON conflicts(name); - CREATE INDEX idx_replaces_name ON replaces(name); - CREATE INDEX idx_dependencies_name ON dependencies(name); - CREATE INDEX idx_dependencies_kind ON dependencies(kind); - CREATE INDEX idx_groups_name ON groups(name); - CREATE INDEX idx_repo_files_path ON files(path);", - ) - .context("Failed to create repo DB indexes")?; - Ok(()) - } - - fn read_indexed_package(&self, pkg_path: &Path) -> Result { - crate::log_info!("Indexing package {}...", pkg_path.display()); - - let filename = pkg_path - .file_name() - .and_then(|name| name.to_str()) - .with_context(|| format!("Invalid package filename: {}", pkg_path.display()))? - .to_string(); - let file = fs::File::open(pkg_path)?; - let size = file.metadata()?.len(); - let mut hashing_reader = HashingReader::new(file); - - let mut name = String::new(); - let mut real_name = None; - let mut version = String::new(); - let mut revision = 1; - let mut abi_breaking = false; - let mut built_against = Vec::new(); - let mut completed_at = path_modified_unix_timestamp(pkg_path)?; - let mut description = None; - let mut homepage = None; - let mut license = None; - let mut provides = Vec::new(); - let mut conflicts = Vec::new(); - let mut replaces = Vec::new(); - let mut runtime_dependencies = Vec::new(); - let mut optional_dependencies = Vec::new(); - let mut groups = Vec::new(); - let mut archive_files = Vec::new(); - - { - let zstd_decoder = zstd::stream::read::Decoder::new(&mut hashing_reader)?; - let mut archive = tar::Archive::new(zstd_decoder); - for entry in archive.entries()? { - let mut entry = entry?; - let path = entry.path()?; - let path_str = path.to_string_lossy().to_string(); - if path_str == ".metadata.toml" { - let mut content = String::new(); - use std::io::Read; - entry.read_to_string(&mut content)?; - let metadata: toml::Value = toml::from_str(&content).with_context(|| { - format!("Failed to parse .metadata.toml in {}", pkg_path.display()) - })?; - - name = metadata - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - real_name = metadata - .get("real_name") - .and_then(|v| v.as_str()) - .map(String::from); - version = metadata - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - revision = metadata - .get("revision") - .and_then(|v| v.as_integer()) - .unwrap_or(1) as u32; - abi_breaking = metadata - .get("abi_breaking") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - built_against = parse_string_array_metadata(&metadata, "built_against"); - completed_at = - metadata_time::parse_completed_at_value(&metadata).or(completed_at); - description = metadata - .get("description") - .and_then(|v| v.as_str()) - .map(String::from); - homepage = metadata - .get("homepage") - .and_then(|v| v.as_str()) - .map(String::from); - license = parse_license_text(&metadata); - - if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array()) - { - provides = provides_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(conflicts_arr) = - metadata.get("conflicts").and_then(|v| v.as_array()) - { - conflicts = conflicts_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(replaces_arr) = metadata.get("replaces").and_then(|v| v.as_array()) - { - replaces = replaces_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(runtime_arr) = metadata - .get("dependencies") - .and_then(|v| v.get("runtime")) - .and_then(|v| v.as_array()) - { - runtime_dependencies = runtime_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(optional_arr) = metadata - .get("dependencies") - .and_then(|v| v.get("optional")) - .and_then(|v| v.as_array()) - { - optional_dependencies = optional_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - if let Some(groups_arr) = metadata - .get("dependencies") - .and_then(|v| v.get("groups")) - .and_then(|v| v.as_array()) - { - groups = groups_arr - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - continue; - } - - let entry_type = entry.header().entry_type(); - if entry_type.is_file() || entry_type.is_symlink() || entry_type.is_hard_link() { - let normalized = path_str.trim_start_matches("./").to_string(); - if normalized == ".metadata.toml" { - continue; - } - archive_files.push(normalized); - } - } - } - let (sha256, sha512) = hashing_reader.finalize_hex(); - - if name.is_empty() { - // Fallback for packages WITHOUT metadata (e.g. legacy or during transition) - let name_parts: Vec<&str> = filename.split('-').collect(); - if name_parts.len() < 4 { - anyhow::bail!( - "Invalid package filename and no .metadata.toml: {}", - filename - ); - } - name = name_parts[0].to_string(); - version = name_parts[1].to_string(); - revision = name_parts[2].parse().unwrap_or(1); - } - - Ok(IndexedPackage { - name, - real_name, - version, - revision, - abi_breaking, - built_against, - completed_at, - description, - homepage, - license, - filename, - size, - sha256, - sha512, - provides, - conflicts, - replaces, - runtime_dependencies, - optional_dependencies, - groups, - archive_files, - }) - } - - fn insert_indexed_package(&self, conn: &mut Connection, indexed: IndexedPackage) -> Result<()> { - let IndexedPackage { - name, - real_name, - version, - revision, - abi_breaking, - built_against, - completed_at, - description, - homepage, - license, - filename, - size, - sha256, - sha512, - provides, - conflicts, - replaces, - runtime_dependencies, - optional_dependencies, - groups, - archive_files, - } = indexed; - - // Insert into database - conn.execute( - "INSERT INTO packages (name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - params![ - name, - real_name, - version, - revision as i64, - abi_breaking, - format_built_against(&built_against), - completed_at, - description, - homepage, - license, - filename, - size as i64, - sha256, - sha512 - ], - )?; - - let package_id = conn.last_insert_rowid(); - - // Insert into provides - for provide in provides { - conn.execute( - "INSERT INTO provides (package_id, name) VALUES (?1, ?2)", - params![package_id, provide], - )?; - } - for conflict in conflicts { - conn.execute( - "INSERT INTO conflicts (package_id, name) VALUES (?1, ?2)", - params![package_id, conflict], - )?; - } - for replacement in replaces { - conn.execute( - "INSERT INTO replaces (package_id, name) VALUES (?1, ?2)", - params![package_id, replacement], - )?; - } - - for dep in runtime_dependencies { - conn.execute( - "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'runtime', ?2)", - params![package_id, dep], - )?; - } - for dep in optional_dependencies { - conn.execute( - "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)", - params![package_id, dep], - )?; - } - for group in groups { - conn.execute( - "INSERT INTO groups (package_id, name) VALUES (?1, ?2)", - params![package_id, group], - )?; - } - - for file_path in archive_files { - conn.execute( - "INSERT INTO files (package_id, path) VALUES (?1, ?2)", - params![package_id, file_path], - )?; - } - - Ok(()) - } - - fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> { - let mut input = fs::File::open(source)?; - let output = fs::File::create(dest)?; - let mut encoder = Encoder::new(output, 19)?; // High compression for repo DB - encoder.multithread(num_cpus() as u32)?; - std::io::copy(&mut input, &mut encoder)?; - encoder.finish()?; - Ok(()) - } -} - -fn binary_repo_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { - package_cache_dir.join("repos").join(repo_name) -} - -fn binary_repo_packages_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { - binary_repo_cache_dir(package_cache_dir, repo_name).join("packages") -} - -fn join_repo_url(base: &str, rel: &str) -> Result { - let base = if base.ends_with('/') { - base.to_string() - } else { - format!("{base}/") - }; - let url = url::Url::parse(&base).with_context(|| format!("Invalid repo URL: {base}"))?; - Ok(url - .join(rel) - .with_context(|| format!("Invalid repo db path '{}'", rel))? - .to_string()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FileUrlCopyOutcome { - NotFileUrl, - Copied, - Missing, -} - -#[derive(Debug, Clone, Eq, Hash, PartialEq)] -struct RepoDbFetchCacheKey { - repo_name: String, - base_url: String, - repo_db_rel: String, - rootfs: PathBuf, - package_cache_dir: PathBuf, -} - -fn repo_db_fetch_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn get_cached_repo_db_path(cache_key: &RepoDbFetchCacheKey) -> Option { - let mut cache = repo_db_fetch_cache() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let cached = cache.get(cache_key).cloned()?; - if cached.exists() { - return Some(cached); - } - cache.remove(cache_key); - None -} - -fn remember_repo_db_path(cache_key: RepoDbFetchCacheKey, db_path: PathBuf) { - let mut cache = repo_db_fetch_cache() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - cache.insert(cache_key, db_path); -} - -fn copy_file_url_to_path(url: &str, dest: &Path) -> Result { - let parsed = match url::Url::parse(url) { - Ok(parsed) => parsed, - Err(_) => return Ok(FileUrlCopyOutcome::NotFileUrl), - }; - if parsed.scheme() != "file" { - return Ok(FileUrlCopyOutcome::NotFileUrl); - } - - let src = parsed - .to_file_path() - .map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", url))?; - if !src.exists() { - return Ok(FileUrlCopyOutcome::Missing); - } - if !src.is_file() { - anyhow::bail!("file:// URL is not a file: {}", src.display()); - } - - fs::copy(&src, dest) - .with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))?; - Ok(FileUrlCopyOutcome::Copied) -} - -fn fetch_url_to_path(client: &reqwest::blocking::Client, url: &str, dest: &Path) -> Result { - match copy_file_url_to_path(url, dest)? { - FileUrlCopyOutcome::Copied => return Ok(true), - FileUrlCopyOutcome::Missing => return Ok(false), - FileUrlCopyOutcome::NotFileUrl => {} - } - - let mut resp = client - .get(url) - .send() - .with_context(|| format!("Failed to fetch {}", url))?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(false); - } - if !resp.status().is_success() { - anyhow::bail!("Failed to fetch {}: HTTP {}", url, resp.status()); - } - - let mut out = - fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?; - std::io::copy(&mut resp, &mut out) - .with_context(|| format!("Failed to save {}", dest.display()))?; - out.flush() - .with_context(|| format!("Failed to flush {}", dest.display()))?; - Ok(true) -} - -fn extract_html_href_targets(html: &str) -> Vec { - let lower = html.to_ascii_lowercase(); - let lower_bytes = lower.as_bytes(); - let html_bytes = html.as_bytes(); - let mut out = Vec::new(); - let mut i = 0usize; - - while i < lower_bytes.len() { - let Some(rel) = lower[i..].find("href") else { - break; - }; - let mut j = i + rel + 4; - while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { - j += 1; - } - if j >= lower_bytes.len() || lower_bytes[j] != b'=' { - i = j; - continue; - } - j += 1; - while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { - j += 1; - } - if j >= lower_bytes.len() { - break; - } - - let (start, end) = if lower_bytes[j] == b'"' || lower_bytes[j] == b'\'' { - let quote = lower_bytes[j]; - let start = j + 1; - let mut k = start; - while k < lower_bytes.len() && lower_bytes[k] != quote { - k += 1; - } - (start, k) - } else { - let start = j; - let mut k = start; - while k < lower_bytes.len() - && !lower_bytes[k].is_ascii_whitespace() - && lower_bytes[k] != b'>' - { - k += 1; - } - (start, k) - }; - - if start < end && end <= html_bytes.len() { - out.push(String::from_utf8_lossy(&html_bytes[start..end]).to_string()); - } - i = end.saturating_add(1); - } - - out -} - -fn default_repo_public_key_candidate_names(base_url: &str) -> Result> { - let mut names = vec![ - "vertex.pub".to_string(), - "depot.pub".to_string(), - "depot.minisign.pub".to_string(), - "minisign.pub".to_string(), - "repo.pub".to_string(), - ]; - - if let Ok(parsed) = url::Url::parse(base_url) - && let Some(last_segment) = parsed - .path_segments() - .and_then(|mut segments| segments.rfind(|s| !s.is_empty())) - { - names.push(format!("{}.pub", last_segment)); - } - - names.sort(); - names.dedup(); - Ok(names) -} - -fn probe_repo_public_key_urls( - base_url: &str, - client: &reqwest::blocking::Client, -) -> Result> { - let mut out = Vec::new(); - for key_name in default_repo_public_key_candidate_names(base_url)? { - let key_url = join_repo_url(base_url, &format!("keys/{}", key_name))?; - let resp = client - .get(&key_url) - .send() - .with_context(|| format!("Failed to fetch {}", key_url))?; - if resp.status().is_success() { - out.push((key_name, key_url)); - } - } - out.sort(); - out.dedup(); - Ok(out) -} - -fn list_repo_public_key_urls( - base_url: &str, - client: &reqwest::blocking::Client, -) -> Result> { - let parsed = - url::Url::parse(base_url).with_context(|| format!("Invalid repo URL: {base_url}"))?; - if parsed.scheme() == "file" { - let repo_dir = parsed - .to_file_path() - .map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", base_url))?; - let keys_dir = repo_dir.join("keys"); - if !keys_dir.exists() { - return Ok(Vec::new()); - } - if !keys_dir.is_dir() { - anyhow::bail!( - "Binary repo keys path is not a directory: {}", - keys_dir.display() - ); - } - - let mut out = Vec::new(); - for entry in fs::read_dir(&keys_dir) - .with_context(|| format!("Failed to read {}", keys_dir.display()))? - { - let path = entry?.path(); - if !path.is_file() { - continue; - } - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - if !name.to_ascii_lowercase().ends_with(".pub") { - continue; - } - let key_url = url::Url::from_file_path(&path) - .map_err(|_| anyhow::anyhow!("Failed to build file:// URL for {}", path.display()))? - .to_string(); - out.push((name.to_string(), key_url)); - } - out.sort(); - out.dedup(); - return Ok(out); - } - - let mut out = Vec::new(); - let keys_url = join_repo_url(base_url, "keys/")?; - let resp = client - .get(&keys_url) - .send() - .with_context(|| format!("Failed to fetch {}", keys_url))?; - if resp.status().is_success() { - let body = resp - .text() - .with_context(|| format!("Failed to read {}", keys_url))?; - let keys_base = url::Url::parse(&keys_url) - .with_context(|| format!("Invalid repo keys URL: {}", keys_url))?; - - for href in extract_html_href_targets(&body) { - if href.is_empty() || href.starts_with('#') || href.starts_with('?') { - continue; - } - let Ok(url) = keys_base.join(&href) else { - continue; - }; - let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else { - continue; - }; - if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") { - continue; - } - out.push((name.to_string(), url.to_string())); - } - } - - if out.is_empty() { - out = probe_repo_public_key_urls(base_url, client)?; - } - - out.sort(); - out.dedup(); - Ok(out) -} - -fn verify_with_any_trusted_public_key( - rootfs: &Path, - input: &Path, - sig_path: &Path, -) -> Result { - let keys = crate::signing::load_trusted_public_keys(rootfs)?; - if keys.is_empty() { - anyhow::bail!("No trusted minisign public keys found in rootfs or host"); - } - crate::signing::verify_zst_file_detached_with_trusted_keys(input, sig_path, &keys) -} - -fn sanitize_filename_component(input: &str) -> String { - input - .chars() - .map(|ch| match ch { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch, - _ => '_', - }) - .collect() -} - -fn install_trusted_repo_public_key( - rootfs: &Path, - repo_name: &str, - source_key_path: &Path, - source_name: &str, -) -> Result { - let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); - fs::create_dir_all(&trusted_dir) - .with_context(|| format!("Failed to create {}", trusted_dir.display()))?; - - let base_name = source_name - .split('/') - .next_back() - .filter(|name| !name.is_empty()) - .unwrap_or("repo.pub"); - let base_name = sanitize_filename_component(base_name); - let repo_prefix = sanitize_filename_component(repo_name); - - let source_bytes = fs::read(source_key_path) - .with_context(|| format!("Failed to read {}", source_key_path.display()))?; - let mut candidates = Vec::new(); - candidates.push(trusted_dir.join(&base_name)); - if !repo_prefix.is_empty() { - candidates.push(trusted_dir.join(format!("{}-{}", repo_prefix, base_name))); - } - - for candidate in &candidates { - if candidate.exists() { - let existing = fs::read(candidate) - .with_context(|| format!("Failed to read {}", candidate.display()))?; - if existing == source_bytes { - return Ok(candidate.clone()); - } - } else { - fs::write(candidate, &source_bytes) - .with_context(|| format!("Failed to write {}", candidate.display()))?; - return Ok(candidate.clone()); - } - } - - for idx in 1usize.. { - let candidate = trusted_dir.join(format!("{}-{}.{}", repo_prefix, base_name, idx)); - if candidate.exists() { - let existing = fs::read(&candidate) - .with_context(|| format!("Failed to read {}", candidate.display()))?; - if existing == source_bytes { - return Ok(candidate); - } - continue; - } - fs::write(&candidate, &source_bytes) - .with_context(|| format!("Failed to write {}", candidate.display()))?; - return Ok(candidate); - } - - unreachable!("infinite loop returns on first available candidate") -} - -fn try_trust_repo_public_key_for_repo_db( - repo_name: &str, - base_url: &str, - rootfs: &Path, - cache_dir: &Path, - client: &reqwest::blocking::Client, - repo_db_zst_path: &Path, - repo_db_sig_path: &Path, -) -> Result> { - let repo_keys = list_repo_public_key_urls(base_url, client)?; - if repo_keys.is_empty() { - return Ok(None); - } - - let repo_keys_cache_dir = cache_dir.join("repo_keys"); - fs::create_dir_all(&repo_keys_cache_dir) - .with_context(|| format!("Failed to create {}", repo_keys_cache_dir.display()))?; - - for (key_name, key_url) in repo_keys { - let cache_name = sanitize_filename_component(&key_name); - let key_tmp_path = repo_keys_cache_dir.join(&cache_name); - if !fetch_url_to_path(client, &key_url, &key_tmp_path)? { - continue; - } - - if crate::signing::verify_zst_file_detached_with_public_key( - repo_db_zst_path, - repo_db_sig_path, - &key_tmp_path, - ) - .is_err() - { - continue; - } - - let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); - let prompt = format!( - "Trust repo key '{}' from binary repo '{}' and copy it to {}?", - key_name, - repo_name, - trusted_dir.display() - ); - if !crate::ui::prompt_yes_no(&prompt, true)? { - crate::log_warn!( - "Skipped trusting repo key '{}' for binary repo '{}'", - key_name, - repo_name - ); - continue; - } - - let installed = - install_trusted_repo_public_key(rootfs, repo_name, &key_tmp_path, &key_name)?; - return Ok(Some(installed)); - } - - Ok(None) -} - -fn normalize_git_mirror_url(url: &str) -> Result { - let parsed = match url::Url::parse(url) { - Ok(parsed) => parsed, - Err(_) => return Ok(url.to_string()), - }; - if parsed.scheme() != "file" { - return Ok(url.to_string()); - } - let path = parsed - .to_file_path() - .map_err(|_| anyhow::anyhow!("Invalid file:// mirror URL: {}", url))?; - Ok(path.to_string_lossy().into_owned()) -} - -fn decompress_zstd_file(src: &Path, dst: &Path) -> Result<()> { - let mut input = - fs::File::open(src).with_context(|| format!("Failed to open {}", src.display()))?; - let mut decoder = zstd::stream::read::Decoder::new(&mut input) - .with_context(|| format!("Failed to open zstd decoder for {}", src.display()))?; - let tmp = dst.with_extension("tmp"); - let mut output = - fs::File::create(&tmp).with_context(|| format!("Failed to create {}", tmp.display()))?; - std::io::copy(&mut decoder, &mut output) - .with_context(|| format!("Failed to decompress {}", src.display()))?; - output - .flush() - .with_context(|| format!("Failed to flush {}", tmp.display()))?; - fs::rename(&tmp, dst) - .with_context(|| format!("Failed to move {} to {}", tmp.display(), dst.display()))?; - Ok(()) -} - -/// Fetch (or refresh) a binary repo `repo.db.zst` into the configured package cache. -/// -/// Returns the path to the decompressed SQLite database file. -pub fn fetch_binary_repo_db( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, -) -> Result { - let machine_arch = std::env::consts::ARCH; - let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { - format!( - "Binary repo '{}' is not configured for machine arch '{}'", - repo_name, machine_arch - ) - })?; - let repo_db_rel = repo - .effective_repo_db_for_arch(machine_arch) - .with_context(|| { - format!( - "Binary repo '{}' is not configured for machine arch '{}'", - repo_name, machine_arch - ) - })?; - let cache_key = RepoDbFetchCacheKey { - repo_name: repo_name.to_string(), - base_url: base_url.to_string(), - repo_db_rel: repo_db_rel.to_string(), - rootfs: rootfs.to_path_buf(), - package_cache_dir: package_cache_dir.to_path_buf(), - }; - if let Some(cached_db_path) = get_cached_repo_db_path(&cache_key) { - return Ok(cached_db_path); - } - - let cache_dir = binary_repo_cache_dir(package_cache_dir, repo_name); - fs::create_dir_all(&cache_dir) - .with_context(|| format!("Failed to create {}", cache_dir.display()))?; - - let repo_db_zst = cache_dir.join("repo.db.zst"); - let repo_db_sig = cache_dir.join("repo.db.zst.sig"); - let repo_db_sqlite = cache_dir.join("repo.db"); - let tmp_zst = cache_dir.join("repo.db.zst.tmp"); - let tmp_sig = cache_dir.join("repo.db.zst.sig.tmp"); - - let repo_db_url = join_repo_url(base_url, repo_db_rel)?; - let repo_sig_url = join_repo_url(base_url, &format!("{}.sig", repo_db_rel))?; - crate::log_info!("Fetching binary repo DB for '{}'", repo_name); - - let client = reqwest::blocking::Client::builder() - .build() - .context("Failed to build HTTP client for binary repo fetch")?; - match copy_file_url_to_path(&repo_db_url, &tmp_zst)? { - FileUrlCopyOutcome::Copied => {} - FileUrlCopyOutcome::Missing => { - if repo_db_sqlite.exists() { - crate::log_warn!( - "Failed to refresh binary repo '{}' (missing local file), using cached DB: {}", - repo_name, - repo_db_url - ); - remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone()); - return Ok(repo_db_sqlite); - } - anyhow::bail!("Failed to fetch {}: local file not found", repo_db_url); - } - FileUrlCopyOutcome::NotFileUrl => { - let resp = client - .get(&repo_db_url) - .send() - .with_context(|| format!("Failed to fetch {}", repo_db_url))?; - - if !resp.status().is_success() { - if repo_db_sqlite.exists() { - crate::log_warn!( - "Failed to refresh binary repo '{}' (HTTP {}), using cached DB", - repo_name, - resp.status() - ); - remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone()); - return Ok(repo_db_sqlite); - } - anyhow::bail!("Failed to fetch {}: HTTP {}", repo_db_url, resp.status()); - } - - let mut resp = resp; - let mut out = fs::File::create(&tmp_zst) - .with_context(|| format!("Failed to create {}", tmp_zst.display()))?; - std::io::copy(&mut resp, &mut out) - .with_context(|| format!("Failed to save {}", tmp_zst.display()))?; - out.flush() - .with_context(|| format!("Failed to flush {}", tmp_zst.display()))?; - } - } - - let sig_downloaded = match copy_file_url_to_path(&repo_sig_url, &tmp_sig)? { - FileUrlCopyOutcome::Copied => true, - FileUrlCopyOutcome::Missing => { - if !repo.allow_unsigned { - anyhow::bail!( - "Failed to fetch detached signature for binary repo '{}' (local file not found): {}", - repo_name, - repo_sig_url - ); - } - crate::log_warn!( - "Binary repo '{}' has no detached signature (missing local file) for {}; allow_unsigned=true so continuing", - repo_name, - repo_db_url - ); - false - } - FileUrlCopyOutcome::NotFileUrl => { - let sig_resp = client - .get(&repo_sig_url) - .send() - .with_context(|| format!("Failed to fetch {}", repo_sig_url))?; - if sig_resp.status().is_success() { - let mut sig_resp = sig_resp; - let mut sig_out = fs::File::create(&tmp_sig) - .with_context(|| format!("Failed to create {}", tmp_sig.display()))?; - std::io::copy(&mut sig_resp, &mut sig_out) - .with_context(|| format!("Failed to save {}", tmp_sig.display()))?; - sig_out - .flush() - .with_context(|| format!("Failed to flush {}", tmp_sig.display()))?; - true - } else { - if !repo.allow_unsigned { - anyhow::bail!( - "Failed to fetch detached signature for binary repo '{}' (HTTP {}): {}", - repo_name, - sig_resp.status(), - repo_sig_url - ); - } - crate::log_warn!( - "Binary repo '{}' has no detached signature (HTTP {}) for {}; allow_unsigned=true so continuing", - repo_name, - sig_resp.status(), - repo_db_url - ); - false - } - } - }; - - if sig_downloaded { - let mut trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?; - if trusted_keys.is_empty() { - if try_trust_repo_public_key_for_repo_db( - repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig, - )? - .is_some() - { - crate::log_info!("Trusted repo key for '{}' installed", repo_name); - } else if !repo.allow_unsigned { - anyhow::bail!( - "No trusted minisign public key found for binary repo '{}' and no trusted key was accepted from {}/keys/", - repo_name, - base_url.trim_end_matches('/') - ); - } else { - crate::log_warn!( - "No trusted minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true", - repo_name - ); - } - trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?; - } - - if trusted_keys.is_empty() { - // No key was trusted/installed, and allow_unsigned=true already handled above. - } else { - if let Err(initial_err) = verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig) - { - if try_trust_repo_public_key_for_repo_db( - repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig, - )? - .is_some() - { - crate::log_info!("Trusted repo key for '{}' installed", repo_name); - verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig).with_context( - || { - format!( - "Failed to verify detached signature for binary repo '{}'", - repo_name - ) - }, - )?; - } else { - return Err(initial_err).with_context(|| { - format!( - "Failed to verify detached signature for binary repo '{}'", - repo_name - ) - }); - } - } - crate::log_info!( - "Verified detached signature for binary repo '{}'", - repo_name - ); - } - } - - fs::rename(&tmp_zst, &repo_db_zst).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_zst.display(), - repo_db_zst.display() - ) - })?; - if sig_downloaded { - fs::rename(&tmp_sig, &repo_db_sig).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_sig.display(), - repo_db_sig.display() - ) - })?; - } else if repo_db_sig.exists() { - let _ = fs::remove_file(&repo_db_sig); - } - - decompress_zstd_file(&repo_db_zst, &repo_db_sqlite)?; - remember_repo_db_path(cache_key, repo_db_sqlite.clone()); - Ok(repo_db_sqlite) -} - -/// Search a cached binary repository SQLite DB by package name or provided feature. -pub fn search_cached_binary_repo_db( - repo_name: &str, - db_path: &Path, - query: &str, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let like = format!("%{}%", query.to_ascii_lowercase()); - let mut stmt = conn.prepare( - "SELECT - p.name, - p.version, - p.revision, - p.description, - p.filename, - p.size, - GROUP_CONCAT(DISTINCT pr_all.name) - FROM packages p - LEFT JOIN provides pr_all ON pr_all.package_id = p.id - WHERE lower(p.name) LIKE ?1 - OR EXISTS ( - SELECT 1 FROM provides pr - WHERE pr.package_id = p.id - AND lower(pr.name) LIKE ?1 - ) - GROUP BY p.id - ORDER BY - CASE - WHEN lower(p.name) = lower(?2) THEN 0 - WHEN lower(p.name) LIKE lower(?3) THEN 1 - ELSE 2 - END, - p.name ASC", - )?; - - let starts = format!("{}%", query.to_ascii_lowercase()); - let rows = stmt.query_map(params![like, query, starts], |row| { - let provides_csv: Option = row.get(6)?; - Ok(BinaryRepoSearchHit { - repo_name: repo_name.to_string(), - name: row.get(0)?, - version: row.get(1)?, - revision: row.get::<_, i64>(2)? as u32, - description: row.get(3)?, - filename: row.get(4)?, - size: row.get::<_, i64>(5)? as u64, - provides: provides_csv - .map(|s| { - s.split(',') - .filter(|v| !v.is_empty()) - .map(|v| v.to_string()) - .collect::>() - }) - .unwrap_or_default(), - }) - })?; - - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -/// Fetch and search a binary repo by name or provide. -pub fn search_binary_repo( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - query: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - search_cached_binary_repo_db(repo_name, &db_path, query) -} - -/// Search a cached binary repo DB by file path substring. -pub fn search_cached_binary_repo_files( - repo_name: &str, - db_path: &Path, - query: &str, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let has_files_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_files_table { - return Ok(Vec::new()); - } - - let like = format!("%{}%", query.to_ascii_lowercase()); - let mut stmt = conn.prepare( - "SELECT p.name, p.version, p.revision, f.path, p.size - FROM files f - JOIN packages p ON p.id = f.package_id - WHERE lower(f.path) LIKE ?1 - ORDER BY p.name ASC, f.path ASC", - )?; - let rows = stmt.query_map(params![like], |row| { - Ok(BinaryRepoFileSearchHit { - repo_name: repo_name.to_string(), - package_name: row.get(0)?, - version: row.get(1)?, - revision: row.get::<_, i64>(2)? as u32, - path: row.get(3)?, - size: row.get::<_, i64>(4)? as u64, - }) - })?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -/// Fetch and search a binary repo by file path substring. -pub fn search_binary_repo_files( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - query: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - search_cached_binary_repo_files(repo_name, &db_path, query) -} - -/// Find the package(s) that own a file path in a cached binary repo DB. -pub fn cached_binary_repo_owns_path( - repo_name: &str, - db_path: &Path, - path: &str, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let has_files_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_files_table { - return Ok(Vec::new()); - } - - let normalized = path.trim_start_matches('/').trim_start_matches("./"); - let mut stmt = conn.prepare( - "SELECT p.name, p.version, p.revision, f.path, p.size - FROM files f - JOIN packages p ON p.id = f.package_id - WHERE f.path = ?1 - ORDER BY p.name ASC", - )?; - let rows = stmt.query_map(params![normalized], |row| { - Ok(BinaryRepoFileSearchHit { - repo_name: repo_name.to_string(), - package_name: row.get(0)?, - version: row.get(1)?, - revision: row.get::<_, i64>(2)? as u32, - path: row.get(3)?, - size: row.get::<_, i64>(4)? as u64, - }) - })?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn repo_owns_query_candidates(rootfs: &Path, path: &str) -> Vec { - let normalized = path.trim_start_matches('/').trim_start_matches("./"); - let mut candidates = BTreeSet::new(); - if !normalized.is_empty() { - candidates.insert(normalized.to_string()); - } - - let query_path = Path::new(path); - let fs_path = if query_path.is_absolute() { - rootfs.join(query_path.strip_prefix("/").unwrap_or(query_path)) - } else { - rootfs.join(query_path) - }; - - if let Ok(resolved) = fs::canonicalize(&fs_path) - && let Some(rel) = resolved_repo_owns_path(rootfs, &resolved) - && !rel.is_empty() - { - candidates.insert(rel); - } - - candidates.into_iter().collect() -} - -fn resolved_repo_owns_path(rootfs: &Path, resolved: &Path) -> Option { - if rootfs == Path::new("/") { - return Some( - resolved - .to_string_lossy() - .trim_start_matches('/') - .to_string(), - ); - } - - resolved - .strip_prefix(rootfs) - .ok() - .map(|rel| rel.to_string_lossy().trim_start_matches('/').to_string()) -} - -/// Fetch repo metadata and resolve file ownership in a binary repo. -pub fn binary_repo_owns_path( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - path: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - let mut hits = Vec::new(); - let mut seen = BTreeSet::new(); - for candidate in repo_owns_query_candidates(rootfs, path) { - for hit in cached_binary_repo_owns_path(repo_name, &db_path, &candidate)? { - let key = format!( - "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{}", - hit.repo_name, hit.package_name, hit.version, hit.revision, hit.path - ); - if seen.insert(key) { - hits.push(hit); - } - } - } - Ok(hits) -} - -fn query_package_provides(conn: &Connection, package_id: i64) -> Result> { - let mut stmt = conn.prepare("SELECT name FROM provides WHERE package_id = ?1 ORDER BY name")?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn query_package_conflicts(conn: &Connection, package_id: i64) -> Result> { - let has_conflicts_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='conflicts'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_conflicts_table { - return Ok(Vec::new()); - } - - let mut stmt = - conn.prepare("SELECT name FROM conflicts WHERE package_id = ?1 ORDER BY name")?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn query_package_replaces(conn: &Connection, package_id: i64) -> Result> { - let has_replaces_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='replaces'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_replaces_table { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare("SELECT name FROM replaces WHERE package_id = ?1 ORDER BY name")?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn query_package_runtime_deps(conn: &Connection, package_id: i64) -> Result> { - let has_dependencies_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_dependencies_table { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare( - "SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'runtime' ORDER BY name", - )?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn query_package_built_against(conn: &Connection, package_id: i64) -> Result> { - if !repo_packages_have_built_against(conn)? { - return Ok(Vec::new()); - } - - let raw: String = conn.query_row( - "SELECT built_against FROM packages WHERE id = ?1", - params![package_id], - |row| row.get(0), - )?; - Ok(parse_built_against(&raw)) -} - -fn query_package_optional_deps(conn: &Connection, package_id: i64) -> Result> { - let has_dependencies_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_dependencies_table { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare( - "SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'optional' ORDER BY name", - )?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn query_package_groups(conn: &Connection, package_id: i64) -> Result> { - let has_groups_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_groups_table { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare("SELECT name FROM groups WHERE package_id = ?1 ORDER BY name")?; - let rows = stmt.query_map(params![package_id], |row| row.get(0))?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -fn find_cached_binary_repo_packages( - repo_name: &str, - db_path: &Path, - query: &str, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let completed_at_expr = if repo_packages_have_completed_at(&conn)? { - "p.completed_at" - } else { - "NULL" - }; - let real_name_expr = if repo_packages_have_real_name(&conn)? { - "p.real_name" - } else { - "NULL" - }; - let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { - "p.abi_breaking" - } else { - "0" - }; - let built_against_expr = if repo_packages_have_built_against(&conn)? { - "p.built_against" - } else { - "''" - }; - let sql = format!( - "SELECT - p.id, - p.name, - {real_name_expr}, - p.version, - p.revision, - {abi_breaking_expr}, - {built_against_expr}, - {completed_at_expr}, - p.filename, - p.size, - p.sha512, - p.description, - p.homepage, - p.license - FROM packages p - WHERE lower(p.name) = lower(?1) - OR lower({real_name_expr}) = lower(?1) - OR EXISTS ( - SELECT 1 FROM replaces rp - WHERE rp.package_id = p.id - AND lower(rp.name) = lower(?1) - ) - OR EXISTS ( - SELECT 1 FROM provides pr - WHERE pr.package_id = p.id - AND lower(pr.name) = lower(?1) - ) - ORDER BY - CASE - WHEN EXISTS ( - SELECT 1 FROM replaces rp - WHERE rp.package_id = p.id - AND lower(rp.name) = lower(?1) - ) THEN 0 - WHEN lower(p.name) = lower(?1) THEN 1 - WHEN lower({real_name_expr}) = lower(?1) THEN 2 - ELSE 2 - END, - p.name ASC" - ); - let mut stmt = conn.prepare(&sql)?; - - let rows = stmt.query_map(params![query], |row| { - let package_id = row.get::<_, i64>(0)?; - Ok(( - package_id, - BinaryRepoPackageRecord { - repo_name: repo_name.to_string(), - name: row.get(1)?, - real_name: row.get(2)?, - version: row.get(3)?, - revision: row.get::<_, i64>(4)? as u32, - abi_breaking: row.get(5)?, - built_against: parse_built_against(&row.get::<_, String>(6)?), - completed_at: row.get(7)?, - filename: row.get(8)?, - size: row.get::<_, i64>(9)? as u64, - sha512: row.get(10)?, - description: row.get(11)?, - homepage: row.get(12)?, - license: row.get(13)?, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }, - )) - })?; - - let mut out = Vec::new(); - for row in rows { - let (package_id, mut rec) = row?; - rec.provides = query_package_provides(&conn, package_id)?; - rec.conflicts = query_package_conflicts(&conn, package_id)?; - rec.replaces = query_package_replaces(&conn, package_id)?; - rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; - rec.built_against = query_package_built_against(&conn, package_id)?; - rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; - rec.groups = query_package_groups(&conn, package_id)?; - out.push(rec); - } - Ok(out) -} - -fn find_cached_binary_repo_packages_by_group( - repo_name: &str, - db_path: &Path, - group: &str, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let has_groups_table: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap_or(false); - if !has_groups_table { - return Ok(Vec::new()); - } - - let completed_at_expr = if repo_packages_have_completed_at(&conn)? { - "p.completed_at" - } else { - "NULL" - }; - let real_name_expr = if repo_packages_have_real_name(&conn)? { - "p.real_name" - } else { - "NULL" - }; - let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { - "p.abi_breaking" - } else { - "0" - }; - let built_against_expr = if repo_packages_have_built_against(&conn)? { - "p.built_against" - } else { - "''" - }; - let sql = format!( - "SELECT - p.id, - p.name, - {real_name_expr}, - p.version, - p.revision, - {abi_breaking_expr}, - {built_against_expr}, - {completed_at_expr}, - p.filename, - p.size, - p.sha512, - p.description, - p.homepage, - p.license - FROM packages p - WHERE EXISTS ( - SELECT 1 FROM groups g - WHERE g.package_id = p.id - AND lower(g.name) = lower(?1) - ) - ORDER BY p.name ASC" - ); - let mut stmt = conn.prepare(&sql)?; - - let rows = stmt.query_map(params![group], |row| { - let package_id = row.get::<_, i64>(0)?; - Ok(( - package_id, - BinaryRepoPackageRecord { - repo_name: repo_name.to_string(), - name: row.get(1)?, - real_name: row.get(2)?, - version: row.get(3)?, - revision: row.get::<_, i64>(4)? as u32, - abi_breaking: row.get(5)?, - built_against: parse_built_against(&row.get::<_, String>(6)?), - completed_at: row.get(7)?, - filename: row.get(8)?, - size: row.get::<_, i64>(9)? as u64, - sha512: row.get(10)?, - description: row.get(11)?, - homepage: row.get(12)?, - license: row.get(13)?, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }, - )) - })?; - - let mut out = Vec::new(); - for row in rows { - let (package_id, mut rec) = row?; - rec.provides = query_package_provides(&conn, package_id)?; - rec.conflicts = query_package_conflicts(&conn, package_id)?; - rec.replaces = query_package_replaces(&conn, package_id)?; - rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; - rec.built_against = query_package_built_against(&conn, package_id)?; - rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; - rec.groups = query_package_groups(&conn, package_id)?; - out.push(rec); - } - Ok(out) -} - -fn list_cached_binary_repo_packages( - repo_name: &str, - db_path: &Path, -) -> Result> { - let conn = Connection::open(db_path) - .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; - - let completed_at_expr = if repo_packages_have_completed_at(&conn)? { - "p.completed_at" - } else { - "NULL" - }; - let real_name_expr = if repo_packages_have_real_name(&conn)? { - "p.real_name" - } else { - "NULL" - }; - let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { - "p.abi_breaking" - } else { - "0" - }; - let built_against_expr = if repo_packages_have_built_against(&conn)? { - "p.built_against" - } else { - "''" - }; - let sql = format!( - "SELECT - p.id, - p.name, - {real_name_expr}, - p.version, - p.revision, - {abi_breaking_expr}, - {built_against_expr}, - {completed_at_expr}, - p.filename, - p.size, - p.sha512, - p.description, - p.homepage, - p.license - FROM packages p - ORDER BY p.name ASC, p.version ASC, p.revision ASC" - ); - let mut stmt = conn.prepare(&sql)?; - - let rows = stmt.query_map([], |row| { - let package_id = row.get::<_, i64>(0)?; - Ok(( - package_id, - BinaryRepoPackageRecord { - repo_name: repo_name.to_string(), - name: row.get(1)?, - real_name: row.get(2)?, - version: row.get(3)?, - revision: row.get::<_, i64>(4)? as u32, - abi_breaking: row.get(5)?, - built_against: parse_built_against(&row.get::<_, String>(6)?), - completed_at: row.get(7)?, - filename: row.get(8)?, - size: row.get::<_, i64>(9)? as u64, - sha512: row.get(10)?, - description: row.get(11)?, - homepage: row.get(12)?, - license: row.get(13)?, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }, - )) - })?; - - let mut out = Vec::new(); - for row in rows { - let (package_id, mut rec) = row?; - rec.provides = query_package_provides(&conn, package_id)?; - rec.conflicts = query_package_conflicts(&conn, package_id)?; - rec.replaces = query_package_replaces(&conn, package_id)?; - rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; - rec.built_against = query_package_built_against(&conn, package_id)?; - rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; - rec.groups = query_package_groups(&conn, package_id)?; - out.push(rec); - } - Ok(out) -} - -fn repo_packages_have_completed_at(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'", - [], - |row| { - let count: i64 = row.get(0)?; - Ok(count > 0) - }, - ) - .context("Failed to inspect binary repo DB schema") -} - -fn repo_packages_have_real_name(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'real_name'", - [], - |row| { - let count: i64 = row.get(0)?; - Ok(count > 0) - }, - ) - .context("Failed to inspect binary repo DB schema") -} - -fn repo_packages_have_abi_breaking(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'abi_breaking'", - [], - |row| { - let count: i64 = row.get(0)?; - Ok(count > 0) - }, - ) - .context("Failed to inspect binary repo DB schema") -} - -fn repo_packages_have_built_against(conn: &Connection) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'built_against'", - [], - |row| { - let count: i64 = row.get(0)?; - Ok(count > 0) - }, - ) - .context("Failed to inspect binary repo DB schema") -} - -fn path_modified_unix_timestamp(path: &Path) -> Result> { - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for {}", path.display()))?; - let modified = metadata - .modified() - .with_context(|| format!("Failed to read modification time for {}", path.display()))?; - Ok(Some(metadata_time::system_time_to_unix(modified)?)) -} - -/// Resolve an exact package name/provide match from a binary repo after verifying -/// and caching its signed `repo.db.zst`. -pub fn find_binary_repo_package( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - query: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - let mut matches = find_cached_binary_repo_packages(repo_name, &db_path, query)?; - if matches.len() > 1 { - crate::log_warn!( - "Multiple binary packages matched '{}' in repo '{}'; using the first match", - query, - repo_name - ); - } - Ok(matches.drain(..).next()) -} - -/// Resolve exact package name/provide matches from a binary repo. -pub fn find_binary_repo_packages( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - query: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - find_cached_binary_repo_packages(repo_name, &db_path, query) -} - -/// Resolve package records that belong to the named group from a binary repo. -pub fn find_binary_repo_packages_by_group( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, - group: &str, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - find_cached_binary_repo_packages_by_group(repo_name, &db_path, group) -} - -/// List all binary packages from a cached, verified repository database. -pub fn list_binary_repo_packages( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - package_cache_dir: &Path, -) -> Result> { - let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; - list_cached_binary_repo_packages(repo_name, &db_path) -} - -fn verify_binary_package_record_checksums( - path: &Path, - rec: &BinaryRepoPackageRecord, -) -> Result<()> { - let expected = expected_binary_package_sha512(path, rec)?; - - let mut file = - fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; - let mut hasher = Sha512::new(); - let mut buf = [0u8; 64 * 1024]; - loop { - let n = file.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - - verify_binary_package_sha512_digest( - path, - rec, - &expected, - &crate::hex::encode_lower(hasher.finalize()), - ) -} - -fn expected_binary_package_sha512(path: &Path, rec: &BinaryRepoPackageRecord) -> Result { - let expected = rec.sha512.trim().to_ascii_lowercase(); - if expected.is_empty() { - anyhow::bail!( - "Missing SHA-512 checksum for {} from repo '{}'", - path.display(), - rec.repo_name - ); - } - Ok(expected) -} - -fn verify_binary_package_sha512_digest( - path: &Path, - rec: &BinaryRepoPackageRecord, - expected: &str, - actual: &str, -) -> Result<()> { - if actual != expected { - anyhow::bail!( - "SHA-512 mismatch for {} from repo '{}'", - path.display(), - rec.repo_name - ); - } - Ok(()) -} - -struct Sha512Reader { - inner: R, - hasher: Sha512, -} - -impl Sha512Reader { - fn new(inner: R) -> Self { - Self { - inner, - hasher: Sha512::new(), - } - } - - fn finalize_hex(self) -> String { - crate::hex::encode_lower(self.hasher.finalize()) - } -} - -impl Read for Sha512Reader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let read = self.inner.read(buf)?; - if read > 0 { - self.hasher.update(&buf[..read]); - } - Ok(read) - } -} - -impl Seek for Sha512Reader { - fn seek(&mut self, position: SeekFrom) -> std::io::Result { - if position != SeekFrom::Start(0) { - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "checksum reader only supports rewinding to the start", - )); - } - let position = self.inner.seek(position)?; - self.hasher = Sha512::new(); - Ok(position) - } -} - -fn download_binary_package_archive( - client: &reqwest::blocking::Client, - pkg_url: &str, - tmp_path: &Path, - progress_cb: &mut Option<&mut dyn FnMut(u64, Option)>, -) -> Result<()> { - match copy_file_url_to_path(pkg_url, tmp_path)? { - FileUrlCopyOutcome::Copied => { - if let Some(cb) = progress_cb.as_mut() { - let total = fs::metadata(tmp_path).map(|m| m.len()).unwrap_or(0); - cb(total, Some(total)); - } - } - FileUrlCopyOutcome::Missing => { - anyhow::bail!("Failed to fetch {}: local file not found", pkg_url); - } - FileUrlCopyOutcome::NotFileUrl => { - let mut resp = client - .get(pkg_url) - .send() - .with_context(|| format!("Failed to fetch {}", pkg_url))?; - if !resp.status().is_success() { - anyhow::bail!("Failed to fetch {}: HTTP {}", pkg_url, resp.status()); - } - - let total = resp.content_length(); - if let Some(cb) = progress_cb.as_mut() { - cb(0, total); - } - - let mut out = fs::File::create(tmp_path) - .with_context(|| format!("Failed to create {}", tmp_path.display()))?; - let mut downloaded = 0u64; - let mut buf = [0u8; 64 * 1024]; - loop { - let n = resp - .read(&mut buf) - .with_context(|| format!("Failed to read {}", pkg_url))?; - if n == 0 { - break; - } - out.write_all(&buf[..n]) - .with_context(|| format!("Failed to save {}", tmp_path.display()))?; - downloaded = downloaded.saturating_add(n as u64); - if let Some(cb) = progress_cb.as_mut() { - cb(downloaded, total); - } - } - out.flush() - .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; - } - } - Ok(()) -} - -fn fetch_binary_package_signature( - repo_name: &str, - repo: &crate::config::BinaryRepo, - client: &reqwest::blocking::Client, - sig_url: &str, - sig_path: &Path, -) -> Result { - let found = fetch_url_to_path(client, sig_url, sig_path)?; - if !found { - if !repo.allow_unsigned { - anyhow::bail!( - "Failed to fetch detached signature for binary package in repo '{}' at {}", - repo_name, - sig_url - ); - } - crate::log_warn!( - "Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing", - repo_name, - sig_url - ); - } - Ok(found) -} - -fn verify_binary_package_signature_with_trusted_keys( - repo_name: &str, - repo: &crate::config::BinaryRepo, - pkg_path: &Path, - sig_path: &Path, - trusted_keys: &[crate::signing::TrustedPublicKey], -) -> Result<()> { - if !sig_path.exists() { - if repo.allow_unsigned { - return Ok(()); - } - anyhow::bail!( - "Detached package signature required but missing for {}", - pkg_path.display() - ); - } - - if trusted_keys.is_empty() { - if repo.allow_unsigned { - crate::log_warn!( - "No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true", - repo_name - ); - return Ok(()); - } - anyhow::bail!( - "No trusted minisign public key found for detached package signature verification in binary repo '{}'", - repo_name - ); - } - - let _verified_key = crate::signing::verify_zst_file_detached_with_trusted_keys( - pkg_path, - sig_path, - trusted_keys, - ) - .with_context(|| { - format!( - "Failed to verify detached package signature for {}", - pkg_path.display() - ) - })?; - Ok(()) -} - -/// Ensure a binary package archive and detached signature are present in cache -/// without performing checksum/signature verification. -pub fn cache_binary_package_archive( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rec: &BinaryRepoPackageRecord, - package_cache_dir: &Path, -) -> Result { - cache_binary_package_archive_with_progress(repo_name, repo, rec, package_cache_dir, None) -} - -/// Ensure a binary package archive and detached signature are present in cache -/// without performing checksum/signature verification, optionally reporting -/// download progress. -pub fn cache_binary_package_archive_with_progress( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rec: &BinaryRepoPackageRecord, - package_cache_dir: &Path, - progress_cb: Option<&mut dyn FnMut(u64, Option)>, -) -> Result { - let client = binary_package_http_client()?; - cache_binary_package_archive_with_client_and_progress( - repo_name, - repo, - rec, - package_cache_dir, - &client, - progress_cb, - ) -} - -pub(crate) fn binary_package_http_client() -> Result { - reqwest::blocking::Client::builder() - .build() - .context("Failed to build HTTP client for binary package fetch") -} - -pub(crate) fn cache_binary_package_archive_with_client_and_progress( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rec: &BinaryRepoPackageRecord, - package_cache_dir: &Path, - client: &reqwest::blocking::Client, - mut progress_cb: Option<&mut dyn FnMut(u64, Option)>, -) -> Result { - let machine_arch = std::env::consts::ARCH; - let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { - format!( - "Binary repo '{}' is not configured for machine arch '{}'", - repo_name, machine_arch - ) - })?; - - let cache_dir = binary_repo_packages_cache_dir(package_cache_dir, repo_name); - fs::create_dir_all(&cache_dir) - .with_context(|| format!("Failed to create {}", cache_dir.display()))?; - let package_path = cache_dir.join(&rec.filename); - let signature_path = cache_dir.join(format!("{}.sig", rec.filename)); - let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename)); - let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename)); - let pkg_url = join_repo_url(base_url, &rec.filename)?; - let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?; - - let package_downloaded = if !package_path.exists() { - download_binary_package_archive(client, &pkg_url, &tmp_path, &mut progress_cb)?; - fs::rename(&tmp_path, &package_path).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_path.display(), - package_path.display() - ) - })?; - true - } else { - if let Some(cb) = progress_cb.as_mut() { - let total = fs::metadata(&package_path) - .with_context(|| format!("Failed to stat {}", package_path.display()))? - .len(); - cb(total, Some(total)); - } - false - }; - - if package_downloaded || !signature_path.exists() { - let sig_downloaded = - fetch_binary_package_signature(repo_name, repo, client, &sig_url, &tmp_sig_path)?; - if sig_downloaded { - fs::rename(&tmp_sig_path, &signature_path).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_sig_path.display(), - signature_path.display() - ) - })?; - } else { - let _ = fs::remove_file(&signature_path); - } - } - - Ok(BinaryRepoCachedArchive { - package_path, - signature_path, - }) -} - -/// Verify a cached/downloaded package archive against checksums from signed -/// repository metadata. -pub fn verify_binary_package_archive_checksums( - archive_path: &Path, - rec: &BinaryRepoPackageRecord, -) -> Result<()> { - verify_binary_package_record_checksums(archive_path, rec) -} - -pub(crate) fn verify_binary_package_archive_integrity_with_trusted_keys( - repo_name: &str, - repo: &crate::config::BinaryRepo, - record: &BinaryRepoPackageRecord, - package_path: &Path, - signature_path: &Path, - trusted_keys: &[crate::signing::TrustedPublicKey], -) -> Result<()> { - if signature_path.exists() && !trusted_keys.is_empty() { - let expected = expected_binary_package_sha512(package_path, record)?; - let file = fs::File::open(package_path) - .with_context(|| format!("Failed to open {}", package_path.display()))?; - let mut reader = Sha512Reader::new(file); - let _verified_key = crate::signing::verify_reader_detached_with_trusted_keys( - &mut reader, - package_path, - signature_path, - trusted_keys, - ) - .with_context(|| { - format!( - "Failed to verify detached package signature for {}", - package_path.display() - ) - })?; - let actual = reader.finalize_hex(); - verify_binary_package_sha512_digest(package_path, record, &expected, &actual)?; - return Ok(()); - } - - verify_binary_package_archive_checksums(package_path, record)?; - verify_binary_package_signature_with_trusted_keys( - repo_name, - repo, - package_path, - signature_path, - trusted_keys, - ) -} - -/// Download a binary package archive and verify it against detached signatures -/// and checksums from signed repository metadata. -pub fn fetch_binary_package_archive( - repo_name: &str, - repo: &crate::config::BinaryRepo, - rootfs: &Path, - rec: &BinaryRepoPackageRecord, - package_cache_dir: &Path, -) -> Result { - let cached = cache_binary_package_archive(repo_name, repo, rec, package_cache_dir)?; - let trusted_keys = if cached.signature_path.exists() { - crate::signing::load_trusted_public_keys(rootfs)? - } else { - Vec::new() - }; - verify_binary_package_archive_integrity_with_trusted_keys( - repo_name, - repo, - rec, - &cached.package_path, - &cached.signature_path, - &trusted_keys, - ) - .with_context(|| { - format!( - "Binary package failed integrity verification: {}", - cached.package_path.display() - ) - })?; - Ok(cached.package_path) -} - -/// Synchronize git mirrors into /usr/src/depot/ -pub fn sync_mirrors( - repo_dir: &std::path::Path, - mirrors: &std::collections::HashMap, -) -> Result<()> { - use git2::{FetchOptions, Repository, ResetType, build::RepoBuilder}; - use std::os::unix::fs::PermissionsExt; - - let base = repo_dir.to_path_buf(); - if !base.exists() { - std::fs::create_dir_all(&base)?; - } - - for (name, url) in mirrors { - let target = base.join(name); - let git_url = normalize_git_mirror_url(url)?; - if !target.exists() { - crate::log_info!("Cloning mirror '{}' -> {}", name, target.display()); - - let mut fo = FetchOptions::new(); - fo.remote_callbacks(crate::source::authenticated_remote_callbacks( - None, &git_url, - )); - - let mut builder = RepoBuilder::new(); - builder.fetch_options(fo); - builder - .clone(&git_url, &target) - .with_context(|| format!("Failed to clone {}", url))?; - } else { - crate::log_info!("Updating mirror '{}' in {}", name, target.display()); - // Open repository and fetch updates - let repo = Repository::open(&target) - .with_context(|| format!("Failed to open repository at {}", target.display()))?; - - let mut fo = FetchOptions::new(); - fo.remote_callbacks(crate::source::authenticated_remote_callbacks( - None, &git_url, - )); - - // Fetch from origin - let mut remote = repo - .find_remote("origin") - .or_else(|_| repo.remote_anonymous(&git_url))?; - remote - .fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fo), None) - .with_context(|| format!("Failed to fetch updates for {}", url))?; - - // Try to fast-forward HEAD to origin/HEAD by resetting to FETCH_HEAD if present - if let Ok(fetch_head) = repo.find_reference("FETCH_HEAD") - && let Some(oid) = fetch_head.target() - { - let obj = repo.find_object(oid, None)?; - repo.reset(&obj, ResetType::Hard, None)?; - } - } - - // Make the tree readable and writable by everyone - for entry in walkdir::WalkDir::new(&target) { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))?; - } else if path.is_file() { - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))?; - } - } - } - - Ok(()) -} - -/// Show status for each mirror repository: path, exists, branch/HEAD, latest commit, dirty -pub fn mirrors_status( - repo_dir: &std::path::Path, - mirrors: &std::collections::HashMap, -) -> Result<()> { - use git2::Repository; - - let base = repo_dir.to_path_buf(); - if !base.exists() { - crate::log_info!("Repo base directory does not exist: {}", base.display()); - return Ok(()); - } - - for name in mirrors.keys() { - let target = base.join(name); - crate::log_info!("--- {} ---", name); - if !target.exists() { - crate::log_info!("Not cloned: {}", target.display()); - continue; - } - - match Repository::open(&target) { - Ok(repo) => { - // Branch / HEAD - let head = repo.head().ok(); - let branch = head - .as_ref() - .and_then(|h| h.shorthand().ok().map(|s| s.to_string())) - .unwrap_or_else(|| "(no branch)".to_string()); - - // Latest commit OID - let oid = repo.refname_to_id("HEAD").ok(); - let short = oid - .map(|o| format!("{}", o)) - .unwrap_or_else(|| "(unknown)".to_string()); - - // Commit time (seconds since epoch) if available - let mut commit_time = String::new(); - if let Some(oid) = oid - && let Ok(commit) = repo.find_commit(oid) - { - let t = commit.time().seconds(); - commit_time = format!("{}", t); - } - - // Dirty status - let statuses = match repo.statuses(None) { - Ok(s) => s, - Err(_) => { - crate::log_warn!("Failed to read status for {}", target.display()); - continue; - } - }; - let dirty = statuses.iter().any(|s| { - s.status().intersects( - git2::Status::WT_MODIFIED | git2::Status::WT_NEW | git2::Status::WT_DELETED, - ) - }); - - crate::log_info!("Path: {}", target.display()); - crate::log_info!("Branch/HEAD: {}", branch); - crate::log_info!("HEAD OID: {}", short); - if !commit_time.is_empty() { - crate::log_info!("Latest commit time (epoch): {}", commit_time); - } - crate::log_info!("Dirty: {}", if dirty { "yes" } else { "no" }); - } - Err(e) => { - crate::log_info!("Failed to open repo at {}: {}", target.display(), e); - } - } - } - - Ok(()) -} +mod archive; +mod fetch; +mod manager; +mod mirrors; +mod query; + +pub use archive::*; +pub use fetch::*; +pub use mirrors::*; +pub use query::*; fn num_cpus() -> usize { std::thread::available_parallelism() @@ -2743,916 +201,4 @@ fn num_cpus() -> usize { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_init_repo_schema() { - let mut conn = Connection::open_in_memory().unwrap(); - let manager = RepoManager::new(PathBuf::from(".")); - manager.init_repo_schema(&mut conn).unwrap(); - - // Check if table exists - let exists: bool = conn - .query_row( - "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='packages'", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(exists); - - let has_sha512: bool = conn - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'sha512'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap(); - assert!(has_sha512); - - let has_completed_at: bool = conn - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'", - [], - |r| { - let n: i64 = r.get(0)?; - Ok(n > 0) - }, - ) - .unwrap(); - assert!(has_completed_at); - } - - #[test] - fn test_index_package() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tmp.path(); - let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); - - // Create a valid .tar.zst with .metadata.toml - let file = fs::File::create(&pkg_path).unwrap(); - let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); - let mut tar = tar::Builder::new(encoder); - - let metadata = r#" -name = "test" -real_name = "icu" -version = "1.0" -revision = 1 -abi_breaking = true -built_against = ["icu78"] -description = "test description" -homepage = "https://example.com" -license = "MIT" -completed_at = "2026-03-10T12:34:56Z" -provides = ["test-feature"] - -[dependencies] -runtime = [] -optional = [] -"#; - let mut header = tar::Header::new_gnu(); - header.set_path(".metadata.toml").unwrap(); - header.set_size(metadata.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append(&header, metadata.as_bytes()).unwrap(); - - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - filetime::set_file_mtime( - &pkg_path, - filetime::FileTime::from_unix_time(1_700_000_000, 0), - ) - .unwrap(); - - let mut conn = Connection::open_in_memory().unwrap(); - let manager = RepoManager::new(repo_dir.to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - let indexed = manager.read_indexed_package(&pkg_path).unwrap(); - manager.insert_indexed_package(&mut conn, indexed).unwrap(); - - type PackageRow = ( - String, - Option, - String, - i64, - i64, - String, - Option, - Option, - Option, - String, - String, - ); - - let ( - name, - real_name, - version, - revision, - abi_breaking, - built_against, - desc, - home, - lic, - sha256, - sha512, - ): PackageRow = conn - .query_row( - "SELECT name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, sha256, sha512 FROM packages", - [], - |r| { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get(5)?, - r.get(6)?, - r.get(7)?, - r.get(8)?, - r.get(9)?, - r.get(10)?, - )) - }, - ) - .unwrap(); - - assert_eq!(name, "test"); - assert_eq!(real_name, Some("icu".to_string())); - assert_eq!(version, "1.0"); - assert_eq!(revision, 1); - assert_eq!(abi_breaking, 1); - assert_eq!(built_against, "icu78"); - assert_eq!(desc, Some("test description".to_string())); - assert_eq!(home, Some("https://example.com".to_string())); - assert_eq!(lic, Some("MIT".to_string())); - assert_eq!(sha256.len(), 64); - assert_eq!(sha512.len(), 128); - - let completed_at: Option = conn - .query_row("SELECT completed_at FROM packages", [], |r| r.get(0)) - .unwrap(); - assert_eq!(completed_at, Some(1_773_146_096)); - - let provides_count: i64 = conn - .query_row("SELECT count(*) FROM provides", [], |r| r.get(0)) - .unwrap(); - assert_eq!(provides_count, 1); - } - - #[test] - fn test_index_package_with_multiple_licenses() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tmp.path(); - let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&pkg_path).unwrap(); - let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); - let mut tar = tar::Builder::new(encoder); - - let metadata = r#" -name = "test" -version = "1.0" -revision = 1 -license = ["MIT", "Apache-2.0"] -"#; - let mut header = tar::Header::new_gnu(); - header.set_path(".metadata.toml").unwrap(); - header.set_size(metadata.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append(&header, metadata.as_bytes()).unwrap(); - - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut conn = Connection::open_in_memory().unwrap(); - let manager = RepoManager::new(repo_dir.to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - let indexed = manager.read_indexed_package(&pkg_path).unwrap(); - manager.insert_indexed_package(&mut conn, indexed).unwrap(); - - let lic: Option = conn - .query_row("SELECT license FROM packages", [], |r| r.get(0)) - .unwrap(); - assert_eq!(lic, Some("MIT, Apache-2.0".to_string())); - } - - #[test] - fn test_index_package_records_symlink_paths_for_repo_owns() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tmp.path(); - let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); - - let file = fs::File::create(&pkg_path).unwrap(); - let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); - let mut tar = tar::Builder::new(encoder); - - let metadata = r#" -name = "test" -version = "1.0" -revision = 1 -"#; - let mut header = tar::Header::new_gnu(); - header.set_path(".metadata.toml").unwrap(); - header.set_size(metadata.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append(&header, metadata.as_bytes()).unwrap(); - - let mut file_header = tar::Header::new_gnu(); - file_header.set_path("usr/bin/coreutils").unwrap(); - file_header.set_size(4); - file_header.set_mode(0o755); - file_header.set_cksum(); - tar.append(&file_header, &b"test"[..]).unwrap(); - - let mut link_header = tar::Header::new_gnu(); - link_header.set_entry_type(tar::EntryType::Symlink); - link_header.set_path("usr/bin/ls").unwrap(); - link_header.set_link_name("coreutils").unwrap(); - link_header.set_size(0); - link_header.set_mode(0o777); - link_header.set_cksum(); - tar.append(&link_header, std::io::empty()).unwrap(); - - let encoder = tar.into_inner().unwrap(); - encoder.finish().unwrap(); - - let mut conn = Connection::open_in_memory().unwrap(); - let manager = RepoManager::new(repo_dir.to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - let indexed = manager.read_indexed_package(&pkg_path).unwrap(); - manager.insert_indexed_package(&mut conn, indexed).unwrap(); - - let db_path = repo_dir.join("repo.db"); - let mut file_conn = Connection::open(&db_path).unwrap(); - manager.init_repo_schema(&mut file_conn).unwrap(); - let indexed = manager.read_indexed_package(&pkg_path).unwrap(); - manager - .insert_indexed_package(&mut file_conn, indexed) - .unwrap(); - drop(file_conn); - - let hits = cached_binary_repo_owns_path("repo", &db_path, "usr/bin/ls").unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].package_name, "test"); - assert_eq!(hits[0].path, "usr/bin/ls"); - } - - #[test] - fn test_search_cached_binary_repo_db_matches_name_and_provides() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("repo.db"); - let mut conn = Connection::open(&db_path).unwrap(); - let manager = RepoManager::new(tmp.path().to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - - conn.execute( - "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) - VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO provides (package_id, name) VALUES (1, 'libfoo.so')", - [], - ) - .unwrap(); - drop(conn); - - let name_hits = search_cached_binary_repo_db("testrepo", &db_path, "foo").unwrap(); - assert_eq!(name_hits.len(), 1); - assert_eq!(name_hits[0].name, "foo"); - assert_eq!(name_hits[0].repo_name, "testrepo"); - assert!(name_hits[0].provides.iter().any(|p| p == "libfoo.so")); - - let provide_hits = search_cached_binary_repo_db("testrepo", &db_path, "libfoo").unwrap(); - assert_eq!(provide_hits.len(), 1); - assert_eq!(provide_hits[0].name, "foo"); - } - - #[test] - fn test_find_cached_binary_repo_packages_by_group() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("repo.db"); - let mut conn = Connection::open(&db_path).unwrap(); - let manager = RepoManager::new(tmp.path().to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - - conn.execute( - "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) - VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO groups (package_id, name) VALUES (1, 'base')", - [], - ) - .unwrap(); - drop(conn); - - let hits = find_cached_binary_repo_packages_by_group("testrepo", &db_path, "base").unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].name, "foo"); - assert_eq!(hits[0].groups, vec!["base".to_string()]); - } - - #[test] - #[cfg(unix)] - fn test_repo_owns_query_candidates_follow_rootfs_symlink_targets() { - let rootfs = tempfile::tempdir().unwrap(); - let usr_bin = rootfs.path().join("usr/bin"); - fs::create_dir_all(&usr_bin).unwrap(); - fs::write(usr_bin.join("coreutils"), b"payload").unwrap(); - std::os::unix::fs::symlink("coreutils", usr_bin.join("ls")).unwrap(); - std::os::unix::fs::symlink("usr/bin", rootfs.path().join("bin")).unwrap(); - - let ls_candidates = repo_owns_query_candidates(rootfs.path(), "/usr/bin/ls"); - assert!( - ls_candidates - .iter() - .any(|candidate| candidate == "usr/bin/ls") - ); - assert!( - ls_candidates - .iter() - .any(|candidate| candidate == "usr/bin/coreutils") - ); - - let bin_candidates = repo_owns_query_candidates(rootfs.path(), "/bin/ls"); - assert!(bin_candidates.iter().any(|candidate| candidate == "bin/ls")); - assert!( - bin_candidates - .iter() - .any(|candidate| candidate == "usr/bin/coreutils") - ); - } - - #[test] - fn test_find_cached_binary_repo_package_prefers_exact_name() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("repo.db"); - let mut conn = Connection::open(&db_path).unwrap(); - let manager = RepoManager::new(tmp.path().to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - - conn.execute( - "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) - VALUES (1, 'foo', '1.0', 1, NULL, NULL, NULL, 'foo-1.0-1.depot.pkg.tar.zst', 10, 'aa', 'bb')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) - VALUES (2, 'bar', '1.0', 1, NULL, NULL, NULL, 'bar-1.0-1.depot.pkg.tar.zst', 10, 'cc', 'dd')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO provides (package_id, name) VALUES (2, 'foo')", - [], - ) - .unwrap(); - drop(conn); - - let recs = find_cached_binary_repo_packages("repo", &db_path, "foo").unwrap(); - let rec = recs.first().expect("expected a match"); - assert_eq!(rec.name, "foo"); - assert_eq!(rec.filename, "foo-1.0-1.depot.pkg.tar.zst"); - } - - #[test] - fn test_find_cached_binary_repo_packages_matches_real_name_and_built_against() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("repo.db"); - let mut conn = Connection::open(&db_path).unwrap(); - let manager = RepoManager::new(tmp.path().to_path_buf()); - manager.init_repo_schema(&mut conn).unwrap(); - - conn.execute( - "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, filename, size, sha256, sha512) - VALUES (1, 'icu78', 'icu', '78.1', 1, 0, '', NULL, NULL, NULL, 'icu78.pkg', 10, 'aa', 'bb')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, filename, size, sha256, sha512) - VALUES (2, 'app', NULL, '1.0', 1, 0, 'icu78', NULL, NULL, NULL, 'app.pkg', 10, 'cc', 'dd')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO dependencies (package_id, kind, name) VALUES (2, 'runtime', 'icu')", - [], - ) - .unwrap(); - drop(conn); - - let icu_matches = find_cached_binary_repo_packages("repo", &db_path, "icu").unwrap(); - assert_eq!(icu_matches.len(), 1); - assert_eq!(icu_matches[0].name, "icu78"); - assert_eq!(icu_matches[0].real_name.as_deref(), Some("icu")); - - let app = find_cached_binary_repo_packages("repo", &db_path, "app") - .unwrap() - .pop() - .unwrap(); - assert_eq!(app.runtime_dependencies, vec!["icu".to_string()]); - assert_eq!(app.built_against, vec!["icu78".to_string()]); - } - - #[test] - fn test_verify_binary_package_record_checksums_accepts_valid_hashes() { - use sha2::{Digest, Sha512}; - - let tmp = tempfile::tempdir().unwrap(); - let pkg = tmp.path().join("pkg.depot.pkg.tar.zst"); - fs::write(&pkg, b"payload").unwrap(); - - let sha512 = { - let mut h = Sha512::new(); - h.update(b"payload"); - crate::hex::encode_lower(h.finalize()) - }; - - let rec = BinaryRepoPackageRecord { - repo_name: "repo".into(), - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: "pkg.depot.pkg.tar.zst".into(), - size: 7, - sha512, - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }; - - verify_binary_package_record_checksums(&pkg, &rec).unwrap(); - } - - #[test] - fn test_verify_binary_package_record_checksums_requires_valid_sha512() { - use sha2::{Digest, Sha512}; - - let tmp = tempfile::tempdir().unwrap(); - let pkg = tmp.path().join("pkg.depot.pkg.tar.zst"); - fs::write(&pkg, b"payload").unwrap(); - - let mut rec = test_record_for_payload("pkg.depot.pkg.tar.zst", b"payload"); - verify_binary_package_record_checksums(&pkg, &rec).unwrap(); - - let mut wrong_sha512 = Sha512::new(); - wrong_sha512.update(b"different payload"); - rec.sha512 = crate::hex::encode_lower(wrong_sha512.finalize()); - let err = verify_binary_package_record_checksums(&pkg, &rec).unwrap_err(); - assert!(err.to_string().contains("SHA-512 mismatch")); - } - - fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord { - use sha2::{Digest, Sha512}; - - let sha512 = { - let mut h = Sha512::new(); - h.update(payload); - crate::hex::encode_lower(h.finalize()) - }; - - BinaryRepoPackageRecord { - repo_name: "repo".into(), - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: filename.to_string(), - size: payload.len() as u64, - sha512, - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - } - } - - #[test] - fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - - let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; - let payload = b"package payload"; - std::fs::write(repo_dir.path().join(filename), payload).unwrap(); - - let rec = test_record_for_payload(filename, payload); - let repo_url = url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(); - let repo_cfg = crate::config::BinaryRepo { - url: repo_url, - allow_unsigned: false, - ..Default::default() - }; - - let err = - fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) - .expect_err("missing detached signature should fail"); - assert!(err.to_string().to_ascii_lowercase().contains("signature")); - } - - #[test] - fn test_fetch_binary_package_archive_verifies_signature_and_checksum() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - - let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); - std::fs::create_dir_all(&trusted_dir).unwrap(); - - let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); - std::fs::write( - trusted_dir.join("repo.pub"), - keypair.pk.to_box().unwrap().to_bytes(), - ) - .unwrap(); - - let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; - let payload = b"signed package payload"; - let package_path = repo_dir.path().join(filename); - std::fs::write(&package_path, payload).unwrap(); - - let sig = minisign::sign( - Some(&keypair.pk), - &keypair.sk, - std::fs::File::open(&package_path).unwrap(), - None, - Some("test signature"), - ) - .unwrap(); - std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap(); - - let rec = test_record_for_payload(filename, payload); - let repo_url = url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(); - let repo_cfg = crate::config::BinaryRepo { - url: repo_url, - allow_unsigned: false, - ..Default::default() - }; - - let fetched = - fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) - .unwrap(); - assert_eq!(std::fs::read(&fetched).unwrap(), payload); - assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists()); - } - - #[test] - fn test_cache_binary_package_archive_supports_combined_integrity_verification() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - - let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); - std::fs::create_dir_all(&trusted_dir).unwrap(); - - let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); - std::fs::write( - trusted_dir.join("repo.pub"), - keypair.pk.to_box().unwrap().to_bytes(), - ) - .unwrap(); - - let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; - let payload = b"staged verification payload"; - let package_path = repo_dir.path().join(filename); - std::fs::write(&package_path, payload).unwrap(); - - let sig = minisign::sign( - Some(&keypair.pk), - &keypair.sk, - std::fs::File::open(&package_path).unwrap(), - None, - Some("test signature"), - ) - .unwrap(); - std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap(); - - let rec = test_record_for_payload(filename, payload); - let repo_url = url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(); - let repo_cfg = crate::config::BinaryRepo { - url: repo_url, - allow_unsigned: false, - ..Default::default() - }; - - let cached = cache_binary_package_archive("repo", &repo_cfg, &rec, cache_dir.path()) - .expect("cache should succeed"); - assert!(cached.package_path.exists()); - assert!(cached.signature_path.exists()); - - verify_binary_package_archive_checksums(&cached.package_path, &rec) - .expect("checksum verification should succeed"); - let trusted_keys = crate::signing::load_trusted_public_keys(rootfs.path()).unwrap(); - verify_binary_package_archive_integrity_with_trusted_keys( - "repo", - &repo_cfg, - &rec, - &cached.package_path, - &cached.signature_path, - &trusted_keys, - ) - .expect("combined integrity verification should succeed"); - - let mut wrong_record = rec.clone(); - wrong_record.sha512 = crate::hex::encode_lower(Sha512::digest(b"wrong payload")); - let error = verify_binary_package_archive_integrity_with_trusted_keys( - "repo", - &repo_cfg, - &wrong_record, - &cached.package_path, - &cached.signature_path, - &trusted_keys, - ) - .expect_err("combined verification must reject a checksum mismatch"); - assert!(error.to_string().contains("SHA-512 mismatch")); - } - - #[test] - fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - - let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; - let payload = b"unsigned package payload"; - std::fs::write(repo_dir.path().join(filename), payload).unwrap(); - - let rec = test_record_for_payload(filename, payload); - let repo_url = url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(); - let repo_cfg = crate::config::BinaryRepo { - url: repo_url, - allow_unsigned: true, - ..Default::default() - }; - - let fetched = - fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) - .unwrap(); - assert_eq!(std::fs::read(&fetched).unwrap(), payload); - assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists()); - } - - #[test] - fn test_copy_file_url_to_path_supports_file_scheme() { - let tmp = tempfile::tempdir().unwrap(); - let src = tmp.path().join("repo.db.zst"); - let dst = tmp.path().join("copy.zst"); - fs::write(&src, b"repo-db").unwrap(); - - let url = format!("file://{}", src.display()); - let outcome = copy_file_url_to_path(&url, &dst).unwrap(); - assert_eq!(outcome, FileUrlCopyOutcome::Copied); - assert_eq!(fs::read(&dst).unwrap(), b"repo-db"); - } - - #[test] - fn test_copy_file_url_to_path_reports_missing_file() { - let tmp = tempfile::tempdir().unwrap(); - let missing = tmp.path().join("missing.db.zst"); - let dst = tmp.path().join("copy.zst"); - - let url = format!("file://{}", missing.display()); - let outcome = copy_file_url_to_path(&url, &dst).unwrap(); - assert_eq!(outcome, FileUrlCopyOutcome::Missing); - assert!(!dst.exists()); - } - - #[test] - fn test_repo_db_fetch_cache_roundtrip_and_prunes_stale_entry() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("repo.db"); - fs::write(&db_path, b"db").unwrap(); - - let key = RepoDbFetchCacheKey { - repo_name: "core".to_string(), - base_url: "https://repo.example.test/core".to_string(), - repo_db_rel: "repo.db.zst".to_string(), - rootfs: PathBuf::from("/tmp/rootfs-test"), - package_cache_dir: PathBuf::from("/tmp/pkg-cache-test"), - }; - - remember_repo_db_path(key.clone(), db_path.clone()); - assert_eq!(get_cached_repo_db_path(&key), Some(db_path.clone())); - - fs::remove_file(&db_path).unwrap(); - assert_eq!(get_cached_repo_db_path(&key), None); - } - - #[test] - fn test_extract_html_href_targets_parses_common_forms() { - let html = r#" - - alpha - beta - gamma - parent - - "#; - let hrefs = extract_html_href_targets(html); - assert!(hrefs.iter().any(|h| h == "alpha.pub")); - assert!(hrefs.iter().any(|h| h == "nested/beta.pub")); - assert!(hrefs.iter().any(|h| h == "gamma.pub")); - assert!(hrefs.iter().any(|h| h == "../")); - } - - #[test] - fn test_list_repo_public_key_urls_reads_file_repo_keys_dir() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tmp.path().join("repo"); - let keys_dir = repo_dir.join("keys"); - fs::create_dir_all(&keys_dir).unwrap(); - fs::write(keys_dir.join("repo.pub"), b"pubkey").unwrap(); - fs::write(keys_dir.join("ignore.txt"), b"nope").unwrap(); - fs::create_dir_all(keys_dir.join("subdir")).unwrap(); - - let base_url = url::Url::from_directory_path(&repo_dir) - .expect("file URL") - .to_string(); - let client = reqwest::blocking::Client::builder().build().unwrap(); - let keys = list_repo_public_key_urls(&base_url, &client).unwrap(); - assert_eq!(keys.len(), 1); - assert_eq!(keys[0].0, "repo.pub"); - assert!(keys[0].1.ends_with("/repo.pub")); - } - - #[test] - fn test_list_repo_public_key_urls_probes_common_names_when_index_missing() { - use std::io::{BufRead, BufReader, Write}; - use std::net::TcpListener; - use std::thread; - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let server = thread::spawn(move || { - for _ in 0..7 { - let (mut stream, _) = listener.accept().unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut request_line = String::new(); - reader.read_line(&mut request_line).unwrap(); - loop { - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - if line == "\r\n" || line.is_empty() { - break; - } - } - - let path = request_line - .split_whitespace() - .nth(1) - .unwrap_or_default() - .to_string(); - let (status, body) = if path == "/core/keys/vertex.pub" { - ("200 OK", "trusted-key") - } else { - ("404 Not Found", "missing") - }; - - write!( - stream, - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ) - .unwrap(); - stream.flush().unwrap(); - } - }); - - let base_url = format!("http://{}/core", addr); - let client = reqwest::blocking::Client::builder().build().unwrap(); - let keys = list_repo_public_key_urls(&base_url, &client).unwrap(); - server.join().unwrap(); - - assert_eq!(keys.len(), 1); - assert_eq!(keys[0].0, "vertex.pub"); - assert!(keys[0].1.ends_with("/core/keys/vertex.pub")); - } - - #[test] - fn test_fetch_binary_repo_db_can_recover_from_stale_trusted_key() { - use std::io::Write; - - struct AssumeYesReset; - impl Drop for AssumeYesReset { - fn drop(&mut self) { - crate::ui::set_assume_yes(false); - } - } - - crate::ui::set_assume_yes(true); - let _reset = AssumeYesReset; - - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - - let stale_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); - let repo_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); - - let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); - fs::create_dir_all(&trusted_dir).unwrap(); - fs::write( - trusted_dir.join("vertex.pub"), - stale_keypair.pk.to_box().unwrap().to_bytes(), - ) - .unwrap(); - - let repo_keys_dir = repo_dir.path().join("keys"); - fs::create_dir_all(&repo_keys_dir).unwrap(); - fs::write( - repo_keys_dir.join("vertex.pub"), - repo_keypair.pk.to_box().unwrap().to_bytes(), - ) - .unwrap(); - - let repo_db_path = repo_dir.path().join("repo.db.zst"); - let mut encoder = - zstd::stream::write::Encoder::new(fs::File::create(&repo_db_path).unwrap(), 3).unwrap(); - encoder.write_all(b"repo-db-content").unwrap(); - encoder.finish().unwrap(); - - let sig = minisign::sign( - Some(&repo_keypair.pk), - &repo_keypair.sk, - fs::File::open(&repo_db_path).unwrap(), - None, - Some("repo db signature"), - ) - .unwrap(); - fs::write(repo_dir.path().join("repo.db.zst.sig"), sig.to_bytes()).unwrap(); - - let repo_cfg = crate::config::BinaryRepo { - url: url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(), - allow_unsigned: false, - ..Default::default() - }; - - let sqlite_db = - fetch_binary_repo_db("core", &repo_cfg, rootfs.path(), cache_dir.path()).unwrap(); - assert_eq!(fs::read(sqlite_db).unwrap(), b"repo-db-content"); - - let installed_key = trusted_dir.join("core-vertex.pub"); - assert!(installed_key.exists()); - assert_eq!( - fs::read(installed_key).unwrap(), - repo_keypair.pk.to_box().unwrap().to_bytes() - ); - } - - #[test] - fn test_normalize_git_mirror_url_converts_file_scheme() { - let tmp = tempfile::tempdir().unwrap(); - let repo_dir = tmp.path().join("repo.git"); - fs::create_dir_all(&repo_dir).unwrap(); - - let url = format!("file://{}", repo_dir.display()); - let normalized = normalize_git_mirror_url(&url).unwrap(); - assert_eq!(normalized, repo_dir.to_string_lossy()); - } -} +mod tests; diff --git a/src/db/repo/archive.rs b/src/db/repo/archive.rs new file mode 100644 index 0000000..dfd7b5b --- /dev/null +++ b/src/db/repo/archive.rs @@ -0,0 +1,412 @@ +use super::*; + +pub(super) fn verify_binary_package_record_checksums( + path: &Path, + rec: &BinaryRepoPackageRecord, +) -> Result<()> { + let expected = expected_binary_package_sha512(path, rec)?; + + let mut file = + fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let mut hasher = Sha512::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + + verify_binary_package_sha512_digest( + path, + rec, + &expected, + &crate::hex::encode_lower(hasher.finalize()), + ) +} + +pub(super) fn expected_binary_package_sha512( + path: &Path, + rec: &BinaryRepoPackageRecord, +) -> Result { + let expected = rec.sha512.trim().to_ascii_lowercase(); + if expected.is_empty() { + anyhow::bail!( + "Missing SHA-512 checksum for {} from repo '{}'", + path.display(), + rec.repo_name + ); + } + Ok(expected) +} + +pub(super) fn verify_binary_package_sha512_digest( + path: &Path, + rec: &BinaryRepoPackageRecord, + expected: &str, + actual: &str, +) -> Result<()> { + if actual != expected { + anyhow::bail!( + "SHA-512 mismatch for {} from repo '{}'", + path.display(), + rec.repo_name + ); + } + Ok(()) +} + +pub(super) struct Sha512Reader { + inner: R, + hasher: Sha512, +} + +impl Sha512Reader { + fn new(inner: R) -> Self { + Self { + inner, + hasher: Sha512::new(), + } + } + + fn finalize_hex(self) -> String { + crate::hex::encode_lower(self.hasher.finalize()) + } +} + +impl Read for Sha512Reader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let read = self.inner.read(buf)?; + if read > 0 { + self.hasher.update(&buf[..read]); + } + Ok(read) + } +} + +impl Seek for Sha512Reader { + fn seek(&mut self, position: SeekFrom) -> std::io::Result { + if position != SeekFrom::Start(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "checksum reader only supports rewinding to the start", + )); + } + let position = self.inner.seek(position)?; + self.hasher = Sha512::new(); + Ok(position) + } +} + +pub(super) fn download_binary_package_archive( + client: &reqwest::blocking::Client, + pkg_url: &str, + tmp_path: &Path, + progress_cb: &mut Option<&mut dyn FnMut(u64, Option)>, +) -> Result<()> { + match copy_file_url_to_path(pkg_url, tmp_path)? { + FileUrlCopyOutcome::Copied => { + if let Some(cb) = progress_cb.as_mut() { + let total = fs::metadata(tmp_path).map(|m| m.len()).unwrap_or(0); + cb(total, Some(total)); + } + } + FileUrlCopyOutcome::Missing => { + anyhow::bail!("Failed to fetch {}: local file not found", pkg_url); + } + FileUrlCopyOutcome::NotFileUrl => { + let mut resp = client + .get(pkg_url) + .send() + .with_context(|| format!("Failed to fetch {}", pkg_url))?; + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch {}: HTTP {}", pkg_url, resp.status()); + } + + let total = resp.content_length(); + if let Some(cb) = progress_cb.as_mut() { + cb(0, total); + } + + let mut out = fs::File::create(tmp_path) + .with_context(|| format!("Failed to create {}", tmp_path.display()))?; + let mut downloaded = 0u64; + let mut buf = [0u8; 64 * 1024]; + loop { + let n = resp + .read(&mut buf) + .with_context(|| format!("Failed to read {}", pkg_url))?; + if n == 0 { + break; + } + out.write_all(&buf[..n]) + .with_context(|| format!("Failed to save {}", tmp_path.display()))?; + downloaded = downloaded.saturating_add(n as u64); + if let Some(cb) = progress_cb.as_mut() { + cb(downloaded, total); + } + } + out.flush() + .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; + } + } + Ok(()) +} + +pub(super) fn fetch_binary_package_signature( + repo_name: &str, + repo: &crate::config::BinaryRepo, + client: &reqwest::blocking::Client, + sig_url: &str, + sig_path: &Path, +) -> Result { + let found = fetch_url_to_path(client, sig_url, sig_path)?; + if !found { + if !repo.allow_unsigned { + anyhow::bail!( + "Failed to fetch detached signature for binary package in repo '{}' at {}", + repo_name, + sig_url + ); + } + crate::log_warn!( + "Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing", + repo_name, + sig_url + ); + } + Ok(found) +} + +pub(super) fn verify_binary_package_signature_with_trusted_keys( + repo_name: &str, + repo: &crate::config::BinaryRepo, + pkg_path: &Path, + sig_path: &Path, + trusted_keys: &[crate::signing::TrustedPublicKey], +) -> Result<()> { + if !sig_path.exists() { + if repo.allow_unsigned { + return Ok(()); + } + anyhow::bail!( + "Detached package signature required but missing for {}", + pkg_path.display() + ); + } + + if trusted_keys.is_empty() { + if repo.allow_unsigned { + crate::log_warn!( + "No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true", + repo_name + ); + return Ok(()); + } + anyhow::bail!( + "No trusted minisign public key found for detached package signature verification in binary repo '{}'", + repo_name + ); + } + + let _verified_key = crate::signing::verify_zst_file_detached_with_trusted_keys( + pkg_path, + sig_path, + trusted_keys, + ) + .with_context(|| { + format!( + "Failed to verify detached package signature for {}", + pkg_path.display() + ) + })?; + Ok(()) +} + +/// Ensure a binary package archive and detached signature are present in cache +/// without performing checksum/signature verification. +pub fn cache_binary_package_archive( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, +) -> Result { + cache_binary_package_archive_with_progress(repo_name, repo, rec, package_cache_dir, None) +} + +/// Ensure a binary package archive and detached signature are present in cache +/// without performing checksum/signature verification, optionally reporting +/// download progress. +pub fn cache_binary_package_archive_with_progress( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, + progress_cb: Option<&mut dyn FnMut(u64, Option)>, +) -> Result { + let client = binary_package_http_client()?; + cache_binary_package_archive_with_client_and_progress( + repo_name, + repo, + rec, + package_cache_dir, + &client, + progress_cb, + ) +} + +pub(crate) fn binary_package_http_client() -> Result { + reqwest::blocking::Client::builder() + .build() + .context("Failed to build HTTP client for binary package fetch") +} + +pub(crate) fn cache_binary_package_archive_with_client_and_progress( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, + client: &reqwest::blocking::Client, + mut progress_cb: Option<&mut dyn FnMut(u64, Option)>, +) -> Result { + let machine_arch = std::env::consts::ARCH; + let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + + let cache_dir = binary_repo_packages_cache_dir(package_cache_dir, repo_name); + fs::create_dir_all(&cache_dir) + .with_context(|| format!("Failed to create {}", cache_dir.display()))?; + let package_path = cache_dir.join(&rec.filename); + let signature_path = cache_dir.join(format!("{}.sig", rec.filename)); + let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename)); + let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename)); + let pkg_url = join_repo_url(base_url, &rec.filename)?; + let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?; + + let package_downloaded = if !package_path.exists() { + download_binary_package_archive(client, &pkg_url, &tmp_path, &mut progress_cb)?; + fs::rename(&tmp_path, &package_path).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_path.display(), + package_path.display() + ) + })?; + true + } else { + if let Some(cb) = progress_cb.as_mut() { + let total = fs::metadata(&package_path) + .with_context(|| format!("Failed to stat {}", package_path.display()))? + .len(); + cb(total, Some(total)); + } + false + }; + + if package_downloaded || !signature_path.exists() { + let sig_downloaded = + fetch_binary_package_signature(repo_name, repo, client, &sig_url, &tmp_sig_path)?; + if sig_downloaded { + fs::rename(&tmp_sig_path, &signature_path).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_sig_path.display(), + signature_path.display() + ) + })?; + } else { + let _ = fs::remove_file(&signature_path); + } + } + + Ok(BinaryRepoCachedArchive { + package_path, + signature_path, + }) +} + +/// Verify a cached/downloaded package archive against checksums from signed +/// repository metadata. +pub fn verify_binary_package_archive_checksums( + archive_path: &Path, + rec: &BinaryRepoPackageRecord, +) -> Result<()> { + verify_binary_package_record_checksums(archive_path, rec) +} + +pub(crate) fn verify_binary_package_archive_integrity_with_trusted_keys( + repo_name: &str, + repo: &crate::config::BinaryRepo, + record: &BinaryRepoPackageRecord, + package_path: &Path, + signature_path: &Path, + trusted_keys: &[crate::signing::TrustedPublicKey], +) -> Result<()> { + if signature_path.exists() && !trusted_keys.is_empty() { + let expected = expected_binary_package_sha512(package_path, record)?; + let file = fs::File::open(package_path) + .with_context(|| format!("Failed to open {}", package_path.display()))?; + let mut reader = Sha512Reader::new(file); + let _verified_key = crate::signing::verify_reader_detached_with_trusted_keys( + &mut reader, + package_path, + signature_path, + trusted_keys, + ) + .with_context(|| { + format!( + "Failed to verify detached package signature for {}", + package_path.display() + ) + })?; + let actual = reader.finalize_hex(); + verify_binary_package_sha512_digest(package_path, record, &expected, &actual)?; + return Ok(()); + } + + verify_binary_package_archive_checksums(package_path, record)?; + verify_binary_package_signature_with_trusted_keys( + repo_name, + repo, + package_path, + signature_path, + trusted_keys, + ) +} + +/// Download a binary package archive and verify it against detached signatures +/// and checksums from signed repository metadata. +pub fn fetch_binary_package_archive( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, +) -> Result { + let cached = cache_binary_package_archive(repo_name, repo, rec, package_cache_dir)?; + let trusted_keys = if cached.signature_path.exists() { + crate::signing::load_trusted_public_keys(rootfs)? + } else { + Vec::new() + }; + verify_binary_package_archive_integrity_with_trusted_keys( + repo_name, + repo, + rec, + &cached.package_path, + &cached.signature_path, + &trusted_keys, + ) + .with_context(|| { + format!( + "Binary package failed integrity verification: {}", + cached.package_path.display() + ) + })?; + Ok(cached.package_path) +} diff --git a/src/db/repo/fetch.rs b/src/db/repo/fetch.rs new file mode 100644 index 0000000..efab85e --- /dev/null +++ b/src/db/repo/fetch.rs @@ -0,0 +1,700 @@ +use super::*; + +pub(super) fn binary_repo_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { + package_cache_dir.join("repos").join(repo_name) +} + +pub(super) fn binary_repo_packages_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf { + binary_repo_cache_dir(package_cache_dir, repo_name).join("packages") +} + +pub(super) fn join_repo_url(base: &str, rel: &str) -> Result { + let base = if base.ends_with('/') { + base.to_string() + } else { + format!("{base}/") + }; + let url = url::Url::parse(&base).with_context(|| format!("Invalid repo URL: {base}"))?; + Ok(url + .join(rel) + .with_context(|| format!("Invalid repo db path '{}'", rel))? + .to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FileUrlCopyOutcome { + NotFileUrl, + Copied, + Missing, +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub(super) struct RepoDbFetchCacheKey { + pub(super) repo_name: String, + pub(super) base_url: String, + pub(super) repo_db_rel: String, + pub(super) rootfs: PathBuf, + pub(super) package_cache_dir: PathBuf, +} + +pub(super) fn repo_db_fetch_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(super) fn get_cached_repo_db_path(cache_key: &RepoDbFetchCacheKey) -> Option { + let mut cache = repo_db_fetch_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cached = cache.get(cache_key).cloned()?; + if cached.exists() { + return Some(cached); + } + cache.remove(cache_key); + None +} + +pub(super) fn remember_repo_db_path(cache_key: RepoDbFetchCacheKey, db_path: PathBuf) { + let mut cache = repo_db_fetch_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.insert(cache_key, db_path); +} + +pub(super) fn copy_file_url_to_path(url: &str, dest: &Path) -> Result { + let parsed = match url::Url::parse(url) { + Ok(parsed) => parsed, + Err(_) => return Ok(FileUrlCopyOutcome::NotFileUrl), + }; + if parsed.scheme() != "file" { + return Ok(FileUrlCopyOutcome::NotFileUrl); + } + + let src = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", url))?; + if !src.exists() { + return Ok(FileUrlCopyOutcome::Missing); + } + if !src.is_file() { + anyhow::bail!("file:// URL is not a file: {}", src.display()); + } + + fs::copy(&src, dest) + .with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))?; + Ok(FileUrlCopyOutcome::Copied) +} + +pub(super) fn fetch_url_to_path( + client: &reqwest::blocking::Client, + url: &str, + dest: &Path, +) -> Result { + match copy_file_url_to_path(url, dest)? { + FileUrlCopyOutcome::Copied => return Ok(true), + FileUrlCopyOutcome::Missing => return Ok(false), + FileUrlCopyOutcome::NotFileUrl => {} + } + + let mut resp = client + .get(url) + .send() + .with_context(|| format!("Failed to fetch {}", url))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch {}: HTTP {}", url, resp.status()); + } + + let mut out = + fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", dest.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", dest.display()))?; + Ok(true) +} + +pub(super) fn extract_html_href_targets(html: &str) -> Vec { + let lower = html.to_ascii_lowercase(); + let lower_bytes = lower.as_bytes(); + let html_bytes = html.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + + while i < lower_bytes.len() { + let Some(rel) = lower[i..].find("href") else { + break; + }; + let mut j = i + rel + 4; + while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { + j += 1; + } + if j >= lower_bytes.len() || lower_bytes[j] != b'=' { + i = j; + continue; + } + j += 1; + while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { + j += 1; + } + if j >= lower_bytes.len() { + break; + } + + let (start, end) = if lower_bytes[j] == b'"' || lower_bytes[j] == b'\'' { + let quote = lower_bytes[j]; + let start = j + 1; + let mut k = start; + while k < lower_bytes.len() && lower_bytes[k] != quote { + k += 1; + } + (start, k) + } else { + let start = j; + let mut k = start; + while k < lower_bytes.len() + && !lower_bytes[k].is_ascii_whitespace() + && lower_bytes[k] != b'>' + { + k += 1; + } + (start, k) + }; + + if start < end && end <= html_bytes.len() { + out.push(String::from_utf8_lossy(&html_bytes[start..end]).to_string()); + } + i = end.saturating_add(1); + } + + out +} + +pub(super) fn default_repo_public_key_candidate_names(base_url: &str) -> Result> { + let mut names = vec![ + "vertex.pub".to_string(), + "depot.pub".to_string(), + "depot.minisign.pub".to_string(), + "minisign.pub".to_string(), + "repo.pub".to_string(), + ]; + + if let Ok(parsed) = url::Url::parse(base_url) + && let Some(last_segment) = parsed + .path_segments() + .and_then(|mut segments| segments.rfind(|s| !s.is_empty())) + { + names.push(format!("{}.pub", last_segment)); + } + + names.sort(); + names.dedup(); + Ok(names) +} + +pub(super) fn probe_repo_public_key_urls( + base_url: &str, + client: &reqwest::blocking::Client, +) -> Result> { + let mut out = Vec::new(); + for key_name in default_repo_public_key_candidate_names(base_url)? { + let key_url = join_repo_url(base_url, &format!("keys/{}", key_name))?; + let resp = client + .get(&key_url) + .send() + .with_context(|| format!("Failed to fetch {}", key_url))?; + if resp.status().is_success() { + out.push((key_name, key_url)); + } + } + out.sort(); + out.dedup(); + Ok(out) +} + +pub(super) fn list_repo_public_key_urls( + base_url: &str, + client: &reqwest::blocking::Client, +) -> Result> { + let parsed = + url::Url::parse(base_url).with_context(|| format!("Invalid repo URL: {base_url}"))?; + if parsed.scheme() == "file" { + let repo_dir = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", base_url))?; + let keys_dir = repo_dir.join("keys"); + if !keys_dir.exists() { + return Ok(Vec::new()); + } + if !keys_dir.is_dir() { + anyhow::bail!( + "Binary repo keys path is not a directory: {}", + keys_dir.display() + ); + } + + let mut out = Vec::new(); + for entry in fs::read_dir(&keys_dir) + .with_context(|| format!("Failed to read {}", keys_dir.display()))? + { + let path = entry?.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.to_ascii_lowercase().ends_with(".pub") { + continue; + } + let key_url = url::Url::from_file_path(&path) + .map_err(|_| anyhow::anyhow!("Failed to build file:// URL for {}", path.display()))? + .to_string(); + out.push((name.to_string(), key_url)); + } + out.sort(); + out.dedup(); + return Ok(out); + } + + let mut out = Vec::new(); + let keys_url = join_repo_url(base_url, "keys/")?; + let resp = client + .get(&keys_url) + .send() + .with_context(|| format!("Failed to fetch {}", keys_url))?; + if resp.status().is_success() { + let body = resp + .text() + .with_context(|| format!("Failed to read {}", keys_url))?; + let keys_base = url::Url::parse(&keys_url) + .with_context(|| format!("Invalid repo keys URL: {}", keys_url))?; + + for href in extract_html_href_targets(&body) { + if href.is_empty() || href.starts_with('#') || href.starts_with('?') { + continue; + } + let Ok(url) = keys_base.join(&href) else { + continue; + }; + let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else { + continue; + }; + if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") { + continue; + } + out.push((name.to_string(), url.to_string())); + } + } + + if out.is_empty() { + out = probe_repo_public_key_urls(base_url, client)?; + } + + out.sort(); + out.dedup(); + Ok(out) +} + +pub(super) fn verify_with_any_trusted_public_key( + rootfs: &Path, + input: &Path, + sig_path: &Path, +) -> Result { + let keys = crate::signing::load_trusted_public_keys(rootfs)?; + if keys.is_empty() { + anyhow::bail!("No trusted minisign public keys found in rootfs or host"); + } + crate::signing::verify_zst_file_detached_with_trusted_keys(input, sig_path, &keys) +} + +pub(super) fn sanitize_filename_component(input: &str) -> String { + input + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch, + _ => '_', + }) + .collect() +} + +pub(super) fn install_trusted_repo_public_key( + rootfs: &Path, + repo_name: &str, + source_key_path: &Path, + source_name: &str, +) -> Result { + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); + fs::create_dir_all(&trusted_dir) + .with_context(|| format!("Failed to create {}", trusted_dir.display()))?; + + let base_name = source_name + .split('/') + .next_back() + .filter(|name| !name.is_empty()) + .unwrap_or("repo.pub"); + let base_name = sanitize_filename_component(base_name); + let repo_prefix = sanitize_filename_component(repo_name); + + let source_bytes = fs::read(source_key_path) + .with_context(|| format!("Failed to read {}", source_key_path.display()))?; + let mut candidates = Vec::new(); + candidates.push(trusted_dir.join(&base_name)); + if !repo_prefix.is_empty() { + candidates.push(trusted_dir.join(format!("{}-{}", repo_prefix, base_name))); + } + + for candidate in &candidates { + if candidate.exists() { + let existing = fs::read(candidate) + .with_context(|| format!("Failed to read {}", candidate.display()))?; + if existing == source_bytes { + return Ok(candidate.clone()); + } + } else { + fs::write(candidate, &source_bytes) + .with_context(|| format!("Failed to write {}", candidate.display()))?; + return Ok(candidate.clone()); + } + } + + for idx in 1usize.. { + let candidate = trusted_dir.join(format!("{}-{}.{}", repo_prefix, base_name, idx)); + if candidate.exists() { + let existing = fs::read(&candidate) + .with_context(|| format!("Failed to read {}", candidate.display()))?; + if existing == source_bytes { + return Ok(candidate); + } + continue; + } + fs::write(&candidate, &source_bytes) + .with_context(|| format!("Failed to write {}", candidate.display()))?; + return Ok(candidate); + } + + unreachable!("infinite loop returns on first available candidate") +} + +pub(super) fn try_trust_repo_public_key_for_repo_db( + repo_name: &str, + base_url: &str, + rootfs: &Path, + cache_dir: &Path, + client: &reqwest::blocking::Client, + repo_db_zst_path: &Path, + repo_db_sig_path: &Path, +) -> Result> { + let repo_keys = list_repo_public_key_urls(base_url, client)?; + if repo_keys.is_empty() { + return Ok(None); + } + + let repo_keys_cache_dir = cache_dir.join("repo_keys"); + fs::create_dir_all(&repo_keys_cache_dir) + .with_context(|| format!("Failed to create {}", repo_keys_cache_dir.display()))?; + + for (key_name, key_url) in repo_keys { + let cache_name = sanitize_filename_component(&key_name); + let key_tmp_path = repo_keys_cache_dir.join(&cache_name); + if !fetch_url_to_path(client, &key_url, &key_tmp_path)? { + continue; + } + + if crate::signing::verify_zst_file_detached_with_public_key( + repo_db_zst_path, + repo_db_sig_path, + &key_tmp_path, + ) + .is_err() + { + continue; + } + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); + let prompt = format!( + "Trust repo key '{}' from binary repo '{}' and copy it to {}?", + key_name, + repo_name, + trusted_dir.display() + ); + if !crate::ui::prompt_yes_no(&prompt, true)? { + crate::log_warn!( + "Skipped trusting repo key '{}' for binary repo '{}'", + key_name, + repo_name + ); + continue; + } + + let installed = + install_trusted_repo_public_key(rootfs, repo_name, &key_tmp_path, &key_name)?; + return Ok(Some(installed)); + } + + Ok(None) +} + +pub(super) fn normalize_git_mirror_url(url: &str) -> Result { + let parsed = match url::Url::parse(url) { + Ok(parsed) => parsed, + Err(_) => return Ok(url.to_string()), + }; + if parsed.scheme() != "file" { + return Ok(url.to_string()); + } + let path = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file:// mirror URL: {}", url))?; + Ok(path.to_string_lossy().into_owned()) +} + +pub(super) fn decompress_zstd_file(src: &Path, dst: &Path) -> Result<()> { + let mut input = + fs::File::open(src).with_context(|| format!("Failed to open {}", src.display()))?; + let mut decoder = zstd::stream::read::Decoder::new(&mut input) + .with_context(|| format!("Failed to open zstd decoder for {}", src.display()))?; + let tmp = dst.with_extension("tmp"); + let mut output = + fs::File::create(&tmp).with_context(|| format!("Failed to create {}", tmp.display()))?; + std::io::copy(&mut decoder, &mut output) + .with_context(|| format!("Failed to decompress {}", src.display()))?; + output + .flush() + .with_context(|| format!("Failed to flush {}", tmp.display()))?; + fs::rename(&tmp, dst) + .with_context(|| format!("Failed to move {} to {}", tmp.display(), dst.display()))?; + Ok(()) +} + +/// Fetch (or refresh) a binary repo `repo.db.zst` into the configured package cache. +/// +/// Returns the path to the decompressed SQLite database file. +pub fn fetch_binary_repo_db( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, +) -> Result { + let machine_arch = std::env::consts::ARCH; + let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + let repo_db_rel = repo + .effective_repo_db_for_arch(machine_arch) + .with_context(|| { + format!( + "Binary repo '{}' is not configured for machine arch '{}'", + repo_name, machine_arch + ) + })?; + let cache_key = RepoDbFetchCacheKey { + repo_name: repo_name.to_string(), + base_url: base_url.to_string(), + repo_db_rel: repo_db_rel.to_string(), + rootfs: rootfs.to_path_buf(), + package_cache_dir: package_cache_dir.to_path_buf(), + }; + if let Some(cached_db_path) = get_cached_repo_db_path(&cache_key) { + return Ok(cached_db_path); + } + + let cache_dir = binary_repo_cache_dir(package_cache_dir, repo_name); + fs::create_dir_all(&cache_dir) + .with_context(|| format!("Failed to create {}", cache_dir.display()))?; + + let repo_db_zst = cache_dir.join("repo.db.zst"); + let repo_db_sig = cache_dir.join("repo.db.zst.sig"); + let repo_db_sqlite = cache_dir.join("repo.db"); + let tmp_zst = cache_dir.join("repo.db.zst.tmp"); + let tmp_sig = cache_dir.join("repo.db.zst.sig.tmp"); + + let repo_db_url = join_repo_url(base_url, repo_db_rel)?; + let repo_sig_url = join_repo_url(base_url, &format!("{}.sig", repo_db_rel))?; + crate::log_info!("Fetching binary repo DB for '{}'", repo_name); + + let client = reqwest::blocking::Client::builder() + .build() + .context("Failed to build HTTP client for binary repo fetch")?; + match copy_file_url_to_path(&repo_db_url, &tmp_zst)? { + FileUrlCopyOutcome::Copied => {} + FileUrlCopyOutcome::Missing => { + if repo_db_sqlite.exists() { + crate::log_warn!( + "Failed to refresh binary repo '{}' (missing local file), using cached DB: {}", + repo_name, + repo_db_url + ); + remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone()); + return Ok(repo_db_sqlite); + } + anyhow::bail!("Failed to fetch {}: local file not found", repo_db_url); + } + FileUrlCopyOutcome::NotFileUrl => { + let resp = client + .get(&repo_db_url) + .send() + .with_context(|| format!("Failed to fetch {}", repo_db_url))?; + + if !resp.status().is_success() { + if repo_db_sqlite.exists() { + crate::log_warn!( + "Failed to refresh binary repo '{}' (HTTP {}), using cached DB", + repo_name, + resp.status() + ); + remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone()); + return Ok(repo_db_sqlite); + } + anyhow::bail!("Failed to fetch {}: HTTP {}", repo_db_url, resp.status()); + } + + let mut resp = resp; + let mut out = fs::File::create(&tmp_zst) + .with_context(|| format!("Failed to create {}", tmp_zst.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", tmp_zst.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", tmp_zst.display()))?; + } + } + + let sig_downloaded = match copy_file_url_to_path(&repo_sig_url, &tmp_sig)? { + FileUrlCopyOutcome::Copied => true, + FileUrlCopyOutcome::Missing => { + if !repo.allow_unsigned { + anyhow::bail!( + "Failed to fetch detached signature for binary repo '{}' (local file not found): {}", + repo_name, + repo_sig_url + ); + } + crate::log_warn!( + "Binary repo '{}' has no detached signature (missing local file) for {}; allow_unsigned=true so continuing", + repo_name, + repo_db_url + ); + false + } + FileUrlCopyOutcome::NotFileUrl => { + let sig_resp = client + .get(&repo_sig_url) + .send() + .with_context(|| format!("Failed to fetch {}", repo_sig_url))?; + if sig_resp.status().is_success() { + let mut sig_resp = sig_resp; + let mut sig_out = fs::File::create(&tmp_sig) + .with_context(|| format!("Failed to create {}", tmp_sig.display()))?; + std::io::copy(&mut sig_resp, &mut sig_out) + .with_context(|| format!("Failed to save {}", tmp_sig.display()))?; + sig_out + .flush() + .with_context(|| format!("Failed to flush {}", tmp_sig.display()))?; + true + } else { + if !repo.allow_unsigned { + anyhow::bail!( + "Failed to fetch detached signature for binary repo '{}' (HTTP {}): {}", + repo_name, + sig_resp.status(), + repo_sig_url + ); + } + crate::log_warn!( + "Binary repo '{}' has no detached signature (HTTP {}) for {}; allow_unsigned=true so continuing", + repo_name, + sig_resp.status(), + repo_db_url + ); + false + } + } + }; + + if sig_downloaded { + let mut trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?; + if trusted_keys.is_empty() { + if try_trust_repo_public_key_for_repo_db( + repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig, + )? + .is_some() + { + crate::log_info!("Trusted repo key for '{}' installed", repo_name); + } else if !repo.allow_unsigned { + anyhow::bail!( + "No trusted minisign public key found for binary repo '{}' and no trusted key was accepted from {}/keys/", + repo_name, + base_url.trim_end_matches('/') + ); + } else { + crate::log_warn!( + "No trusted minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true", + repo_name + ); + } + trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?; + } + + if trusted_keys.is_empty() { + // No key was trusted/installed, and allow_unsigned=true already handled above. + } else { + if let Err(initial_err) = verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig) + { + if try_trust_repo_public_key_for_repo_db( + repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig, + )? + .is_some() + { + crate::log_info!("Trusted repo key for '{}' installed", repo_name); + verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig).with_context( + || { + format!( + "Failed to verify detached signature for binary repo '{}'", + repo_name + ) + }, + )?; + } else { + return Err(initial_err).with_context(|| { + format!( + "Failed to verify detached signature for binary repo '{}'", + repo_name + ) + }); + } + } + crate::log_info!( + "Verified detached signature for binary repo '{}'", + repo_name + ); + } + } + + fs::rename(&tmp_zst, &repo_db_zst).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_zst.display(), + repo_db_zst.display() + ) + })?; + if sig_downloaded { + fs::rename(&tmp_sig, &repo_db_sig).with_context(|| { + format!( + "Failed to move {} to {}", + tmp_sig.display(), + repo_db_sig.display() + ) + })?; + } else if repo_db_sig.exists() { + let _ = fs::remove_file(&repo_db_sig); + } + + decompress_zstd_file(&repo_db_zst, &repo_db_sqlite)?; + remember_repo_db_path(cache_key, repo_db_sqlite.clone()); + Ok(repo_db_sqlite) +} diff --git a/src/db/repo/manager.rs b/src/db/repo/manager.rs new file mode 100644 index 0000000..7a8ecff --- /dev/null +++ b/src/db/repo/manager.rs @@ -0,0 +1,511 @@ +use super::*; + +impl RepoManager { + pub fn new(repo_dir: PathBuf) -> Self { + Self { repo_dir } + } + + /// Create a compressed SQLite repository database from a directory of packages + pub fn create_repo_db(&self) -> Result { + let db_path = self.repo_dir.join("repo.db"); + let compressed_db_path = self.repo_dir.join("repo.db.zst"); + + // Remove existing DB if it exists + if db_path.exists() { + fs::remove_file(&db_path)?; + } + + let mut conn = Connection::open(&db_path) + .with_context(|| format!("Failed to create repo database at {}", db_path.display()))?; + + self.configure_repo_build_pragmas(&mut conn)?; + self.init_repo_schema(&mut conn)?; + + let package_paths = self.collect_repo_package_paths()?; + let indexed_packages = self.collect_indexed_packages_parallel(&package_paths)?; + + conn.execute_batch("BEGIN IMMEDIATE TRANSACTION;") + .context("Failed to begin repo DB write transaction")?; + let insert_result: Result<()> = (|| { + for indexed in indexed_packages { + self.insert_indexed_package(&mut conn, indexed)?; + } + Ok(()) + })(); + match insert_result { + Ok(()) => { + conn.execute_batch("COMMIT;") + .context("Failed to commit repo DB write transaction")?; + } + Err(err) => { + let _ = conn.execute_batch("ROLLBACK;"); + return Err(err); + } + } + + self.create_repo_indexes(&mut conn)?; + + conn.close().map_err(|(_, e)| e)?; + + // Compress the database + self.compress_db(&db_path, &compressed_db_path)?; + + // Remove the uncompressed DB + fs::remove_file(&db_path)?; + + Ok(compressed_db_path) + } + + pub(super) fn configure_repo_build_pragmas(&self, conn: &mut Connection) -> Result<()> { + // Speed-focused settings are scoped to this temporary repo DB build process. + conn.execute_batch( + "PRAGMA synchronous = OFF; + PRAGMA journal_mode = MEMORY; + PRAGMA temp_store = MEMORY; + PRAGMA locking_mode = EXCLUSIVE; + PRAGMA cache_size = -200000;", + ) + .context("Failed to apply SQLite build PRAGMAs for repo DB creation")?; + Ok(()) + } + + pub(super) fn collect_repo_package_paths(&self) -> Result> { + let mut package_paths = Vec::new(); + for entry in fs::read_dir(&self.repo_dir) + .with_context(|| format!("Failed to read {}", self.repo_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.to_string_lossy().ends_with(".depot.pkg.tar.zst") { + package_paths.push(path); + } + } + package_paths.sort(); + Ok(package_paths) + } + + pub(super) fn collect_indexed_packages_parallel( + &self, + package_paths: &[PathBuf], + ) -> Result> { + if package_paths.is_empty() { + return Ok(Vec::new()); + } + + let worker_count = num_cpus().min(package_paths.len()); + crate::log_info!( + "Using {} thread(s) to index {} package(s)...", + worker_count, + package_paths.len() + ); + + let next_index = AtomicUsize::new(0); + let mut indexed = std::thread::scope(|scope| -> Result> { + let (tx, rx) = mpsc::channel::<(usize, Result)>(); + + for _ in 0..worker_count { + let tx = tx.clone(); + let next_index = &next_index; + scope.spawn(move || { + loop { + let idx = next_index.fetch_add(1, Ordering::Relaxed); + if idx >= package_paths.len() { + break; + } + let result = self.read_indexed_package(&package_paths[idx]); + if tx.send((idx, result)).is_err() { + break; + } + } + }); + } + drop(tx); + + let mut indexed = Vec::with_capacity(package_paths.len()); + for _ in 0..package_paths.len() { + let (idx, result) = rx + .recv() + .context("Failed to receive package indexing result from worker")?; + indexed.push((idx, result?)); + } + Ok(indexed) + })?; + + indexed.sort_by_key(|(idx, _)| *idx); + Ok(indexed.into_iter().map(|(_, pkg)| pkg).collect()) + } + + pub(super) fn init_repo_schema(&self, conn: &mut Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE packages ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + real_name TEXT, + version TEXT NOT NULL, + revision INTEGER NOT NULL, + abi_breaking INTEGER NOT NULL DEFAULT 0, + built_against TEXT NOT NULL DEFAULT '', + completed_at INTEGER, + description TEXT, + homepage TEXT, + license TEXT, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + sha512 TEXT NOT NULL + ); + CREATE TABLE provides ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE conflicts ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE replaces ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE dependencies ( + package_id INTEGER, + kind TEXT NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE groups ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); + CREATE TABLE files ( + package_id INTEGER, + path TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + );", + ) + .context("Failed to initialize repo schema")?; + Ok(()) + } + + pub(super) fn create_repo_indexes(&self, conn: &mut Connection) -> Result<()> { + conn.execute_batch( + "CREATE INDEX idx_packages_name ON packages(name); + CREATE INDEX idx_provides_name ON provides(name); + CREATE INDEX idx_conflicts_name ON conflicts(name); + CREATE INDEX idx_replaces_name ON replaces(name); + CREATE INDEX idx_dependencies_name ON dependencies(name); + CREATE INDEX idx_dependencies_kind ON dependencies(kind); + CREATE INDEX idx_groups_name ON groups(name); + CREATE INDEX idx_repo_files_path ON files(path);", + ) + .context("Failed to create repo DB indexes")?; + Ok(()) + } + + pub(super) fn read_indexed_package(&self, pkg_path: &Path) -> Result { + crate::log_info!("Indexing package {}...", pkg_path.display()); + + let filename = pkg_path + .file_name() + .and_then(|name| name.to_str()) + .with_context(|| format!("Invalid package filename: {}", pkg_path.display()))? + .to_string(); + let file = fs::File::open(pkg_path)?; + let size = file.metadata()?.len(); + let mut hashing_reader = HashingReader::new(file); + + let mut name = String::new(); + let mut real_name = None; + let mut version = String::new(); + let mut revision = 1; + let mut abi_breaking = false; + let mut built_against = Vec::new(); + let mut completed_at = path_modified_unix_timestamp(pkg_path)?; + let mut description = None; + let mut homepage = None; + let mut license = None; + let mut provides = Vec::new(); + let mut conflicts = Vec::new(); + let mut replaces = Vec::new(); + let mut runtime_dependencies = Vec::new(); + let mut optional_dependencies = Vec::new(); + let mut groups = Vec::new(); + let mut archive_files = Vec::new(); + + { + let zstd_decoder = zstd::stream::read::Decoder::new(&mut hashing_reader)?; + let mut archive = tar::Archive::new(zstd_decoder); + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?; + let path_str = path.to_string_lossy().to_string(); + if path_str == ".metadata.toml" { + let mut content = String::new(); + use std::io::Read; + entry.read_to_string(&mut content)?; + let metadata: toml::Value = toml::from_str(&content).with_context(|| { + format!("Failed to parse .metadata.toml in {}", pkg_path.display()) + })?; + + name = metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + real_name = metadata + .get("real_name") + .and_then(|v| v.as_str()) + .map(String::from); + version = metadata + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + revision = metadata + .get("revision") + .and_then(|v| v.as_integer()) + .unwrap_or(1) as u32; + abi_breaking = metadata + .get("abi_breaking") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + built_against = parse_string_array_metadata(&metadata, "built_against"); + completed_at = + metadata_time::parse_completed_at_value(&metadata).or(completed_at); + description = metadata + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + homepage = metadata + .get("homepage") + .and_then(|v| v.as_str()) + .map(String::from); + license = parse_license_text(&metadata); + + if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array()) + { + provides = provides_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(conflicts_arr) = + metadata.get("conflicts").and_then(|v| v.as_array()) + { + conflicts = conflicts_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(replaces_arr) = metadata.get("replaces").and_then(|v| v.as_array()) + { + replaces = replaces_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(runtime_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("runtime")) + .and_then(|v| v.as_array()) + { + runtime_dependencies = runtime_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(optional_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("optional")) + .and_then(|v| v.as_array()) + { + optional_dependencies = optional_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + if let Some(groups_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("groups")) + .and_then(|v| v.as_array()) + { + groups = groups_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + continue; + } + + let entry_type = entry.header().entry_type(); + if entry_type.is_file() || entry_type.is_symlink() || entry_type.is_hard_link() { + let normalized = path_str.trim_start_matches("./").to_string(); + if normalized == ".metadata.toml" { + continue; + } + archive_files.push(normalized); + } + } + } + let (sha256, sha512) = hashing_reader.finalize_hex(); + + if name.is_empty() { + // Fallback for packages WITHOUT metadata (e.g. legacy or during transition) + let name_parts: Vec<&str> = filename.split('-').collect(); + if name_parts.len() < 4 { + anyhow::bail!( + "Invalid package filename and no .metadata.toml: {}", + filename + ); + } + name = name_parts[0].to_string(); + version = name_parts[1].to_string(); + revision = name_parts[2].parse().unwrap_or(1); + } + + Ok(IndexedPackage { + name, + real_name, + version, + revision, + abi_breaking, + built_against, + completed_at, + description, + homepage, + license, + filename, + size, + sha256, + sha512, + provides, + conflicts, + replaces, + runtime_dependencies, + optional_dependencies, + groups, + archive_files, + }) + } + + pub(super) fn insert_indexed_package( + &self, + conn: &mut Connection, + indexed: IndexedPackage, + ) -> Result<()> { + let IndexedPackage { + name, + real_name, + version, + revision, + abi_breaking, + built_against, + completed_at, + description, + homepage, + license, + filename, + size, + sha256, + sha512, + provides, + conflicts, + replaces, + runtime_dependencies, + optional_dependencies, + groups, + archive_files, + } = indexed; + + // Insert into database + conn.execute( + "INSERT INTO packages (name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + name, + real_name, + version, + revision as i64, + abi_breaking, + format_built_against(&built_against), + completed_at, + description, + homepage, + license, + filename, + size as i64, + sha256, + sha512 + ], + )?; + + let package_id = conn.last_insert_rowid(); + + // Insert into provides + for provide in provides { + conn.execute( + "INSERT INTO provides (package_id, name) VALUES (?1, ?2)", + params![package_id, provide], + )?; + } + for conflict in conflicts { + conn.execute( + "INSERT INTO conflicts (package_id, name) VALUES (?1, ?2)", + params![package_id, conflict], + )?; + } + for replacement in replaces { + conn.execute( + "INSERT INTO replaces (package_id, name) VALUES (?1, ?2)", + params![package_id, replacement], + )?; + } + + for dep in runtime_dependencies { + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'runtime', ?2)", + params![package_id, dep], + )?; + } + for dep in optional_dependencies { + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)", + params![package_id, dep], + )?; + } + for group in groups { + conn.execute( + "INSERT INTO groups (package_id, name) VALUES (?1, ?2)", + params![package_id, group], + )?; + } + + for file_path in archive_files { + conn.execute( + "INSERT INTO files (package_id, path) VALUES (?1, ?2)", + params![package_id, file_path], + )?; + } + + Ok(()) + } + + pub(super) fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> { + let mut input = fs::File::open(source)?; + let output = fs::File::create(dest)?; + let mut encoder = Encoder::new(output, 19)?; // High compression for repo DB + encoder.multithread(num_cpus() as u32)?; + std::io::copy(&mut input, &mut encoder)?; + encoder.finish()?; + Ok(()) + } +} diff --git a/src/db/repo/mirrors.rs b/src/db/repo/mirrors.rs new file mode 100644 index 0000000..f9eb1c9 --- /dev/null +++ b/src/db/repo/mirrors.rs @@ -0,0 +1,149 @@ +use super::*; + +/// Synchronize git mirrors into /usr/src/depot/ +pub fn sync_mirrors( + repo_dir: &std::path::Path, + mirrors: &std::collections::HashMap, +) -> Result<()> { + use git2::{FetchOptions, Repository, ResetType, build::RepoBuilder}; + use std::os::unix::fs::PermissionsExt; + + let base = repo_dir.to_path_buf(); + if !base.exists() { + std::fs::create_dir_all(&base)?; + } + + for (name, url) in mirrors { + let target = base.join(name); + let git_url = normalize_git_mirror_url(url)?; + if !target.exists() { + crate::log_info!("Cloning mirror '{}' -> {}", name, target.display()); + + let mut fo = FetchOptions::new(); + fo.remote_callbacks(crate::source::authenticated_remote_callbacks( + None, &git_url, + )); + + let mut builder = RepoBuilder::new(); + builder.fetch_options(fo); + builder + .clone(&git_url, &target) + .with_context(|| format!("Failed to clone {}", url))?; + } else { + crate::log_info!("Updating mirror '{}' in {}", name, target.display()); + // Open repository and fetch updates + let repo = Repository::open(&target) + .with_context(|| format!("Failed to open repository at {}", target.display()))?; + + let mut fo = FetchOptions::new(); + fo.remote_callbacks(crate::source::authenticated_remote_callbacks( + None, &git_url, + )); + + // Fetch from origin + let mut remote = repo + .find_remote("origin") + .or_else(|_| repo.remote_anonymous(&git_url))?; + remote + .fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fo), None) + .with_context(|| format!("Failed to fetch updates for {}", url))?; + + // Try to fast-forward HEAD to origin/HEAD by resetting to FETCH_HEAD if present + if let Ok(fetch_head) = repo.find_reference("FETCH_HEAD") + && let Some(oid) = fetch_head.target() + { + let obj = repo.find_object(oid, None)?; + repo.reset(&obj, ResetType::Hard, None)?; + } + } + + // Make the tree readable and writable by everyone + for entry in walkdir::WalkDir::new(&target) { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))?; + } else if path.is_file() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))?; + } + } + } + + Ok(()) +} + +/// Show status for each mirror repository: path, exists, branch/HEAD, latest commit, dirty +pub fn mirrors_status( + repo_dir: &std::path::Path, + mirrors: &std::collections::HashMap, +) -> Result<()> { + use git2::Repository; + + let base = repo_dir.to_path_buf(); + if !base.exists() { + crate::log_info!("Repo base directory does not exist: {}", base.display()); + return Ok(()); + } + + for name in mirrors.keys() { + let target = base.join(name); + crate::log_info!("--- {} ---", name); + if !target.exists() { + crate::log_info!("Not cloned: {}", target.display()); + continue; + } + + match Repository::open(&target) { + Ok(repo) => { + // Branch / HEAD + let head = repo.head().ok(); + let branch = head + .as_ref() + .and_then(|h| h.shorthand().ok().map(|s| s.to_string())) + .unwrap_or_else(|| "(no branch)".to_string()); + + // Latest commit OID + let oid = repo.refname_to_id("HEAD").ok(); + let short = oid + .map(|o| format!("{}", o)) + .unwrap_or_else(|| "(unknown)".to_string()); + + // Commit time (seconds since epoch) if available + let mut commit_time = String::new(); + if let Some(oid) = oid + && let Ok(commit) = repo.find_commit(oid) + { + let t = commit.time().seconds(); + commit_time = format!("{}", t); + } + + // Dirty status + let statuses = match repo.statuses(None) { + Ok(s) => s, + Err(_) => { + crate::log_warn!("Failed to read status for {}", target.display()); + continue; + } + }; + let dirty = statuses.iter().any(|s| { + s.status().intersects( + git2::Status::WT_MODIFIED | git2::Status::WT_NEW | git2::Status::WT_DELETED, + ) + }); + + crate::log_info!("Path: {}", target.display()); + crate::log_info!("Branch/HEAD: {}", branch); + crate::log_info!("HEAD OID: {}", short); + if !commit_time.is_empty() { + crate::log_info!("Latest commit time (epoch): {}", commit_time); + } + crate::log_info!("Dirty: {}", if dirty { "yes" } else { "no" }); + } + Err(e) => { + crate::log_info!("Failed to open repo at {}: {}", target.display(), e); + } + } + } + + Ok(()) +} diff --git a/src/db/repo/query.rs b/src/db/repo/query.rs new file mode 100644 index 0000000..13a73a7 --- /dev/null +++ b/src/db/repo/query.rs @@ -0,0 +1,806 @@ +use super::*; + +/// Search a cached binary repository SQLite DB by package name or provided feature. +pub fn search_cached_binary_repo_db( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let like = format!("%{}%", query.to_ascii_lowercase()); + let mut stmt = conn.prepare( + "SELECT + p.name, + p.version, + p.revision, + p.description, + p.filename, + p.size, + GROUP_CONCAT(DISTINCT pr_all.name) + FROM packages p + LEFT JOIN provides pr_all ON pr_all.package_id = p.id + WHERE lower(p.name) LIKE ?1 + OR EXISTS ( + SELECT 1 FROM provides pr + WHERE pr.package_id = p.id + AND lower(pr.name) LIKE ?1 + ) + GROUP BY p.id + ORDER BY + CASE + WHEN lower(p.name) = lower(?2) THEN 0 + WHEN lower(p.name) LIKE lower(?3) THEN 1 + ELSE 2 + END, + p.name ASC", + )?; + + let starts = format!("{}%", query.to_ascii_lowercase()); + let rows = stmt.query_map(params![like, query, starts], |row| { + let provides_csv: Option = row.get(6)?; + Ok(BinaryRepoSearchHit { + repo_name: repo_name.to_string(), + name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + description: row.get(3)?, + filename: row.get(4)?, + size: row.get::<_, i64>(5)? as u64, + provides: provides_csv + .map(|s| { + s.split(',') + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect::>() + }) + .unwrap_or_default(), + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Fetch and search a binary repo by name or provide. +pub fn search_binary_repo( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + search_cached_binary_repo_db(repo_name, &db_path, query) +} + +/// Search a cached binary repo DB by file path substring. +pub fn search_cached_binary_repo_files( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_files_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_files_table { + return Ok(Vec::new()); + } + + let like = format!("%{}%", query.to_ascii_lowercase()); + let mut stmt = conn.prepare( + "SELECT p.name, p.version, p.revision, f.path, p.size + FROM files f + JOIN packages p ON p.id = f.package_id + WHERE lower(f.path) LIKE ?1 + ORDER BY p.name ASC, f.path ASC", + )?; + let rows = stmt.query_map(params![like], |row| { + Ok(BinaryRepoFileSearchHit { + repo_name: repo_name.to_string(), + package_name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + path: row.get(3)?, + size: row.get::<_, i64>(4)? as u64, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Fetch and search a binary repo by file path substring. +pub fn search_binary_repo_files( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + search_cached_binary_repo_files(repo_name, &db_path, query) +} + +/// Find the package(s) that own a file path in a cached binary repo DB. +pub fn cached_binary_repo_owns_path( + repo_name: &str, + db_path: &Path, + path: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_files_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_files_table { + return Ok(Vec::new()); + } + + let normalized = path.trim_start_matches('/').trim_start_matches("./"); + let mut stmt = conn.prepare( + "SELECT p.name, p.version, p.revision, f.path, p.size + FROM files f + JOIN packages p ON p.id = f.package_id + WHERE f.path = ?1 + ORDER BY p.name ASC", + )?; + let rows = stmt.query_map(params![normalized], |row| { + Ok(BinaryRepoFileSearchHit { + repo_name: repo_name.to_string(), + package_name: row.get(0)?, + version: row.get(1)?, + revision: row.get::<_, i64>(2)? as u32, + path: row.get(3)?, + size: row.get::<_, i64>(4)? as u64, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn repo_owns_query_candidates(rootfs: &Path, path: &str) -> Vec { + let normalized = path.trim_start_matches('/').trim_start_matches("./"); + let mut candidates = BTreeSet::new(); + if !normalized.is_empty() { + candidates.insert(normalized.to_string()); + } + + let query_path = Path::new(path); + let fs_path = if query_path.is_absolute() { + rootfs.join(query_path.strip_prefix("/").unwrap_or(query_path)) + } else { + rootfs.join(query_path) + }; + + if let Ok(resolved) = fs::canonicalize(&fs_path) + && let Some(rel) = resolved_repo_owns_path(rootfs, &resolved) + && !rel.is_empty() + { + candidates.insert(rel); + } + + candidates.into_iter().collect() +} + +pub(super) fn resolved_repo_owns_path(rootfs: &Path, resolved: &Path) -> Option { + if rootfs == Path::new("/") { + return Some( + resolved + .to_string_lossy() + .trim_start_matches('/') + .to_string(), + ); + } + + resolved + .strip_prefix(rootfs) + .ok() + .map(|rel| rel.to_string_lossy().trim_start_matches('/').to_string()) +} + +/// Fetch repo metadata and resolve file ownership in a binary repo. +pub fn binary_repo_owns_path( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + path: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + let mut hits = Vec::new(); + let mut seen = BTreeSet::new(); + for candidate in repo_owns_query_candidates(rootfs, path) { + for hit in cached_binary_repo_owns_path(repo_name, &db_path, &candidate)? { + let key = format!( + "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{}", + hit.repo_name, hit.package_name, hit.version, hit.revision, hit.path + ); + if seen.insert(key) { + hits.push(hit); + } + } + } + Ok(hits) +} + +pub(super) fn query_package_provides(conn: &Connection, package_id: i64) -> Result> { + let mut stmt = conn.prepare("SELECT name FROM provides WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn query_package_conflicts(conn: &Connection, package_id: i64) -> Result> { + let has_conflicts_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='conflicts'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_conflicts_table { + return Ok(Vec::new()); + } + + let mut stmt = + conn.prepare("SELECT name FROM conflicts WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn query_package_replaces(conn: &Connection, package_id: i64) -> Result> { + let has_replaces_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='replaces'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_replaces_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare("SELECT name FROM replaces WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn query_package_runtime_deps( + conn: &Connection, + package_id: i64, +) -> Result> { + let has_dependencies_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_dependencies_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare( + "SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'runtime' ORDER BY name", + )?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn query_package_built_against( + conn: &Connection, + package_id: i64, +) -> Result> { + if !repo_packages_have_built_against(conn)? { + return Ok(Vec::new()); + } + + let raw: String = conn.query_row( + "SELECT built_against FROM packages WHERE id = ?1", + params![package_id], + |row| row.get(0), + )?; + Ok(parse_built_against(&raw)) +} + +pub(super) fn query_package_optional_deps( + conn: &Connection, + package_id: i64, +) -> Result> { + let has_dependencies_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_dependencies_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare( + "SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'optional' ORDER BY name", + )?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn query_package_groups(conn: &Connection, package_id: i64) -> Result> { + let has_groups_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_groups_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare("SELECT name FROM groups WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +pub(super) fn find_cached_binary_repo_packages( + repo_name: &str, + db_path: &Path, + query: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let completed_at_expr = if repo_packages_have_completed_at(&conn)? { + "p.completed_at" + } else { + "NULL" + }; + let real_name_expr = if repo_packages_have_real_name(&conn)? { + "p.real_name" + } else { + "NULL" + }; + let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { + "p.abi_breaking" + } else { + "0" + }; + let built_against_expr = if repo_packages_have_built_against(&conn)? { + "p.built_against" + } else { + "''" + }; + let sql = format!( + "SELECT + p.id, + p.name, + {real_name_expr}, + p.version, + p.revision, + {abi_breaking_expr}, + {built_against_expr}, + {completed_at_expr}, + p.filename, + p.size, + p.sha512, + p.description, + p.homepage, + p.license + FROM packages p + WHERE lower(p.name) = lower(?1) + OR lower({real_name_expr}) = lower(?1) + OR EXISTS ( + SELECT 1 FROM replaces rp + WHERE rp.package_id = p.id + AND lower(rp.name) = lower(?1) + ) + OR EXISTS ( + SELECT 1 FROM provides pr + WHERE pr.package_id = p.id + AND lower(pr.name) = lower(?1) + ) + ORDER BY + CASE + WHEN EXISTS ( + SELECT 1 FROM replaces rp + WHERE rp.package_id = p.id + AND lower(rp.name) = lower(?1) + ) THEN 0 + WHEN lower(p.name) = lower(?1) THEN 1 + WHEN lower({real_name_expr}) = lower(?1) THEN 2 + ELSE 2 + END, + p.name ASC" + ); + let mut stmt = conn.prepare(&sql)?; + + let rows = stmt.query_map(params![query], |row| { + let package_id = row.get::<_, i64>(0)?; + Ok(( + package_id, + BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: row.get(1)?, + real_name: row.get(2)?, + version: row.get(3)?, + revision: row.get::<_, i64>(4)? as u32, + abi_breaking: row.get(5)?, + built_against: parse_built_against(&row.get::<_, String>(6)?), + completed_at: row.get(7)?, + filename: row.get(8)?, + size: row.get::<_, i64>(9)? as u64, + sha512: row.get(10)?, + description: row.get(11)?, + homepage: row.get(12)?, + license: row.get(13)?, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (package_id, mut rec) = row?; + rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; + rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + rec.built_against = query_package_built_against(&conn, package_id)?; + rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; + out.push(rec); + } + Ok(out) +} + +pub(super) fn find_cached_binary_repo_packages_by_group( + repo_name: &str, + db_path: &Path, + group: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_groups_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_groups_table { + return Ok(Vec::new()); + } + + let completed_at_expr = if repo_packages_have_completed_at(&conn)? { + "p.completed_at" + } else { + "NULL" + }; + let real_name_expr = if repo_packages_have_real_name(&conn)? { + "p.real_name" + } else { + "NULL" + }; + let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { + "p.abi_breaking" + } else { + "0" + }; + let built_against_expr = if repo_packages_have_built_against(&conn)? { + "p.built_against" + } else { + "''" + }; + let sql = format!( + "SELECT + p.id, + p.name, + {real_name_expr}, + p.version, + p.revision, + {abi_breaking_expr}, + {built_against_expr}, + {completed_at_expr}, + p.filename, + p.size, + p.sha512, + p.description, + p.homepage, + p.license + FROM packages p + WHERE EXISTS ( + SELECT 1 FROM groups g + WHERE g.package_id = p.id + AND lower(g.name) = lower(?1) + ) + ORDER BY p.name ASC" + ); + let mut stmt = conn.prepare(&sql)?; + + let rows = stmt.query_map(params![group], |row| { + let package_id = row.get::<_, i64>(0)?; + Ok(( + package_id, + BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: row.get(1)?, + real_name: row.get(2)?, + version: row.get(3)?, + revision: row.get::<_, i64>(4)? as u32, + abi_breaking: row.get(5)?, + built_against: parse_built_against(&row.get::<_, String>(6)?), + completed_at: row.get(7)?, + filename: row.get(8)?, + size: row.get::<_, i64>(9)? as u64, + sha512: row.get(10)?, + description: row.get(11)?, + homepage: row.get(12)?, + license: row.get(13)?, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (package_id, mut rec) = row?; + rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; + rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + rec.built_against = query_package_built_against(&conn, package_id)?; + rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; + out.push(rec); + } + Ok(out) +} + +pub(super) fn list_cached_binary_repo_packages( + repo_name: &str, + db_path: &Path, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let completed_at_expr = if repo_packages_have_completed_at(&conn)? { + "p.completed_at" + } else { + "NULL" + }; + let real_name_expr = if repo_packages_have_real_name(&conn)? { + "p.real_name" + } else { + "NULL" + }; + let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { + "p.abi_breaking" + } else { + "0" + }; + let built_against_expr = if repo_packages_have_built_against(&conn)? { + "p.built_against" + } else { + "''" + }; + let sql = format!( + "SELECT + p.id, + p.name, + {real_name_expr}, + p.version, + p.revision, + {abi_breaking_expr}, + {built_against_expr}, + {completed_at_expr}, + p.filename, + p.size, + p.sha512, + p.description, + p.homepage, + p.license + FROM packages p + ORDER BY p.name ASC, p.version ASC, p.revision ASC" + ); + let mut stmt = conn.prepare(&sql)?; + + let rows = stmt.query_map([], |row| { + let package_id = row.get::<_, i64>(0)?; + Ok(( + package_id, + BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: row.get(1)?, + real_name: row.get(2)?, + version: row.get(3)?, + revision: row.get::<_, i64>(4)? as u32, + abi_breaking: row.get(5)?, + built_against: parse_built_against(&row.get::<_, String>(6)?), + completed_at: row.get(7)?, + filename: row.get(8)?, + size: row.get::<_, i64>(9)? as u64, + sha512: row.get(10)?, + description: row.get(11)?, + homepage: row.get(12)?, + license: row.get(13)?, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (package_id, mut rec) = row?; + rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; + rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + rec.built_against = query_package_built_against(&conn, package_id)?; + rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; + out.push(rec); + } + Ok(out) +} + +pub(super) fn repo_packages_have_completed_at(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'", + [], + |row| { + let count: i64 = row.get(0)?; + Ok(count > 0) + }, + ) + .context("Failed to inspect binary repo DB schema") +} + +pub(super) fn repo_packages_have_real_name(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'real_name'", + [], + |row| { + let count: i64 = row.get(0)?; + Ok(count > 0) + }, + ) + .context("Failed to inspect binary repo DB schema") +} + +pub(super) fn repo_packages_have_abi_breaking(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'abi_breaking'", + [], + |row| { + let count: i64 = row.get(0)?; + Ok(count > 0) + }, + ) + .context("Failed to inspect binary repo DB schema") +} + +pub(super) fn repo_packages_have_built_against(conn: &Connection) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'built_against'", + [], + |row| { + let count: i64 = row.get(0)?; + Ok(count > 0) + }, + ) + .context("Failed to inspect binary repo DB schema") +} + +pub(super) fn path_modified_unix_timestamp(path: &Path) -> Result> { + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for {}", path.display()))?; + let modified = metadata + .modified() + .with_context(|| format!("Failed to read modification time for {}", path.display()))?; + Ok(Some(metadata_time::system_time_to_unix(modified)?)) +} + +/// Resolve an exact package name/provide match from a binary repo after verifying +/// and caching its signed `repo.db.zst`. +pub fn find_binary_repo_package( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + let mut matches = find_cached_binary_repo_packages(repo_name, &db_path, query)?; + if matches.len() > 1 { + crate::log_warn!( + "Multiple binary packages matched '{}' in repo '{}'; using the first match", + query, + repo_name + ); + } + Ok(matches.drain(..).next()) +} + +/// Resolve exact package name/provide matches from a binary repo. +pub fn find_binary_repo_packages( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + query: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + find_cached_binary_repo_packages(repo_name, &db_path, query) +} + +/// Resolve package records that belong to the named group from a binary repo. +pub fn find_binary_repo_packages_by_group( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + group: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + find_cached_binary_repo_packages_by_group(repo_name, &db_path, group) +} + +/// List all binary packages from a cached, verified repository database. +pub fn list_binary_repo_packages( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + list_cached_binary_repo_packages(repo_name, &db_path) +} diff --git a/src/db/repo/tests.rs b/src/db/repo/tests.rs new file mode 100644 index 0000000..03008b2 --- /dev/null +++ b/src/db/repo/tests.rs @@ -0,0 +1,911 @@ +use super::*; + +#[test] +fn test_init_repo_schema() { + let mut conn = Connection::open_in_memory().unwrap(); + let manager = RepoManager::new(PathBuf::from(".")); + manager.init_repo_schema(&mut conn).unwrap(); + + // Check if table exists + let exists: bool = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='packages'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(exists); + + let has_sha512: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'sha512'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap(); + assert!(has_sha512); + + let has_completed_at: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap(); + assert!(has_completed_at); +} + +#[test] +fn test_index_package() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path(); + let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); + + // Create a valid .tar.zst with .metadata.toml + let file = fs::File::create(&pkg_path).unwrap(); + let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); + let mut tar = tar::Builder::new(encoder); + + let metadata = r#" +name = "test" +real_name = "icu" +version = "1.0" +revision = 1 +abi_breaking = true +built_against = ["icu78"] +description = "test description" +homepage = "https://example.com" +license = "MIT" +completed_at = "2026-03-10T12:34:56Z" +provides = ["test-feature"] + +[dependencies] +runtime = [] +optional = [] +"#; + let mut header = tar::Header::new_gnu(); + header.set_path(".metadata.toml").unwrap(); + header.set_size(metadata.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, metadata.as_bytes()).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + filetime::set_file_mtime( + &pkg_path, + filetime::FileTime::from_unix_time(1_700_000_000, 0), + ) + .unwrap(); + + let mut conn = Connection::open_in_memory().unwrap(); + let manager = RepoManager::new(repo_dir.to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + let indexed = manager.read_indexed_package(&pkg_path).unwrap(); + manager.insert_indexed_package(&mut conn, indexed).unwrap(); + + type PackageRow = ( + String, + Option, + String, + i64, + i64, + String, + Option, + Option, + Option, + String, + String, + ); + + let ( + name, + real_name, + version, + revision, + abi_breaking, + built_against, + desc, + home, + lic, + sha256, + sha512, + ): PackageRow = conn + .query_row( + "SELECT name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, sha256, sha512 FROM packages", + [], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + r.get(10)?, + )) + }, + ) + .unwrap(); + + assert_eq!(name, "test"); + assert_eq!(real_name, Some("icu".to_string())); + assert_eq!(version, "1.0"); + assert_eq!(revision, 1); + assert_eq!(abi_breaking, 1); + assert_eq!(built_against, "icu78"); + assert_eq!(desc, Some("test description".to_string())); + assert_eq!(home, Some("https://example.com".to_string())); + assert_eq!(lic, Some("MIT".to_string())); + assert_eq!(sha256.len(), 64); + assert_eq!(sha512.len(), 128); + + let completed_at: Option = conn + .query_row("SELECT completed_at FROM packages", [], |r| r.get(0)) + .unwrap(); + assert_eq!(completed_at, Some(1_773_146_096)); + + let provides_count: i64 = conn + .query_row("SELECT count(*) FROM provides", [], |r| r.get(0)) + .unwrap(); + assert_eq!(provides_count, 1); +} + +#[test] +fn test_index_package_with_multiple_licenses() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path(); + let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&pkg_path).unwrap(); + let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); + let mut tar = tar::Builder::new(encoder); + + let metadata = r#" +name = "test" +version = "1.0" +revision = 1 +license = ["MIT", "Apache-2.0"] +"#; + let mut header = tar::Header::new_gnu(); + header.set_path(".metadata.toml").unwrap(); + header.set_size(metadata.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, metadata.as_bytes()).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut conn = Connection::open_in_memory().unwrap(); + let manager = RepoManager::new(repo_dir.to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + let indexed = manager.read_indexed_package(&pkg_path).unwrap(); + manager.insert_indexed_package(&mut conn, indexed).unwrap(); + + let lic: Option = conn + .query_row("SELECT license FROM packages", [], |r| r.get(0)) + .unwrap(); + assert_eq!(lic, Some("MIT, Apache-2.0".to_string())); +} + +#[test] +fn test_index_package_records_symlink_paths_for_repo_owns() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path(); + let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst"); + + let file = fs::File::create(&pkg_path).unwrap(); + let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap(); + let mut tar = tar::Builder::new(encoder); + + let metadata = r#" +name = "test" +version = "1.0" +revision = 1 +"#; + let mut header = tar::Header::new_gnu(); + header.set_path(".metadata.toml").unwrap(); + header.set_size(metadata.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, metadata.as_bytes()).unwrap(); + + let mut file_header = tar::Header::new_gnu(); + file_header.set_path("usr/bin/coreutils").unwrap(); + file_header.set_size(4); + file_header.set_mode(0o755); + file_header.set_cksum(); + tar.append(&file_header, &b"test"[..]).unwrap(); + + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Symlink); + link_header.set_path("usr/bin/ls").unwrap(); + link_header.set_link_name("coreutils").unwrap(); + link_header.set_size(0); + link_header.set_mode(0o777); + link_header.set_cksum(); + tar.append(&link_header, std::io::empty()).unwrap(); + + let encoder = tar.into_inner().unwrap(); + encoder.finish().unwrap(); + + let mut conn = Connection::open_in_memory().unwrap(); + let manager = RepoManager::new(repo_dir.to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + let indexed = manager.read_indexed_package(&pkg_path).unwrap(); + manager.insert_indexed_package(&mut conn, indexed).unwrap(); + + let db_path = repo_dir.join("repo.db"); + let mut file_conn = Connection::open(&db_path).unwrap(); + manager.init_repo_schema(&mut file_conn).unwrap(); + let indexed = manager.read_indexed_package(&pkg_path).unwrap(); + manager + .insert_indexed_package(&mut file_conn, indexed) + .unwrap(); + drop(file_conn); + + let hits = cached_binary_repo_owns_path("repo", &db_path, "usr/bin/ls").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].package_name, "test"); + assert_eq!(hits[0].path, "usr/bin/ls"); +} + +#[test] +fn test_search_cached_binary_repo_db_matches_name_and_provides() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO provides (package_id, name) VALUES (1, 'libfoo.so')", + [], + ) + .unwrap(); + drop(conn); + + let name_hits = search_cached_binary_repo_db("testrepo", &db_path, "foo").unwrap(); + assert_eq!(name_hits.len(), 1); + assert_eq!(name_hits[0].name, "foo"); + assert_eq!(name_hits[0].repo_name, "testrepo"); + assert!(name_hits[0].provides.iter().any(|p| p == "libfoo.so")); + + let provide_hits = search_cached_binary_repo_db("testrepo", &db_path, "libfoo").unwrap(); + assert_eq!(provide_hits.len(), 1); + assert_eq!(provide_hits[0].name, "foo"); +} + +#[test] +fn test_find_cached_binary_repo_packages_by_group() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO groups (package_id, name) VALUES (1, 'base')", + [], + ) + .unwrap(); + drop(conn); + + let hits = find_cached_binary_repo_packages_by_group("testrepo", &db_path, "base").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "foo"); + assert_eq!(hits[0].groups, vec!["base".to_string()]); +} + +#[test] +#[cfg(unix)] +fn test_repo_owns_query_candidates_follow_rootfs_symlink_targets() { + let rootfs = tempfile::tempdir().unwrap(); + let usr_bin = rootfs.path().join("usr/bin"); + fs::create_dir_all(&usr_bin).unwrap(); + fs::write(usr_bin.join("coreutils"), b"payload").unwrap(); + std::os::unix::fs::symlink("coreutils", usr_bin.join("ls")).unwrap(); + std::os::unix::fs::symlink("usr/bin", rootfs.path().join("bin")).unwrap(); + + let ls_candidates = repo_owns_query_candidates(rootfs.path(), "/usr/bin/ls"); + assert!( + ls_candidates + .iter() + .any(|candidate| candidate == "usr/bin/ls") + ); + assert!( + ls_candidates + .iter() + .any(|candidate| candidate == "usr/bin/coreutils") + ); + + let bin_candidates = repo_owns_query_candidates(rootfs.path(), "/bin/ls"); + assert!(bin_candidates.iter().any(|candidate| candidate == "bin/ls")); + assert!( + bin_candidates + .iter() + .any(|candidate| candidate == "usr/bin/coreutils") + ); +} + +#[test] +fn test_find_cached_binary_repo_package_prefers_exact_name() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.0', 1, NULL, NULL, NULL, 'foo-1.0-1.depot.pkg.tar.zst', 10, 'aa', 'bb')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (2, 'bar', '1.0', 1, NULL, NULL, NULL, 'bar-1.0-1.depot.pkg.tar.zst', 10, 'cc', 'dd')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO provides (package_id, name) VALUES (2, 'foo')", + [], + ) + .unwrap(); + drop(conn); + + let recs = find_cached_binary_repo_packages("repo", &db_path, "foo").unwrap(); + let rec = recs.first().expect("expected a match"); + assert_eq!(rec.name, "foo"); + assert_eq!(rec.filename, "foo-1.0-1.depot.pkg.tar.zst"); +} + +#[test] +fn test_find_cached_binary_repo_packages_matches_real_name_and_built_against() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'icu78', 'icu', '78.1', 1, 0, '', NULL, NULL, NULL, 'icu78.pkg', 10, 'aa', 'bb')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, description, homepage, license, filename, size, sha256, sha512) + VALUES (2, 'app', NULL, '1.0', 1, 0, 'icu78', NULL, NULL, NULL, 'app.pkg', 10, 'cc', 'dd')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (2, 'runtime', 'icu')", + [], + ) + .unwrap(); + drop(conn); + + let icu_matches = find_cached_binary_repo_packages("repo", &db_path, "icu").unwrap(); + assert_eq!(icu_matches.len(), 1); + assert_eq!(icu_matches[0].name, "icu78"); + assert_eq!(icu_matches[0].real_name.as_deref(), Some("icu")); + + let app = find_cached_binary_repo_packages("repo", &db_path, "app") + .unwrap() + .pop() + .unwrap(); + assert_eq!(app.runtime_dependencies, vec!["icu".to_string()]); + assert_eq!(app.built_against, vec!["icu78".to_string()]); +} + +#[test] +fn test_verify_binary_package_record_checksums_accepts_valid_hashes() { + use sha2::{Digest, Sha512}; + + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("pkg.depot.pkg.tar.zst"); + fs::write(&pkg, b"payload").unwrap(); + + let sha512 = { + let mut h = Sha512::new(); + h.update(b"payload"); + crate::hex::encode_lower(h.finalize()) + }; + + let rec = BinaryRepoPackageRecord { + repo_name: "repo".into(), + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: "pkg.depot.pkg.tar.zst".into(), + size: 7, + sha512, + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }; + + verify_binary_package_record_checksums(&pkg, &rec).unwrap(); +} + +#[test] +fn test_verify_binary_package_record_checksums_requires_valid_sha512() { + use sha2::{Digest, Sha512}; + + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("pkg.depot.pkg.tar.zst"); + fs::write(&pkg, b"payload").unwrap(); + + let mut rec = test_record_for_payload("pkg.depot.pkg.tar.zst", b"payload"); + verify_binary_package_record_checksums(&pkg, &rec).unwrap(); + + let mut wrong_sha512 = Sha512::new(); + wrong_sha512.update(b"different payload"); + rec.sha512 = crate::hex::encode_lower(wrong_sha512.finalize()); + let err = verify_binary_package_record_checksums(&pkg, &rec).unwrap_err(); + assert!(err.to_string().contains("SHA-512 mismatch")); +} + +fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord { + use sha2::{Digest, Sha512}; + + let sha512 = { + let mut h = Sha512::new(); + h.update(payload); + crate::hex::encode_lower(h.finalize()) + }; + + BinaryRepoPackageRecord { + repo_name: "repo".into(), + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: filename.to_string(), + size: payload.len() as u64, + sha512, + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + } +} + +#[test] +fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"package payload"; + std::fs::write(repo_dir.path().join(filename), payload).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: false, + ..Default::default() + }; + + let err = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .expect_err("missing detached signature should fail"); + assert!(err.to_string().to_ascii_lowercase().contains("signature")); +} + +#[test] +fn test_fetch_binary_package_archive_verifies_signature_and_checksum() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); + std::fs::create_dir_all(&trusted_dir).unwrap(); + + let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + std::fs::write( + trusted_dir.join("repo.pub"), + keypair.pk.to_box().unwrap().to_bytes(), + ) + .unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"signed package payload"; + let package_path = repo_dir.path().join(filename); + std::fs::write(&package_path, payload).unwrap(); + + let sig = minisign::sign( + Some(&keypair.pk), + &keypair.sk, + std::fs::File::open(&package_path).unwrap(), + None, + Some("test signature"), + ) + .unwrap(); + std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: false, + ..Default::default() + }; + + let fetched = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .unwrap(); + assert_eq!(std::fs::read(&fetched).unwrap(), payload); + assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists()); +} + +#[test] +fn test_cache_binary_package_archive_supports_combined_integrity_verification() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); + std::fs::create_dir_all(&trusted_dir).unwrap(); + + let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + std::fs::write( + trusted_dir.join("repo.pub"), + keypair.pk.to_box().unwrap().to_bytes(), + ) + .unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"staged verification payload"; + let package_path = repo_dir.path().join(filename); + std::fs::write(&package_path, payload).unwrap(); + + let sig = minisign::sign( + Some(&keypair.pk), + &keypair.sk, + std::fs::File::open(&package_path).unwrap(), + None, + Some("test signature"), + ) + .unwrap(); + std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: false, + ..Default::default() + }; + + let cached = cache_binary_package_archive("repo", &repo_cfg, &rec, cache_dir.path()) + .expect("cache should succeed"); + assert!(cached.package_path.exists()); + assert!(cached.signature_path.exists()); + + verify_binary_package_archive_checksums(&cached.package_path, &rec) + .expect("checksum verification should succeed"); + let trusted_keys = crate::signing::load_trusted_public_keys(rootfs.path()).unwrap(); + verify_binary_package_archive_integrity_with_trusted_keys( + "repo", + &repo_cfg, + &rec, + &cached.package_path, + &cached.signature_path, + &trusted_keys, + ) + .expect("combined integrity verification should succeed"); + + let mut wrong_record = rec.clone(); + wrong_record.sha512 = crate::hex::encode_lower(Sha512::digest(b"wrong payload")); + let error = verify_binary_package_archive_integrity_with_trusted_keys( + "repo", + &repo_cfg, + &wrong_record, + &cached.package_path, + &cached.signature_path, + &trusted_keys, + ) + .expect_err("combined verification must reject a checksum mismatch"); + assert!(error.to_string().contains("SHA-512 mismatch")); +} + +#[test] +fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst"; + let payload = b"unsigned package payload"; + std::fs::write(repo_dir.path().join(filename), payload).unwrap(); + + let rec = test_record_for_payload(filename, payload); + let repo_url = url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(); + let repo_cfg = crate::config::BinaryRepo { + url: repo_url, + allow_unsigned: true, + ..Default::default() + }; + + let fetched = + fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path()) + .unwrap(); + assert_eq!(std::fs::read(&fetched).unwrap(), payload); + assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists()); +} + +#[test] +fn test_copy_file_url_to_path_supports_file_scheme() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("repo.db.zst"); + let dst = tmp.path().join("copy.zst"); + fs::write(&src, b"repo-db").unwrap(); + + let url = format!("file://{}", src.display()); + let outcome = copy_file_url_to_path(&url, &dst).unwrap(); + assert_eq!(outcome, FileUrlCopyOutcome::Copied); + assert_eq!(fs::read(&dst).unwrap(), b"repo-db"); +} + +#[test] +fn test_copy_file_url_to_path_reports_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("missing.db.zst"); + let dst = tmp.path().join("copy.zst"); + + let url = format!("file://{}", missing.display()); + let outcome = copy_file_url_to_path(&url, &dst).unwrap(); + assert_eq!(outcome, FileUrlCopyOutcome::Missing); + assert!(!dst.exists()); +} + +#[test] +fn test_repo_db_fetch_cache_roundtrip_and_prunes_stale_entry() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + fs::write(&db_path, b"db").unwrap(); + + let key = RepoDbFetchCacheKey { + repo_name: "core".to_string(), + base_url: "https://repo.example.test/core".to_string(), + repo_db_rel: "repo.db.zst".to_string(), + rootfs: PathBuf::from("/tmp/rootfs-test"), + package_cache_dir: PathBuf::from("/tmp/pkg-cache-test"), + }; + + remember_repo_db_path(key.clone(), db_path.clone()); + assert_eq!(get_cached_repo_db_path(&key), Some(db_path.clone())); + + fs::remove_file(&db_path).unwrap(); + assert_eq!(get_cached_repo_db_path(&key), None); +} + +#[test] +fn test_extract_html_href_targets_parses_common_forms() { + let html = r#" + + alpha + beta + gamma + parent + + "#; + let hrefs = extract_html_href_targets(html); + assert!(hrefs.iter().any(|h| h == "alpha.pub")); + assert!(hrefs.iter().any(|h| h == "nested/beta.pub")); + assert!(hrefs.iter().any(|h| h == "gamma.pub")); + assert!(hrefs.iter().any(|h| h == "../")); +} + +#[test] +fn test_list_repo_public_key_urls_reads_file_repo_keys_dir() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path().join("repo"); + let keys_dir = repo_dir.join("keys"); + fs::create_dir_all(&keys_dir).unwrap(); + fs::write(keys_dir.join("repo.pub"), b"pubkey").unwrap(); + fs::write(keys_dir.join("ignore.txt"), b"nope").unwrap(); + fs::create_dir_all(keys_dir.join("subdir")).unwrap(); + + let base_url = url::Url::from_directory_path(&repo_dir) + .expect("file URL") + .to_string(); + let client = reqwest::blocking::Client::builder().build().unwrap(); + let keys = list_repo_public_key_urls(&base_url, &client).unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, "repo.pub"); + assert!(keys[0].1.ends_with("/repo.pub")); +} + +#[test] +fn test_list_repo_public_key_urls_probes_common_names_when_index_missing() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for _ in 0..7 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + } + + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + let (status, body) = if path == "/core/keys/vertex.pub" { + ("200 OK", "trusted-key") + } else { + ("404 Not Found", "missing") + }; + + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + stream.flush().unwrap(); + } + }); + + let base_url = format!("http://{}/core", addr); + let client = reqwest::blocking::Client::builder().build().unwrap(); + let keys = list_repo_public_key_urls(&base_url, &client).unwrap(); + server.join().unwrap(); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, "vertex.pub"); + assert!(keys[0].1.ends_with("/core/keys/vertex.pub")); +} + +#[test] +fn test_fetch_binary_repo_db_can_recover_from_stale_trusted_key() { + use std::io::Write; + + struct AssumeYesReset; + impl Drop for AssumeYesReset { + fn drop(&mut self) { + crate::ui::set_assume_yes(false); + } + } + + crate::ui::set_assume_yes(true); + let _reset = AssumeYesReset; + + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + + let stale_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + let repo_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path()); + fs::create_dir_all(&trusted_dir).unwrap(); + fs::write( + trusted_dir.join("vertex.pub"), + stale_keypair.pk.to_box().unwrap().to_bytes(), + ) + .unwrap(); + + let repo_keys_dir = repo_dir.path().join("keys"); + fs::create_dir_all(&repo_keys_dir).unwrap(); + fs::write( + repo_keys_dir.join("vertex.pub"), + repo_keypair.pk.to_box().unwrap().to_bytes(), + ) + .unwrap(); + + let repo_db_path = repo_dir.path().join("repo.db.zst"); + let mut encoder = + zstd::stream::write::Encoder::new(fs::File::create(&repo_db_path).unwrap(), 3).unwrap(); + encoder.write_all(b"repo-db-content").unwrap(); + encoder.finish().unwrap(); + + let sig = minisign::sign( + Some(&repo_keypair.pk), + &repo_keypair.sk, + fs::File::open(&repo_db_path).unwrap(), + None, + Some("repo db signature"), + ) + .unwrap(); + fs::write(repo_dir.path().join("repo.db.zst.sig"), sig.to_bytes()).unwrap(); + + let repo_cfg = crate::config::BinaryRepo { + url: url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(), + allow_unsigned: false, + ..Default::default() + }; + + let sqlite_db = + fetch_binary_repo_db("core", &repo_cfg, rootfs.path(), cache_dir.path()).unwrap(); + assert_eq!(fs::read(sqlite_db).unwrap(), b"repo-db-content"); + + let installed_key = trusted_dir.join("core-vertex.pub"); + assert!(installed_key.exists()); + assert_eq!( + fs::read(installed_key).unwrap(), + repo_keypair.pk.to_box().unwrap().to_bytes() + ); +} + +#[test] +fn test_normalize_git_mirror_url_converts_file_scheme() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path().join("repo.git"); + fs::create_dir_all(&repo_dir).unwrap(); + + let url = format!("file://{}", repo_dir.display()); + let normalized = normalize_git_mirror_url(&url).unwrap(); + assert_eq!(normalized, repo_dir.to_string_lossy()); +} diff --git a/src/db/tests.rs b/src/db/tests.rs new file mode 100644 index 0000000..7cb7fb2 --- /dev/null +++ b/src/db/tests.rs @@ -0,0 +1,488 @@ +use super::*; +use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, +}; +use crate::test_support::TestEnv; +use std::path::PathBuf; + +fn mk_spec(name: &str, version: &str) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: name.into(), + real_name: None, + version: version.into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives { + provides: vec![format!("{}-virtual", name)], + conflicts: Vec::new(), + replaces: Vec::new(), + lib32: None, + }, + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +#[test] +fn register_package_updates_in_place_and_replaces_file_list() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + let spec_v1 = mk_spec("foo", "1.0"); + let dest1 = tmp.path().join("dest1"); + std::fs::create_dir_all(dest1.join("usr/bin")).unwrap(); + std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); + + register_package(&db_path, &spec_v1, &dest1).unwrap(); + + // Capture package id + let conn = Connection::open(&db_path).unwrap(); + let id1: i64 = conn + .query_row( + "SELECT id FROM packages WHERE name = ?1", + params!["foo"], + |r| r.get(0), + ) + .unwrap(); + + // Update with different file set + let spec_v2 = mk_spec("foo", "2.0"); + let dest2 = tmp.path().join("dest2"); + std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); + std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); + std::fs::write(dest2.join("usr/bin/new_only"), "x").unwrap(); + + register_package(&db_path, &spec_v2, &dest2).unwrap(); + + let id2: i64 = conn + .query_row( + "SELECT id FROM packages WHERE name = ?1", + params!["foo"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(id1, id2); + + let files = get_package_files(&db_path, "foo").unwrap(); + assert!(files.contains(&"usr/bin/foo".to_string())); + assert!(files.contains(&"usr/bin/new_only".to_string())); + + let version = get_package_version(&db_path, "foo").unwrap(); + assert_eq!(version.as_deref(), Some("2.0")); +} + +#[test] +fn installed_dependency_names_include_real_name_aliases() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&destdir).unwrap(); + + let mut spec = mk_spec("libressl43", "4.3.2"); + spec.package.real_name = Some("libressl".into()); + register_package(&db_path, &spec, &destdir).unwrap(); + + let names = get_installed_dependency_names(&db_path).unwrap(); + assert!(names.contains("libressl43")); + assert!(names.contains("libressl")); + assert_eq!( + get_dependency_version(&db_path, "libressl") + .unwrap() + .as_deref(), + Some("4.3.2") + ); +} + +#[test] +fn register_package_uses_metadata_completed_at_when_present() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let spec = mk_spec("foo", "1.0"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap(); + std::fs::write( + dest.join(".metadata.toml"), + "completed_at = \"2026-03-10T12:34:56Z\"\n", + ) + .unwrap(); + + register_package(&db_path, &spec, &dest).unwrap(); + + let records = list_installed_package_records(&db_path).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].completed_at, Some(1_773_146_096)); +} + +#[test] +fn register_package_falls_back_to_destdir_mtime_when_metadata_missing() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let spec = mk_spec("foo", "1.0"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + let file = dest.join("usr/bin/foo"); + std::fs::write(&file, "bin").unwrap(); + + let ts = filetime::FileTime::from_unix_time(1_700_000_000, 0); + filetime::set_file_mtime(&file, ts).unwrap(); + filetime::set_file_mtime(dest.join("usr"), ts).unwrap(); + filetime::set_file_mtime(dest.join("usr/bin"), ts).unwrap(); + filetime::set_file_mtime(&dest, ts).unwrap(); + + register_package(&db_path, &spec, &dest).unwrap(); + + let records = list_installed_package_records(&db_path).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].completed_at, Some(1_700_000_000)); +} + +#[test] +fn register_package_detects_conflicting_files() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Install package 'alpha' owning usr/bin/shared + let spec_a = mk_spec("alpha", "1.0"); + let dest_a = tmp.path().join("dest_a"); + std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); + std::fs::write(dest_a.join("usr/bin/shared"), "a").unwrap(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + // Try to install package 'beta' that also includes the same path -> should fail + let spec_b = mk_spec("beta", "1.0"); + let dest_b = tmp.path().join("dest_b"); + std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); + std::fs::write(dest_b.join("usr/bin/shared"), "b").unwrap(); + + let res = register_package(&db_path, &spec_b, &dest_b); + assert!(res.is_err()); + let err = format!("{}", res.err().unwrap()); + assert!(err.contains("File ownership conflict detected")); + assert!(err.contains("usr/bin/shared")); + assert!(err.contains("alpha")); +} + +#[test] +fn register_package_auto_clears_safe_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Install package 'alpha' owning a known shared Perl path + let spec_a = mk_spec("alpha", "1.0"); + let dest_a = tmp.path().join("dest_a"); + std::fs::create_dir_all(dest_a.join("usr/share/perl5")).unwrap(); + std::fs::write(dest_a.join("usr/share/perl5/shared.pm"), "package A;").unwrap(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + // Now install package 'beta' that also provides the same shared path -> should auto-clear + let spec_b = mk_spec("beta", "1.0"); + let dest_b = tmp.path().join("dest_b"); + std::fs::create_dir_all(dest_b.join("usr/share/perl5")).unwrap(); + std::fs::write(dest_b.join("usr/share/perl5/shared.pm"), "package B;").unwrap(); + + // This should succeed and transfer ownership of the shared path to beta + register_package(&db_path, &spec_b, &dest_b).unwrap(); + + // Verify DB: alpha should no longer own the path, beta should + let files_a = get_package_files(&db_path, "alpha").unwrap(); + assert!(!files_a.contains(&"usr/share/perl5/shared.pm".to_string())); + let files_b = get_package_files(&db_path, "beta").unwrap(); + assert!(files_b.contains(&"usr/share/perl5/shared.pm".to_string())); +} + +#[test] +fn register_package_auto_clears_sbase_conflicts_when_requested() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let db_path = crate::config::Config::for_rootfs(&rootfs).installed_db_path(&rootfs); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::write(rootfs.join("usr/bin/find"), "sbase find").unwrap(); + + let spec_a = mk_spec("sbase", "1.0"); + let dest_a = tmp.path().join("dest_a"); + std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); + std::fs::write(dest_a.join("usr/bin/find"), "sbase find").unwrap(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + let spec_b = mk_spec("bfs", "4.1"); + let dest_b = tmp.path().join("dest_b"); + std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); + std::fs::write(dest_b.join("usr/bin/find"), "bfs find").unwrap(); + + let mut env = TestEnv::new(); + env.set_var(DEPOT_BOOTSTRAP_IGNORE_SBASE_CONFLICTS, "1"); + register_package(&db_path, &spec_b, &dest_b).unwrap(); + + let files_sbase = get_package_files(&db_path, "sbase").unwrap(); + assert!(!files_sbase.contains(&"usr/bin/find".to_string())); + let files_bfs = get_package_files(&db_path, "bfs").unwrap(); + assert!(files_bfs.contains(&"usr/bin/find".to_string())); +} + +#[test] +fn register_package_auto_clear_preserves_new_payload_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let db_path = crate::config::Config::for_rootfs(&rootfs).installed_db_path(&rootfs); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::write(rootfs.join("usr/bin/find"), "sbase find").unwrap(); + + let spec_a = mk_spec("sbase", "1.0"); + let dest_a = tmp.path().join("dest_a"); + std::fs::create_dir_all(dest_a.join("usr/bin")).unwrap(); + std::fs::write(dest_a.join("usr/bin/find"), "sbase find").unwrap(); + register_package(&db_path, &spec_a, &dest_a).unwrap(); + + std::fs::write(rootfs.join("usr/bin/find"), "bfs find").unwrap(); + let spec_b = mk_spec("bfs", "4.1"); + let dest_b = tmp.path().join("dest_b"); + std::fs::create_dir_all(dest_b.join("usr/bin")).unwrap(); + std::fs::write(dest_b.join("usr/bin/find"), "bfs find").unwrap(); + + let mut env = TestEnv::new(); + env.set_var(DEPOT_BOOTSTRAP_IGNORE_SBASE_CONFLICTS, "1"); + register_package(&db_path, &spec_b, &dest_b).unwrap(); + + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/find")).unwrap(), + "bfs find" + ); + let files_sbase = get_package_files(&db_path, "sbase").unwrap(); + assert!(!files_sbase.contains(&"usr/bin/find".to_string())); + let files_bfs = get_package_files(&db_path, "bfs").unwrap(); + assert!(files_bfs.contains(&"usr/bin/find".to_string())); +} + +#[test] +fn get_package_files_missing_package_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + // Create an empty database file with schema but no packages + let conn = Connection::open(&db_path).unwrap(); + init_db(&conn).unwrap(); + drop(conn); + + // Querying files for a package that doesn't exist should return an empty list + let files = get_package_files(&db_path, "nonexistent").unwrap(); + assert!(files.is_empty()); +} + +#[test] +fn get_package_version_missing_db_returns_none_without_creating_db() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + + let version = get_package_version(&db_path, "nonexistent").unwrap(); + assert!(version.is_none()); + assert!(!db_path.exists()); +} + +#[test] +fn calculate_upgrade_paths_handles_existing_db_file_without_schema() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + std::fs::File::create(&db_path).unwrap(); + let manifest = staging::Manifest { + files: vec!["usr/bin/foo".to_string()], + directories: Vec::new(), + }; + + let remove_paths = calculate_upgrade_paths(&db_path, "nonexistent", &manifest).unwrap(); + assert!(remove_paths.is_empty()); +} + +#[test] +fn remove_package_tolerates_missing_files_and_cleans_db() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&rootfs).unwrap(); + + let spec = mk_spec("foo", "1.0"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "bin").unwrap(); + register_package(&db_path, &spec, &dest).unwrap(); + + // Create the installed file in rootfs (one real) + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::write(rootfs.join("usr/bin/foo"), "bin").unwrap(); + + // Inject an extra missing file into DB to ensure we tolerate it. + let conn = Connection::open(&db_path).unwrap(); + let pkg_id: i64 = conn + .query_row( + "SELECT id FROM packages WHERE name = ?1", + params!["foo"], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT OR IGNORE INTO files (package_id, path) VALUES (?1, ?2)", + params![pkg_id, "usr/bin/does_not_exist"], + ) + .unwrap(); + + remove_package(&db_path, "foo", &rootfs).unwrap(); + assert!(get_package_version(&db_path, "foo").unwrap().is_none()); +} + +#[test] +fn test_package_upgrade_removes_orphaned_files() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let rootfs = tmp.path().join("root"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + + // 1. Install v1: usr/bin/foo, usr/bin/shared_dir/old_file + let spec_v1 = mk_spec("foo", "1.0"); + let dest1 = tmp.path().join("dest1"); + std::fs::create_dir_all(dest1.join("usr/bin/shared_dir")).unwrap(); + std::fs::write(dest1.join("usr/bin/foo"), "v1").unwrap(); + std::fs::write(dest1.join("usr/bin/shared_dir/old_file"), "old").unwrap(); + + register_package(&db_path, &spec_v1, &dest1).unwrap(); + let _ = crate::staging::install_atomic(&dest1, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(rootfs.join("usr/bin/foo").exists()); + assert!(rootfs.join("usr/bin/shared_dir/old_file").exists()); + + // 2. Prepare v2: usr/bin/foo (updated), usr/bin/new_file + // (shared_dir/old_file is removed from spec) + let spec_v2 = mk_spec("foo", "2.0"); + let dest2 = tmp.path().join("dest2"); + std::fs::create_dir_all(dest2.join("usr/bin")).unwrap(); + std::fs::write(dest2.join("usr/bin/foo"), "v2").unwrap(); + std::fs::write(dest2.join("usr/bin/new_file"), "new").unwrap(); + + let manifest2 = crate::staging::generate_manifest_with_dirs(&dest2).unwrap(); + let remove_paths = calculate_upgrade_paths(&db_path, "foo", &manifest2).unwrap(); + + assert_eq!( + remove_paths, + vec![ + "usr/bin/shared_dir/old_file".to_string(), + "usr/bin/shared_dir".to_string() + ] + ); + + let tx = crate::staging::install_atomic(&dest2, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + register_package(&db_path, &spec_v2, &dest2).unwrap(); + tx.commit().unwrap(); + + // 3. Verify filesystem + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), + "v2" + ); + assert!(rootfs.join("usr/bin/new_file").exists()); + assert!(!rootfs.join("usr/bin/shared_dir/old_file").exists()); + + // Check DB + let files = get_package_files(&db_path, "foo").unwrap(); + assert!(files.contains(&"usr/bin/foo".to_string())); + assert!(files.contains(&"usr/bin/new_file".to_string())); + assert!(!files.contains(&"usr/bin/shared_dir/old_file".to_string())); +} + +#[test] +fn register_package_persists_replacements() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/vx"), "vx").unwrap(); + + let mut spec = mk_spec("vx", "1.0"); + spec.alternatives.replaces = vec!["grep".into(), "patch".into()]; + + register_package(&db_path, &spec, &dest).unwrap(); + + let replaces = get_all_replaces(&db_path).unwrap(); + assert!(replaces.contains("grep")); + assert!(replaces.contains("patch")); +} + +#[test] +fn register_package_persists_groups() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); + + let mut spec = mk_spec("foo", "1.0"); + spec.dependencies.groups = vec!["base".into(), "desktop".into()]; + + register_package(&db_path, &spec, &dest).unwrap(); + + assert_eq!( + get_package_groups(&db_path, "foo").unwrap(), + vec!["base".to_string(), "desktop".to_string()] + ); +} + +#[test] +fn register_package_records_built_against_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(dest.join("payload"), "x").unwrap(); + + let mut spec = mk_spec("app", "1.0"); + spec.package.built_against = vec!["icu78".into()]; + register_package(&db_path, &spec, &dest).unwrap(); + + let records = list_installed_package_records(&db_path).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].built_against, vec!["icu78".to_string()]); +} + +#[test] +fn installed_group_helpers_round_trip_membership() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); + + let mut spec = mk_spec("foo", "1.0"); + spec.dependencies.groups = vec!["base".into()]; + register_package(&db_path, &spec, &dest).unwrap(); + + record_installed_groups(&db_path, &[String::from("base")]).unwrap(); + assert!(is_installed_group(&db_path, "base").unwrap()); + assert_eq!( + get_packages_in_installed_group(&db_path, "base").unwrap(), + vec!["foo".to_string()] + ); + + remove_installed_group(&db_path, "base").unwrap(); + assert!(!is_installed_group(&db_path, "base").unwrap()); +} diff --git a/src/install/scripts.rs b/src/install/scripts.rs index 1c28350..b2b6966 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -1549,448 +1549,4 @@ fn cleanup_empty_package_dir(rootfs: &Path, pkg_name: &str) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{ - Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source, - }; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - - fn mk_spec(spec_dir: &Path) -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: spec_dir.to_path_buf(), - } - } - - #[test] - fn stage_scripts_from_spec_dir_copies_tree() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(spec_dir.join("scripts/lib")).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - std::fs::write(spec_dir.join("scripts/pre_install"), "echo pre").unwrap(); - std::fs::write(spec_dir.join("scripts/lib/common.sh"), "echo lib").unwrap(); - - let spec = mk_spec(&spec_dir); - let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); - assert!(staged); - assert!(destdir.join("scripts/pre_install").exists()); - assert!(destdir.join("scripts/lib/common.sh").exists()); - #[cfg(unix)] - { - let mode = std::fs::metadata(destdir.join("scripts/pre_install")) - .unwrap() - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0); - } - } - - #[test] - fn run_hook_if_present_executes_script() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - std::fs::write( - scripts.join("pre_install"), - "echo \"$DEPOT_ACTION:$DEPOT_PHASE:$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap(); - assert!(ran); - assert_eq!( - std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), - "install:pre:foo\n" - ); - } - - #[test] - fn run_hook_if_present_uses_safe_script_path() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - std::fs::write( - scripts.join("pre_install"), - "echo \"$PATH\" > \"$DEPOT_ROOTFS/path.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap(); - assert!(ran); - assert_eq!( - std::fs::read_to_string(rootfs.join("path.out")) - .unwrap() - .trim_end(), - crate::runtime_env::safe_script_path() - ); - } - - #[test] - fn run_hook_if_present_accepts_compact_script_name() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - std::fs::write( - scripts.join("postinstall.sh"), - "echo compact > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "foo").unwrap(); - assert!(ran); - assert_eq!( - std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), - "compact\n" - ); - } - - #[test] - fn run_hook_if_present_prefers_lib32_specific_script_name() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - std::fs::write( - scripts.join("post_install"), - "echo generic > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - std::fs::write( - scripts.join("post_install-lib32"), - "echo lib32 > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "lib32-foo").unwrap(); - assert!(ran); - assert_eq!( - std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), - "lib32\n" - ); - } - - #[test] - fn run_hook_if_present_rejects_ambiguous_names() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - std::fs::write(scripts.join("pre_update"), "echo one").unwrap(); - std::fs::write(scripts.join("pre-update"), "echo two").unwrap(); - - let err = run_hook_if_present(&scripts, Hook::PreUpdate, &rootfs, "foo") - .expect_err("expected ambiguous script names to fail"); - assert!(err.to_string().contains("Ambiguous lifecycle hook")); - } - - #[test] - fn run_hook_if_present_with_relative_rootfs_uses_correct_script_and_env_paths() { - let cwd = std::env::current_dir().unwrap(); - let tmp = tempfile::Builder::new() - .prefix("depot-hook-rel-rootfs-") - .tempdir_in(&cwd) - .unwrap(); - let rootfs_abs = tmp.path().join("root"); - std::fs::create_dir_all(&rootfs_abs).unwrap(); - let rootfs_rel = rootfs_abs.strip_prefix(&cwd).unwrap().to_path_buf(); - let scripts = rootfs_rel.join("scripts"); - std::fs::create_dir_all(&scripts).unwrap(); - - std::fs::write( - scripts.join("pre_install"), - "echo ok > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs_rel, "foo").unwrap(); - assert!(ran); - assert_eq!( - std::fs::read_to_string(rootfs_abs.join("hook.out")).unwrap(), - "ok\n" - ); - } - - #[test] - fn should_bootstrap_host_shell_only_for_chrooted_root_installs_without_shell() { - assert!(should_bootstrap_host_shell(true, true, false)); - assert!(!should_bootstrap_host_shell(true, true, true)); - assert!(!should_bootstrap_host_shell(true, false, false)); - assert!(!should_bootstrap_host_shell(false, true, false)); - } - - #[test] - fn parse_ldd_dependency_paths_extracts_absolute_paths() { - let parsed = parse_ldd_dependency_paths( - "linux-vdso.so.1 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0000)\n/lib64/ld-linux-x86-64.so.2 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0001)\n", - ) - .unwrap(); - assert_eq!( - parsed, - vec![ - PathBuf::from("/lib/libc.so.6"), - PathBuf::from("/lib64/ld-linux-x86-64.so.2") - ] - ); - } - - #[test] - fn parse_ldd_dependency_paths_rejects_missing_dependencies() { - let err = parse_ldd_dependency_paths("libedit.so.0 => not found\n") - .expect_err("expected ldd parse to fail when a dependency is missing"); - assert!( - err.to_string() - .contains("Missing shared-library dependency") - ); - } - - #[test] - fn parse_hook_command_candidates_finds_commands_after_assignments_and_operators() { - let commands = parse_hook_command_candidates( - "PATH=/tmp:$PATH grep -q foo etc/shells || echo foo >> etc/shells\ncat \"$DEPOT_ROOTFS/usr/bin/find\" | sed 's/x/y/'\n", - ); - assert_eq!( - commands, - vec!["cat".to_string(), "grep".to_string(), "sed".to_string()] - ); - } - - #[test] - fn parse_hook_command_candidates_ignores_builtins_and_control_words() { - let commands = parse_hook_command_candidates("if true; then export FOO=bar; echo hi; fi\n"); - assert!(commands.is_empty()); - } - - #[test] - fn deferred_hooks_file_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("hooks.tsv"); - let hooks = vec![ - DeferredHook { - pkg_name: "foo".into(), - hook: Hook::PostInstall, - script_rel: PathBuf::from("usr/share/depot/foo/scripts/post_install"), - }, - DeferredHook { - pkg_name: "bar".into(), - hook: Hook::PostUpdate, - script_rel: PathBuf::from("usr/share/depot/bar/scripts/post_update"), - }, - ]; - write_deferred_hooks(&path, &hooks).unwrap(); - let loaded = read_deferred_hooks(&path).unwrap(); - assert_eq!(loaded.len(), hooks.len()); - assert_eq!(loaded[0].pkg_name, hooks[0].pkg_name); - assert_eq!(loaded[0].hook, hooks[0].hook); - assert_eq!(loaded[0].script_rel, hooks[0].script_rel); - assert_eq!(loaded[1].pkg_name, hooks[1].pkg_name); - assert_eq!(loaded[1].hook, hooks[1].hook); - 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(); - let rootfs = tmp.path().join("root"); - let staged = tmp.path().join("staged"); - std::fs::create_dir_all(staged.join("scripts")).unwrap(); - - let installed = installed_scripts_dir(&rootfs, "foo"); - std::fs::create_dir_all(&installed).unwrap(); - std::fs::write(installed.join("old"), "old").unwrap(); - - std::fs::write(staged.join("scripts/post_install"), "echo ok").unwrap(); - let has_scripts = - sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); - - assert!(has_scripts); - let installed = installed_scripts_dir(&rootfs, "foo"); - assert!(!installed.join("old").exists()); - assert!(installed.join("post_install").exists()); - #[cfg(unix)] - { - let mode = std::fs::metadata(installed.join("post_install")) - .unwrap() - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0); - } - } - - #[test] - fn sync_staged_scripts_to_rootfs_removes_old_when_none_staged() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let staged = tmp.path().join("staged"); - std::fs::create_dir_all(&staged).unwrap(); - - let installed = installed_scripts_dir(&rootfs, "foo"); - std::fs::create_dir_all(&installed).unwrap(); - std::fs::write(installed.join("pre_remove"), "echo old").unwrap(); - - let has_scripts = - sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); - assert!(!has_scripts); - assert!(!installed_scripts_dir(&rootfs, "foo").exists()); - } - - #[test] - fn stage_scripts_from_spec_dir_stages_legacy_root_hook() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&spec_dir).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - std::fs::write(spec_dir.join("postinstall.sh"), "echo post").unwrap(); - - let spec = mk_spec(&spec_dir); - let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); - assert!(staged); - assert!(destdir.join("scripts/post_install").exists()); - #[cfg(unix)] - { - let mode = std::fs::metadata(destdir.join("scripts/post_install")) - .unwrap() - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0); - } - } - - #[test] - fn stage_scripts_from_spec_dir_stages_lib32_prefixed_legacy_root_hook() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&spec_dir).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - std::fs::write(spec_dir.join("lib32-postinstall.sh"), "echo lib32-post").unwrap(); - - let mut spec = mk_spec(&spec_dir); - spec.package.name = "lib32-foo".into(); - let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); - assert!(staged); - assert!(destdir.join("scripts/post_install").exists()); - #[cfg(unix)] - { - let mode = std::fs::metadata(destdir.join("scripts/post_install")) - .unwrap() - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0); - } - } - - #[test] - fn stage_scripts_from_spec_dir_lib32_ignores_generic_legacy_root_hook() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&spec_dir).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - // No lib32-prefixed hook; native-only scripts must NOT be staged for lib32 packages. - std::fs::write(spec_dir.join("postinstall.sh"), "echo fallback").unwrap(); - - let mut spec = mk_spec(&spec_dir); - spec.package.name = "lib32-foo".into(); - let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); - assert!(!staged); - assert!(!destdir.join("scripts/post_install").exists()); - } - - #[test] - fn run_hook_if_present_lib32_ignores_generic_script() { - let tmp = tempfile::tempdir().unwrap(); - let scripts = tmp.path().join("scripts"); - let rootfs = tmp.path().join("root"); - std::fs::create_dir_all(&scripts).unwrap(); - std::fs::create_dir_all(&rootfs).unwrap(); - - // Only a generic script exists; lib32 package must NOT execute it. - std::fs::write( - scripts.join("post_install"), - "echo generic > \"$DEPOT_ROOTFS/hook.out\"\n", - ) - .unwrap(); - - let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "lib32-foo").unwrap(); - assert!(!ran); - assert!(!rootfs.join("hook.out").exists()); - } -} +mod tests; diff --git a/src/install/scripts/tests.rs b/src/install/scripts/tests.rs new file mode 100644 index 0000000..0cd70cf --- /dev/null +++ b/src/install/scripts/tests.rs @@ -0,0 +1,443 @@ +use super::*; +use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source, +}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +fn mk_spec(spec_dir: &Path) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: spec_dir.to_path_buf(), + } +} + +#[test] +fn stage_scripts_from_spec_dir_copies_tree() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(spec_dir.join("scripts/lib")).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(spec_dir.join("scripts/pre_install"), "echo pre").unwrap(); + std::fs::write(spec_dir.join("scripts/lib/common.sh"), "echo lib").unwrap(); + + let spec = mk_spec(&spec_dir); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(staged); + assert!(destdir.join("scripts/pre_install").exists()); + assert!(destdir.join("scripts/lib/common.sh").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(destdir.join("scripts/pre_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } +} + +#[test] +fn run_hook_if_present_executes_script() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("pre_install"), + "echo \"$DEPOT_ACTION:$DEPOT_PHASE:$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), + "install:pre:foo\n" + ); +} + +#[test] +fn run_hook_if_present_uses_safe_script_path() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("pre_install"), + "echo \"$PATH\" > \"$DEPOT_ROOTFS/path.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("path.out")) + .unwrap() + .trim_end(), + crate::runtime_env::safe_script_path() + ); +} + +#[test] +fn run_hook_if_present_accepts_compact_script_name() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("postinstall.sh"), + "echo compact > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), + "compact\n" + ); +} + +#[test] +fn run_hook_if_present_prefers_lib32_specific_script_name() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write( + scripts.join("post_install"), + "echo generic > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + std::fs::write( + scripts.join("post_install-lib32"), + "echo lib32 > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "lib32-foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs.join("hook.out")).unwrap(), + "lib32\n" + ); +} + +#[test] +fn run_hook_if_present_rejects_ambiguous_names() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + std::fs::write(scripts.join("pre_update"), "echo one").unwrap(); + std::fs::write(scripts.join("pre-update"), "echo two").unwrap(); + + let err = run_hook_if_present(&scripts, Hook::PreUpdate, &rootfs, "foo") + .expect_err("expected ambiguous script names to fail"); + assert!(err.to_string().contains("Ambiguous lifecycle hook")); +} + +#[test] +fn run_hook_if_present_with_relative_rootfs_uses_correct_script_and_env_paths() { + let cwd = std::env::current_dir().unwrap(); + let tmp = tempfile::Builder::new() + .prefix("depot-hook-rel-rootfs-") + .tempdir_in(&cwd) + .unwrap(); + let rootfs_abs = tmp.path().join("root"); + std::fs::create_dir_all(&rootfs_abs).unwrap(); + let rootfs_rel = rootfs_abs.strip_prefix(&cwd).unwrap().to_path_buf(); + let scripts = rootfs_rel.join("scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + + std::fs::write( + scripts.join("pre_install"), + "echo ok > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PreInstall, &rootfs_rel, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs_abs.join("hook.out")).unwrap(), + "ok\n" + ); +} + +#[test] +fn should_bootstrap_host_shell_only_for_chrooted_root_installs_without_shell() { + assert!(should_bootstrap_host_shell(true, true, false)); + assert!(!should_bootstrap_host_shell(true, true, true)); + assert!(!should_bootstrap_host_shell(true, false, false)); + assert!(!should_bootstrap_host_shell(false, true, false)); +} + +#[test] +fn parse_ldd_dependency_paths_extracts_absolute_paths() { + let parsed = parse_ldd_dependency_paths( + "linux-vdso.so.1 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0000)\n/lib64/ld-linux-x86-64.so.2 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0001)\n", + ) + .unwrap(); + assert_eq!( + parsed, + vec![ + PathBuf::from("/lib/libc.so.6"), + PathBuf::from("/lib64/ld-linux-x86-64.so.2") + ] + ); +} + +#[test] +fn parse_ldd_dependency_paths_rejects_missing_dependencies() { + let err = parse_ldd_dependency_paths("libedit.so.0 => not found\n") + .expect_err("expected ldd parse to fail when a dependency is missing"); + assert!( + err.to_string() + .contains("Missing shared-library dependency") + ); +} + +#[test] +fn parse_hook_command_candidates_finds_commands_after_assignments_and_operators() { + let commands = parse_hook_command_candidates( + "PATH=/tmp:$PATH grep -q foo etc/shells || echo foo >> etc/shells\ncat \"$DEPOT_ROOTFS/usr/bin/find\" | sed 's/x/y/'\n", + ); + assert_eq!( + commands, + vec!["cat".to_string(), "grep".to_string(), "sed".to_string()] + ); +} + +#[test] +fn parse_hook_command_candidates_ignores_builtins_and_control_words() { + let commands = parse_hook_command_candidates("if true; then export FOO=bar; echo hi; fi\n"); + assert!(commands.is_empty()); +} + +#[test] +fn deferred_hooks_file_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("hooks.tsv"); + let hooks = vec![ + DeferredHook { + pkg_name: "foo".into(), + hook: Hook::PostInstall, + script_rel: PathBuf::from("usr/share/depot/foo/scripts/post_install"), + }, + DeferredHook { + pkg_name: "bar".into(), + hook: Hook::PostUpdate, + script_rel: PathBuf::from("usr/share/depot/bar/scripts/post_update"), + }, + ]; + write_deferred_hooks(&path, &hooks).unwrap(); + let loaded = read_deferred_hooks(&path).unwrap(); + assert_eq!(loaded.len(), hooks.len()); + assert_eq!(loaded[0].pkg_name, hooks[0].pkg_name); + assert_eq!(loaded[0].hook, hooks[0].hook); + assert_eq!(loaded[0].script_rel, hooks[0].script_rel); + assert_eq!(loaded[1].pkg_name, hooks[1].pkg_name); + assert_eq!(loaded[1].hook, hooks[1].hook); + 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(); + let rootfs = tmp.path().join("root"); + let staged = tmp.path().join("staged"); + std::fs::create_dir_all(staged.join("scripts")).unwrap(); + + let installed = installed_scripts_dir(&rootfs, "foo"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write(installed.join("old"), "old").unwrap(); + + std::fs::write(staged.join("scripts/post_install"), "echo ok").unwrap(); + let has_scripts = + sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); + + assert!(has_scripts); + let installed = installed_scripts_dir(&rootfs, "foo"); + assert!(!installed.join("old").exists()); + assert!(installed.join("post_install").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(installed.join("post_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } +} + +#[test] +fn sync_staged_scripts_to_rootfs_removes_old_when_none_staged() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let staged = tmp.path().join("staged"); + std::fs::create_dir_all(&staged).unwrap(); + + let installed = installed_scripts_dir(&rootfs, "foo"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write(installed.join("pre_remove"), "echo old").unwrap(); + + let has_scripts = + sync_staged_scripts_to_rootfs(&staged.join("scripts"), &rootfs, "foo").unwrap(); + assert!(!has_scripts); + assert!(!installed_scripts_dir(&rootfs, "foo").exists()); +} + +#[test] +fn stage_scripts_from_spec_dir_stages_legacy_root_hook() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(spec_dir.join("postinstall.sh"), "echo post").unwrap(); + + let spec = mk_spec(&spec_dir); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(staged); + assert!(destdir.join("scripts/post_install").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(destdir.join("scripts/post_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } +} + +#[test] +fn stage_scripts_from_spec_dir_stages_lib32_prefixed_legacy_root_hook() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(spec_dir.join("lib32-postinstall.sh"), "echo lib32-post").unwrap(); + + let mut spec = mk_spec(&spec_dir); + spec.package.name = "lib32-foo".into(); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(staged); + assert!(destdir.join("scripts/post_install").exists()); + #[cfg(unix)] + { + let mode = std::fs::metadata(destdir.join("scripts/post_install")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0); + } +} + +#[test] +fn stage_scripts_from_spec_dir_lib32_ignores_generic_legacy_root_hook() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + // No lib32-prefixed hook; native-only scripts must NOT be staged for lib32 packages. + std::fs::write(spec_dir.join("postinstall.sh"), "echo fallback").unwrap(); + + let mut spec = mk_spec(&spec_dir); + spec.package.name = "lib32-foo".into(); + let staged = stage_scripts_from_spec_dir(&spec, &destdir).unwrap(); + assert!(!staged); + assert!(!destdir.join("scripts/post_install").exists()); +} + +#[test] +fn run_hook_if_present_lib32_ignores_generic_script() { + let tmp = tempfile::tempdir().unwrap(); + let scripts = tmp.path().join("scripts"); + let rootfs = tmp.path().join("root"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + + // Only a generic script exists; lib32 package must NOT execute it. + std::fs::write( + scripts.join("post_install"), + "echo generic > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs, "lib32-foo").unwrap(); + assert!(!ran); + assert!(!rootfs.join("hook.out").exists()); +} diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 5a0e42c..6409380 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -1743,603 +1743,4 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{ - BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, PackageSpec, Source, - }; - - #[test] - fn spec_to_minimal_toml_omits_defaults() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("name = \"foo\"")); - assert!(toml.contains("version = \"1.0\"")); - assert!(toml.contains("description = \"A test\"")); - // defaults should not be present - assert!(!toml.contains("cflags")); - assert!(!toml.contains("rustflags")); - // sha256="skip" should be omitted - assert!(!toml.contains("sha256")); - } - - #[test] - fn spec_to_minimal_toml_includes_additional_packages() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: vec![PackageInfo { - name: "foo-dev".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "dev files".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }], - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("[[packages]]")); - assert!(toml.contains("name = \"foo-dev\"")); - } - - #[test] - fn spec_to_minimal_toml_includes_multiple_licenses_as_array() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into(), "Apache-2.0".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("license = [")); - assert!(toml.contains("\"MIT\"")); - assert!(toml.contains("\"Apache-2.0\"")); - } - - #[test] - fn spec_to_minimal_toml_includes_extended_build_flags() { - let flags = BuildFlags { - source_subdir: "project/subdir".into(), - configure: vec!["--disable-static".into(), "--enable-foo".into()], - configure_file: "build-aux/configure".into(), - post_configure: vec!["./configure-helper.sh".into()], - post_compile: vec!["make check".into()], - post_install: vec!["strip $DESTDIR/usr/bin/foo".into()], - makefile_commands: vec!["make".into()], - makefile_install_commands: vec!["make DESTDIR=$DESTDIR install".into()], - cargs: vec!["--locked".into()], - config_settings: vec!["editable_mode=compat".into()], - rustflags: vec!["-Ctarget-cpu=native".into()], - cxxflags: vec!["-O2".into(), "-fno-rtti".into()], - fuse_ld: "lld".into(), - ltoflags: vec!["-flto=auto".into()], - target: "x86_64-unknown-linux-gnu".into(), - keep: vec!["etc/locale.gen".into()], - sbindir: "/usr/sbin".into(), - libdir: "/usr/lib64".into(), - libexecdir: "/usr/libexec".into(), - sysconfdir: "/etc/custom".into(), - localstatedir: "/var/custom".into(), - sharedstatedir: "/var/lib/custom".into(), - includedir: "/usr/include/custom".into(), - datarootdir: "/usr/share/root".into(), - datadir: "/usr/share/custom".into(), - mandir: "/usr/share/custom/man".into(), - infodir: "/usr/share/custom/info".into(), - use_lto: false, - no_flags: true, - no_strip: true, - no_delete_static: true, - no_compress_man: true, - skip_tests: true, - makeflags: "-j12 --output-sync=target".into(), - make_vars: vec!["V=1".into()], - make_dirs: vec!["lib".into(), "libelf".into()], - make_test_vars: vec!["TESTS=unit".into()], - make_test_dirs: vec!["tests".into()], - make_install_vars: vec!["STRIPPROG=true".into()], - make_install_dirs: vec!["lib".into(), "apps".into()], - env_vars: vec![ - "SETUPTOOLS_SCM_PRETEND_VERSION=$version".into(), - "PYO3_CONFIG_FILE=$specdir/pyo3.toml".into(), - ], - ..BuildFlags::default() - }; - - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: vec!["fix.patch".into()], - post_extract: vec!["autoreconf -fi".into()], - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("source_subdir = \"project/subdir\"")); - assert!(toml.contains("configure = [")); - assert!(toml.contains("configure_file = \"build-aux/configure\"")); - assert!(toml.contains("post_configure = [")); - assert!(toml.contains("post_compile = [")); - assert!(toml.contains("post_install = [")); - assert!(toml.contains("makefile_commands = [")); - assert!(toml.contains("makefile_install_commands = [")); - assert!(toml.contains("cargs = [")); - assert!(toml.contains("config_setting = [")); - assert!(toml.contains("rustflags = [")); - assert!(toml.contains("cxxflags = [")); - assert!(toml.contains("fuse_ld = \"lld\"")); - assert!(toml.contains("ltoflags = [")); - assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\"")); - assert!(toml.contains("keep = [")); - assert!(toml.contains("\"etc/locale.gen\"")); - assert!(toml.contains("sbindir = \"/usr/sbin\"")); - assert!(toml.contains("libdir = \"/usr/lib64\"")); - assert!(toml.contains("libexecdir = \"/usr/libexec\"")); - assert!(toml.contains("sysconfdir = \"/etc/custom\"")); - assert!(toml.contains("localstatedir = \"/var/custom\"")); - assert!(toml.contains("sharedstatedir = \"/var/lib/custom\"")); - assert!(toml.contains("includedir = \"/usr/include/custom\"")); - assert!(toml.contains("datarootdir = \"/usr/share/root\"")); - assert!(toml.contains("datadir = \"/usr/share/custom\"")); - assert!(toml.contains("mandir = \"/usr/share/custom/man\"")); - assert!(toml.contains("infodir = \"/usr/share/custom/info\"")); - assert!(toml.contains("use_lto = false")); - assert!(toml.contains("no_flags = true")); - assert!(toml.contains("no_strip = true")); - assert!(toml.contains("no_delete_static = true")); - assert!(toml.contains("no_compress_man = true")); - assert!(toml.contains("skip_tests = true")); - assert!(toml.contains("makeflags = \"-j12 --output-sync=target\"")); - assert!(toml.contains("make_vars = [")); - assert!(toml.contains("make_dirs = [")); - assert!(toml.contains("make_test_vars = [")); - assert!(toml.contains("make_test_dirs = [")); - assert!(toml.contains("make_install_vars = [")); - assert!(toml.contains("make_install_dirs = [")); - assert!(toml.contains("env_vars = [")); - assert!(toml.contains("patches = [")); - assert!(toml.contains("post_extract = [")); - } - - #[test] - fn spec_to_minimal_toml_includes_extract_dir_for_variable_default() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "$name-$version".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("extract_dir = \"$name-$version\"")); - } - - #[test] - fn spec_to_minimal_toml_includes_test_and_optional_dependencies() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies { - build: vec![], - runtime: vec![], - test: vec!["python".into(), "bats".into()], - optional: vec!["gtk-doc".into()], - groups: vec!["base".into(), "devtools".into()], - lib32: None, - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - let val: toml::Value = toml::from_str(&toml).unwrap(); - let test_deps = val - .get("dependencies") - .and_then(|d| d.get("test")) - .and_then(|t| t.as_array()) - .expect("expected dependencies.test array"); - assert_eq!(test_deps.len(), 2); - assert_eq!(test_deps[0].as_str(), Some("python")); - assert_eq!(test_deps[1].as_str(), Some("bats")); - let optional_deps = val - .get("dependencies") - .and_then(|d| d.get("optional")) - .and_then(|t| t.as_array()) - .expect("expected dependencies.optional array"); - assert_eq!(optional_deps.len(), 1); - assert_eq!(optional_deps[0].as_str(), Some("gtk-doc")); - let groups = val - .get("dependencies") - .and_then(|d| d.get("groups")) - .and_then(|t| t.as_array()) - .expect("expected dependencies.groups array"); - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].as_str(), Some("base")); - assert_eq!(groups[1].as_str(), Some("devtools")); - } - - #[test] - fn spec_to_minimal_toml_includes_alternatives_conflicts_and_provides() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "A test".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives { - provides: vec!["editor".into(), "sh".into()], - conflicts: vec!["nano".into(), "busybox-sh".into()], - replaces: vec!["vi".into()], - lib32: Some(crate::package::AlternativeGroup { - provides: Vec::new(), - conflicts: Vec::new(), - replaces: vec!["lib32-vi".into()], - }), - }, - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.com/foo-1.0.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo-1.0".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - let val: toml::Value = toml::from_str(&toml).unwrap(); - let alternatives = val - .get("alternatives") - .and_then(|v| v.as_table()) - .expect("expected alternatives table"); - let provides = alternatives - .get("provides") - .and_then(|v| v.as_array()) - .expect("expected alternatives.provides array"); - let conflicts = alternatives - .get("conflicts") - .and_then(|v| v.as_array()) - .expect("expected alternatives.conflicts array"); - let replaces = alternatives - .get("replaces") - .and_then(|v| v.as_array()) - .expect("expected alternatives.replaces array"); - let lib32 = alternatives - .get("lib32") - .and_then(|v| v.as_table()) - .expect("expected alternatives.lib32 table"); - let lib32_replaces = lib32 - .get("replaces") - .and_then(|v| v.as_array()) - .expect("expected alternatives.lib32.replaces array"); - - assert_eq!(provides.len(), 2); - assert_eq!(provides[0].as_str(), Some("editor")); - assert_eq!(provides[1].as_str(), Some("sh")); - assert_eq!(conflicts.len(), 2); - assert_eq!(conflicts[0].as_str(), Some("nano")); - assert_eq!(conflicts[1].as_str(), Some("busybox-sh")); - assert_eq!(replaces.len(), 1); - assert_eq!(replaces[0].as_str(), Some("vi")); - assert_eq!(lib32_replaces.len(), 1); - assert_eq!(lib32_replaces[0].as_str(), Some("lib32-vi")); - } - - #[test] - fn spec_to_minimal_toml_supports_metapackage_without_sources() { - let spec = PackageSpec { - package: PackageInfo { - name: "foo-meta".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "Meta package".into(), - homepage: "".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Meta, - flags: BuildFlags::default(), - }, - dependencies: Dependencies { - build: Vec::new(), - runtime: vec!["foo".into(), "bar".into()], - test: Vec::new(), - optional: Vec::new(), - groups: vec!["base".into()], - lib32: None, - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("type = \"meta\"")); - assert!(!toml.contains("[[source]]")); - - let val: toml::Value = toml::from_str(&toml).unwrap(); - assert!(val.get("source").is_none()); - } - - #[test] - fn spec_to_minimal_toml_includes_manual_sources() { - let spec = PackageSpec { - package: PackageInfo { - name: "vertex-keyring".into(), - real_name: None, - version: "1.0.0".into(), - revision: 1, - description: "keyring".into(), - homepage: "https://www.vertexlinux.net".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: vec![ - ManualSource { - file: Some("vertex.pub".into()), - files: Vec::new(), - url: None, - urls: Vec::new(), - sha256: None, - dest: Some("usr/share/depot/keys/public/vertex.pub".into()), - }, - ManualSource { - file: None, - files: Vec::new(), - url: Some("file:///tmp/vertex.minisig".into()), - urls: Vec::new(), - sha256: Some("skip".into()), - dest: Some("usr/share/depot/keys/sign/vertex.minisig".into()), - }, - ], - source: Vec::new(), - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let toml = spec_to_minimal_toml(&spec).unwrap(); - assert!(toml.contains("[[manual_sources]]")); - assert!(toml.contains("file = \"vertex.pub\"")); - assert!(toml.contains("url = \"file:///tmp/vertex.minisig\"")); - assert!(toml.contains("dest = \"usr/share/depot/keys/public/vertex.pub\"")); - assert!(!toml.contains("sha256 = \"skip\"")); - - let val: toml::Value = toml::from_str(&toml).unwrap(); - let arr = val - .get("manual_sources") - .and_then(|v| v.as_array()) - .expect("expected manual_sources array"); - assert_eq!(arr.len(), 2); - } - - #[test] - fn compute_sha256_for_local_path_and_file_url() { - use sha2::Digest as TestDigest; - use sha2::Sha256 as TestSha256; - use tempfile::NamedTempFile; - - let mut tmp = NamedTempFile::new().unwrap(); - std::io::Write::write_all(&mut tmp, b"abc").unwrap(); - let expected = { - let mut h = TestSha256::new(); - h.update(b"abc"); - crate::hex::encode_lower(h.finalize()) - }; - - // plain path - let p = tmp.path().to_str().unwrap().to_string(); - assert_eq!(compute_sha256_for_url(&p).unwrap(), expected); - - // file:// URL - let file_url = format!("file://{}", tmp.path().to_str().unwrap()); - assert_eq!(compute_sha256_for_url(&file_url).unwrap(), expected); - } - - #[test] - fn expand_known_package_vars_replaces_name_and_version_patterns() { - let input = "https://example.com/$name/${name}-$version-${version}.tar.xz"; - let out = expand_known_package_vars(input, "python", "3.13.1"); - assert_eq!( - out, - "https://example.com/python/python-3.13.1-3.13.1.tar.xz" - ); - } -} +mod tests; diff --git a/src/package/interactive/tests.rs b/src/package/interactive/tests.rs new file mode 100644 index 0000000..3ea321b --- /dev/null +++ b/src/package/interactive/tests.rs @@ -0,0 +1,598 @@ +use super::*; +use crate::package::{ + BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, PackageSpec, Source, +}; + +#[test] +fn spec_to_minimal_toml_omits_defaults() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("name = \"foo\"")); + assert!(toml.contains("version = \"1.0\"")); + assert!(toml.contains("description = \"A test\"")); + // defaults should not be present + assert!(!toml.contains("cflags")); + assert!(!toml.contains("rustflags")); + // sha256="skip" should be omitted + assert!(!toml.contains("sha256")); +} + +#[test] +fn spec_to_minimal_toml_includes_additional_packages() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: vec![PackageInfo { + name: "foo-dev".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "dev files".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }], + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("[[packages]]")); + assert!(toml.contains("name = \"foo-dev\"")); +} + +#[test] +fn spec_to_minimal_toml_includes_multiple_licenses_as_array() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into(), "Apache-2.0".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("license = [")); + assert!(toml.contains("\"MIT\"")); + assert!(toml.contains("\"Apache-2.0\"")); +} + +#[test] +fn spec_to_minimal_toml_includes_extended_build_flags() { + let flags = BuildFlags { + source_subdir: "project/subdir".into(), + configure: vec!["--disable-static".into(), "--enable-foo".into()], + configure_file: "build-aux/configure".into(), + post_configure: vec!["./configure-helper.sh".into()], + post_compile: vec!["make check".into()], + post_install: vec!["strip $DESTDIR/usr/bin/foo".into()], + makefile_commands: vec!["make".into()], + makefile_install_commands: vec!["make DESTDIR=$DESTDIR install".into()], + cargs: vec!["--locked".into()], + config_settings: vec!["editable_mode=compat".into()], + rustflags: vec!["-Ctarget-cpu=native".into()], + cxxflags: vec!["-O2".into(), "-fno-rtti".into()], + fuse_ld: "lld".into(), + ltoflags: vec!["-flto=auto".into()], + target: "x86_64-unknown-linux-gnu".into(), + keep: vec!["etc/locale.gen".into()], + sbindir: "/usr/sbin".into(), + libdir: "/usr/lib64".into(), + libexecdir: "/usr/libexec".into(), + sysconfdir: "/etc/custom".into(), + localstatedir: "/var/custom".into(), + sharedstatedir: "/var/lib/custom".into(), + includedir: "/usr/include/custom".into(), + datarootdir: "/usr/share/root".into(), + datadir: "/usr/share/custom".into(), + mandir: "/usr/share/custom/man".into(), + infodir: "/usr/share/custom/info".into(), + use_lto: false, + no_flags: true, + no_strip: true, + no_delete_static: true, + no_compress_man: true, + skip_tests: true, + makeflags: "-j12 --output-sync=target".into(), + make_vars: vec!["V=1".into()], + make_dirs: vec!["lib".into(), "libelf".into()], + make_test_vars: vec!["TESTS=unit".into()], + make_test_dirs: vec!["tests".into()], + make_install_vars: vec!["STRIPPROG=true".into()], + make_install_dirs: vec!["lib".into(), "apps".into()], + env_vars: vec![ + "SETUPTOOLS_SCM_PRETEND_VERSION=$version".into(), + "PYO3_CONFIG_FILE=$specdir/pyo3.toml".into(), + ], + ..BuildFlags::default() + }; + + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: vec!["fix.patch".into()], + post_extract: vec!["autoreconf -fi".into()], + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("source_subdir = \"project/subdir\"")); + assert!(toml.contains("configure = [")); + assert!(toml.contains("configure_file = \"build-aux/configure\"")); + assert!(toml.contains("post_configure = [")); + assert!(toml.contains("post_compile = [")); + assert!(toml.contains("post_install = [")); + assert!(toml.contains("makefile_commands = [")); + assert!(toml.contains("makefile_install_commands = [")); + assert!(toml.contains("cargs = [")); + assert!(toml.contains("config_setting = [")); + assert!(toml.contains("rustflags = [")); + assert!(toml.contains("cxxflags = [")); + assert!(toml.contains("fuse_ld = \"lld\"")); + assert!(toml.contains("ltoflags = [")); + assert!(toml.contains("target = \"x86_64-unknown-linux-gnu\"")); + assert!(toml.contains("keep = [")); + assert!(toml.contains("\"etc/locale.gen\"")); + assert!(toml.contains("sbindir = \"/usr/sbin\"")); + assert!(toml.contains("libdir = \"/usr/lib64\"")); + assert!(toml.contains("libexecdir = \"/usr/libexec\"")); + assert!(toml.contains("sysconfdir = \"/etc/custom\"")); + assert!(toml.contains("localstatedir = \"/var/custom\"")); + assert!(toml.contains("sharedstatedir = \"/var/lib/custom\"")); + assert!(toml.contains("includedir = \"/usr/include/custom\"")); + assert!(toml.contains("datarootdir = \"/usr/share/root\"")); + assert!(toml.contains("datadir = \"/usr/share/custom\"")); + assert!(toml.contains("mandir = \"/usr/share/custom/man\"")); + assert!(toml.contains("infodir = \"/usr/share/custom/info\"")); + assert!(toml.contains("use_lto = false")); + assert!(toml.contains("no_flags = true")); + assert!(toml.contains("no_strip = true")); + assert!(toml.contains("no_delete_static = true")); + assert!(toml.contains("no_compress_man = true")); + assert!(toml.contains("skip_tests = true")); + assert!(toml.contains("makeflags = \"-j12 --output-sync=target\"")); + assert!(toml.contains("make_vars = [")); + assert!(toml.contains("make_dirs = [")); + assert!(toml.contains("make_test_vars = [")); + assert!(toml.contains("make_test_dirs = [")); + assert!(toml.contains("make_install_vars = [")); + assert!(toml.contains("make_install_dirs = [")); + assert!(toml.contains("env_vars = [")); + assert!(toml.contains("patches = [")); + assert!(toml.contains("post_extract = [")); +} + +#[test] +fn spec_to_minimal_toml_includes_extract_dir_for_variable_default() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "$name-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("extract_dir = \"$name-$version\"")); +} + +#[test] +fn spec_to_minimal_toml_includes_test_and_optional_dependencies() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies { + build: vec![], + runtime: vec![], + test: vec!["python".into(), "bats".into()], + optional: vec!["gtk-doc".into()], + groups: vec!["base".into(), "devtools".into()], + lib32: None, + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + let val: toml::Value = toml::from_str(&toml).unwrap(); + let test_deps = val + .get("dependencies") + .and_then(|d| d.get("test")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.test array"); + assert_eq!(test_deps.len(), 2); + assert_eq!(test_deps[0].as_str(), Some("python")); + assert_eq!(test_deps[1].as_str(), Some("bats")); + let optional_deps = val + .get("dependencies") + .and_then(|d| d.get("optional")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.optional array"); + assert_eq!(optional_deps.len(), 1); + assert_eq!(optional_deps[0].as_str(), Some("gtk-doc")); + let groups = val + .get("dependencies") + .and_then(|d| d.get("groups")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.groups array"); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].as_str(), Some("base")); + assert_eq!(groups[1].as_str(), Some("devtools")); +} + +#[test] +fn spec_to_minimal_toml_includes_alternatives_conflicts_and_provides() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "A test".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives { + provides: vec!["editor".into(), "sh".into()], + conflicts: vec!["nano".into(), "busybox-sh".into()], + replaces: vec!["vi".into()], + lib32: Some(crate::package::AlternativeGroup { + provides: Vec::new(), + conflicts: Vec::new(), + replaces: vec!["lib32-vi".into()], + }), + }, + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.com/foo-1.0.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo-1.0".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + let val: toml::Value = toml::from_str(&toml).unwrap(); + let alternatives = val + .get("alternatives") + .and_then(|v| v.as_table()) + .expect("expected alternatives table"); + let provides = alternatives + .get("provides") + .and_then(|v| v.as_array()) + .expect("expected alternatives.provides array"); + let conflicts = alternatives + .get("conflicts") + .and_then(|v| v.as_array()) + .expect("expected alternatives.conflicts array"); + let replaces = alternatives + .get("replaces") + .and_then(|v| v.as_array()) + .expect("expected alternatives.replaces array"); + let lib32 = alternatives + .get("lib32") + .and_then(|v| v.as_table()) + .expect("expected alternatives.lib32 table"); + let lib32_replaces = lib32 + .get("replaces") + .and_then(|v| v.as_array()) + .expect("expected alternatives.lib32.replaces array"); + + assert_eq!(provides.len(), 2); + assert_eq!(provides[0].as_str(), Some("editor")); + assert_eq!(provides[1].as_str(), Some("sh")); + assert_eq!(conflicts.len(), 2); + assert_eq!(conflicts[0].as_str(), Some("nano")); + assert_eq!(conflicts[1].as_str(), Some("busybox-sh")); + assert_eq!(replaces.len(), 1); + assert_eq!(replaces[0].as_str(), Some("vi")); + assert_eq!(lib32_replaces.len(), 1); + assert_eq!(lib32_replaces[0].as_str(), Some("lib32-vi")); +} + +#[test] +fn spec_to_minimal_toml_supports_metapackage_without_sources() { + let spec = PackageSpec { + package: PackageInfo { + name: "foo-meta".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "Meta package".into(), + homepage: "".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Meta, + flags: BuildFlags::default(), + }, + dependencies: Dependencies { + build: Vec::new(), + runtime: vec!["foo".into(), "bar".into()], + test: Vec::new(), + optional: Vec::new(), + groups: vec!["base".into()], + lib32: None, + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("type = \"meta\"")); + assert!(!toml.contains("[[source]]")); + + let val: toml::Value = toml::from_str(&toml).unwrap(); + assert!(val.get("source").is_none()); +} + +#[test] +fn spec_to_minimal_toml_includes_manual_sources() { + let spec = PackageSpec { + package: PackageInfo { + name: "vertex-keyring".into(), + real_name: None, + version: "1.0.0".into(), + revision: 1, + description: "keyring".into(), + homepage: "https://www.vertexlinux.net".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: vec![ + ManualSource { + file: Some("vertex.pub".into()), + files: Vec::new(), + url: None, + urls: Vec::new(), + sha256: None, + dest: Some("usr/share/depot/keys/public/vertex.pub".into()), + }, + ManualSource { + file: None, + files: Vec::new(), + url: Some("file:///tmp/vertex.minisig".into()), + urls: Vec::new(), + sha256: Some("skip".into()), + dest: Some("usr/share/depot/keys/sign/vertex.minisig".into()), + }, + ], + source: Vec::new(), + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let toml = spec_to_minimal_toml(&spec).unwrap(); + assert!(toml.contains("[[manual_sources]]")); + assert!(toml.contains("file = \"vertex.pub\"")); + assert!(toml.contains("url = \"file:///tmp/vertex.minisig\"")); + assert!(toml.contains("dest = \"usr/share/depot/keys/public/vertex.pub\"")); + assert!(!toml.contains("sha256 = \"skip\"")); + + let val: toml::Value = toml::from_str(&toml).unwrap(); + let arr = val + .get("manual_sources") + .and_then(|v| v.as_array()) + .expect("expected manual_sources array"); + assert_eq!(arr.len(), 2); +} + +#[test] +fn compute_sha256_for_local_path_and_file_url() { + use sha2::Digest as TestDigest; + use sha2::Sha256 as TestSha256; + use tempfile::NamedTempFile; + + let mut tmp = NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, b"abc").unwrap(); + let expected = { + let mut h = TestSha256::new(); + h.update(b"abc"); + crate::hex::encode_lower(h.finalize()) + }; + + // plain path + let p = tmp.path().to_str().unwrap().to_string(); + assert_eq!(compute_sha256_for_url(&p).unwrap(), expected); + + // file:// URL + let file_url = format!("file://{}", tmp.path().to_str().unwrap()); + assert_eq!(compute_sha256_for_url(&file_url).unwrap(), expected); +} + +#[test] +fn expand_known_package_vars_replaces_name_and_version_patterns() { + let input = "https://example.com/$name/${name}-$version-${version}.tar.xz"; + let out = expand_known_package_vars(input, "python", "3.13.1"); + assert_eq!( + out, + "https://example.com/python/python-3.13.1-3.13.1.tar.xz" + ); +} diff --git a/src/package/spec/tests.rs b/src/package/spec/tests.rs index 5105efa..0ad7e3a 100644 --- a/src/package/spec/tests.rs +++ b/src/package/spec/tests.rs @@ -1,2480 +1,6 @@ use super::*; use std::path::{Path, PathBuf}; -#[test] -fn parse_single_source_table() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo-$version.tar.gz" -sha256 = "skip" -extract_dir = "foo-$version" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.package.name, "foo"); - assert_eq!(spec.sources().len(), 1); - assert_eq!( - spec.expand_vars(&spec.sources()[0].url), - "https://example.com/foo-1.0.tar.gz" - ); - assert!(spec.sources()[0].patches.is_empty()); - assert!(spec.sources()[0].post_extract.is_empty()); - assert!(spec.sources()[0].cherry_pick.is_empty()); - assert_eq!(spec.spec_dir, tmp.path()); -} - -#[test] -fn parse_package_built_against_metadata() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" -built-against = ["icu78"] - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.package.built_against, vec!["icu78".to_string()]); -} - -#[test] -fn parse_source_array() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[source]] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[[source]] -url = "https://example.com/bar.tar.gz" -sha256 = "skip" -extract_dir = "bar" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.sources().len(), 2); - assert_eq!(spec.sources()[0].extract_dir, "foo"); - assert_eq!(spec.sources()[1].extract_dir, "bar"); -} - -#[test] -fn parse_source_without_sha256_defaults_to_skip() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -extract_dir = "foo" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.sources()[0].sha256, "skip"); -} - -#[test] -fn parse_git_source_with_cherry_pick() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.git#main" -sha256 = "skip" -extract_dir = "foo" -cherry_pick = ["deadbeef", "cafebabe"] - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.sources()[0].cherry_pick, - vec!["deadbeef".to_string(), "cafebabe".to_string()] - ); -} - -#[test] -fn parse_package_dependencies_overrides() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "llvm" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/llvm.tar.gz" -sha256 = "skip" -extract_dir = "llvm" - -[build] -type = "custom" - -[dependencies] -runtime = ["base"] -groups = ["toolchain"] - -[package_dependencies.clang] -runtime = ["llvm-libs", "llvm-libgcc"] -groups = ["compiler"] - -[package_dependencies.llvm-libs] -runtime = ["llvm-libgcc", "zstd"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.dependencies_for_output("llvm").runtime, - vec!["base".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("llvm").groups, - vec!["toolchain".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("clang").runtime, - vec!["llvm-libs".to_string(), "llvm-libgcc".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("clang").groups, - vec!["compiler".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("llvm-libs").runtime, - vec!["llvm-libgcc".to_string(), "zstd".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("llvm-libs").groups, - Vec::::new() - ); -} - -#[test] -fn parse_lib32_dependencies_override() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "llvm" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/llvm.tar.gz" -sha256 = "skip" -extract_dir = "llvm" - -[build] -type = "custom" - -[dependencies] -runtime = ["base"] -groups = ["toolchain"] - -[dependencies.lib32] -build = ["gcc-multilib"] -runtime = ["lib32-zlib"] -test = ["bats"] -groups = ["lib32-toolchain"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.dependencies_for_output("llvm").runtime, - vec!["base".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("llvm").groups, - vec!["toolchain".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("lib32-llvm").build, - vec!["gcc-multilib".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("lib32-llvm").runtime, - vec!["lib32-zlib".to_string(), "llvm".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("lib32-llvm").test, - vec!["bats".to_string()] - ); - assert_eq!( - spec.dependencies_for_output("lib32-llvm").groups, - vec!["lib32-toolchain".to_string()] - ); -} - -#[test] -fn parse_package_alternatives_overrides() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "llvm" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/llvm.tar.gz" -sha256 = "skip" -extract_dir = "llvm" - -[build] -type = "custom" - -[alternatives] -provides = ["toolchain"] -conflicts = ["gcc"] - -[package_alternatives.clang] -provides = ["cc", "c++", "gcc"] -conflicts = ["clang-legacy"] - -[package_alternatives.llvm] -provides = ["binutils"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.alternatives_for_output("llvm").provides, - vec!["binutils".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("llvm").conflicts, - Vec::::new() - ); - assert_eq!( - spec.alternatives_for_output("clang").provides, - vec!["cc".to_string(), "c++".to_string(), "gcc".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("clang").conflicts, - vec!["clang-legacy".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("other").provides, - vec!["toolchain".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("other").conflicts, - vec!["gcc".to_string()] - ); -} - -#[test] -fn parse_lib32_alternatives_override() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "llvm" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/llvm.tar.gz" -sha256 = "skip" -extract_dir = "llvm" - -[build] -type = "custom" - -[alternatives] -provides = ["toolchain"] -replaces = ["clang"] - -[alternatives.lib32] -provides = ["lib32-toolchain"] -conflicts = ["lib32-gcc"] -replaces = ["lib32-clang"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.alternatives_for_output("llvm").replaces, - vec!["clang".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("lib32-llvm").provides, - vec!["lib32-toolchain".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("lib32-llvm").conflicts, - vec!["lib32-gcc".to_string()] - ); - assert_eq!( - spec.alternatives_for_output("lib32-llvm").replaces, - vec!["lib32-clang".to_string()] - ); -} - -#[test] -fn lib32_output_does_not_fallback_to_primary_alternatives() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "llvm" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -build_32 = true - -[alternatives] -provides = ["toolchain"] -conflicts = ["gcc"] -replaces = ["clang"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - let lib32 = spec.alternatives_for_output("lib32-llvm"); - - assert!(lib32.provides.is_empty()); - assert!(lib32.conflicts.is_empty()); - assert!(lib32.replaces.is_empty()); -} - -#[test] -fn parse_python_build_type() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "python" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(matches!(spec.build.build_type, BuildType::Python)); -} - -#[test] -fn parse_perl_build_type() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "perl" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(matches!(spec.build.build_type, BuildType::Perl)); -} - -#[test] -fn parse_python_config_settings_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "python" - -[build.flags] -config-setting = ["editable_mode=compat", "setup-args=--plat-name=x86_64"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.config_settings, - vec![ - "editable_mode=compat".to_string(), - "setup-args=--plat-name=x86_64".to_string() - ] - ); -} - -#[test] -fn parse_multiple_licenses() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = ["MIT", "Apache-2.0"] - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.package.license, - vec!["MIT".to_string(), "Apache-2.0".to_string()] - ); -} - -#[test] -fn parse_rejects_empty_sources() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - // `source = []` is not accepted (must have at least one entry) - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -source = [] - -[build] -type = "custom" -"#, - ) - .unwrap(); - - assert!(PackageSpec::from_file(&path).is_err()); -} - -#[test] -fn parse_allows_metapackage_without_sources() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo-meta" -version = "1.0" -description = "metapackage" -homepage = "https://example.com" -license = "MIT" - -[build] -type = "meta" - -[dependencies] -runtime = ["foo", "bar"] -groups = ["base"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.source.is_empty()); - assert!(spec.manual_sources.is_empty()); - assert!(spec.is_metapackage()); - assert_eq!(spec.dependencies.runtime, vec!["foo", "bar"]); - assert_eq!(spec.dependencies.groups, vec!["base"]); -} - -#[test] -fn parse_manual_source_with_url() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[manual_sources]] -url = "https://example.com/manual.patch" -sha256 = "skip" -dest = "patches/manual.patch" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.manual_sources.len(), 1); - assert_eq!( - spec.manual_sources[0].url.as_deref(), - Some("https://example.com/manual.patch") - ); - assert_eq!(spec.manual_sources[0].file, None); -} - -#[test] -fn parse_manual_source_rejects_missing_file_and_url() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[manual_sources]] -sha256 = "skip" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); - assert!( - err.to_string() - .contains("must specify one of 'file', 'files', 'url', or 'urls'") - ); -} - -#[test] -fn parse_manual_source_rejects_file_and_url_together() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[manual_sources]] -file = "manual.patch" -url = "https://example.com/manual.patch" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); - assert!( - err.to_string() - .contains("cannot mix local ('file'/'files') and remote ('url'/'urls') entries") - ); -} - -#[test] -fn parse_manual_source_with_files_array() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[manual_sources]] -files = ["other", "system-auth"] - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.manual_sources.len(), 1); - assert_eq!(spec.manual_sources[0].files, vec!["other", "system-auth"]); - assert!(spec.manual_sources[0].urls.is_empty()); -} - -#[test] -fn test_apply_config() { - let mut spec = mk_spec("foo", "1.0"); - let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); - - // Mock some overrides and appends - config.build_overrides = toml::from_str( - r#" -[flags] -cc = "my-cc" -cxx = "my-cxx" -ar = "my-ar" -ranlib = "my-ranlib" -strip = "my-strip" -ld = "ld.lld" -nm = "my-nm" -objcopy = "my-objcopy" -objdump = "my-objdump" -readelf = "my-readelf" -CPP = "clang-cpp" -tool_dir = "/opt/toolchain/bin" -cflags = ["-O2"] -replace_cflags = ["-O2=>-O3"] -cxxflags = ["-O2", "-pipe"] -replace_cxxflags = ["-pipe=>-fPIC"] -passthrough_env = ["RUSTFLAGS"] -env_vars = ["SETUPTOOLS_SCM_PRETEND_VERSION=$version"] -bindir = "/opt/bin" -sbindir = "/opt/sbin" -libdir = "/opt/lib64" -sysconfdir = "/opt/etc" -datarootdir = "/opt/share-root" -makeflags = "-j8" -make_vars = ["V=1"] -make_dirs = ["lib"] -make_test_dirs = ["tests"] -make_install_dirs = ["lib"] -ltoflags = ["-flto=auto"] -RUSTLTOFLAGS = ["-Clinker-plugin-lto"] -replace_ltoflags = ["auto=>thin"] -rustflags = ["-C", "debuginfo=2"] -replace_rustflags = ["debuginfo=2=>opt-level=2"] -use_lto = true -no_flags = true -no_strip = true -no_delete_static = true -no_compress_man = true -skip_tests = true -keep = ["etc/locale.gen"] -configure_file = "configure.gnu" -config-setting = ["editable_mode=compat"] -post_configure = ["echo configured"] -"#, - ) - .unwrap(); - config.appends.insert( - "build.flags.cflags".to_string(), - vec![toml::Value::String("-g".to_string())], - ); - config.appends.insert( - "build.flags.replace_cflags".to_string(), - vec![toml::Value::String( - "-D_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string(), - )], - ); - config.appends.insert( - "build.flags.cxxflags".to_string(), - vec![toml::Value::String("-stdlib=libc++".to_string())], - ); - config.appends.insert( - "build.flags.replace_cxxflags".to_string(), - vec![toml::Value::String( - "-stdlib=libc++=>-stdlib=libstdc++".to_string(), - )], - ); - config.appends.insert( - "build.flags.rustflags".to_string(), - vec![toml::Value::Array(vec![ - toml::Value::String("-C".to_string()), - toml::Value::String("opt-level=3".to_string()), - ])], - ); - config.appends.insert( - "build.flags.replace_rustflags".to_string(), - vec![toml::Value::String("opt-level=3=>opt-level=z".to_string())], - ); - config.appends.insert( - "build.flags.keep".to_string(), - vec![toml::Value::Array(vec![toml::Value::String( - "etc/locale.gen".to_string(), - )])], - ); - config.appends.insert( - "build.flags.ltoflags".to_string(), - vec![toml::Value::Array(vec![toml::Value::String( - "-fno-fat-lto-objects".to_string(), - )])], - ); - config.appends.insert( - "build.flags.replace_ltoflags".to_string(), - vec![toml::Value::String( - "-fno-fat-lto-objects=>-flto-jobs=8".to_string(), - )], - ); - config.appends.insert( - "build.flags.use_lto".to_string(), - vec![toml::Value::Boolean(false)], - ); - config.appends.insert( - "build.flags.no_strip".to_string(), - vec![toml::Value::Boolean(false)], - ); - config.appends.insert( - "build.flags.no_compress_man".to_string(), - vec![toml::Value::Boolean(false)], - ); - config.appends.insert( - "build.flags.no_delete_static".to_string(), - vec![toml::Value::Boolean(false)], - ); - config.appends.insert( - "build.flags.passthrough_env".to_string(), - vec![toml::Value::String("CARGO_HOME".to_string())], - ); - config.appends.insert( - "build.flags.env_vars".to_string(), - vec![toml::Value::String( - "SOURCE_DATE_EPOCH=1700000000".to_string(), - )], - ); - config.appends.insert( - "build.flags.make_test_vars".to_string(), - vec![toml::Value::String("TESTS=smoke".to_string())], - ); - config.appends.insert( - "build.flags.makeflags".to_string(), - vec![toml::Value::String("--output-sync=target".to_string())], - ); - config.appends.insert( - "build.flags.make_dirs".to_string(), - vec![toml::Value::String("libelf".to_string())], - ); - config.appends.insert( - "build.flags.make_test_dirs".to_string(), - vec![toml::Value::String("fuzz".to_string())], - ); - config.appends.insert( - "build.flags.make_install_dirs".to_string(), - vec![toml::Value::String("tools".to_string())], - ); - config.appends.insert( - "build.flags.make_install_vars".to_string(), - vec![toml::Value::String("DESTDIR=/tmp/pkg".to_string())], - ); - config.appends.insert( - "build.flags.configure_file".to_string(), - vec![toml::Value::String("build-aux/configure".to_string())], - ); - config.appends.insert( - "build.flags.libexecdir".to_string(), - vec![toml::Value::String("/opt/libexec".to_string())], - ); - config.appends.insert( - "build.flags.datadir".to_string(), - vec![toml::Value::String("/opt/share-data".to_string())], - ); - config.appends.insert( - "build.flags.config-setting".to_string(), - vec![toml::Value::String( - "setup-args=--plat-name=x86_64".to_string(), - )], - ); - config.appends.insert( - "build.flags.post_configure".to_string(), - vec![toml::Value::String("touch configured.stamp".to_string())], - ); - config.appends.insert( - "build.flags.configure_x86_64".to_string(), - vec![toml::Value::String("--enable-x86-tuning".to_string())], - ); - - spec.apply_config(&config); - - assert_eq!(spec.build.flags.cc, "my-cc"); - assert_eq!(spec.build.flags.cxx, "my-cxx"); - assert_eq!(spec.build.flags.ar, "my-ar"); - assert_eq!(spec.build.flags.ranlib, "my-ranlib"); - assert_eq!(spec.build.flags.strip, "my-strip"); - assert_eq!(spec.build.flags.ld, "ld.lld"); - assert_eq!(spec.build.flags.nm, "my-nm"); - assert_eq!(spec.build.flags.objcopy, "my-objcopy"); - assert_eq!(spec.build.flags.objdump, "my-objdump"); - assert_eq!(spec.build.flags.readelf, "my-readelf"); - assert_eq!(spec.build.flags.cpp, "clang-cpp"); - assert_eq!(spec.build.flags.tool_dir, "/opt/toolchain/bin"); - assert!(spec.build.flags.cflags.contains(&"-O2".to_string())); - assert!(spec.build.flags.cflags.contains(&"-g".to_string())); - assert!( - spec.build - .flags - .replace_cflags - .contains(&"-O2=>-O3".to_string()) - ); - assert!( - spec.build - .flags - .replace_cflags - .contains(&"-D_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string()) - ); - assert!(spec.build.flags.cxxflags.contains(&"-O2".to_string())); - assert!(spec.build.flags.cxxflags.contains(&"-pipe".to_string())); - assert!( - spec.build - .flags - .cxxflags - .contains(&"-stdlib=libc++".to_string()) - ); - assert!( - spec.build - .flags - .replace_cxxflags - .contains(&"-pipe=>-fPIC".to_string()) - ); - assert!( - spec.build - .flags - .replace_cxxflags - .contains(&"-stdlib=libc++=>-stdlib=libstdc++".to_string()) - ); - assert!(spec.build.flags.rustflags.contains(&"-C".to_string())); - assert!( - spec.build - .flags - .rustflags - .contains(&"debuginfo=2".to_string()) - ); - assert!( - spec.build - .flags - .rustflags - .contains(&"opt-level=3".to_string()) - ); - assert!( - spec.build - .flags - .replace_rustflags - .contains(&"debuginfo=2=>opt-level=2".to_string()) - ); - assert!( - spec.build - .flags - .replace_rustflags - .contains(&"opt-level=3=>opt-level=z".to_string()) - ); - assert!( - spec.build - .flags - .ltoflags - .contains(&"-flto=auto".to_string()) - ); - assert!( - spec.build - .flags - .rustltoflags - .contains(&"-Clinker-plugin-lto".to_string()) - ); - assert!( - spec.build - .flags - .ltoflags - .contains(&"-fno-fat-lto-objects".to_string()) - ); - assert!( - spec.build - .flags - .replace_ltoflags - .contains(&"auto=>thin".to_string()) - ); - assert!( - spec.build - .flags - .replace_ltoflags - .contains(&"-fno-fat-lto-objects=>-flto-jobs=8".to_string()) - ); - assert!(!spec.build.flags.use_lto); - assert!(spec.build.flags.no_flags); - assert!(!spec.build.flags.no_strip); - assert!(!spec.build.flags.no_delete_static); - assert!(!spec.build.flags.no_compress_man); - assert!( - spec.build - .flags - .keep - .contains(&"etc/locale.gen".to_string()) - ); - assert!( - spec.build - .flags - .passthrough_env - .contains(&"RUSTFLAGS".to_string()) - ); - assert!( - spec.build - .flags - .passthrough_env - .contains(&"CARGO_HOME".to_string()) - ); - assert!( - spec.build - .flags - .env_vars - .contains(&"SETUPTOOLS_SCM_PRETEND_VERSION=$version".to_string()) - ); - assert!( - spec.build - .flags - .env_vars - .contains(&"SOURCE_DATE_EPOCH=1700000000".to_string()) - ); - assert_eq!(spec.build.flags.bindir, "/opt/bin"); - assert_eq!(spec.build.flags.sbindir, "/opt/sbin"); - assert_eq!(spec.build.flags.libdir, "/opt/lib64"); - assert_eq!(spec.build.flags.libexecdir, "/opt/libexec"); - assert_eq!(spec.build.flags.sysconfdir, "/opt/etc"); - assert_eq!(spec.build.flags.datarootdir, "/opt/share-root"); - assert_eq!(spec.build.flags.datadir, "/opt/share-data"); - assert_eq!( - spec.build.flags.configure_arch.get("x86_64"), - Some(&vec!["--enable-x86-tuning".to_string()]) - ); - assert_eq!(spec.build.flags.makeflags, "-j8 --output-sync=target"); - assert!(spec.build.flags.make_vars.contains(&"V=1".to_string())); - assert!(spec.build.flags.make_dirs.contains(&"lib".to_string())); - assert!(spec.build.flags.make_dirs.contains(&"libelf".to_string())); - assert!(spec.build.flags.skip_tests); - assert!( - spec.build - .flags - .make_test_vars - .contains(&"TESTS=smoke".to_string()) - ); - assert!( - spec.build - .flags - .make_test_dirs - .contains(&"tests".to_string()) - ); - assert!( - spec.build - .flags - .make_test_dirs - .contains(&"fuzz".to_string()) - ); - assert!( - spec.build - .flags - .make_install_vars - .contains(&"DESTDIR=/tmp/pkg".to_string()) - ); - assert!( - spec.build - .flags - .make_install_dirs - .contains(&"lib".to_string()) - ); - assert!( - spec.build - .flags - .make_install_dirs - .contains(&"tools".to_string()) - ); - assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); - assert!( - spec.build - .flags - .config_settings - .contains(&"editable_mode=compat".to_string()) - ); - assert!( - spec.build - .flags - .config_settings - .contains(&"setup-args=--plat-name=x86_64".to_string()) - ); - assert!( - spec.build - .flags - .post_configure - .contains(&"echo configured".to_string()) - ); - assert!( - spec.build - .flags - .post_configure - .contains(&"touch configured.stamp".to_string()) - ); -} - -#[test] -fn test_apply_config_preserves_package_scalar_tool_and_layout_overrides() { - let mut spec = mk_spec("foo", "1.0"); - spec.build.flags.ld = "ld.lld".to_string(); - spec.build.flags.libdir = "/package/lib".to_string(); - spec.build.flags.sysconfdir = "/package/etc".to_string(); - let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); - config.build_overrides = toml::from_str( - r#" -ld = "/config/bin/ld" -fuse_ld = "/config/bin/ld.lld" -ranlib = "/config/bin/ranlib" -libdir = "/config/lib" -sysconfdir = "/config/etc" -"#, - ) - .unwrap(); - - spec.apply_config(&config); - - assert_eq!(spec.build.flags.ld, "ld.lld"); - assert_eq!(spec.build.flags.libdir, "/package/lib"); - assert_eq!(spec.build.flags.sysconfdir, "/package/etc"); - assert_eq!(spec.build.flags.ranlib, "/config/bin/ranlib"); - assert_eq!(spec.build.flags.fuse_ld, "/config/bin/ld.lld"); -} - -#[test] -fn test_apply_config_preserves_package_lto_disable() { - let mut spec = mk_spec("glibc", "2.43"); - spec.build.flags.use_lto = false; - let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); - config.build_overrides = toml::from_str( - r#" -[flags] -ltoflags = ["-flto=thin"] -use_lto = true -"#, - ) - .unwrap(); - - spec.apply_config(&config); - - assert!(!spec.build.flags.use_lto); - assert_eq!(spec.build.flags.ltoflags, vec!["-flto=thin".to_string()]); -} - -#[test] -fn parse_no_flags_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -no_flags = true -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.no_flags); -} - -#[test] -fn parse_tool_commands_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -cc = "/tools/bin/cc" -cxx = "/tools/bin/c++" -ar = "/tools/bin/ar" -ranlib = "/tools/bin/ranlib" -strip = "/tools/bin/strip" -ld = "/tools/bin/ld" -fuse_ld = "/usr/bin/ld.lld" -nm = "/tools/bin/nm" -objcopy = "/tools/bin/objcopy" -objdump = "/tools/bin/objdump" -readelf = "/tools/bin/readelf" -cpp = "/tools/bin/cpp" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.cc, "/tools/bin/cc"); - assert_eq!(spec.build.flags.cxx, "/tools/bin/c++"); - assert_eq!(spec.build.flags.ar, "/tools/bin/ar"); - assert_eq!(spec.build.flags.ranlib, "/tools/bin/ranlib"); - assert_eq!(spec.build.flags.strip, "/tools/bin/strip"); - assert_eq!(spec.build.flags.ld, "/tools/bin/ld"); - assert_eq!(spec.build.flags.fuse_ld, "/usr/bin/ld.lld"); - assert_eq!(spec.build.flags.nm, "/tools/bin/nm"); - assert_eq!(spec.build.flags.objcopy, "/tools/bin/objcopy"); - assert_eq!(spec.build.flags.objdump, "/tools/bin/objdump"); - assert_eq!(spec.build.flags.readelf, "/tools/bin/readelf"); - assert_eq!(spec.build.flags.cpp, "/tools/bin/cpp"); -} - -#[test] -fn parse_ltoflags_and_use_lto_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -ltoflags = ["-flto=auto", "-fuse-linker-plugin"] -use_lto = false -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.ltoflags, - vec!["-flto=auto".to_string(), "-fuse-linker-plugin".to_string()] - ); - assert!(!spec.build.flags.use_lto); -} - -#[test] -fn parse_ltoflags_and_use_lto_aliases_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -LTOFLAGS = "-flto=auto" -"use-lto" = false -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.ltoflags, vec!["-flto=auto".to_string()]); - assert!(!spec.build.flags.use_lto); -} - -#[test] -fn parse_no_strip_no_delete_static_and_no_compress_man_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -no_strip = true -"no-delete-static" = true -no-compress-man = true -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.no_strip); - assert!(spec.build.flags.no_delete_static); - assert!(spec.build.flags.no_compress_man); -} - -#[test] -fn parse_no_flags_alias_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -"no-flags" = true -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.no_flags); -} - -#[test] -fn parse_skip_tests_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -skip_tests = true -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.skip_tests); -} - -#[test] -fn parse_skip_tests_alias_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -"skip-tests" = true -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.skip_tests); -} - -#[test] -fn reject_unknown_nested_keys_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[dependencies] -runtime = ["glibc"] -skip_tests = true -"#, - ) - .unwrap(); - - let err = PackageSpec::from_file(&path).expect_err("expected unknown nested key to fail"); - assert!( - err.to_string() - .contains("unknown key: dependencies.skip_tests") - ); -} - -#[test] -fn parse_configure_file_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -configure_file = "build-aux/configure" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); -} - -#[test] -fn parse_install_dirs_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "cmake" - -[build.flags] -bindir = "/custom/bin" -sbindir = "/custom/sbin" -libdir = "/custom/lib64" -libexecdir = "/custom/libexec" -sysconfdir = "/custom/etc" -localstatedir = "/custom/var" -sharedstatedir = "/custom/var/lib" -includedir = "/custom/include" -datarootdir = "/custom/share-root" -datadir = "/custom/share" -mandir = "/custom/share/man" -infodir = "/custom/share/info" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.bindir, "/custom/bin"); - assert_eq!(spec.build.flags.sbindir, "/custom/sbin"); - assert_eq!(spec.build.flags.libdir, "/custom/lib64"); - assert_eq!(spec.build.flags.libexecdir, "/custom/libexec"); - assert_eq!(spec.build.flags.sysconfdir, "/custom/etc"); - assert_eq!(spec.build.flags.localstatedir, "/custom/var"); - assert_eq!(spec.build.flags.sharedstatedir, "/custom/var/lib"); - assert_eq!(spec.build.flags.includedir, "/custom/include"); - assert_eq!(spec.build.flags.datarootdir, "/custom/share-root"); - assert_eq!(spec.build.flags.datadir, "/custom/share"); - assert_eq!(spec.build.flags.mandir, "/custom/share/man"); - assert_eq!(spec.build.flags.infodir, "/custom/share/info"); -} - -#[test] -fn parse_lib32_build_flags_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -"build-32" = "true" -"lib32-only" = "yes" -"CFLAGS-lib32" = ["-mstackrealign"] -"CXXFLAGS-lib32" = ["-fno-rtti"] -"configure-lib32" = ["--disable-static"] -"post_configure-lib32" = ["echo configured lib32"] -"post_compile-lib32" = ["echo compiled lib32"] -"post_install-lib32" = ["echo lib32"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.build_32); - assert!(spec.build.flags.lib32_only); - assert!(spec.builds_lib32_output()); - assert!(spec.builds_only_lib32_output()); - assert_eq!(spec.build.flags.cflags_lib32, vec!["-mstackrealign"]); - assert_eq!(spec.build.flags.cxxflags_lib32, vec!["-fno-rtti"]); - assert_eq!(spec.build.flags.configure_lib32, vec!["--disable-static"]); - assert_eq!( - spec.build.flags.post_configure_lib32, - vec!["echo configured lib32"] - ); - assert_eq!( - spec.build.flags.post_compile_lib32, - vec!["echo compiled lib32"] - ); - assert_eq!(spec.build.flags.post_install_lib32, vec!["echo lib32"]); -} - -#[test] -fn multilib_builds_skip_automatic_tests() { - let mut spec = mk_spec("foo", "1.0"); - assert!(!spec.should_skip_automatic_tests()); - - spec.build.flags.build_32 = true; - assert!(spec.should_skip_automatic_tests()); - - spec.build.flags.build_32 = false; - spec.build.flags.skip_tests = true; - assert!(spec.should_skip_automatic_tests()); -} - -#[test] -fn parse_post_configure_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "cmake" - -[build.flags] -post_configure = ["cmake -L . > cmake-options.txt"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.post_configure, - vec!["cmake -L . > cmake-options.txt".to_string()] - ); -} - -#[test] -fn parse_keep_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -keep = ["etc/locale.gen", "etc/resolv.conf"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.keep, - vec!["etc/locale.gen".to_string(), "etc/resolv.conf".to_string()] - ); -} - -#[test] -fn parse_split_docs_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -split_docs = true -doc_dirs = ["/opt/docs", "usr/share/devhelp"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(spec.build.flags.split_docs); - assert_eq!( - spec.build.flags.doc_dirs, - vec!["/opt/docs".to_string(), "usr/share/devhelp".to_string()] - ); -} - -#[test] -fn parse_build_flags_appends_from_spec_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -replace_cflags = ["-O2=>-O3"] -replace_rustflags = ["debuginfo=2=>opt-level=2"] -cxxflags = ["-O2"] -cxxflags += [ "-Wno-gnu-statement-expression-from-macro-expansion" ] -ldflags += "-Wl,--as-needed" -replace_cflags += [ "_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2" ] -replace_rustflags += "opt-level=3=>opt-level=z" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.cxxflags, - vec![ - "-O2".to_string(), - "-Wno-gnu-statement-expression-from-macro-expansion".to_string() - ] - ); - assert_eq!( - spec.build.flags.ldflags, - vec!["-Wl,--as-needed".to_string()] - ); - assert_eq!( - spec.build.flags.replace_cflags, - vec![ - "-O2=>-O3".to_string(), - "_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string() - ] - ); - assert_eq!( - spec.build.flags.replace_rustflags, - vec![ - "debuginfo=2=>opt-level=2".to_string(), - "opt-level=3=>opt-level=z".to_string() - ] - ); -} - -#[test] -fn parse_configure_arch_appends_from_spec_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -configure = ["--base"] -configure_x86_64 += ["--enable-sse2"] -configure_aarch64 += "--enable-neon" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.configure, vec!["--base".to_string()]); - assert_eq!( - spec.build.flags.configure_arch.get("x86_64"), - Some(&vec!["--enable-sse2".to_string()]) - ); - assert_eq!( - spec.build.flags.configure_arch.get("aarch64"), - Some(&vec!["--enable-neon".to_string()]) - ); -} - -#[test] -fn parse_build_flags_appends_accepts_quoted_and_uppercase_keys() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -"cflags" += ["-fPIC"] -CXXFLAGS += ["-stdlib=libc++"] -"LDFLAGS" += "-Wl,--as-needed" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.cflags, vec!["-fPIC".to_string()]); - assert_eq!( - spec.build.flags.cxxflags, - vec!["-stdlib=libc++".to_string()] - ); - assert_eq!( - spec.build.flags.ldflags, - vec!["-Wl,--as-needed".to_string()] - ); -} - -#[test] -fn apply_config_reads_build_flag_appends_from_rootfs_build_toml() { - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join("etc/depot.d/build.toml"); - std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - std::fs::write( - &config_path, - r#" -[flags] -cflags += ["-g"] -CXXFLAGS += ["-stdlib=libc++"] -LDFLAGS += "-Wl,--as-needed" -"#, - ) - .unwrap(); - - let config = crate::config::Config::for_rootfs(tmp.path()); - assert_eq!( - config.appends.get("build.flags.cflags").unwrap()[0] - .as_array() - .unwrap()[0] - .as_str(), - Some("-g") - ); - assert_eq!( - config.appends.get("build.flags.cxxflags").unwrap()[0] - .as_array() - .unwrap()[0] - .as_str(), - Some("-stdlib=libc++") - ); - assert_eq!( - config.appends.get("build.flags.ldflags").unwrap()[0].as_str(), - Some("-Wl,--as-needed") - ); - let mut spec = mk_spec("foo", "1.0"); - spec.apply_config(&config); - - assert!(spec.build.flags.cflags.contains(&"-g".to_string())); - assert!( - spec.build - .flags - .cxxflags - .contains(&"-stdlib=libc++".to_string()) - ); - assert!( - spec.build - .flags - .ldflags - .contains(&"-Wl,--as-needed".to_string()) - ); -} - -#[test] -fn parse_passthrough_env_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "custom" - -[build.flags] -passthrough_env = ["RUSTFLAGS", "CARGO_HOME"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.passthrough_env, - vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()] - ); -} - -#[test] -fn parse_env_vars_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "python" - -[build.flags] -env_vars = ["SETUPTOOLS_SCM_PRETEND_VERSION=$version", "PYO3_CONFIG_FILE=$specdir/pyo3.toml"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.env_vars, - vec![ - "SETUPTOOLS_SCM_PRETEND_VERSION=$version".to_string(), - "PYO3_CONFIG_FILE=$specdir/pyo3.toml".to_string() - ] - ); -} - -#[test] -fn parse_test_dependencies_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - - [dependencies] - build = ["make"] - test = ["python", "bats"] - optional = ["gtk-doc"] - "#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.dependencies.test, - vec!["python".to_string(), "bats".to_string()] - ); - assert_eq!(spec.dependencies.optional, vec!["gtk-doc".to_string()]); -} - -#[test] -fn parse_make_var_overrides_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -make_vars = ["V=1", "CC=clang"] -make_exec = "ninja" -make_target = "bootstrap" -make_targets = ["stage1", "stage2"] -make_dirs = ["lib", "libelf"] -make_test_vars = ["TESTS=unit"] -make_test_target = "test" -make_test_targets = ["test-unit", "test-integration"] -make_test_dirs = ["tests"] -make_install_vars = ["STRIPPROG=true"] -make_install_target = "install/strip" -make_install_targets = ["install-runtime", "install-devel"] -make_install_dirs = ["lib", "apps"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!( - spec.build.flags.make_vars, - vec!["V=1".to_string(), "CC=clang".to_string()] - ); - assert_eq!(spec.build.flags.make_exec, "ninja"); - assert_eq!(spec.build.flags.make_target, "bootstrap"); - assert_eq!( - spec.build.flags.make_targets, - vec!["stage1".to_string(), "stage2".to_string()] - ); - assert_eq!( - spec.build.flags.make_dirs, - vec!["lib".to_string(), "libelf".to_string()] - ); - assert_eq!( - spec.build.flags.make_test_vars, - vec!["TESTS=unit".to_string()] - ); - assert_eq!(spec.build.flags.make_test_target, "test".to_string()); - assert_eq!( - spec.build.flags.make_test_targets, - vec!["test-unit".to_string(), "test-integration".to_string()] - ); - assert_eq!(spec.build.flags.make_test_dirs, vec!["tests".to_string()]); - assert_eq!( - spec.build.flags.make_install_vars, - vec!["STRIPPROG=true".to_string()] - ); - assert_eq!( - spec.build.flags.make_install_target, - "install/strip".to_string() - ); - assert_eq!( - spec.build.flags.make_install_targets, - vec!["install-runtime".to_string(), "install-devel".to_string()] - ); - assert_eq!( - spec.build.flags.make_install_dirs, - vec!["lib".to_string(), "apps".to_string()] - ); -} - -#[test] -fn parse_makeflags_from_spec() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/foo.tar.gz" -sha256 = "skip" -extract_dir = "foo" - -[build] -type = "autotools" - -[build.flags] -MAKEFLAGS = ["-j12", "--output-sync=target"] -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert_eq!(spec.build.flags.makeflags, "-j12 --output-sync=target"); -} - -#[test] -fn test_chost_cbuild_overrides() { - let mut spec = mk_spec("foo", "1.0"); - let config = crate::config::Config { - cache_dir: "/tmp".into(), - build_dir: "/tmp".into(), - db_dir: "/tmp".into(), - build_overrides: toml::from_str( - r#" -chost = "x86_64-sfg-linux-gnu" -cbuild = "x86_64-pc-linux-gnu" -"#, - ) - .unwrap(), - package_overrides: toml::Value::Table(toml::map::Map::new()), - appends: std::collections::HashMap::new(), - repo_settings: crate::config::RepoSettings::default(), - source_repos: std::collections::BTreeMap::new(), - binary_repos: std::collections::BTreeMap::new(), - mirrors: std::collections::HashMap::new(), - repo_clone_dir: PathBuf::from("/tmp"), - package_cache_dir: PathBuf::from("/tmp"), - install_test_deps: false, - }; - - spec.apply_config(&config); - assert_eq!(spec.build.flags.chost, "x86_64-sfg-linux-gnu"); - assert_eq!(spec.build.flags.cbuild, "x86_64-pc-linux-gnu"); -} - -#[test] -fn test_default_and_override_carch() { - let mut spec = mk_spec("foo", "1.0"); - // Default should be host arch - assert_eq!(spec.build.flags.carch, std::env::consts::ARCH.to_string()); - - // Override via config - let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); - config.build_overrides = toml::from_str( - r#"[flags] -carch = "armv7" -"#, - ) - .unwrap(); - spec.apply_config(&config); - assert_eq!(spec.build.flags.carch, "armv7"); -} - -#[test] -fn test_package_filename() { - let mut spec = mk_spec("foo", "1.0"); - spec.package.revision = 2; - assert_eq!( - spec.package_filename("x86_64"), - "foo-1.0-2-x86_64.depot.pkg.tar.zst" - ); -} - -#[test] -fn parse_packages_array() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("pkg.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "foo" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[[packages]] -name = "foo-dev" -version = "1.0" -description = "development files" -homepage = "h" -license = "MIT" - -[[source]] -url = "https://example.com/foo-1.0.tar.gz" -sha256 = "skip" -extract_dir = "foo-1.0" - -[build] -type = "custom" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - let outputs = spec.outputs(); - assert_eq!(outputs.len(), 2); - assert_eq!(outputs[0].name, "foo"); - assert_eq!(outputs[1].name, "foo-dev"); -} - -#[test] -fn docs_output_uses_runtime_dependency_on_parent_package() { - let mut spec = mk_spec("foo", "1.0"); - spec.build.flags.split_docs = true; - let docs_name = PackageSpec::docs_package_name("foo"); - - let deps = spec.dependencies_for_output(&docs_name); - assert_eq!(deps.runtime, vec!["foo".to_string()]); - - let alternatives = spec.alternatives_for_output(&docs_name); - assert!(alternatives.provides.is_empty()); - assert!(alternatives.conflicts.is_empty()); -} - -#[test] -fn docs_package_for_output_derives_name_and_description() { - let mut spec = mk_spec("foo", "1.0"); - spec.build.flags.split_docs = true; - - let docs = spec.docs_package_for_output(&spec.package); - assert_eq!(docs.name, "foo-docs"); - assert_eq!(docs.description, "Documentation for foo"); - assert_eq!(docs.version, "1.0"); -} - -#[test] -fn parse_dkms_build_type_and_modules() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("zfs.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "zfs-dkms" -version = "2.4.3" -description = "d" -homepage = "h" -license = "CDDL" - -[source] -url = "https://github.com/openzfs/zfs/releases/download/zfs-$version/zfs-$version.tar.gz" -sha256 = "skip" -extract_dir = "zfs-$version" - -[build] -type = "dkms" - -[build.flags] -dkms_name = "zfs" -dkms_version = "$version" -dkms_source_dir = "." -dkms_install_dir = "updates/depot" -dkms_make_args = ["V=1"] -dkms_pre_build = [ - "./configure --with-config=kernel --with-linux=$kernel_build_dir --with-linux-obj=$kernel_build_dir" -] - -[[build.flags.dkms_modules]] -name = "zfs" -path = "module" -built_location = "module/zfs" - -[[build.flags.dkms_modules]] -name = "spl" -dest_name = "spl_compat" -build_dir = "module" -built_location = "module/spl" -install_dir = "/updates/storage" -"#, - ) - .unwrap(); - - let spec = PackageSpec::from_file(&path).unwrap(); - assert!(matches!(spec.build.build_type, BuildType::Dkms)); - assert_eq!(spec.effective_dkms_name(), "zfs"); - assert_eq!(spec.effective_dkms_version(), "2.4.3"); - assert_eq!(spec.effective_dkms_install_dir(), "updates/depot"); - assert_eq!(spec.build.flags.dkms_pre_build.len(), 1); - assert_eq!(spec.build.flags.dkms_modules.len(), 2); - assert_eq!(spec.build.flags.dkms_modules[0].build_dir, "module"); - assert_eq!( - spec.effective_dkms_module_install_dir(&spec.build.flags.dkms_modules[1]), - "updates/storage" - ); -} - -#[test] -fn parse_dkms_rejects_unsafe_module_paths() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("bad.toml"); - - std::fs::write( - &path, - r#" -[package] -name = "bad-dkms" -version = "1.0" -description = "d" -homepage = "h" -license = "MIT" - -[source] -url = "https://example.com/bad.tar.gz" -sha256 = "skip" -extract_dir = "bad" - -[build] -type = "dkms" - -[[build.flags.dkms_modules]] -name = "bad" -path = "../outside" -"#, - ) - .unwrap(); - - let err = PackageSpec::from_file(&path).unwrap_err().to_string(); - assert!(err.contains("unsafe path component"), "{err}"); -} - fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { @@ -2509,3 +35,8 @@ fn mk_spec(name: &str, version: &str) -> PackageSpec { spec_dir: PathBuf::from("."), } } + +mod build_config_cases; +mod package_cases; +mod source_cases; +mod validation_cases; diff --git a/src/package/spec/tests/build_config_cases.rs b/src/package/spec/tests/build_config_cases.rs new file mode 100644 index 0000000..7a88666 --- /dev/null +++ b/src/package/spec/tests/build_config_cases.rs @@ -0,0 +1,1455 @@ +use super::*; + +#[test] +fn parse_python_config_settings_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "python" + +[build.flags] +config-setting = ["editable_mode=compat", "setup-args=--plat-name=x86_64"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.config_settings, + vec![ + "editable_mode=compat".to_string(), + "setup-args=--plat-name=x86_64".to_string() + ] + ); +} + +#[test] +fn test_apply_config() { + let mut spec = mk_spec("foo", "1.0"); + let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); + + // Mock some overrides and appends + config.build_overrides = toml::from_str( + r#" +[flags] +cc = "my-cc" +cxx = "my-cxx" +ar = "my-ar" +ranlib = "my-ranlib" +strip = "my-strip" +ld = "ld.lld" +nm = "my-nm" +objcopy = "my-objcopy" +objdump = "my-objdump" +readelf = "my-readelf" +CPP = "clang-cpp" +tool_dir = "/opt/toolchain/bin" +cflags = ["-O2"] +replace_cflags = ["-O2=>-O3"] +cxxflags = ["-O2", "-pipe"] +replace_cxxflags = ["-pipe=>-fPIC"] +passthrough_env = ["RUSTFLAGS"] +env_vars = ["SETUPTOOLS_SCM_PRETEND_VERSION=$version"] +bindir = "/opt/bin" +sbindir = "/opt/sbin" +libdir = "/opt/lib64" +sysconfdir = "/opt/etc" +datarootdir = "/opt/share-root" +makeflags = "-j8" +make_vars = ["V=1"] +make_dirs = ["lib"] +make_test_dirs = ["tests"] +make_install_dirs = ["lib"] +ltoflags = ["-flto=auto"] +RUSTLTOFLAGS = ["-Clinker-plugin-lto"] +replace_ltoflags = ["auto=>thin"] +rustflags = ["-C", "debuginfo=2"] +replace_rustflags = ["debuginfo=2=>opt-level=2"] +use_lto = true +no_flags = true +no_strip = true +no_delete_static = true +no_compress_man = true +skip_tests = true +keep = ["etc/locale.gen"] +configure_file = "configure.gnu" +config-setting = ["editable_mode=compat"] +post_configure = ["echo configured"] +"#, + ) + .unwrap(); + config.appends.insert( + "build.flags.cflags".to_string(), + vec![toml::Value::String("-g".to_string())], + ); + config.appends.insert( + "build.flags.replace_cflags".to_string(), + vec![toml::Value::String( + "-D_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string(), + )], + ); + config.appends.insert( + "build.flags.cxxflags".to_string(), + vec![toml::Value::String("-stdlib=libc++".to_string())], + ); + config.appends.insert( + "build.flags.replace_cxxflags".to_string(), + vec![toml::Value::String( + "-stdlib=libc++=>-stdlib=libstdc++".to_string(), + )], + ); + config.appends.insert( + "build.flags.rustflags".to_string(), + vec![toml::Value::Array(vec![ + toml::Value::String("-C".to_string()), + toml::Value::String("opt-level=3".to_string()), + ])], + ); + config.appends.insert( + "build.flags.replace_rustflags".to_string(), + vec![toml::Value::String("opt-level=3=>opt-level=z".to_string())], + ); + config.appends.insert( + "build.flags.keep".to_string(), + vec![toml::Value::Array(vec![toml::Value::String( + "etc/locale.gen".to_string(), + )])], + ); + config.appends.insert( + "build.flags.ltoflags".to_string(), + vec![toml::Value::Array(vec![toml::Value::String( + "-fno-fat-lto-objects".to_string(), + )])], + ); + config.appends.insert( + "build.flags.replace_ltoflags".to_string(), + vec![toml::Value::String( + "-fno-fat-lto-objects=>-flto-jobs=8".to_string(), + )], + ); + config.appends.insert( + "build.flags.use_lto".to_string(), + vec![toml::Value::Boolean(false)], + ); + config.appends.insert( + "build.flags.no_strip".to_string(), + vec![toml::Value::Boolean(false)], + ); + config.appends.insert( + "build.flags.no_compress_man".to_string(), + vec![toml::Value::Boolean(false)], + ); + config.appends.insert( + "build.flags.no_delete_static".to_string(), + vec![toml::Value::Boolean(false)], + ); + config.appends.insert( + "build.flags.passthrough_env".to_string(), + vec![toml::Value::String("CARGO_HOME".to_string())], + ); + config.appends.insert( + "build.flags.env_vars".to_string(), + vec![toml::Value::String( + "SOURCE_DATE_EPOCH=1700000000".to_string(), + )], + ); + config.appends.insert( + "build.flags.make_test_vars".to_string(), + vec![toml::Value::String("TESTS=smoke".to_string())], + ); + config.appends.insert( + "build.flags.makeflags".to_string(), + vec![toml::Value::String("--output-sync=target".to_string())], + ); + config.appends.insert( + "build.flags.make_dirs".to_string(), + vec![toml::Value::String("libelf".to_string())], + ); + config.appends.insert( + "build.flags.make_test_dirs".to_string(), + vec![toml::Value::String("fuzz".to_string())], + ); + config.appends.insert( + "build.flags.make_install_dirs".to_string(), + vec![toml::Value::String("tools".to_string())], + ); + config.appends.insert( + "build.flags.make_install_vars".to_string(), + vec![toml::Value::String("DESTDIR=/tmp/pkg".to_string())], + ); + config.appends.insert( + "build.flags.configure_file".to_string(), + vec![toml::Value::String("build-aux/configure".to_string())], + ); + config.appends.insert( + "build.flags.libexecdir".to_string(), + vec![toml::Value::String("/opt/libexec".to_string())], + ); + config.appends.insert( + "build.flags.datadir".to_string(), + vec![toml::Value::String("/opt/share-data".to_string())], + ); + config.appends.insert( + "build.flags.config-setting".to_string(), + vec![toml::Value::String( + "setup-args=--plat-name=x86_64".to_string(), + )], + ); + config.appends.insert( + "build.flags.post_configure".to_string(), + vec![toml::Value::String("touch configured.stamp".to_string())], + ); + config.appends.insert( + "build.flags.configure_x86_64".to_string(), + vec![toml::Value::String("--enable-x86-tuning".to_string())], + ); + + spec.apply_config(&config); + + assert_eq!(spec.build.flags.cc, "my-cc"); + assert_eq!(spec.build.flags.cxx, "my-cxx"); + assert_eq!(spec.build.flags.ar, "my-ar"); + assert_eq!(spec.build.flags.ranlib, "my-ranlib"); + assert_eq!(spec.build.flags.strip, "my-strip"); + assert_eq!(spec.build.flags.ld, "ld.lld"); + assert_eq!(spec.build.flags.nm, "my-nm"); + assert_eq!(spec.build.flags.objcopy, "my-objcopy"); + assert_eq!(spec.build.flags.objdump, "my-objdump"); + assert_eq!(spec.build.flags.readelf, "my-readelf"); + assert_eq!(spec.build.flags.cpp, "clang-cpp"); + assert_eq!(spec.build.flags.tool_dir, "/opt/toolchain/bin"); + assert!(spec.build.flags.cflags.contains(&"-O2".to_string())); + assert!(spec.build.flags.cflags.contains(&"-g".to_string())); + assert!( + spec.build + .flags + .replace_cflags + .contains(&"-O2=>-O3".to_string()) + ); + assert!( + spec.build + .flags + .replace_cflags + .contains(&"-D_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string()) + ); + assert!(spec.build.flags.cxxflags.contains(&"-O2".to_string())); + assert!(spec.build.flags.cxxflags.contains(&"-pipe".to_string())); + assert!( + spec.build + .flags + .cxxflags + .contains(&"-stdlib=libc++".to_string()) + ); + assert!( + spec.build + .flags + .replace_cxxflags + .contains(&"-pipe=>-fPIC".to_string()) + ); + assert!( + spec.build + .flags + .replace_cxxflags + .contains(&"-stdlib=libc++=>-stdlib=libstdc++".to_string()) + ); + assert!(spec.build.flags.rustflags.contains(&"-C".to_string())); + assert!( + spec.build + .flags + .rustflags + .contains(&"debuginfo=2".to_string()) + ); + assert!( + spec.build + .flags + .rustflags + .contains(&"opt-level=3".to_string()) + ); + assert!( + spec.build + .flags + .replace_rustflags + .contains(&"debuginfo=2=>opt-level=2".to_string()) + ); + assert!( + spec.build + .flags + .replace_rustflags + .contains(&"opt-level=3=>opt-level=z".to_string()) + ); + assert!( + spec.build + .flags + .ltoflags + .contains(&"-flto=auto".to_string()) + ); + assert!( + spec.build + .flags + .rustltoflags + .contains(&"-Clinker-plugin-lto".to_string()) + ); + assert!( + spec.build + .flags + .ltoflags + .contains(&"-fno-fat-lto-objects".to_string()) + ); + assert!( + spec.build + .flags + .replace_ltoflags + .contains(&"auto=>thin".to_string()) + ); + assert!( + spec.build + .flags + .replace_ltoflags + .contains(&"-fno-fat-lto-objects=>-flto-jobs=8".to_string()) + ); + assert!(!spec.build.flags.use_lto); + assert!(spec.build.flags.no_flags); + assert!(!spec.build.flags.no_strip); + assert!(!spec.build.flags.no_delete_static); + assert!(!spec.build.flags.no_compress_man); + assert!( + spec.build + .flags + .keep + .contains(&"etc/locale.gen".to_string()) + ); + assert!( + spec.build + .flags + .passthrough_env + .contains(&"RUSTFLAGS".to_string()) + ); + assert!( + spec.build + .flags + .passthrough_env + .contains(&"CARGO_HOME".to_string()) + ); + assert!( + spec.build + .flags + .env_vars + .contains(&"SETUPTOOLS_SCM_PRETEND_VERSION=$version".to_string()) + ); + assert!( + spec.build + .flags + .env_vars + .contains(&"SOURCE_DATE_EPOCH=1700000000".to_string()) + ); + assert_eq!(spec.build.flags.bindir, "/opt/bin"); + assert_eq!(spec.build.flags.sbindir, "/opt/sbin"); + assert_eq!(spec.build.flags.libdir, "/opt/lib64"); + assert_eq!(spec.build.flags.libexecdir, "/opt/libexec"); + assert_eq!(spec.build.flags.sysconfdir, "/opt/etc"); + assert_eq!(spec.build.flags.datarootdir, "/opt/share-root"); + assert_eq!(spec.build.flags.datadir, "/opt/share-data"); + assert_eq!( + spec.build.flags.configure_arch.get("x86_64"), + Some(&vec!["--enable-x86-tuning".to_string()]) + ); + assert_eq!(spec.build.flags.makeflags, "-j8 --output-sync=target"); + assert!(spec.build.flags.make_vars.contains(&"V=1".to_string())); + assert!(spec.build.flags.make_dirs.contains(&"lib".to_string())); + assert!(spec.build.flags.make_dirs.contains(&"libelf".to_string())); + assert!(spec.build.flags.skip_tests); + assert!( + spec.build + .flags + .make_test_vars + .contains(&"TESTS=smoke".to_string()) + ); + assert!( + spec.build + .flags + .make_test_dirs + .contains(&"tests".to_string()) + ); + assert!( + spec.build + .flags + .make_test_dirs + .contains(&"fuzz".to_string()) + ); + assert!( + spec.build + .flags + .make_install_vars + .contains(&"DESTDIR=/tmp/pkg".to_string()) + ); + assert!( + spec.build + .flags + .make_install_dirs + .contains(&"lib".to_string()) + ); + assert!( + spec.build + .flags + .make_install_dirs + .contains(&"tools".to_string()) + ); + assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); + assert!( + spec.build + .flags + .config_settings + .contains(&"editable_mode=compat".to_string()) + ); + assert!( + spec.build + .flags + .config_settings + .contains(&"setup-args=--plat-name=x86_64".to_string()) + ); + assert!( + spec.build + .flags + .post_configure + .contains(&"echo configured".to_string()) + ); + assert!( + spec.build + .flags + .post_configure + .contains(&"touch configured.stamp".to_string()) + ); +} + +#[test] +fn test_apply_config_preserves_package_scalar_tool_and_layout_overrides() { + let mut spec = mk_spec("foo", "1.0"); + spec.build.flags.ld = "ld.lld".to_string(); + spec.build.flags.libdir = "/package/lib".to_string(); + spec.build.flags.sysconfdir = "/package/etc".to_string(); + let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); + config.build_overrides = toml::from_str( + r#" +ld = "/config/bin/ld" +fuse_ld = "/config/bin/ld.lld" +ranlib = "/config/bin/ranlib" +libdir = "/config/lib" +sysconfdir = "/config/etc" +"#, + ) + .unwrap(); + + spec.apply_config(&config); + + assert_eq!(spec.build.flags.ld, "ld.lld"); + assert_eq!(spec.build.flags.libdir, "/package/lib"); + assert_eq!(spec.build.flags.sysconfdir, "/package/etc"); + assert_eq!(spec.build.flags.ranlib, "/config/bin/ranlib"); + assert_eq!(spec.build.flags.fuse_ld, "/config/bin/ld.lld"); +} + +#[test] +fn test_apply_config_preserves_package_lto_disable() { + let mut spec = mk_spec("glibc", "2.43"); + spec.build.flags.use_lto = false; + let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); + config.build_overrides = toml::from_str( + r#" +[flags] +ltoflags = ["-flto=thin"] +use_lto = true +"#, + ) + .unwrap(); + + spec.apply_config(&config); + + assert!(!spec.build.flags.use_lto); + assert_eq!(spec.build.flags.ltoflags, vec!["-flto=thin".to_string()]); +} + +#[test] +fn parse_no_flags_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +no_flags = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_flags); +} + +#[test] +fn parse_tool_commands_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +cc = "/tools/bin/cc" +cxx = "/tools/bin/c++" +ar = "/tools/bin/ar" +ranlib = "/tools/bin/ranlib" +strip = "/tools/bin/strip" +ld = "/tools/bin/ld" +fuse_ld = "/usr/bin/ld.lld" +nm = "/tools/bin/nm" +objcopy = "/tools/bin/objcopy" +objdump = "/tools/bin/objdump" +readelf = "/tools/bin/readelf" +cpp = "/tools/bin/cpp" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.cc, "/tools/bin/cc"); + assert_eq!(spec.build.flags.cxx, "/tools/bin/c++"); + assert_eq!(spec.build.flags.ar, "/tools/bin/ar"); + assert_eq!(spec.build.flags.ranlib, "/tools/bin/ranlib"); + assert_eq!(spec.build.flags.strip, "/tools/bin/strip"); + assert_eq!(spec.build.flags.ld, "/tools/bin/ld"); + assert_eq!(spec.build.flags.fuse_ld, "/usr/bin/ld.lld"); + assert_eq!(spec.build.flags.nm, "/tools/bin/nm"); + assert_eq!(spec.build.flags.objcopy, "/tools/bin/objcopy"); + assert_eq!(spec.build.flags.objdump, "/tools/bin/objdump"); + assert_eq!(spec.build.flags.readelf, "/tools/bin/readelf"); + assert_eq!(spec.build.flags.cpp, "/tools/bin/cpp"); +} + +#[test] +fn parse_ltoflags_and_use_lto_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +ltoflags = ["-flto=auto", "-fuse-linker-plugin"] +use_lto = false +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.ltoflags, + vec!["-flto=auto".to_string(), "-fuse-linker-plugin".to_string()] + ); + assert!(!spec.build.flags.use_lto); +} + +#[test] +fn parse_ltoflags_and_use_lto_aliases_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +LTOFLAGS = "-flto=auto" +"use-lto" = false +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.ltoflags, vec!["-flto=auto".to_string()]); + assert!(!spec.build.flags.use_lto); +} + +#[test] +fn parse_no_strip_no_delete_static_and_no_compress_man_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +no_strip = true +"no-delete-static" = true +no-compress-man = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_strip); + assert!(spec.build.flags.no_delete_static); + assert!(spec.build.flags.no_compress_man); +} + +#[test] +fn parse_no_flags_alias_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +"no-flags" = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.no_flags); +} + +#[test] +fn parse_skip_tests_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +skip_tests = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.skip_tests); +} + +#[test] +fn parse_skip_tests_alias_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +"skip-tests" = true +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.skip_tests); +} + +#[test] +fn parse_configure_file_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +configure_file = "build-aux/configure" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.configure_file, "build-aux/configure"); +} + +#[test] +fn parse_install_dirs_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "cmake" + +[build.flags] +bindir = "/custom/bin" +sbindir = "/custom/sbin" +libdir = "/custom/lib64" +libexecdir = "/custom/libexec" +sysconfdir = "/custom/etc" +localstatedir = "/custom/var" +sharedstatedir = "/custom/var/lib" +includedir = "/custom/include" +datarootdir = "/custom/share-root" +datadir = "/custom/share" +mandir = "/custom/share/man" +infodir = "/custom/share/info" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.bindir, "/custom/bin"); + assert_eq!(spec.build.flags.sbindir, "/custom/sbin"); + assert_eq!(spec.build.flags.libdir, "/custom/lib64"); + assert_eq!(spec.build.flags.libexecdir, "/custom/libexec"); + assert_eq!(spec.build.flags.sysconfdir, "/custom/etc"); + assert_eq!(spec.build.flags.localstatedir, "/custom/var"); + assert_eq!(spec.build.flags.sharedstatedir, "/custom/var/lib"); + assert_eq!(spec.build.flags.includedir, "/custom/include"); + assert_eq!(spec.build.flags.datarootdir, "/custom/share-root"); + assert_eq!(spec.build.flags.datadir, "/custom/share"); + assert_eq!(spec.build.flags.mandir, "/custom/share/man"); + assert_eq!(spec.build.flags.infodir, "/custom/share/info"); +} + +#[test] +fn parse_lib32_build_flags_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +"build-32" = "true" +"lib32-only" = "yes" +"CFLAGS-lib32" = ["-mstackrealign"] +"CXXFLAGS-lib32" = ["-fno-rtti"] +"configure-lib32" = ["--disable-static"] +"post_configure-lib32" = ["echo configured lib32"] +"post_compile-lib32" = ["echo compiled lib32"] +"post_install-lib32" = ["echo lib32"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.build_32); + assert!(spec.build.flags.lib32_only); + assert!(spec.builds_lib32_output()); + assert!(spec.builds_only_lib32_output()); + assert_eq!(spec.build.flags.cflags_lib32, vec!["-mstackrealign"]); + assert_eq!(spec.build.flags.cxxflags_lib32, vec!["-fno-rtti"]); + assert_eq!(spec.build.flags.configure_lib32, vec!["--disable-static"]); + assert_eq!( + spec.build.flags.post_configure_lib32, + vec!["echo configured lib32"] + ); + assert_eq!( + spec.build.flags.post_compile_lib32, + vec!["echo compiled lib32"] + ); + assert_eq!(spec.build.flags.post_install_lib32, vec!["echo lib32"]); +} + +#[test] +fn multilib_builds_skip_automatic_tests() { + let mut spec = mk_spec("foo", "1.0"); + assert!(!spec.should_skip_automatic_tests()); + + spec.build.flags.build_32 = true; + assert!(spec.should_skip_automatic_tests()); + + spec.build.flags.build_32 = false; + spec.build.flags.skip_tests = true; + assert!(spec.should_skip_automatic_tests()); +} + +#[test] +fn parse_post_configure_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "cmake" + +[build.flags] +post_configure = ["cmake -L . > cmake-options.txt"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.post_configure, + vec!["cmake -L . > cmake-options.txt".to_string()] + ); +} + +#[test] +fn parse_build_flags_appends_from_spec_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +replace_cflags = ["-O2=>-O3"] +replace_rustflags = ["debuginfo=2=>opt-level=2"] +cxxflags = ["-O2"] +cxxflags += [ "-Wno-gnu-statement-expression-from-macro-expansion" ] +ldflags += "-Wl,--as-needed" +replace_cflags += [ "_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2" ] +replace_rustflags += "opt-level=3=>opt-level=z" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.cxxflags, + vec![ + "-O2".to_string(), + "-Wno-gnu-statement-expression-from-macro-expansion".to_string() + ] + ); + assert_eq!( + spec.build.flags.ldflags, + vec!["-Wl,--as-needed".to_string()] + ); + assert_eq!( + spec.build.flags.replace_cflags, + vec![ + "-O2=>-O3".to_string(), + "_FORTIFY_SOURCE=3=_FORTIFY_SOURCE=2".to_string() + ] + ); + assert_eq!( + spec.build.flags.replace_rustflags, + vec![ + "debuginfo=2=>opt-level=2".to_string(), + "opt-level=3=>opt-level=z".to_string() + ] + ); +} + +#[test] +fn parse_configure_arch_appends_from_spec_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +configure = ["--base"] +configure_x86_64 += ["--enable-sse2"] +configure_aarch64 += "--enable-neon" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.configure, vec!["--base".to_string()]); + assert_eq!( + spec.build.flags.configure_arch.get("x86_64"), + Some(&vec!["--enable-sse2".to_string()]) + ); + assert_eq!( + spec.build.flags.configure_arch.get("aarch64"), + Some(&vec!["--enable-neon".to_string()]) + ); +} + +#[test] +fn parse_build_flags_appends_accepts_quoted_and_uppercase_keys() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +"cflags" += ["-fPIC"] +CXXFLAGS += ["-stdlib=libc++"] +"LDFLAGS" += "-Wl,--as-needed" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.cflags, vec!["-fPIC".to_string()]); + assert_eq!( + spec.build.flags.cxxflags, + vec!["-stdlib=libc++".to_string()] + ); + assert_eq!( + spec.build.flags.ldflags, + vec!["-Wl,--as-needed".to_string()] + ); +} + +#[test] +fn apply_config_reads_build_flag_appends_from_rootfs_build_toml() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("etc/depot.d/build.toml"); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + r#" +[flags] +cflags += ["-g"] +CXXFLAGS += ["-stdlib=libc++"] +LDFLAGS += "-Wl,--as-needed" +"#, + ) + .unwrap(); + + let config = crate::config::Config::for_rootfs(tmp.path()); + assert_eq!( + config.appends.get("build.flags.cflags").unwrap()[0] + .as_array() + .unwrap()[0] + .as_str(), + Some("-g") + ); + assert_eq!( + config.appends.get("build.flags.cxxflags").unwrap()[0] + .as_array() + .unwrap()[0] + .as_str(), + Some("-stdlib=libc++") + ); + assert_eq!( + config.appends.get("build.flags.ldflags").unwrap()[0].as_str(), + Some("-Wl,--as-needed") + ); + let mut spec = mk_spec("foo", "1.0"); + spec.apply_config(&config); + + assert!(spec.build.flags.cflags.contains(&"-g".to_string())); + assert!( + spec.build + .flags + .cxxflags + .contains(&"-stdlib=libc++".to_string()) + ); + assert!( + spec.build + .flags + .ldflags + .contains(&"-Wl,--as-needed".to_string()) + ); +} + +#[test] +fn parse_passthrough_env_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +passthrough_env = ["RUSTFLAGS", "CARGO_HOME"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.passthrough_env, + vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()] + ); +} + +#[test] +fn parse_env_vars_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "python" + +[build.flags] +env_vars = ["SETUPTOOLS_SCM_PRETEND_VERSION=$version", "PYO3_CONFIG_FILE=$specdir/pyo3.toml"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.env_vars, + vec![ + "SETUPTOOLS_SCM_PRETEND_VERSION=$version".to_string(), + "PYO3_CONFIG_FILE=$specdir/pyo3.toml".to_string() + ] + ); +} + +#[test] +fn parse_test_dependencies_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + + [dependencies] + build = ["make"] + test = ["python", "bats"] + optional = ["gtk-doc"] + "#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.dependencies.test, + vec!["python".to_string(), "bats".to_string()] + ); + assert_eq!(spec.dependencies.optional, vec!["gtk-doc".to_string()]); +} + +#[test] +fn parse_make_var_overrides_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +make_vars = ["V=1", "CC=clang"] +make_exec = "ninja" +make_target = "bootstrap" +make_targets = ["stage1", "stage2"] +make_dirs = ["lib", "libelf"] +make_test_vars = ["TESTS=unit"] +make_test_target = "test" +make_test_targets = ["test-unit", "test-integration"] +make_test_dirs = ["tests"] +make_install_vars = ["STRIPPROG=true"] +make_install_target = "install/strip" +make_install_targets = ["install-runtime", "install-devel"] +make_install_dirs = ["lib", "apps"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.make_vars, + vec!["V=1".to_string(), "CC=clang".to_string()] + ); + assert_eq!(spec.build.flags.make_exec, "ninja"); + assert_eq!(spec.build.flags.make_target, "bootstrap"); + assert_eq!( + spec.build.flags.make_targets, + vec!["stage1".to_string(), "stage2".to_string()] + ); + assert_eq!( + spec.build.flags.make_dirs, + vec!["lib".to_string(), "libelf".to_string()] + ); + assert_eq!( + spec.build.flags.make_test_vars, + vec!["TESTS=unit".to_string()] + ); + assert_eq!(spec.build.flags.make_test_target, "test".to_string()); + assert_eq!( + spec.build.flags.make_test_targets, + vec!["test-unit".to_string(), "test-integration".to_string()] + ); + assert_eq!(spec.build.flags.make_test_dirs, vec!["tests".to_string()]); + assert_eq!( + spec.build.flags.make_install_vars, + vec!["STRIPPROG=true".to_string()] + ); + assert_eq!( + spec.build.flags.make_install_target, + "install/strip".to_string() + ); + assert_eq!( + spec.build.flags.make_install_targets, + vec!["install-runtime".to_string(), "install-devel".to_string()] + ); + assert_eq!( + spec.build.flags.make_install_dirs, + vec!["lib".to_string(), "apps".to_string()] + ); +} + +#[test] +fn parse_makeflags_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[build.flags] +MAKEFLAGS = ["-j12", "--output-sync=target"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.build.flags.makeflags, "-j12 --output-sync=target"); +} + +#[test] +fn test_chost_cbuild_overrides() { + let mut spec = mk_spec("foo", "1.0"); + let config = crate::config::Config { + cache_dir: "/tmp".into(), + build_dir: "/tmp".into(), + db_dir: "/tmp".into(), + build_overrides: toml::from_str( + r#" +chost = "x86_64-sfg-linux-gnu" +cbuild = "x86_64-pc-linux-gnu" +"#, + ) + .unwrap(), + package_overrides: toml::Value::Table(toml::map::Map::new()), + appends: std::collections::HashMap::new(), + repo_settings: crate::config::RepoSettings::default(), + source_repos: std::collections::BTreeMap::new(), + binary_repos: std::collections::BTreeMap::new(), + mirrors: std::collections::HashMap::new(), + repo_clone_dir: PathBuf::from("/tmp"), + package_cache_dir: PathBuf::from("/tmp"), + install_test_deps: false, + }; + + spec.apply_config(&config); + assert_eq!(spec.build.flags.chost, "x86_64-sfg-linux-gnu"); + assert_eq!(spec.build.flags.cbuild, "x86_64-pc-linux-gnu"); +} + +#[test] +fn test_default_and_override_carch() { + let mut spec = mk_spec("foo", "1.0"); + // Default should be host arch + assert_eq!(spec.build.flags.carch, std::env::consts::ARCH.to_string()); + + // Override via config + let mut config = crate::config::Config::for_rootfs(Path::new("/tmp/nonexistent")); + config.build_overrides = toml::from_str( + r#"[flags] +carch = "armv7" +"#, + ) + .unwrap(); + spec.apply_config(&config); + assert_eq!(spec.build.flags.carch, "armv7"); +} diff --git a/src/package/spec/tests/package_cases.rs b/src/package/spec/tests/package_cases.rs new file mode 100644 index 0000000..24e265f --- /dev/null +++ b/src/package/spec/tests/package_cases.rs @@ -0,0 +1,657 @@ +use super::*; + +#[test] +fn parse_package_built_against_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" +built-against = ["icu78"] + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.package.built_against, vec!["icu78".to_string()]); +} + +#[test] +fn parse_package_dependencies_overrides() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[dependencies] +runtime = ["base"] +groups = ["toolchain"] + +[package_dependencies.clang] +runtime = ["llvm-libs", "llvm-libgcc"] +groups = ["compiler"] + +[package_dependencies.llvm-libs] +runtime = ["llvm-libgcc", "zstd"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.dependencies_for_output("llvm").runtime, + vec!["base".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("llvm").groups, + vec!["toolchain".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("clang").runtime, + vec!["llvm-libs".to_string(), "llvm-libgcc".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("clang").groups, + vec!["compiler".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("llvm-libs").runtime, + vec!["llvm-libgcc".to_string(), "zstd".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("llvm-libs").groups, + Vec::::new() + ); +} + +#[test] +fn parse_lib32_dependencies_override() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[dependencies] +runtime = ["base"] +groups = ["toolchain"] + +[dependencies.lib32] +build = ["gcc-multilib"] +runtime = ["lib32-zlib"] +test = ["bats"] +groups = ["lib32-toolchain"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.dependencies_for_output("llvm").runtime, + vec!["base".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("llvm").groups, + vec!["toolchain".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("lib32-llvm").build, + vec!["gcc-multilib".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("lib32-llvm").runtime, + vec!["lib32-zlib".to_string(), "llvm".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("lib32-llvm").test, + vec!["bats".to_string()] + ); + assert_eq!( + spec.dependencies_for_output("lib32-llvm").groups, + vec!["lib32-toolchain".to_string()] + ); +} + +#[test] +fn parse_package_alternatives_overrides() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[alternatives] +provides = ["toolchain"] +conflicts = ["gcc"] + +[package_alternatives.clang] +provides = ["cc", "c++", "gcc"] +conflicts = ["clang-legacy"] + +[package_alternatives.llvm] +provides = ["binutils"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.alternatives_for_output("llvm").provides, + vec!["binutils".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("llvm").conflicts, + Vec::::new() + ); + assert_eq!( + spec.alternatives_for_output("clang").provides, + vec!["cc".to_string(), "c++".to_string(), "gcc".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("clang").conflicts, + vec!["clang-legacy".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("other").provides, + vec!["toolchain".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("other").conflicts, + vec!["gcc".to_string()] + ); +} + +#[test] +fn parse_lib32_alternatives_override() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/llvm.tar.gz" +sha256 = "skip" +extract_dir = "llvm" + +[build] +type = "custom" + +[alternatives] +provides = ["toolchain"] +replaces = ["clang"] + +[alternatives.lib32] +provides = ["lib32-toolchain"] +conflicts = ["lib32-gcc"] +replaces = ["lib32-clang"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.alternatives_for_output("llvm").replaces, + vec!["clang".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").provides, + vec!["lib32-toolchain".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").conflicts, + vec!["lib32-gcc".to_string()] + ); + assert_eq!( + spec.alternatives_for_output("lib32-llvm").replaces, + vec!["lib32-clang".to_string()] + ); +} + +#[test] +fn lib32_output_does_not_fallback_to_primary_alternatives() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "llvm" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +build_32 = true + +[alternatives] +provides = ["toolchain"] +conflicts = ["gcc"] +replaces = ["clang"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + let lib32 = spec.alternatives_for_output("lib32-llvm"); + + assert!(lib32.provides.is_empty()); + assert!(lib32.conflicts.is_empty()); + assert!(lib32.replaces.is_empty()); +} + +#[test] +fn parse_python_build_type() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "python" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(matches!(spec.build.build_type, BuildType::Python)); +} + +#[test] +fn parse_perl_build_type() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "perl" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(matches!(spec.build.build_type, BuildType::Perl)); +} + +#[test] +fn parse_multiple_licenses() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = ["MIT", "Apache-2.0"] + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.package.license, + vec!["MIT".to_string(), "Apache-2.0".to_string()] + ); +} + +#[test] +fn parse_keep_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +keep = ["etc/locale.gen", "etc/resolv.conf"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.build.flags.keep, + vec!["etc/locale.gen".to_string(), "etc/resolv.conf".to_string()] + ); +} + +#[test] +fn parse_split_docs_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "custom" + +[build.flags] +split_docs = true +doc_dirs = ["/opt/docs", "usr/share/devhelp"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.build.flags.split_docs); + assert_eq!( + spec.build.flags.doc_dirs, + vec!["/opt/docs".to_string(), "usr/share/devhelp".to_string()] + ); +} + +#[test] +fn test_package_filename() { + let mut spec = mk_spec("foo", "1.0"); + spec.package.revision = 2; + assert_eq!( + spec.package_filename("x86_64"), + "foo-1.0-2-x86_64.depot.pkg.tar.zst" + ); +} + +#[test] +fn parse_packages_array() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[packages]] +name = "foo-dev" +version = "1.0" +description = "development files" +homepage = "h" +license = "MIT" + +[[source]] +url = "https://example.com/foo-1.0.tar.gz" +sha256 = "skip" +extract_dir = "foo-1.0" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + let outputs = spec.outputs(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].name, "foo"); + assert_eq!(outputs[1].name, "foo-dev"); +} + +#[test] +fn docs_output_uses_runtime_dependency_on_parent_package() { + let mut spec = mk_spec("foo", "1.0"); + spec.build.flags.split_docs = true; + let docs_name = PackageSpec::docs_package_name("foo"); + + let deps = spec.dependencies_for_output(&docs_name); + assert_eq!(deps.runtime, vec!["foo".to_string()]); + + let alternatives = spec.alternatives_for_output(&docs_name); + assert!(alternatives.provides.is_empty()); + assert!(alternatives.conflicts.is_empty()); +} + +#[test] +fn docs_package_for_output_derives_name_and_description() { + let mut spec = mk_spec("foo", "1.0"); + spec.build.flags.split_docs = true; + + let docs = spec.docs_package_for_output(&spec.package); + assert_eq!(docs.name, "foo-docs"); + assert_eq!(docs.description, "Documentation for foo"); + assert_eq!(docs.version, "1.0"); +} + +#[test] +fn parse_dkms_build_type_and_modules() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("zfs.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "zfs-dkms" +version = "2.4.3" +description = "d" +homepage = "h" +license = "CDDL" + +[source] +url = "https://github.com/openzfs/zfs/releases/download/zfs-$version/zfs-$version.tar.gz" +sha256 = "skip" +extract_dir = "zfs-$version" + +[build] +type = "dkms" + +[build.flags] +dkms_name = "zfs" +dkms_version = "$version" +dkms_source_dir = "." +dkms_install_dir = "updates/depot" +dkms_make_args = ["V=1"] +dkms_pre_build = [ + "./configure --with-config=kernel --with-linux=$kernel_build_dir --with-linux-obj=$kernel_build_dir" +] + +[[build.flags.dkms_modules]] +name = "zfs" +path = "module" +built_location = "module/zfs" + +[[build.flags.dkms_modules]] +name = "spl" +dest_name = "spl_compat" +build_dir = "module" +built_location = "module/spl" +install_dir = "/updates/storage" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(matches!(spec.build.build_type, BuildType::Dkms)); + assert_eq!(spec.effective_dkms_name(), "zfs"); + assert_eq!(spec.effective_dkms_version(), "2.4.3"); + assert_eq!(spec.effective_dkms_install_dir(), "updates/depot"); + assert_eq!(spec.build.flags.dkms_pre_build.len(), 1); + assert_eq!(spec.build.flags.dkms_modules.len(), 2); + assert_eq!(spec.build.flags.dkms_modules[0].build_dir, "module"); + assert_eq!( + spec.effective_dkms_module_install_dir(&spec.build.flags.dkms_modules[1]), + "updates/storage" + ); +} + +#[test] +fn parse_dkms_rejects_unsafe_module_paths() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("bad.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "bad-dkms" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/bad.tar.gz" +sha256 = "skip" +extract_dir = "bad" + +[build] +type = "dkms" + +[[build.flags.dkms_modules]] +name = "bad" +path = "../outside" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).unwrap_err().to_string(); + assert!(err.contains("unsafe path component"), "{err}"); +} diff --git a/src/package/spec/tests/source_cases.rs b/src/package/spec/tests/source_cases.rs new file mode 100644 index 0000000..f4dab03 --- /dev/null +++ b/src/package/spec/tests/source_cases.rs @@ -0,0 +1,328 @@ +use super::*; + +#[test] +fn parse_single_source_table() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo-$version.tar.gz" +sha256 = "skip" +extract_dir = "foo-$version" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.package.name, "foo"); + assert_eq!(spec.sources().len(), 1); + assert_eq!( + spec.expand_vars(&spec.sources()[0].url), + "https://example.com/foo-1.0.tar.gz" + ); + assert!(spec.sources()[0].patches.is_empty()); + assert!(spec.sources()[0].post_extract.is_empty()); + assert!(spec.sources()[0].cherry_pick.is_empty()); + assert_eq!(spec.spec_dir, tmp.path()); +} + +#[test] +fn parse_source_array() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[source]] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[[source]] +url = "https://example.com/bar.tar.gz" +sha256 = "skip" +extract_dir = "bar" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.sources().len(), 2); + assert_eq!(spec.sources()[0].extract_dir, "foo"); + assert_eq!(spec.sources()[1].extract_dir, "bar"); +} + +#[test] +fn parse_source_without_sha256_defaults_to_skip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +extract_dir = "foo" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.sources()[0].sha256, "skip"); +} + +#[test] +fn parse_git_source_with_cherry_pick() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.git#main" +sha256 = "skip" +extract_dir = "foo" +cherry_pick = ["deadbeef", "cafebabe"] + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!( + spec.sources()[0].cherry_pick, + vec!["deadbeef".to_string(), "cafebabe".to_string()] + ); +} + +#[test] +fn parse_rejects_empty_sources() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + // `source = []` is not accepted (must have at least one entry) + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +source = [] + +[build] +type = "custom" +"#, + ) + .unwrap(); + + assert!(PackageSpec::from_file(&path).is_err()); +} + +#[test] +fn parse_allows_metapackage_without_sources() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo-meta" +version = "1.0" +description = "metapackage" +homepage = "https://example.com" +license = "MIT" + +[build] +type = "meta" + +[dependencies] +runtime = ["foo", "bar"] +groups = ["base"] +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(spec.source.is_empty()); + assert!(spec.manual_sources.is_empty()); + assert!(spec.is_metapackage()); + assert_eq!(spec.dependencies.runtime, vec!["foo", "bar"]); + assert_eq!(spec.dependencies.groups, vec!["base"]); +} + +#[test] +fn parse_manual_source_with_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +url = "https://example.com/manual.patch" +sha256 = "skip" +dest = "patches/manual.patch" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.manual_sources.len(), 1); + assert_eq!( + spec.manual_sources[0].url.as_deref(), + Some("https://example.com/manual.patch") + ); + assert_eq!(spec.manual_sources[0].file, None); +} + +#[test] +fn parse_manual_source_rejects_missing_file_and_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +sha256 = "skip" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); + assert!( + err.to_string() + .contains("must specify one of 'file', 'files', 'url', or 'urls'") + ); +} + +#[test] +fn parse_manual_source_rejects_file_and_url_together() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +file = "manual.patch" +url = "https://example.com/manual.patch" + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).expect_err("spec should be rejected"); + assert!( + err.to_string() + .contains("cannot mix local ('file'/'files') and remote ('url'/'urls') entries") + ); +} + +#[test] +fn parse_manual_source_with_files_array() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[[manual_sources]] +files = ["other", "system-auth"] + +[build] +type = "custom" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert_eq!(spec.manual_sources.len(), 1); + assert_eq!(spec.manual_sources[0].files, vec!["other", "system-auth"]); + assert!(spec.manual_sources[0].urls.is_empty()); +} diff --git a/src/package/spec/tests/validation_cases.rs b/src/package/spec/tests/validation_cases.rs new file mode 100644 index 0000000..f14f519 --- /dev/null +++ b/src/package/spec/tests/validation_cases.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn reject_unknown_nested_keys_from_spec() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("pkg.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "foo" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/foo.tar.gz" +sha256 = "skip" +extract_dir = "foo" + +[build] +type = "autotools" + +[dependencies] +runtime = ["glibc"] +skip_tests = true +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).expect_err("expected unknown nested key to fail"); + assert!( + err.to_string() + .contains("unknown key: dependencies.skip_tests") + ); +} diff --git a/src/package/starbuild.rs b/src/package/starbuild.rs index a985887..3f6e0f8 100644 --- a/src/package/starbuild.rs +++ b/src/package/starbuild.rs @@ -1234,139 +1234,4 @@ fn sh_single_quote(value: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn convert_single_package_starbuild_generates_custom_spec_and_build_script() -> Result<()> { - let temp = tempfile::tempdir()?; - let starbuild = temp.path().join("STARBUILD"); - fs::write( - &starbuild, - r#" -package_name="meson" -package_version="1.2.3" -description="build system" -license=( "Apache-2.0" ) -dependencies=( "python" "ninja" ) -build_dependencies=( "git" ) -sources=( "helper.sh" "https://github.com/mesonbuild/meson.git#v$package_version" "ne+https://example.com/extra.tar.xz" ) -BUILD_ZLIB=True - -compile() { - cd meson - python -m build -} - -assemble() { - cd meson - python -m installer --destdir="$pkgdir" dist/*.whl -} -"#, - )?; - - let converted = convert_starbuild_file(&starbuild, None)?; - let spec_path = temp.path().join("meson.toml"); - assert_eq!(converted.output_path, spec_path); - assert!(converted.build_script.is_some()); - assert_eq!( - converted.build_script_path, - Some(temp.path().join("build.sh")) - ); - assert!(converted.toml.contains("type = \"custom\"")); - assert!( - converted - .toml - .contains("url = \"https://github.com/mesonbuild/meson.git#v1.2.3\"") - ); - assert!(converted.toml.contains("file = \"helper.sh\"")); - assert!( - converted - .toml - .contains("url = \"https://example.com/extra.tar.xz\"") - ); - - fs::write(&spec_path, &converted.toml)?; - fs::write( - temp.path().join("build.sh"), - converted.build_script.unwrap(), - )?; - let spec = PackageSpec::from_file(&spec_path)?; - assert_eq!(spec.package.name, "meson"); - assert_eq!(spec.dependencies.runtime, vec!["python", "ninja"]); - assert_eq!(spec.dependencies.build, vec!["git"]); - assert_eq!(spec.manual_sources.len(), 2); - assert_eq!(spec.source.len(), 1); - - Ok(()) - } - - #[test] - fn convert_multioutput_starbuild_maps_output_metadata() -> Result<()> { - let temp = tempfile::tempdir()?; - let starbuild = temp.path().join("STARBUILD"); - fs::write( - &starbuild, - r#" -package_name=( "mesa" "vulkan-intel" ) -package_version="25.3.1" -package_descriptions=( "Mesa" "Intel Vulkan" ) -license=( "MIT" ) -dependencies=( "expat" ) -build_dependencies=( "meson" ) -sources=( "https://mesa.freedesktop.org/archive/mesa-$package_version.tar.xz" ) -dependencies_vulkan-intel=( "vulkan-icd-loader" "mesa" ) -gives_vulkan-intel=( "vulkan-driver" ) -clashes_vulkan-intel=( "old-vulkan-intel" ) -keep_vulkan-intel=( "etc/vulkan/intel.conf" ) - -compile() { - cd mesa-$package_version - meson setup build -} - -assemble_mesa() { - cd mesa-$package_version - mesoni -C build -} - -assemble_vulkan-intel() { - starmove usr/lib/libvulkan_intel.so -} -"#, - )?; - - let converted = convert_starbuild_file(&starbuild, None)?; - assert!(converted.toml.contains("name = \"vulkan-intel\"")); - assert!( - converted - .toml - .contains("keep = [\"etc/vulkan/intel.conf\"]") - ); - let build_script = converted.build_script.as_deref().unwrap_or(""); - assert!(build_script.contains("depot_install_vulkan_intel()")); - assert!(build_script.contains("haul \"$DEPOT_OUTPUT_NAME\" \"$@\"")); - assert!(build_script.contains("packages/vulkan-intel/files")); - - let spec_path = temp.path().join("mesa.toml"); - fs::write(&spec_path, &converted.toml)?; - fs::write(temp.path().join("build.sh"), build_script)?; - let spec = PackageSpec::from_file(&spec_path)?; - assert_eq!(spec.packages.len(), 1); - assert_eq!(spec.packages[0].name, "vulkan-intel"); - assert_eq!( - spec.package_dependencies["vulkan-intel"].runtime, - vec!["vulkan-icd-loader", "mesa"] - ); - assert_eq!( - spec.package_alternatives["vulkan-intel"].provides, - vec!["vulkan-driver"] - ); - assert_eq!( - spec.package_alternatives["vulkan-intel"].conflicts, - vec!["old-vulkan-intel"] - ); - - Ok(()) - } -} +mod tests; diff --git a/src/package/starbuild/tests.rs b/src/package/starbuild/tests.rs new file mode 100644 index 0000000..4933b2c --- /dev/null +++ b/src/package/starbuild/tests.rs @@ -0,0 +1,134 @@ +use super::*; + +#[test] +fn convert_single_package_starbuild_generates_custom_spec_and_build_script() -> Result<()> { + let temp = tempfile::tempdir()?; + let starbuild = temp.path().join("STARBUILD"); + fs::write( + &starbuild, + r#" +package_name="meson" +package_version="1.2.3" +description="build system" +license=( "Apache-2.0" ) +dependencies=( "python" "ninja" ) +build_dependencies=( "git" ) +sources=( "helper.sh" "https://github.com/mesonbuild/meson.git#v$package_version" "ne+https://example.com/extra.tar.xz" ) +BUILD_ZLIB=True + +compile() { + cd meson + python -m build +} + +assemble() { + cd meson + python -m installer --destdir="$pkgdir" dist/*.whl +} +"#, + )?; + + let converted = convert_starbuild_file(&starbuild, None)?; + let spec_path = temp.path().join("meson.toml"); + assert_eq!(converted.output_path, spec_path); + assert!(converted.build_script.is_some()); + assert_eq!( + converted.build_script_path, + Some(temp.path().join("build.sh")) + ); + assert!(converted.toml.contains("type = \"custom\"")); + assert!( + converted + .toml + .contains("url = \"https://github.com/mesonbuild/meson.git#v1.2.3\"") + ); + assert!(converted.toml.contains("file = \"helper.sh\"")); + assert!( + converted + .toml + .contains("url = \"https://example.com/extra.tar.xz\"") + ); + + fs::write(&spec_path, &converted.toml)?; + fs::write( + temp.path().join("build.sh"), + converted.build_script.unwrap(), + )?; + let spec = PackageSpec::from_file(&spec_path)?; + assert_eq!(spec.package.name, "meson"); + assert_eq!(spec.dependencies.runtime, vec!["python", "ninja"]); + assert_eq!(spec.dependencies.build, vec!["git"]); + assert_eq!(spec.manual_sources.len(), 2); + assert_eq!(spec.source.len(), 1); + + Ok(()) +} + +#[test] +fn convert_multioutput_starbuild_maps_output_metadata() -> Result<()> { + let temp = tempfile::tempdir()?; + let starbuild = temp.path().join("STARBUILD"); + fs::write( + &starbuild, + r#" +package_name=( "mesa" "vulkan-intel" ) +package_version="25.3.1" +package_descriptions=( "Mesa" "Intel Vulkan" ) +license=( "MIT" ) +dependencies=( "expat" ) +build_dependencies=( "meson" ) +sources=( "https://mesa.freedesktop.org/archive/mesa-$package_version.tar.xz" ) +dependencies_vulkan-intel=( "vulkan-icd-loader" "mesa" ) +gives_vulkan-intel=( "vulkan-driver" ) +clashes_vulkan-intel=( "old-vulkan-intel" ) +keep_vulkan-intel=( "etc/vulkan/intel.conf" ) + +compile() { + cd mesa-$package_version + meson setup build +} + +assemble_mesa() { + cd mesa-$package_version + mesoni -C build +} + +assemble_vulkan-intel() { + starmove usr/lib/libvulkan_intel.so +} +"#, + )?; + + let converted = convert_starbuild_file(&starbuild, None)?; + assert!(converted.toml.contains("name = \"vulkan-intel\"")); + assert!( + converted + .toml + .contains("keep = [\"etc/vulkan/intel.conf\"]") + ); + let build_script = converted.build_script.as_deref().unwrap_or(""); + assert!(build_script.contains("depot_install_vulkan_intel()")); + assert!(build_script.contains("haul \"$DEPOT_OUTPUT_NAME\" \"$@\"")); + assert!(build_script.contains("packages/vulkan-intel/files")); + + let spec_path = temp.path().join("mesa.toml"); + fs::write(&spec_path, &converted.toml)?; + fs::write(temp.path().join("build.sh"), build_script)?; + let spec = PackageSpec::from_file(&spec_path)?; + assert_eq!(spec.packages.len(), 1); + assert_eq!(spec.packages[0].name, "vulkan-intel"); + assert_eq!( + spec.package_dependencies["vulkan-intel"].runtime, + vec!["vulkan-icd-loader", "mesa"] + ); + assert_eq!( + spec.package_alternatives["vulkan-intel"].provides, + vec!["vulkan-driver"] + ); + assert_eq!( + spec.package_alternatives["vulkan-intel"].conflicts, + vec!["old-vulkan-intel"] + ); + + Ok(()) +} diff --git a/src/planner.rs b/src/planner.rs index e302d50..e15a98c 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -924,581 +924,4 @@ pub(crate) fn build_dependency_install_plan( } #[cfg(test)] -mod tests { - use super::{ - Candidate, CandidateKind, MatchKind, PlannerOptions, build_dependency_install_plan, - dedupe_candidate_packages, prune_replacement_fallback_candidates, sort_candidates, - source_deps_for_install, - }; - use crate::config::{BinaryRepo, Config}; - use crate::db; - use crate::package::{ - Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, - }; - use std::collections::BTreeMap; - use std::fs; - use std::path::{Path, PathBuf}; - - fn mk_spec() -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: vec![PackageInfo { - name: "foo-libs".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }], - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://example.test/foo.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "foo".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Autotools, - flags: BuildFlags::default(), - }, - dependencies: Dependencies { - build: vec!["make".into()], - runtime: vec!["foo-libs".into(), "zlib".into()], - test: vec!["bats".into()], - optional: vec!["docs-viewer".into()], - groups: Vec::new(), - lib32: None, - }, - package_alternatives: BTreeMap::from([( - "foo-libs".into(), - Alternatives { - provides: vec!["libfoo".into()], - conflicts: Vec::new(), - replaces: Vec::new(), - lib32: None, - }, - )]), - package_dependencies: BTreeMap::from([( - "foo".into(), - Dependencies { - build: Vec::new(), - runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()], - test: Vec::new(), - optional: Vec::new(), - groups: Vec::new(), - lib32: None, - }, - )]), - spec_dir: PathBuf::from("."), - } - } - - fn mk_installed_spec(name: &str, version: &str) -> PackageSpec { - let mut spec = mk_spec(); - spec.package.name = name.to_string(); - spec.package.version = version.to_string(); - spec - } - - fn mk_binary_candidate(name: &str, repo_name: &str, priority: i32) -> Candidate { - Candidate { - package: name.to_string(), - kind: CandidateKind::Binary { - repo_name: repo_name.to_string(), - record: Box::new(db::repo::BinaryRepoPackageRecord { - repo_name: repo_name.to_string(), - name: name.to_string(), - real_name: None, - version: "1.0.0".to_string(), - revision: 1, - abi_breaking: false, - built_against: Vec::new(), - completed_at: None, - filename: format!("{name}-1.0.0-1-x86_64.depot.pkg.tar.zst"), - size: 1024, - sha512: "sha512".to_string(), - description: None, - homepage: None, - license: None, - provides: Vec::new(), - conflicts: Vec::new(), - replaces: Vec::new(), - runtime_dependencies: Vec::new(), - optional_dependencies: Vec::new(), - groups: Vec::new(), - }), - }, - match_kind: MatchKind::Exact, - sort_repo_priority: priority, - sort_label: format!("binary:{repo_name}"), - } - } - - fn mk_source_candidate(name: &str, path: &Path, local_sibling: bool) -> Candidate { - Candidate { - package: name.to_string(), - kind: CandidateKind::Source { - path: path.to_path_buf(), - local_sibling, - }, - match_kind: MatchKind::Exact, - sort_repo_priority: if local_sibling { -10 } else { 0 }, - sort_label: if local_sibling { - "source:local-sibling".to_string() - } else { - "source:local".to_string() - }, - } - } - - #[test] - fn source_deps_for_install_excludes_local_runtime_outputs_and_provides() { - let spec = mk_spec(); - let deps = source_deps_for_install(&spec, false, false); - assert!(deps.contains(&"make".to_string())); - assert!(deps.contains(&"zlib".to_string())); - assert!(deps.contains(&"openssl".to_string())); - assert!(!deps.contains(&"foo-libs".to_string())); - assert!(!deps.contains(&"libfoo".to_string())); - } - - #[test] - fn source_deps_for_install_does_not_include_test_deps() { - let spec = mk_spec(); - let deps = source_deps_for_install(&spec, false, false); - assert!(!deps.contains(&"bats".to_string())); - } - - #[test] - fn source_deps_for_install_includes_test_deps_when_enabled() { - let spec = mk_spec(); - let deps = source_deps_for_install(&spec, true, false); - assert!(deps.contains(&"bats".to_string())); - } - - #[test] - fn source_deps_for_install_uses_lib32_only_dependencies_when_requested() { - let mut spec = mk_spec(); - spec.dependencies.lib32 = Some(crate::package::DependencyGroup { - build: vec!["gcc-multilib".into()], - runtime: vec!["lib32-zlib".into()], - test: vec!["lib32-bats".into()], - optional: Vec::new(), - groups: Vec::new(), - }); - - let deps = source_deps_for_install(&spec, true, true); - assert!(deps.contains(&"gcc-multilib".to_string())); - assert!(deps.contains(&"lib32-zlib".to_string())); - assert!(!deps.contains(&"lib32-bats".to_string())); - assert!(!deps.contains(&"make".to_string())); - assert!(!deps.contains(&"zlib".to_string())); - assert!(!deps.contains(&"bats".to_string())); - } - - #[test] - fn source_deps_for_install_uses_lib32_only_dependencies_from_spec_flag() { - let mut spec = mk_spec(); - spec.build.flags.lib32_only = true; - spec.dependencies.lib32 = Some(crate::package::DependencyGroup { - build: vec!["gcc-multilib".into()], - runtime: vec!["lib32-zlib".into()], - test: vec!["lib32-bats".into()], - optional: Vec::new(), - groups: Vec::new(), - }); - - let deps = source_deps_for_install(&spec, true, false); - assert!(deps.contains(&"gcc-multilib".to_string())); - assert!(deps.contains(&"lib32-zlib".to_string())); - assert!(!deps.contains(&"lib32-bats".to_string())); - assert!(!deps.contains(&"make".to_string())); - assert!(!deps.contains(&"zlib".to_string())); - assert!(!deps.contains(&"bats".to_string())); - } - - #[test] - fn candidate_dedup_keeps_highest_priority_origin_for_same_package() { - let candidates = vec![ - mk_source_candidate("meson", Path::new("packages/core/meson/meson.toml"), false), - mk_binary_candidate("meson", "core", 0), - mk_source_candidate( - "meson", - Path::new("../packages/core/meson/meson.toml"), - true, - ), - ]; - - let deduped = dedupe_candidate_packages(sort_candidates(&candidates, true)); - assert_eq!(deduped.len(), 1); - assert!(matches!(deduped[0].kind, CandidateKind::Binary { .. })); - } - - #[test] - fn candidate_dedup_uses_local_origin_when_binaries_are_not_preferred() { - let candidates = vec![ - mk_binary_candidate("ninja", "core", 0), - mk_source_candidate("ninja", Path::new("packages/core/ninja/ninja.toml"), false), - ]; - - let deduped = dedupe_candidate_packages(sort_candidates(&candidates, false)); - assert_eq!(deduped.len(), 1); - assert!(matches!(deduped[0].kind, CandidateKind::Source { .. })); - } - - #[test] - fn replacement_candidates_are_pruned_when_direct_matches_exist() { - let mut replacement = mk_binary_candidate("vx", "core", 0); - replacement.match_kind = MatchKind::Replaces; - - let mut exact = mk_binary_candidate("patch", "core", 0); - exact.match_kind = MatchKind::Exact; - - let mut provides = mk_binary_candidate("busybox", "core", 0); - provides.match_kind = MatchKind::Provides; - - let pruned = prune_replacement_fallback_candidates(vec![replacement, exact, provides]); - assert_eq!(pruned.len(), 2); - assert!( - pruned - .iter() - .all(|candidate| candidate.match_kind != MatchKind::Replaces) - ); - } - - #[test] - fn replacement_candidates_remain_when_they_are_the_only_matches() { - let mut replacement = mk_binary_candidate("vx", "core", 0); - replacement.match_kind = MatchKind::Replaces; - - let pruned = prune_replacement_fallback_candidates(vec![replacement]); - assert_eq!(pruned.len(), 1); - assert!(matches!(pruned[0].match_kind, MatchKind::Replaces)); - assert_eq!(pruned[0].package, "vx"); - } - - #[test] - fn build_dependency_install_plan_skips_installed_dependency() { - let rootfs = tempfile::tempdir().unwrap(); - let config = Config::for_rootfs(rootfs.path()); - let db_path = config.db_dir.join("packages.db"); - let destdir = rootfs.path().join("dest"); - std::fs::create_dir_all(&destdir).unwrap(); - db::register_package(&db_path, &mk_installed_spec("meson", "1.0.0"), &destdir).unwrap(); - - let plan = build_dependency_install_plan( - &config, - rootfs.path(), - &["meson".to_string()], - PlannerOptions { - assume_yes: false, - prefer_binary: true, - local_sibling_root: None, - include_test_deps: false, - lib32_only_requested_specs: false, - }, - ) - .unwrap(); - - 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 build_dependency_install_plan_matches_local_sibling_real_name() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_root = tempfile::tempdir().unwrap(); - let config = Config::for_rootfs(rootfs.path()); - - let libressl_dir = repo_root.path().join("libressl43"); - fs::create_dir_all(&libressl_dir).unwrap(); - fs::write( - libressl_dir.join("libressl43.toml"), - r#" -[build] -type = "meta" - -[package] -description = "LibreSSL" -homepage = "https://www.libressl.org/" -license = "ISC" -name = "libressl43" -real_name = "libressl" -version = "4.3.2" -"#, - ) - .unwrap(); - - let plan = build_dependency_install_plan( - &config, - rootfs.path(), - &["libressl".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(); - - assert_eq!(plan.steps.len(), 1); - assert_eq!(plan.steps[0].package, "libressl43"); - assert!(matches!( - plan.steps[0].origin, - super::PlanOrigin::Source { - local_sibling: true, - .. - } - )); - } - - fn write_compressed_repo_db(db_path: &Path, zst_path: &Path) { - let mut input = fs::File::open(db_path).unwrap(); - let output = fs::File::create(zst_path).unwrap(); - let mut encoder = zstd::stream::write::Encoder::new(output, 3).unwrap(); - std::io::copy(&mut input, &mut encoder).unwrap(); - encoder.finish().unwrap(); - } - - #[test] - fn binary_plan_uses_built_against_concrete_dependency() { - let rootfs = tempfile::tempdir().unwrap(); - let repo_dir = tempfile::tempdir().unwrap(); - let cache_dir = tempfile::tempdir().unwrap(); - let mut config = Config::for_rootfs(rootfs.path()); - config.package_cache_dir = cache_dir.path().to_path_buf(); - config.repo_settings.prefer_binary = true; - config.binary_repos.insert( - "core".into(), - BinaryRepo { - url: url::Url::from_directory_path(repo_dir.path()) - .expect("file URL") - .to_string(), - allow_unsigned: true, - ..BinaryRepo::default() - }, - ); - - let installed_dest = rootfs.path().join("installed"); - fs::create_dir_all(&installed_dest).unwrap(); - let mut installed = mk_installed_spec("icu79", "79.1"); - installed.package.real_name = Some("icu".into()); - db::register_package( - &config.installed_db_path(rootfs.path()), - &installed, - &installed_dest, - ) - .unwrap(); - - let db_path = repo_dir.path().join("repo.db"); - let conn = rusqlite::Connection::open(&db_path).unwrap(); - conn.execute_batch( - " - CREATE TABLE packages ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - real_name TEXT, - version TEXT NOT NULL, - revision INTEGER NOT NULL, - abi_breaking INTEGER NOT NULL DEFAULT 0, - built_against TEXT NOT NULL DEFAULT '', - completed_at INTEGER, - description TEXT, - homepage TEXT, - license TEXT, - filename TEXT NOT NULL, - size INTEGER NOT NULL, - sha256 TEXT NOT NULL, - sha512 TEXT NOT NULL - ); - CREATE TABLE provides (package_id INTEGER, name TEXT NOT NULL); - CREATE TABLE replaces (package_id INTEGER, name TEXT NOT NULL); - CREATE TABLE conflicts (package_id INTEGER, name TEXT NOT NULL); - CREATE TABLE dependencies (package_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL); - CREATE TABLE groups (package_id INTEGER, name TEXT NOT NULL); - ", - ) - .unwrap(); - conn.execute( - "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) - VALUES (1, 'app', NULL, '1.0', 1, 0, 'icu78', NULL, NULL, NULL, NULL, 'app.pkg', 1, 'aa', 'bb')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO dependencies (package_id, kind, name) VALUES (1, 'runtime', 'icu')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) - VALUES (2, 'icu78', 'icu', '78.1', 1, 0, '', NULL, NULL, NULL, NULL, 'icu78.pkg', 1, 'cc', 'dd')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) - VALUES (3, 'icu79', 'icu', '79.1', 1, 0, '', NULL, NULL, NULL, NULL, 'icu79.pkg', 1, 'ee', 'ff')", - [], - ) - .unwrap(); - drop(conn); - write_compressed_repo_db(&db_path, &repo_dir.path().join("repo.db.zst")); - fs::remove_file(&db_path).unwrap(); - - let plan = super::build_install_plan( - &config, - rootfs.path(), - super::InstallTarget::PackageName("app".into()), - PlannerOptions { - assume_yes: true, - prefer_binary: true, - local_sibling_root: None, - include_test_deps: false, - lib32_only_requested_specs: false, - }, - ) - .unwrap(); - - let actionable: Vec<_> = plan - .actionable_steps() - .map(|step| step.package.as_str()) - .collect(); - assert_eq!(actionable, vec!["icu78", "app"]); - } - - #[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); - } -} +mod tests; diff --git a/src/planner/tests.rs b/src/planner/tests.rs new file mode 100644 index 0000000..b609e4a --- /dev/null +++ b/src/planner/tests.rs @@ -0,0 +1,576 @@ +use super::{ + Candidate, CandidateKind, MatchKind, PlannerOptions, build_dependency_install_plan, + dedupe_candidate_packages, prune_replacement_fallback_candidates, sort_candidates, + source_deps_for_install, +}; +use crate::config::{BinaryRepo, Config}; +use crate::db; +use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, +}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +fn mk_spec() -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: vec![PackageInfo { + name: "foo-libs".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }], + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.test/foo.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "foo".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Autotools, + flags: BuildFlags::default(), + }, + dependencies: Dependencies { + build: vec!["make".into()], + runtime: vec!["foo-libs".into(), "zlib".into()], + test: vec!["bats".into()], + optional: vec!["docs-viewer".into()], + groups: Vec::new(), + lib32: None, + }, + package_alternatives: BTreeMap::from([( + "foo-libs".into(), + Alternatives { + provides: vec!["libfoo".into()], + conflicts: Vec::new(), + replaces: Vec::new(), + lib32: None, + }, + )]), + package_dependencies: BTreeMap::from([( + "foo".into(), + Dependencies { + build: Vec::new(), + runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()], + test: Vec::new(), + optional: Vec::new(), + groups: Vec::new(), + lib32: None, + }, + )]), + spec_dir: PathBuf::from("."), + } +} + +fn mk_installed_spec(name: &str, version: &str) -> PackageSpec { + let mut spec = mk_spec(); + spec.package.name = name.to_string(); + spec.package.version = version.to_string(); + spec +} + +fn mk_binary_candidate(name: &str, repo_name: &str, priority: i32) -> Candidate { + Candidate { + package: name.to_string(), + kind: CandidateKind::Binary { + repo_name: repo_name.to_string(), + record: Box::new(db::repo::BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: name.to_string(), + real_name: None, + version: "1.0.0".to_string(), + revision: 1, + abi_breaking: false, + built_against: Vec::new(), + completed_at: None, + filename: format!("{name}-1.0.0-1-x86_64.depot.pkg.tar.zst"), + size: 1024, + sha512: "sha512".to_string(), + description: None, + homepage: None, + license: None, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }), + }, + match_kind: MatchKind::Exact, + sort_repo_priority: priority, + sort_label: format!("binary:{repo_name}"), + } +} + +fn mk_source_candidate(name: &str, path: &Path, local_sibling: bool) -> Candidate { + Candidate { + package: name.to_string(), + kind: CandidateKind::Source { + path: path.to_path_buf(), + local_sibling, + }, + match_kind: MatchKind::Exact, + sort_repo_priority: if local_sibling { -10 } else { 0 }, + sort_label: if local_sibling { + "source:local-sibling".to_string() + } else { + "source:local".to_string() + }, + } +} + +#[test] +fn source_deps_for_install_excludes_local_runtime_outputs_and_provides() { + let spec = mk_spec(); + let deps = source_deps_for_install(&spec, false, false); + assert!(deps.contains(&"make".to_string())); + assert!(deps.contains(&"zlib".to_string())); + assert!(deps.contains(&"openssl".to_string())); + assert!(!deps.contains(&"foo-libs".to_string())); + assert!(!deps.contains(&"libfoo".to_string())); +} + +#[test] +fn source_deps_for_install_does_not_include_test_deps() { + let spec = mk_spec(); + let deps = source_deps_for_install(&spec, false, false); + assert!(!deps.contains(&"bats".to_string())); +} + +#[test] +fn source_deps_for_install_includes_test_deps_when_enabled() { + let spec = mk_spec(); + let deps = source_deps_for_install(&spec, true, false); + assert!(deps.contains(&"bats".to_string())); +} + +#[test] +fn source_deps_for_install_uses_lib32_only_dependencies_when_requested() { + let mut spec = mk_spec(); + spec.dependencies.lib32 = Some(crate::package::DependencyGroup { + build: vec!["gcc-multilib".into()], + runtime: vec!["lib32-zlib".into()], + test: vec!["lib32-bats".into()], + optional: Vec::new(), + groups: Vec::new(), + }); + + let deps = source_deps_for_install(&spec, true, true); + assert!(deps.contains(&"gcc-multilib".to_string())); + assert!(deps.contains(&"lib32-zlib".to_string())); + assert!(!deps.contains(&"lib32-bats".to_string())); + assert!(!deps.contains(&"make".to_string())); + assert!(!deps.contains(&"zlib".to_string())); + assert!(!deps.contains(&"bats".to_string())); +} + +#[test] +fn source_deps_for_install_uses_lib32_only_dependencies_from_spec_flag() { + let mut spec = mk_spec(); + spec.build.flags.lib32_only = true; + spec.dependencies.lib32 = Some(crate::package::DependencyGroup { + build: vec!["gcc-multilib".into()], + runtime: vec!["lib32-zlib".into()], + test: vec!["lib32-bats".into()], + optional: Vec::new(), + groups: Vec::new(), + }); + + let deps = source_deps_for_install(&spec, true, false); + assert!(deps.contains(&"gcc-multilib".to_string())); + assert!(deps.contains(&"lib32-zlib".to_string())); + assert!(!deps.contains(&"lib32-bats".to_string())); + assert!(!deps.contains(&"make".to_string())); + assert!(!deps.contains(&"zlib".to_string())); + assert!(!deps.contains(&"bats".to_string())); +} + +#[test] +fn candidate_dedup_keeps_highest_priority_origin_for_same_package() { + let candidates = vec![ + mk_source_candidate("meson", Path::new("packages/core/meson/meson.toml"), false), + mk_binary_candidate("meson", "core", 0), + mk_source_candidate( + "meson", + Path::new("../packages/core/meson/meson.toml"), + true, + ), + ]; + + let deduped = dedupe_candidate_packages(sort_candidates(&candidates, true)); + assert_eq!(deduped.len(), 1); + assert!(matches!(deduped[0].kind, CandidateKind::Binary { .. })); +} + +#[test] +fn candidate_dedup_uses_local_origin_when_binaries_are_not_preferred() { + let candidates = vec![ + mk_binary_candidate("ninja", "core", 0), + mk_source_candidate("ninja", Path::new("packages/core/ninja/ninja.toml"), false), + ]; + + let deduped = dedupe_candidate_packages(sort_candidates(&candidates, false)); + assert_eq!(deduped.len(), 1); + assert!(matches!(deduped[0].kind, CandidateKind::Source { .. })); +} + +#[test] +fn replacement_candidates_are_pruned_when_direct_matches_exist() { + let mut replacement = mk_binary_candidate("vx", "core", 0); + replacement.match_kind = MatchKind::Replaces; + + let mut exact = mk_binary_candidate("patch", "core", 0); + exact.match_kind = MatchKind::Exact; + + let mut provides = mk_binary_candidate("busybox", "core", 0); + provides.match_kind = MatchKind::Provides; + + let pruned = prune_replacement_fallback_candidates(vec![replacement, exact, provides]); + assert_eq!(pruned.len(), 2); + assert!( + pruned + .iter() + .all(|candidate| candidate.match_kind != MatchKind::Replaces) + ); +} + +#[test] +fn replacement_candidates_remain_when_they_are_the_only_matches() { + let mut replacement = mk_binary_candidate("vx", "core", 0); + replacement.match_kind = MatchKind::Replaces; + + let pruned = prune_replacement_fallback_candidates(vec![replacement]); + assert_eq!(pruned.len(), 1); + assert!(matches!(pruned[0].match_kind, MatchKind::Replaces)); + assert_eq!(pruned[0].package, "vx"); +} + +#[test] +fn build_dependency_install_plan_skips_installed_dependency() { + let rootfs = tempfile::tempdir().unwrap(); + let config = Config::for_rootfs(rootfs.path()); + let db_path = config.db_dir.join("packages.db"); + let destdir = rootfs.path().join("dest"); + std::fs::create_dir_all(&destdir).unwrap(); + db::register_package(&db_path, &mk_installed_spec("meson", "1.0.0"), &destdir).unwrap(); + + let plan = build_dependency_install_plan( + &config, + rootfs.path(), + &["meson".to_string()], + PlannerOptions { + assume_yes: false, + prefer_binary: true, + local_sibling_root: None, + include_test_deps: false, + lib32_only_requested_specs: false, + }, + ) + .unwrap(); + + 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 build_dependency_install_plan_matches_local_sibling_real_name() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_root = tempfile::tempdir().unwrap(); + let config = Config::for_rootfs(rootfs.path()); + + let libressl_dir = repo_root.path().join("libressl43"); + fs::create_dir_all(&libressl_dir).unwrap(); + fs::write( + libressl_dir.join("libressl43.toml"), + r#" +[build] +type = "meta" + +[package] +description = "LibreSSL" +homepage = "https://www.libressl.org/" +license = "ISC" +name = "libressl43" +real_name = "libressl" +version = "4.3.2" +"#, + ) + .unwrap(); + + let plan = build_dependency_install_plan( + &config, + rootfs.path(), + &["libressl".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(); + + assert_eq!(plan.steps.len(), 1); + assert_eq!(plan.steps[0].package, "libressl43"); + assert!(matches!( + plan.steps[0].origin, + super::PlanOrigin::Source { + local_sibling: true, + .. + } + )); +} + +fn write_compressed_repo_db(db_path: &Path, zst_path: &Path) { + let mut input = fs::File::open(db_path).unwrap(); + let output = fs::File::create(zst_path).unwrap(); + let mut encoder = zstd::stream::write::Encoder::new(output, 3).unwrap(); + std::io::copy(&mut input, &mut encoder).unwrap(); + encoder.finish().unwrap(); +} + +#[test] +fn binary_plan_uses_built_against_concrete_dependency() { + let rootfs = tempfile::tempdir().unwrap(); + let repo_dir = tempfile::tempdir().unwrap(); + let cache_dir = tempfile::tempdir().unwrap(); + let mut config = Config::for_rootfs(rootfs.path()); + config.package_cache_dir = cache_dir.path().to_path_buf(); + config.repo_settings.prefer_binary = true; + config.binary_repos.insert( + "core".into(), + BinaryRepo { + url: url::Url::from_directory_path(repo_dir.path()) + .expect("file URL") + .to_string(), + allow_unsigned: true, + ..BinaryRepo::default() + }, + ); + + let installed_dest = rootfs.path().join("installed"); + fs::create_dir_all(&installed_dest).unwrap(); + let mut installed = mk_installed_spec("icu79", "79.1"); + installed.package.real_name = Some("icu".into()); + db::register_package( + &config.installed_db_path(rootfs.path()), + &installed, + &installed_dest, + ) + .unwrap(); + + let db_path = repo_dir.path().join("repo.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + " + CREATE TABLE packages ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + real_name TEXT, + version TEXT NOT NULL, + revision INTEGER NOT NULL, + abi_breaking INTEGER NOT NULL DEFAULT 0, + built_against TEXT NOT NULL DEFAULT '', + completed_at INTEGER, + description TEXT, + homepage TEXT, + license TEXT, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + sha512 TEXT NOT NULL + ); + CREATE TABLE provides (package_id INTEGER, name TEXT NOT NULL); + CREATE TABLE replaces (package_id INTEGER, name TEXT NOT NULL); + CREATE TABLE conflicts (package_id INTEGER, name TEXT NOT NULL); + CREATE TABLE dependencies (package_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL); + CREATE TABLE groups (package_id INTEGER, name TEXT NOT NULL); + ", + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'app', NULL, '1.0', 1, 0, 'icu78', NULL, NULL, NULL, NULL, 'app.pkg', 1, 'aa', 'bb')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO dependencies (package_id, kind, name) VALUES (1, 'runtime', 'icu')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) + VALUES (2, 'icu78', 'icu', '78.1', 1, 0, '', NULL, NULL, NULL, NULL, 'icu78.pkg', 1, 'cc', 'dd')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO packages (id, name, real_name, version, revision, abi_breaking, built_against, completed_at, description, homepage, license, filename, size, sha256, sha512) + VALUES (3, 'icu79', 'icu', '79.1', 1, 0, '', NULL, NULL, NULL, NULL, 'icu79.pkg', 1, 'ee', 'ff')", + [], + ) + .unwrap(); + drop(conn); + write_compressed_repo_db(&db_path, &repo_dir.path().join("repo.db.zst")); + fs::remove_file(&db_path).unwrap(); + + let plan = super::build_install_plan( + &config, + rootfs.path(), + super::InstallTarget::PackageName("app".into()), + PlannerOptions { + assume_yes: true, + prefer_binary: true, + local_sibling_root: None, + include_test_deps: false, + lib32_only_requested_specs: false, + }, + ) + .unwrap(); + + let actionable: Vec<_> = plan + .actionable_steps() + .map(|step| step.package.as_str()) + .collect(); + assert_eq!(actionable, vec!["icu78", "app"]); +} + +#[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/extractor.rs b/src/source/extractor.rs old mode 100755 new mode 100644 index 0db5bed..da3eb74 --- a/src/source/extractor.rs +++ b/src/source/extractor.rs @@ -693,374 +693,4 @@ fn signal_child_group(child: &std::process::Child, signal: i32) { } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; - use lz4_flex::frame::FrameEncoder as Lz4FrameEncoder; - use lzma_rust2::{LzipOptions, LzipWriter, LzmaOptions, LzmaWriter}; - use std::io::Write; - use std::time::{Duration, SystemTime}; - use tempfile::tempdir; - - fn test_spec() -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: "pkg".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - fn test_source(extract_dir: &str) -> Source { - Source { - url: "https://example.test/src.tar".into(), - sha256: "skip".into(), - extract_dir: extract_dir.into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - } - } - - fn simple_tar_bytes(top_dir: &str, file_name: &str, contents: &[u8]) -> Vec { - let mut tar_buf = Vec::new(); - { - let mut tar = tar::Builder::new(&mut tar_buf); - let mut header = tar::Header::new_gnu(); - header.set_size(contents.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append_data(&mut header, format!("{top_dir}/{file_name}"), contents) - .unwrap(); - tar.finish().unwrap(); - } - tar_buf - } - - #[test] - fn test_extract_deb_roundtrip() { - let tmp = tempdir().unwrap(); - let deb_path = tmp.path().join("test.deb"); - let extract_dir = tmp.path().join("out-deb"); - - // create a small tar.gz payload with one file - let mut tar_buf = Vec::new(); - { - let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default()); - let mut tar = tar::Builder::new(gz); - let mut header = tar::Header::new_gnu(); - let data = b"hello-deb"; - header.set_size(data.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append_data(&mut header, "usr/bin/hello-deb", &data[..]) - .unwrap(); - tar.finish().unwrap(); - } - - // write ar archive with member data.tar.gz (use temp files because ar::Builder expects File) - { - let db = tmp.path().join("debian-binary"); - let ct = tmp.path().join("control.tar.gz"); - let dt = tmp.path().join("data.tar.gz"); - std::fs::write(&db, b"2.0\n").unwrap(); - std::fs::write(&ct, b"").unwrap(); - std::fs::write(&dt, &tar_buf[..]).unwrap(); - - let mut f = File::create(&deb_path).unwrap(); - let mut builder = ar::Builder::new(&mut f); - let mut dbf = File::open(&db).unwrap(); - let mut ctf = File::open(&ct).unwrap(); - let mut dtf = File::open(&dt).unwrap(); - builder.append_file(b"debian-binary", &mut dbf).unwrap(); - builder.append_file(b"control.tar.gz", &mut ctf).unwrap(); - builder.append_file(b"data.tar.gz", &mut dtf).unwrap(); - } - - fs::create_dir_all(&extract_dir).unwrap(); - extract_deb(&deb_path, &extract_dir).unwrap(); - assert!(extract_dir.join("usr/bin/hello-deb").exists()); - } - - #[test] - fn test_archive_format_for_new_extensions() { - assert_eq!( - archive_format_for_filename("pkg.tar.lz4"), - Some(ArchiveFormat::TarLz4) - ); - assert_eq!( - archive_format_for_filename("pkg.tar.lzma"), - Some(ArchiveFormat::TarLzma) - ); - assert_eq!( - archive_format_for_filename("pkg.tar.lz"), - Some(ArchiveFormat::TarLzip) - ); - assert_eq!( - archive_format_for_filename("pkg.tar.Z"), - Some(ArchiveFormat::TarCompress) - ); - assert_eq!( - archive_format_for_filename("pkg.cpio"), - Some(ArchiveFormat::Cpio) - ); - } - - #[test] - fn test_extract_archive_tar_lz4_roundtrip() { - let tmp = tempdir().unwrap(); - let archive_path = tmp.path().join("pkg.tar.lz4"); - let build_dir = tmp.path().join("build"); - let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lz4"); - - let mut compressed = Vec::new(); - { - let mut encoder = Lz4FrameEncoder::new(&mut compressed); - encoder.write_all(&tar_buf).unwrap(); - encoder.finish().unwrap(); - } - fs::write(&archive_path, compressed).unwrap(); - - let extracted = extract_archive( - &archive_path, - &test_spec(), - &test_source("pkg-1.0"), - &build_dir, - ) - .unwrap(); - - assert_eq!( - fs::read_to_string(extracted.join("hello.txt")).unwrap(), - "hello-lz4" - ); - } - - #[test] - fn test_extract_archive_tar_lzma_roundtrip() { - let tmp = tempdir().unwrap(); - let archive_path = tmp.path().join("pkg.tar.lzma"); - let build_dir = tmp.path().join("build"); - let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzma"); - - let mut compressed = Vec::new(); - { - let options = LzmaOptions::default(); - let mut writer = - LzmaWriter::new_use_header(&mut compressed, &options, Some(tar_buf.len() as u64)) - .unwrap(); - writer.write_all(&tar_buf).unwrap(); - writer.finish().unwrap(); - } - fs::write(&archive_path, compressed).unwrap(); - - let extracted = extract_archive( - &archive_path, - &test_spec(), - &test_source("pkg-1.0"), - &build_dir, - ) - .unwrap(); - - assert_eq!( - fs::read_to_string(extracted.join("hello.txt")).unwrap(), - "hello-lzma" - ); - } - - #[test] - fn test_extract_archive_tar_lzip_roundtrip() { - let tmp = tempdir().unwrap(); - let archive_path = tmp.path().join("pkg.tar.lz"); - let build_dir = tmp.path().join("build"); - let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzip"); - - let mut compressed = Vec::new(); - { - let mut writer = LzipWriter::new(&mut compressed, LzipOptions::default()); - writer.write_all(&tar_buf).unwrap(); - writer.finish().unwrap(); - } - fs::write(&archive_path, compressed).unwrap(); - - let extracted = extract_archive( - &archive_path, - &test_spec(), - &test_source("pkg-1.0"), - &build_dir, - ) - .unwrap(); - - assert_eq!( - fs::read_to_string(extracted.join("hello.txt")).unwrap(), - "hello-lzip" - ); - } - - #[test] - fn test_extract_archive_cpio_roundtrip() { - let tmp = tempdir().unwrap(); - let archive_path = tmp.path().join("pkg.cpio"); - let build_dir = tmp.path().join("build"); - - let mut cpio = Vec::new(); - write_cpio_newc_one_file(&mut cpio, "pkg-1.0/hello.txt", b"hello-cpio"); - write_cpio_trailer(&mut cpio); - fs::write(&archive_path, cpio).unwrap(); - - let extracted = extract_archive( - &archive_path, - &test_spec(), - &test_source("pkg-1.0"), - &build_dir, - ) - .unwrap(); - - assert_eq!( - fs::read_to_string(extracted.join("hello.txt")).unwrap(), - "hello-cpio" - ); - } - - #[test] - fn copy_file_preserve_metadata_keeps_mtime() { - let tmp = tempdir().unwrap(); - let src = tmp.path().join("src"); - let dst = tmp.path().join("dst"); - std::fs::write(&src, b"hello").unwrap(); - - let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); // 2000-01-01 UTC - let ts = FileTime::from_system_time(fixed); - filetime::set_file_times(&src, ts, ts).unwrap(); - - copy_file_preserve_metadata(&src, &dst).unwrap(); - let src_meta = std::fs::metadata(&src).unwrap(); - let dst_meta = std::fs::metadata(&dst).unwrap(); - assert_eq!( - FileTime::from_last_modification_time(&dst_meta), - FileTime::from_last_modification_time(&src_meta) - ); - } - - #[test] - fn copy_entry_fallback_preserves_symlink_when_target_is_missing() { - let tmp = tempdir().unwrap(); - let src_dir = tmp.path().join("src"); - let dst_dir = tmp.path().join("dst"); - std::fs::create_dir_all(&src_dir).unwrap(); - std::fs::create_dir_all(&dst_dir).unwrap(); - - let src_link = src_dir.join("RELEASE-NOTES"); - std::os::unix::fs::symlink("doc/RelNotes/v1.47.3.txt", &src_link).unwrap(); - - // Simulate target already moved/removed before copying this symlink entry. - let src_target_dir = src_dir.join("doc"); - std::fs::create_dir_all(src_target_dir.join("RelNotes")).unwrap(); - std::fs::write(src_target_dir.join("RelNotes/v1.47.3.txt"), "notes").unwrap(); - std::fs::remove_dir_all(&src_target_dir).unwrap(); - - let dst_link = dst_dir.join("RELEASE-NOTES"); - let file_type = std::fs::symlink_metadata(&src_link).unwrap().file_type(); - copy_entry_fallback(&src_link, &dst_link, file_type).unwrap(); - - assert!(std::fs::symlink_metadata(&src_link).is_err()); - let dst_meta = std::fs::symlink_metadata(&dst_link).unwrap(); - assert!(dst_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(&dst_link).unwrap(), - PathBuf::from("doc/RelNotes/v1.47.3.txt") - ); - } - - fn write_cpio_newc_one_file(w: &mut Vec, name: &str, data: &[u8]) { - // magic + 13 fields of 8 hex chars each - fn h8(v: u64) -> String { - format!("{:08x}", v) - } - let namesize = name.len() + 1; - let filesize = data.len(); - let mut header = Vec::new(); - header.extend_from_slice(b"070701"); - // ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check - header.extend_from_slice(h8(0).as_bytes()); // ino - header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms - header.extend_from_slice(h8(0).as_bytes()); // uid - header.extend_from_slice(h8(0).as_bytes()); // gid - header.extend_from_slice(h8(1).as_bytes()); // nlink - header.extend_from_slice(h8(0).as_bytes()); // mtime - header.extend_from_slice(h8(filesize as u64).as_bytes()); - header.extend_from_slice(h8(0).as_bytes()); // devmajor - header.extend_from_slice(h8(0).as_bytes()); // devminor - header.extend_from_slice(h8(0).as_bytes()); // rdevmajor - header.extend_from_slice(h8(0).as_bytes()); // rdevminor - header.extend_from_slice(h8(namesize as u64).as_bytes()); - header.extend_from_slice(h8(0).as_bytes()); // check - w.extend_from_slice(&header); - w.extend_from_slice(name.as_bytes()); - w.push(0); - // pad to 4 - let pad = (4 - ((110 + namesize) % 4)) % 4; - for _ in 0..pad { - w.push(0); - } - // file data - w.extend_from_slice(data); - let dpad = (4 - (filesize % 4)) % 4; - for _ in 0..dpad { - w.push(0); - } - } - - fn write_cpio_trailer(w: &mut Vec) { - write_cpio_newc_one_file(w, "TRAILER!!!", &[]); - } - - #[test] - fn test_extract_rpm_roundtrip() { - let tmp = tempdir().unwrap(); - let rpm_path = tmp.path().join("test.rpm"); - let extract_dir = tmp.path().join("out-rpm"); - - // build cpio newc stream with one file - let mut cpio = Vec::new(); - write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm"); - write_cpio_trailer(&mut cpio); - - // gzip compress - let mut gz = Vec::new(); - { - let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); - enc.write_all(&cpio).unwrap(); - enc.finish().unwrap(); - } - - // write fake rpm: some header bytes then gz payload - { - let mut f = File::create(&rpm_path).unwrap(); - f.write_all(b"RPMHEAD").unwrap(); - f.write_all(&gz).unwrap(); - } - - fs::create_dir_all(&extract_dir).unwrap(); - extract_rpm(&rpm_path, &extract_dir).unwrap(); - assert!(extract_dir.join("usr/bin/hello-rpm").exists()); - } -} +mod tests; diff --git a/src/source/extractor/tests.rs b/src/source/extractor/tests.rs new file mode 100644 index 0000000..b668440 --- /dev/null +++ b/src/source/extractor/tests.rs @@ -0,0 +1,369 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; +use lz4_flex::frame::FrameEncoder as Lz4FrameEncoder; +use lzma_rust2::{LzipOptions, LzipWriter, LzmaOptions, LzmaWriter}; +use std::io::Write; +use std::time::{Duration, SystemTime}; +use tempfile::tempdir; + +fn test_spec() -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "pkg".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +fn test_source(extract_dir: &str) -> Source { + Source { + url: "https://example.test/src.tar".into(), + sha256: "skip".into(), + extract_dir: extract_dir.into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + } +} + +fn simple_tar_bytes(top_dir: &str, file_name: &str, contents: &[u8]) -> Vec { + let mut tar_buf = Vec::new(); + { + let mut tar = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, format!("{top_dir}/{file_name}"), contents) + .unwrap(); + tar.finish().unwrap(); + } + tar_buf +} + +#[test] +fn test_extract_deb_roundtrip() { + let tmp = tempdir().unwrap(); + let deb_path = tmp.path().join("test.deb"); + let extract_dir = tmp.path().join("out-deb"); + + // create a small tar.gz payload with one file + let mut tar_buf = Vec::new(); + { + let gz = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default()); + let mut tar = tar::Builder::new(gz); + let mut header = tar::Header::new_gnu(); + let data = b"hello-deb"; + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, "usr/bin/hello-deb", &data[..]) + .unwrap(); + tar.finish().unwrap(); + } + + // write ar archive with member data.tar.gz (use temp files because ar::Builder expects File) + { + let db = tmp.path().join("debian-binary"); + let ct = tmp.path().join("control.tar.gz"); + let dt = tmp.path().join("data.tar.gz"); + std::fs::write(&db, b"2.0\n").unwrap(); + std::fs::write(&ct, b"").unwrap(); + std::fs::write(&dt, &tar_buf[..]).unwrap(); + + let mut f = File::create(&deb_path).unwrap(); + let mut builder = ar::Builder::new(&mut f); + let mut dbf = File::open(&db).unwrap(); + let mut ctf = File::open(&ct).unwrap(); + let mut dtf = File::open(&dt).unwrap(); + builder.append_file(b"debian-binary", &mut dbf).unwrap(); + builder.append_file(b"control.tar.gz", &mut ctf).unwrap(); + builder.append_file(b"data.tar.gz", &mut dtf).unwrap(); + } + + fs::create_dir_all(&extract_dir).unwrap(); + extract_deb(&deb_path, &extract_dir).unwrap(); + assert!(extract_dir.join("usr/bin/hello-deb").exists()); +} + +#[test] +fn test_archive_format_for_new_extensions() { + assert_eq!( + archive_format_for_filename("pkg.tar.lz4"), + Some(ArchiveFormat::TarLz4) + ); + assert_eq!( + archive_format_for_filename("pkg.tar.lzma"), + Some(ArchiveFormat::TarLzma) + ); + assert_eq!( + archive_format_for_filename("pkg.tar.lz"), + Some(ArchiveFormat::TarLzip) + ); + assert_eq!( + archive_format_for_filename("pkg.tar.Z"), + Some(ArchiveFormat::TarCompress) + ); + assert_eq!( + archive_format_for_filename("pkg.cpio"), + Some(ArchiveFormat::Cpio) + ); +} + +#[test] +fn test_extract_archive_tar_lz4_roundtrip() { + let tmp = tempdir().unwrap(); + let archive_path = tmp.path().join("pkg.tar.lz4"); + let build_dir = tmp.path().join("build"); + let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lz4"); + + let mut compressed = Vec::new(); + { + let mut encoder = Lz4FrameEncoder::new(&mut compressed); + encoder.write_all(&tar_buf).unwrap(); + encoder.finish().unwrap(); + } + fs::write(&archive_path, compressed).unwrap(); + + let extracted = extract_archive( + &archive_path, + &test_spec(), + &test_source("pkg-1.0"), + &build_dir, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(extracted.join("hello.txt")).unwrap(), + "hello-lz4" + ); +} + +#[test] +fn test_extract_archive_tar_lzma_roundtrip() { + let tmp = tempdir().unwrap(); + let archive_path = tmp.path().join("pkg.tar.lzma"); + let build_dir = tmp.path().join("build"); + let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzma"); + + let mut compressed = Vec::new(); + { + let options = LzmaOptions::default(); + let mut writer = + LzmaWriter::new_use_header(&mut compressed, &options, Some(tar_buf.len() as u64)) + .unwrap(); + writer.write_all(&tar_buf).unwrap(); + writer.finish().unwrap(); + } + fs::write(&archive_path, compressed).unwrap(); + + let extracted = extract_archive( + &archive_path, + &test_spec(), + &test_source("pkg-1.0"), + &build_dir, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(extracted.join("hello.txt")).unwrap(), + "hello-lzma" + ); +} + +#[test] +fn test_extract_archive_tar_lzip_roundtrip() { + let tmp = tempdir().unwrap(); + let archive_path = tmp.path().join("pkg.tar.lz"); + let build_dir = tmp.path().join("build"); + let tar_buf = simple_tar_bytes("pkg-1.0", "hello.txt", b"hello-lzip"); + + let mut compressed = Vec::new(); + { + let mut writer = LzipWriter::new(&mut compressed, LzipOptions::default()); + writer.write_all(&tar_buf).unwrap(); + writer.finish().unwrap(); + } + fs::write(&archive_path, compressed).unwrap(); + + let extracted = extract_archive( + &archive_path, + &test_spec(), + &test_source("pkg-1.0"), + &build_dir, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(extracted.join("hello.txt")).unwrap(), + "hello-lzip" + ); +} + +#[test] +fn test_extract_archive_cpio_roundtrip() { + let tmp = tempdir().unwrap(); + let archive_path = tmp.path().join("pkg.cpio"); + let build_dir = tmp.path().join("build"); + + let mut cpio = Vec::new(); + write_cpio_newc_one_file(&mut cpio, "pkg-1.0/hello.txt", b"hello-cpio"); + write_cpio_trailer(&mut cpio); + fs::write(&archive_path, cpio).unwrap(); + + let extracted = extract_archive( + &archive_path, + &test_spec(), + &test_source("pkg-1.0"), + &build_dir, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(extracted.join("hello.txt")).unwrap(), + "hello-cpio" + ); +} + +#[test] +fn copy_file_preserve_metadata_keeps_mtime() { + let tmp = tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::write(&src, b"hello").unwrap(); + + let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); // 2000-01-01 UTC + let ts = FileTime::from_system_time(fixed); + filetime::set_file_times(&src, ts, ts).unwrap(); + + copy_file_preserve_metadata(&src, &dst).unwrap(); + let src_meta = std::fs::metadata(&src).unwrap(); + let dst_meta = std::fs::metadata(&dst).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(&dst_meta), + FileTime::from_last_modification_time(&src_meta) + ); +} + +#[test] +fn copy_entry_fallback_preserves_symlink_when_target_is_missing() { + let tmp = tempdir().unwrap(); + let src_dir = tmp.path().join("src"); + let dst_dir = tmp.path().join("dst"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + + let src_link = src_dir.join("RELEASE-NOTES"); + std::os::unix::fs::symlink("doc/RelNotes/v1.47.3.txt", &src_link).unwrap(); + + // Simulate target already moved/removed before copying this symlink entry. + let src_target_dir = src_dir.join("doc"); + std::fs::create_dir_all(src_target_dir.join("RelNotes")).unwrap(); + std::fs::write(src_target_dir.join("RelNotes/v1.47.3.txt"), "notes").unwrap(); + std::fs::remove_dir_all(&src_target_dir).unwrap(); + + let dst_link = dst_dir.join("RELEASE-NOTES"); + let file_type = std::fs::symlink_metadata(&src_link).unwrap().file_type(); + copy_entry_fallback(&src_link, &dst_link, file_type).unwrap(); + + assert!(std::fs::symlink_metadata(&src_link).is_err()); + let dst_meta = std::fs::symlink_metadata(&dst_link).unwrap(); + assert!(dst_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(&dst_link).unwrap(), + PathBuf::from("doc/RelNotes/v1.47.3.txt") + ); +} + +fn write_cpio_newc_one_file(w: &mut Vec, name: &str, data: &[u8]) { + // magic + 13 fields of 8 hex chars each + fn h8(v: u64) -> String { + format!("{:08x}", v) + } + let namesize = name.len() + 1; + let filesize = data.len(); + let mut header = Vec::new(); + header.extend_from_slice(b"070701"); + // ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, check + header.extend_from_slice(h8(0).as_bytes()); // ino + header.extend_from_slice(h8(0o100644).as_bytes()); // mode regular file with perms + header.extend_from_slice(h8(0).as_bytes()); // uid + header.extend_from_slice(h8(0).as_bytes()); // gid + header.extend_from_slice(h8(1).as_bytes()); // nlink + header.extend_from_slice(h8(0).as_bytes()); // mtime + header.extend_from_slice(h8(filesize as u64).as_bytes()); + header.extend_from_slice(h8(0).as_bytes()); // devmajor + header.extend_from_slice(h8(0).as_bytes()); // devminor + header.extend_from_slice(h8(0).as_bytes()); // rdevmajor + header.extend_from_slice(h8(0).as_bytes()); // rdevminor + header.extend_from_slice(h8(namesize as u64).as_bytes()); + header.extend_from_slice(h8(0).as_bytes()); // check + w.extend_from_slice(&header); + w.extend_from_slice(name.as_bytes()); + w.push(0); + // pad to 4 + let pad = (4 - ((110 + namesize) % 4)) % 4; + for _ in 0..pad { + w.push(0); + } + // file data + w.extend_from_slice(data); + let dpad = (4 - (filesize % 4)) % 4; + for _ in 0..dpad { + w.push(0); + } +} + +fn write_cpio_trailer(w: &mut Vec) { + write_cpio_newc_one_file(w, "TRAILER!!!", &[]); +} + +#[test] +fn test_extract_rpm_roundtrip() { + let tmp = tempdir().unwrap(); + let rpm_path = tmp.path().join("test.rpm"); + let extract_dir = tmp.path().join("out-rpm"); + + // build cpio newc stream with one file + let mut cpio = Vec::new(); + write_cpio_newc_one_file(&mut cpio, "usr/bin/hello-rpm", b"hello-rpm"); + write_cpio_trailer(&mut cpio); + + // gzip compress + let mut gz = Vec::new(); + { + let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + enc.write_all(&cpio).unwrap(); + enc.finish().unwrap(); + } + + // write fake rpm: some header bytes then gz payload + { + let mut f = File::create(&rpm_path).unwrap(); + f.write_all(b"RPMHEAD").unwrap(); + f.write_all(&gz).unwrap(); + } + + fs::create_dir_all(&extract_dir).unwrap(); + extract_rpm(&rpm_path, &extract_dir).unwrap(); + assert!(extract_dir.join("usr/bin/hello-rpm").exists()); +} diff --git a/src/source/git.rs b/src/source/git.rs old mode 100755 new mode 100644 index 0e784f3..512bf8a --- a/src/source/git.rs +++ b/src/source/git.rs @@ -823,379 +823,4 @@ fn ensure_prompt_terminal(url: &str) -> std::result::Result<(), git2::Error> { } #[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - fn commit_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { - let full_path = workdir.join(rel); - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&full_path, data).unwrap(); - - let mut index = repo.index().unwrap(); - index.add_path(Path::new(rel)).unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); - let mut parents = Vec::new(); - if let Ok(head) = repo.head() - && let Some(oid) = head.target() - { - parents.push(repo.find_commit(oid).unwrap()); - } - let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); - - repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) - .unwrap() - } - - #[test] - fn checkout_rev_head_falls_back_to_remote_branch_when_local_head_is_invalid() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let commit_oid = commit_file(&repo, &workdir, "README", "hello"); - repo.reference( - "refs/remotes/origin/main", - commit_oid, - true, - "test setup: remote tracking ref", - ) - .unwrap(); - repo.reference_symbolic( - "HEAD", - "refs/heads/HEAD", - true, - "test setup: break local HEAD", - ) - .unwrap(); - - checkout_rev(&repo, "HEAD").unwrap(); - assert!(repo.head_detached().unwrap()); - assert_eq!(repo.head().unwrap().target().unwrap(), commit_oid); - } - - #[test] - fn checkout_rev_resolves_named_branch_from_remote_tracking_refs() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let commit_oid = commit_file(&repo, &workdir, "src/main.rs", "fn main() {}"); - repo.reference( - "refs/remotes/origin/feature", - commit_oid, - true, - "test setup: remote feature branch", - ) - .unwrap(); - - checkout_rev(&repo, "feature").unwrap(); - assert_eq!(repo.head().unwrap().target().unwrap(), commit_oid); - } - - #[test] - fn apply_cherry_picks_applies_commit_in_order() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let base = commit_file(&repo, &workdir, "README", "base"); - let picked = commit_file(&repo, &workdir, "README", "picked"); - - checkout_rev(&repo, &base.to_string()).unwrap(); - apply_cherry_picks(&repo, &[picked.to_string()]).unwrap(); - - let head = repo.head().unwrap().target().unwrap(); - assert_eq!(head, picked); - assert_eq!( - std::fs::read_to_string(workdir.join("README")).unwrap(), - "picked" - ); - } - - #[test] - fn apply_cherry_picks_errors_for_unknown_rev() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let base = commit_file(&repo, &workdir, "README", "base"); - checkout_rev(&repo, &base.to_string()).unwrap(); - - let err = apply_cherry_picks(&repo, &["deadbeef".to_string()]) - .expect_err("unknown cherry-pick rev should fail"); - assert!( - err.to_string() - .contains("Could not resolve cherry-pick rev") - ); - } - - #[test] - fn fetch_attempts_for_head_prefers_heads_only_before_full_fetch() { - let attempts = fetch_attempts_for_rev("HEAD"); - assert!(matches!( - attempts.as_slice(), - [FetchAttempt::HeadRefs, FetchAttempt::FullRefs] - )); - } - - #[test] - fn fetch_attempts_for_named_revision_try_tag_then_branch_then_fallback() { - let attempts = fetch_attempts_for_rev("v1.2.3"); - assert_eq!(attempts.len(), 3); - assert!( - matches!(&attempts[0], FetchAttempt::Tag(tag) if tag == "+refs/tags/v1.2.3:refs/tags/v1.2.3") - ); - assert!( - matches!(&attempts[1], FetchAttempt::Branch(branch) if branch == "+refs/heads/v1.2.3:refs/heads/v1.2.3") - ); - assert!(matches!(attempts[2], FetchAttempt::FullRefs)); - } - - #[test] - fn fetch_attempts_for_oid_use_full_fetch_only() { - let attempts = fetch_attempts_for_rev("0123456789abcdef"); - assert!(matches!(attempts.as_slice(), [FetchAttempt::FullRefs])); - } - - #[test] - fn full_fetch_attempt_includes_heads_and_tags_refspecs() { - let refspecs = FetchAttempt::FullRefs.refspecs(); - assert_eq!(refspecs, vec![ALL_HEADS_REFSPEC, ALL_TAGS_REFSPEC]); - } - - #[test] - fn should_skip_fetch_for_cached_tag_revisions() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let commit_oid = commit_file(&repo, &workdir, "README", "hello"); - let tag_target = repo.find_object(commit_oid, None).unwrap(); - repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); - - assert!(should_skip_fetch_for_cached_revs(&repo, "v1.0.0", &[])); - assert!(!should_skip_fetch_for_cached_revs(&repo, "main", &[])); - assert!(!should_skip_fetch_for_cached_revs(&repo, "HEAD", &[])); - } - - #[test] - fn should_not_skip_fetch_when_cherry_pick_rev_is_missing() { - let temp = tempfile::tempdir().unwrap(); - let workdir = temp.path().join("repo"); - std::fs::create_dir_all(&workdir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - - let commit_oid = commit_file(&repo, &workdir, "README", "hello"); - let tag_target = repo.find_object(commit_oid, None).unwrap(); - repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); - - assert!(!should_skip_fetch_for_cached_revs( - &repo, - "v1.0.0", - &[String::from("deadbeef")] - )); - } - - #[test] - fn git_operation_should_continue_allows_normal_progress() { - let bar = ProgressBar::hidden(); - crate::interrupts::reset(); - assert!(git_operation_should_continue(Some(&bar))); - } - - #[test] - fn ensure_valid_local_head_prefers_main_when_head_branch_is_missing() { - let temp = tempfile::tempdir().unwrap(); - let bare_dir = temp.path().join("bare.git"); - let workdir = temp.path().join("work"); - let repo = Repository::init_bare(&bare_dir).unwrap(); - std::fs::create_dir_all(&workdir).unwrap(); - let work_repo = Repository::init(&workdir).unwrap(); - work_repo.set_head("refs/heads/main").unwrap(); - commit_file(&work_repo, &workdir, "README", "hello"); - let topic_branch = work_repo - .branch( - "topic", - &work_repo.head().unwrap().peel_to_commit().unwrap(), - false, - ) - .unwrap(); - drop(topic_branch); - let mut remote = work_repo - .remote("origin", bare_dir.to_str().unwrap()) - .unwrap(); - remote - .push( - &[ - "refs/heads/main:refs/heads/main", - "refs/heads/topic:refs/heads/topic", - ], - None, - ) - .unwrap(); - repo.set_head("refs/heads/missing").unwrap(); - - ensure_valid_local_head(&repo).unwrap(); - - assert_eq!( - repo.head().unwrap().resolve().unwrap().name(), - Ok("refs/heads/main") - ); - } - - #[test] - fn checkout_head_succeeds_with_bare_mirror_heads_only_fetch() { - let temp = tempfile::tempdir().unwrap(); - let origin_dir = temp.path().join("origin.git"); - let workdir = temp.path().join("work"); - let cache_dir = temp.path().join("cache"); - let checkout_dir = temp.path().join("checkout"); - std::fs::create_dir_all(&workdir).unwrap(); - - let origin = Repository::init_bare(&origin_dir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - repo.set_head("refs/heads/main").unwrap(); - let commit_oid = commit_file(&repo, &workdir, "README", "hello\n"); - let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); - remote - .push(&["refs/heads/main:refs/heads/main"], None) - .unwrap(); - origin.set_head("refs/heads/main").unwrap(); - - let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); - checkout( - &origin_url, - "HEAD", - &checkout_dir, - &cache_dir, - "test-pkg", - &[], - ) - .unwrap(); - - let checkout_repo = Repository::open(&checkout_dir).unwrap(); - assert_eq!(checkout_repo.head().unwrap().target(), Some(commit_oid)); - assert_eq!( - std::fs::read_to_string(checkout_dir.join("README")).unwrap(), - "hello\n" - ); - } - - #[test] - fn checkout_fetches_cherry_pick_revs_after_tag_checkout_resolves() { - let temp = tempfile::tempdir().unwrap(); - let origin_dir = temp.path().join("origin.git"); - let workdir = temp.path().join("work"); - let cache_dir = temp.path().join("cache"); - let checkout_dir = temp.path().join("checkout"); - std::fs::create_dir_all(&workdir).unwrap(); - - let origin = Repository::init_bare(&origin_dir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - repo.set_head("refs/heads/main").unwrap(); - - let base = commit_file(&repo, &workdir, "README", "base\n"); - let release_target = repo.find_object(base, None).unwrap(); - repo.tag_lightweight("v1.0.0", &release_target, false) - .unwrap(); - - let base_commit = repo.find_commit(base).unwrap(); - repo.branch("topic", &base_commit, false).unwrap(); - repo.set_head("refs/heads/topic").unwrap(); - let mut branch_checkout = git2::build::CheckoutBuilder::new(); - branch_checkout.force(); - repo.checkout_head(Some(&mut branch_checkout)).unwrap(); - let cherry_pick = commit_file(&repo, &workdir, "TOPIC", "topic\n"); - - let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); - remote - .push( - &[ - "refs/heads/main:refs/heads/main", - "refs/heads/topic:refs/heads/topic", - "refs/tags/v1.0.0:refs/tags/v1.0.0", - ], - None, - ) - .unwrap(); - origin.set_head("refs/heads/main").unwrap(); - - let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); - checkout( - &origin_url, - "v1.0.0", - &checkout_dir, - &cache_dir, - "test-pkg", - &[cherry_pick.to_string()], - ) - .unwrap(); - - let checkout_repo = Repository::open(&checkout_dir).unwrap(); - assert_ne!(checkout_repo.head().unwrap().target(), Some(base)); - assert_eq!( - std::fs::read_to_string(checkout_dir.join("TOPIC")).unwrap(), - "topic\n" - ); - } - - #[test] - fn checkout_resolves_annotated_tags_from_remote() { - let temp = tempfile::tempdir().unwrap(); - let origin_dir = temp.path().join("origin.git"); - let workdir = temp.path().join("work"); - let cache_dir = temp.path().join("cache"); - let checkout_dir = temp.path().join("checkout"); - std::fs::create_dir_all(&workdir).unwrap(); - - let origin = Repository::init_bare(&origin_dir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - repo.set_head("refs/heads/main").unwrap(); - - let release_commit = commit_file(&repo, &workdir, "README", "release\n"); - let release_target = repo.find_object(release_commit, None).unwrap(); - let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); - repo.tag("v1.0.0", &release_target, &sig, "release tag", false) - .unwrap(); - - let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); - remote - .push( - &[ - "refs/heads/main:refs/heads/main", - "refs/tags/v1.0.0:refs/tags/v1.0.0", - ], - None, - ) - .unwrap(); - origin.set_head("refs/heads/main").unwrap(); - - let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); - checkout( - &origin_url, - "v1.0.0", - &checkout_dir, - &cache_dir, - "test-pkg", - &[], - ) - .unwrap(); - - let checkout_repo = Repository::open(&checkout_dir).unwrap(); - assert_eq!(checkout_repo.head().unwrap().target(), Some(release_commit)); - assert_eq!( - std::fs::read_to_string(checkout_dir.join("README")).unwrap(), - "release\n" - ); - } -} +mod tests; diff --git a/src/source/git/tests.rs b/src/source/git/tests.rs new file mode 100644 index 0000000..398f419 --- /dev/null +++ b/src/source/git/tests.rs @@ -0,0 +1,374 @@ +use super::*; +use std::path::Path; + +fn commit_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { + let full_path = workdir.join(rel); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&full_path, data).unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new(rel)).unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); + let mut parents = Vec::new(); + if let Ok(head) = repo.head() + && let Some(oid) = head.target() + { + parents.push(repo.find_commit(oid).unwrap()); + } + let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); + + repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) + .unwrap() +} + +#[test] +fn checkout_rev_head_falls_back_to_remote_branch_when_local_head_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let commit_oid = commit_file(&repo, &workdir, "README", "hello"); + repo.reference( + "refs/remotes/origin/main", + commit_oid, + true, + "test setup: remote tracking ref", + ) + .unwrap(); + repo.reference_symbolic( + "HEAD", + "refs/heads/HEAD", + true, + "test setup: break local HEAD", + ) + .unwrap(); + + checkout_rev(&repo, "HEAD").unwrap(); + assert!(repo.head_detached().unwrap()); + assert_eq!(repo.head().unwrap().target().unwrap(), commit_oid); +} + +#[test] +fn checkout_rev_resolves_named_branch_from_remote_tracking_refs() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let commit_oid = commit_file(&repo, &workdir, "src/main.rs", "fn main() {}"); + repo.reference( + "refs/remotes/origin/feature", + commit_oid, + true, + "test setup: remote feature branch", + ) + .unwrap(); + + checkout_rev(&repo, "feature").unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap(), commit_oid); +} + +#[test] +fn apply_cherry_picks_applies_commit_in_order() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let base = commit_file(&repo, &workdir, "README", "base"); + let picked = commit_file(&repo, &workdir, "README", "picked"); + + checkout_rev(&repo, &base.to_string()).unwrap(); + apply_cherry_picks(&repo, &[picked.to_string()]).unwrap(); + + let head = repo.head().unwrap().target().unwrap(); + assert_eq!(head, picked); + assert_eq!( + std::fs::read_to_string(workdir.join("README")).unwrap(), + "picked" + ); +} + +#[test] +fn apply_cherry_picks_errors_for_unknown_rev() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let base = commit_file(&repo, &workdir, "README", "base"); + checkout_rev(&repo, &base.to_string()).unwrap(); + + let err = apply_cherry_picks(&repo, &["deadbeef".to_string()]) + .expect_err("unknown cherry-pick rev should fail"); + assert!( + err.to_string() + .contains("Could not resolve cherry-pick rev") + ); +} + +#[test] +fn fetch_attempts_for_head_prefers_heads_only_before_full_fetch() { + let attempts = fetch_attempts_for_rev("HEAD"); + assert!(matches!( + attempts.as_slice(), + [FetchAttempt::HeadRefs, FetchAttempt::FullRefs] + )); +} + +#[test] +fn fetch_attempts_for_named_revision_try_tag_then_branch_then_fallback() { + let attempts = fetch_attempts_for_rev("v1.2.3"); + assert_eq!(attempts.len(), 3); + assert!( + matches!(&attempts[0], FetchAttempt::Tag(tag) if tag == "+refs/tags/v1.2.3:refs/tags/v1.2.3") + ); + assert!( + matches!(&attempts[1], FetchAttempt::Branch(branch) if branch == "+refs/heads/v1.2.3:refs/heads/v1.2.3") + ); + assert!(matches!(attempts[2], FetchAttempt::FullRefs)); +} + +#[test] +fn fetch_attempts_for_oid_use_full_fetch_only() { + let attempts = fetch_attempts_for_rev("0123456789abcdef"); + assert!(matches!(attempts.as_slice(), [FetchAttempt::FullRefs])); +} + +#[test] +fn full_fetch_attempt_includes_heads_and_tags_refspecs() { + let refspecs = FetchAttempt::FullRefs.refspecs(); + assert_eq!(refspecs, vec![ALL_HEADS_REFSPEC, ALL_TAGS_REFSPEC]); +} + +#[test] +fn should_skip_fetch_for_cached_tag_revisions() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let commit_oid = commit_file(&repo, &workdir, "README", "hello"); + let tag_target = repo.find_object(commit_oid, None).unwrap(); + repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); + + assert!(should_skip_fetch_for_cached_revs(&repo, "v1.0.0", &[])); + assert!(!should_skip_fetch_for_cached_revs(&repo, "main", &[])); + assert!(!should_skip_fetch_for_cached_revs(&repo, "HEAD", &[])); +} + +#[test] +fn should_not_skip_fetch_when_cherry_pick_rev_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let workdir = temp.path().join("repo"); + std::fs::create_dir_all(&workdir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + + let commit_oid = commit_file(&repo, &workdir, "README", "hello"); + let tag_target = repo.find_object(commit_oid, None).unwrap(); + repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); + + assert!(!should_skip_fetch_for_cached_revs( + &repo, + "v1.0.0", + &[String::from("deadbeef")] + )); +} + +#[test] +fn git_operation_should_continue_allows_normal_progress() { + let bar = ProgressBar::hidden(); + crate::interrupts::reset(); + assert!(git_operation_should_continue(Some(&bar))); +} + +#[test] +fn ensure_valid_local_head_prefers_main_when_head_branch_is_missing() { + let temp = tempfile::tempdir().unwrap(); + let bare_dir = temp.path().join("bare.git"); + let workdir = temp.path().join("work"); + let repo = Repository::init_bare(&bare_dir).unwrap(); + std::fs::create_dir_all(&workdir).unwrap(); + let work_repo = Repository::init(&workdir).unwrap(); + work_repo.set_head("refs/heads/main").unwrap(); + commit_file(&work_repo, &workdir, "README", "hello"); + let topic_branch = work_repo + .branch( + "topic", + &work_repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + drop(topic_branch); + let mut remote = work_repo + .remote("origin", bare_dir.to_str().unwrap()) + .unwrap(); + remote + .push( + &[ + "refs/heads/main:refs/heads/main", + "refs/heads/topic:refs/heads/topic", + ], + None, + ) + .unwrap(); + repo.set_head("refs/heads/missing").unwrap(); + + ensure_valid_local_head(&repo).unwrap(); + + assert_eq!( + repo.head().unwrap().resolve().unwrap().name(), + Ok("refs/heads/main") + ); +} + +#[test] +fn checkout_head_succeeds_with_bare_mirror_heads_only_fetch() { + let temp = tempfile::tempdir().unwrap(); + let origin_dir = temp.path().join("origin.git"); + let workdir = temp.path().join("work"); + let cache_dir = temp.path().join("cache"); + let checkout_dir = temp.path().join("checkout"); + std::fs::create_dir_all(&workdir).unwrap(); + + let origin = Repository::init_bare(&origin_dir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + repo.set_head("refs/heads/main").unwrap(); + let commit_oid = commit_file(&repo, &workdir, "README", "hello\n"); + let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); + remote + .push(&["refs/heads/main:refs/heads/main"], None) + .unwrap(); + origin.set_head("refs/heads/main").unwrap(); + + let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); + checkout( + &origin_url, + "HEAD", + &checkout_dir, + &cache_dir, + "test-pkg", + &[], + ) + .unwrap(); + + let checkout_repo = Repository::open(&checkout_dir).unwrap(); + assert_eq!(checkout_repo.head().unwrap().target(), Some(commit_oid)); + assert_eq!( + std::fs::read_to_string(checkout_dir.join("README")).unwrap(), + "hello\n" + ); +} + +#[test] +fn checkout_fetches_cherry_pick_revs_after_tag_checkout_resolves() { + let temp = tempfile::tempdir().unwrap(); + let origin_dir = temp.path().join("origin.git"); + let workdir = temp.path().join("work"); + let cache_dir = temp.path().join("cache"); + let checkout_dir = temp.path().join("checkout"); + std::fs::create_dir_all(&workdir).unwrap(); + + let origin = Repository::init_bare(&origin_dir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + repo.set_head("refs/heads/main").unwrap(); + + let base = commit_file(&repo, &workdir, "README", "base\n"); + let release_target = repo.find_object(base, None).unwrap(); + repo.tag_lightweight("v1.0.0", &release_target, false) + .unwrap(); + + let base_commit = repo.find_commit(base).unwrap(); + repo.branch("topic", &base_commit, false).unwrap(); + repo.set_head("refs/heads/topic").unwrap(); + let mut branch_checkout = git2::build::CheckoutBuilder::new(); + branch_checkout.force(); + repo.checkout_head(Some(&mut branch_checkout)).unwrap(); + let cherry_pick = commit_file(&repo, &workdir, "TOPIC", "topic\n"); + + let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); + remote + .push( + &[ + "refs/heads/main:refs/heads/main", + "refs/heads/topic:refs/heads/topic", + "refs/tags/v1.0.0:refs/tags/v1.0.0", + ], + None, + ) + .unwrap(); + origin.set_head("refs/heads/main").unwrap(); + + let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); + checkout( + &origin_url, + "v1.0.0", + &checkout_dir, + &cache_dir, + "test-pkg", + &[cherry_pick.to_string()], + ) + .unwrap(); + + let checkout_repo = Repository::open(&checkout_dir).unwrap(); + assert_ne!(checkout_repo.head().unwrap().target(), Some(base)); + assert_eq!( + std::fs::read_to_string(checkout_dir.join("TOPIC")).unwrap(), + "topic\n" + ); +} + +#[test] +fn checkout_resolves_annotated_tags_from_remote() { + let temp = tempfile::tempdir().unwrap(); + let origin_dir = temp.path().join("origin.git"); + let workdir = temp.path().join("work"); + let cache_dir = temp.path().join("cache"); + let checkout_dir = temp.path().join("checkout"); + std::fs::create_dir_all(&workdir).unwrap(); + + let origin = Repository::init_bare(&origin_dir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + repo.set_head("refs/heads/main").unwrap(); + + let release_commit = commit_file(&repo, &workdir, "README", "release\n"); + let release_target = repo.find_object(release_commit, None).unwrap(); + let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); + repo.tag("v1.0.0", &release_target, &sig, "release tag", false) + .unwrap(); + + let mut remote = repo.remote("origin", origin_dir.to_str().unwrap()).unwrap(); + remote + .push( + &[ + "refs/heads/main:refs/heads/main", + "refs/tags/v1.0.0:refs/tags/v1.0.0", + ], + None, + ) + .unwrap(); + origin.set_head("refs/heads/main").unwrap(); + + let origin_url = url::Url::from_file_path(&origin_dir).unwrap().to_string(); + checkout( + &origin_url, + "v1.0.0", + &checkout_dir, + &cache_dir, + "test-pkg", + &[], + ) + .unwrap(); + + let checkout_repo = Repository::open(&checkout_dir).unwrap(); + assert_eq!(checkout_repo.head().unwrap().target(), Some(release_commit)); + assert_eq!( + std::fs::read_to_string(checkout_dir.join("README")).unwrap(), + "release\n" + ); +} diff --git a/src/source/mod.rs b/src/source/mod.rs old mode 100755 new mode 100644 index b477396..8456796 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -715,494 +715,4 @@ fn checkout_hg(url: &str, rev: &str, checkout_dir: &Path) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use crate::package::{ - Alternatives, Build, BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, - PackageSpec, Source, - }; - use git2::{Oid, Repository}; - use std::path::Path; - - fn commit_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { - let full_path = workdir.join(rel); - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&full_path, data).unwrap(); - - let mut index = repo.index().unwrap(); - index.add_path(Path::new(rel)).unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); - let mut parents = Vec::new(); - if let Ok(head) = repo.head() - && let Some(oid) = head.target() - { - parents.push(repo.find_commit(oid).unwrap()); - } - let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); - - repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) - .unwrap() - } - - fn make_git_source_spec(source_url: String, extract_dir: &str) -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: source_url, - sha256: "skip".into(), - extract_dir: extract_dir.into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - fn make_remote_git_repo() -> (tempfile::TempDir, String, Oid, Oid) { - let tmp = tempfile::tempdir().unwrap(); - let remote_dir = tmp.path().join("origin.git"); - let workdir = tmp.path().join("work"); - - Repository::init_bare(&remote_dir).unwrap(); - let repo = Repository::init(&workdir).unwrap(); - let tagged = commit_file(&repo, &workdir, "README", "tagged\n"); - let tag_target = repo.find_object(tagged, None).unwrap(); - repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); - let hashed = commit_file(&repo, &workdir, "README", "hashed\n"); - - let branch_ref = repo.head().unwrap().name().unwrap().to_string(); - let mut remote = repo.remote("origin", remote_dir.to_str().unwrap()).unwrap(); - let push_specs = [ - format!("{branch_ref}:{branch_ref}"), - "refs/tags/v1.0.0:refs/tags/v1.0.0".to_string(), - ]; - let push_spec_refs: Vec<&String> = push_specs.iter().collect(); - remote.push(&push_spec_refs, None).unwrap(); - - let remote_url = url::Url::from_file_path(&remote_dir).unwrap().to_string(); - (tmp, remote_url, tagged, hashed) - } - - fn mk_spec_with_manuals(spec_dir: PathBuf, manuals: Vec) -> PackageSpec { - PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: manuals, - source: vec![Source { - url: "https://example.com/src.tar.gz".into(), - sha256: "skip".into(), - extract_dir: "src".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir, - } - } - - #[test] - fn split_git_url_accepts_git_with_rev() { - let (base, rev) = split_git_url("https://example.com/repo.git#v1.2.3").unwrap(); - assert_eq!(base, "https://example.com/repo.git"); - assert_eq!(rev, "v1.2.3"); - } - - #[test] - fn split_git_url_accepts_bare_git_url() { - let (base, rev) = split_git_url("https://example.com/repo.git").unwrap(); - assert_eq!(base, "https://example.com/repo.git"); - assert_eq!(rev, "HEAD"); - } - - #[test] - fn split_git_url_accepts_bare_git_scheme_url() { - let (base, rev) = split_git_url("git://git.suckless.org/ubase").unwrap(); - assert_eq!(base, "git://git.suckless.org/ubase"); - assert_eq!(rev, "HEAD"); - } - - #[test] - fn split_git_url_rejects_archive_urls() { - assert!(split_git_url("https://example.com/foo.tar.gz#deadbeef").is_none()); - assert!(split_git_url("https://example.com/foo.zip#v1").is_none()); - } - - #[test] - fn split_git_url_empty_rev_defaults_to_head() { - let (base, rev) = split_git_url("https://example.com/repo.git#").unwrap(); - assert_eq!(base, "https://example.com/repo.git"); - assert_eq!(rev, "HEAD"); - } - - #[test] - fn split_git_url_accepts_expanded_tag_or_hash_revision() { - let spec = PackageSpec { - package: PackageInfo { - name: "json".into(), - real_name: None, - version: "3.11.3".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Alternatives::default(), - manual_sources: Vec::new(), - source: vec![Source { - url: "https://github.com/nlohmann/json.git#v$version".into(), - sha256: "skip".into(), - extract_dir: "json-$version".into(), - patches: Vec::new(), - post_extract: Vec::new(), - cherry_pick: Vec::new(), - }], - build: Build { - build_type: BuildType::Custom, - flags: BuildFlags::default(), - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - let expanded = spec.expand_vars(&spec.source[0].url); - let (base, rev) = split_git_url(&expanded).unwrap(); - assert_eq!(base, "https://github.com/nlohmann/json.git"); - assert_eq!(rev, "v3.11.3"); - - let (base, rev) = - split_git_url("https://github.com/nlohmann/json.git#0123456789abcdef").unwrap(); - assert_eq!(base, "https://github.com/nlohmann/json.git"); - assert_eq!(rev, "0123456789abcdef"); - } - - #[test] - fn split_hg_url_accepts_revision_and_default_tip() { - let (base, rev) = split_hg_url("hg+https://hg.example.test/repo#v1").unwrap(); - assert_eq!(base, "https://hg.example.test/repo"); - assert_eq!(rev, "v1"); - - let (base, rev) = split_hg_url("hg+https://hg.example.test/repo").unwrap(); - assert_eq!(base, "https://hg.example.test/repo"); - assert_eq!(rev, "tip"); - } - - #[test] - fn prepare_one_rejects_cherry_pick_for_non_git_sources() { - let tmp = tempfile::tempdir().unwrap(); - let cache_dir = tmp.path().join("cache"); - let build_dir = tmp.path().join("build"); - let mut spec = mk_spec_with_manuals(PathBuf::from("."), Vec::new()); - spec.source[0].url = "https://example.com/foo.tar.gz".into(); - spec.source[0].cherry_pick = vec!["deadbeef".into()]; - - let err = prepare_one(&spec, &spec.source[0], &cache_dir, &build_dir) - .expect_err("non-git source with cherry_pick must be rejected"); - assert!( - err.to_string() - .contains("source.cherry_pick is only supported for git sources") - ); - } - - #[test] - fn prepare_one_checks_out_git_tag_revision() { - let (_tmp, remote_url, tagged, _hashed) = make_remote_git_repo(); - let cache_dir = tempfile::tempdir().unwrap(); - let build_dir = tempfile::tempdir().unwrap(); - let spec = make_git_source_spec(format!("{remote_url}#v1.0.0"), "src-tag"); - - let checkout_dir = - prepare_one(&spec, &spec.source[0], cache_dir.path(), build_dir.path()).unwrap(); - let repo = Repository::open(&checkout_dir).unwrap(); - - assert_eq!(repo.head().unwrap().target().unwrap(), tagged); - assert_eq!( - std::fs::read_to_string(checkout_dir.join("README")).unwrap(), - "tagged\n" - ); - } - - #[test] - fn prepare_one_checks_out_git_commit_hash_revision() { - let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); - let cache_dir = tempfile::tempdir().unwrap(); - let build_dir = tempfile::tempdir().unwrap(); - let spec = make_git_source_spec(format!("{remote_url}#{hashed}"), "src-hash"); - - let checkout_dir = - prepare_one(&spec, &spec.source[0], cache_dir.path(), build_dir.path()).unwrap(); - let repo = Repository::open(&checkout_dir).unwrap(); - - assert_eq!(repo.head().unwrap().target().unwrap(), hashed); - assert_eq!( - std::fs::read_to_string(checkout_dir.join("README")).unwrap(), - "hashed\n" - ); - } - - #[test] - fn verify_file_hash_accepts_multiple_algorithms() { - use sha1::Sha1; - use sha2::{Digest, Sha256, Sha512}; - - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), b"abc").unwrap(); - - let sha256_hex = { - let mut h = Sha256::new(); - h.update(b"abc"); - crate::hex::encode_lower(h.finalize()) - }; - let sha512_hex = { - let mut h = Sha512::new(); - h.update(b"abc"); - crate::hex::encode_lower(h.finalize()) - }; - let sha1_hex = { - let mut h = Sha1::new(); - h.update(b"abc"); - crate::hex::encode_lower(h.finalize()) - }; - let md5_hex = format!("{:x}", md5::compute(b"abc")); - let b2_hex = b2sum_rust::Blake2bSum::new(64) - .read(tmp.path()) - .to_ascii_lowercase(); - - assert!(verify_file_hash(tmp.path(), &sha256_hex).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("sha256:{}", sha256_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("sha512:{}", sha512_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("sha1:{}", sha1_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("md5:{}", md5_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("b2:{}", b2_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!("b2sum:{}", b2_hex)).unwrap()); - assert!(verify_file_hash(tmp.path(), &format!(":{}", sha256_hex)).unwrap()); - assert!(!verify_file_hash(tmp.path(), "md5:deadbeef").unwrap()); - } - - #[test] - fn build_blocking_client_with_and_without_timeout() { - use std::time::Duration; - let ua = "depot/test"; - let c1 = build_blocking_client(ua, None).expect("client build failed"); - assert!(c1.get("https://example.com").build().is_ok()); - - let c2 = - build_blocking_client(ua, Some(Duration::from_secs(5))).expect("client build failed"); - assert!(c2.get("https://example.com").build().is_ok()); - } - - #[test] - fn copy_manual_sources_local_file_mode() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let cache_dir = tmp.path().join("cache"); - let build_dir = tmp.path().join("build"); - std::fs::create_dir_all(&spec_dir).unwrap(); - std::fs::write(spec_dir.join("manual.patch"), "patch-data").unwrap(); - - let spec = mk_spec_with_manuals( - spec_dir.clone(), - vec![ManualSource { - file: Some("manual.patch".into()), - files: Vec::new(), - url: None, - urls: Vec::new(), - sha256: None, - dest: None, - }], - ); - - copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); - assert_eq!( - std::fs::read_to_string(build_dir.join("manual.patch")).unwrap(), - "patch-data" - ); - } - - #[test] - fn copy_manual_sources_url_mode_file_scheme() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let cache_dir = tmp.path().join("cache"); - let build_dir = tmp.path().join("build"); - std::fs::create_dir_all(&spec_dir).unwrap(); - let remote_file = tmp.path().join("remote-resource.txt"); - std::fs::write(&remote_file, "remote-data").unwrap(); - let url = format!("file://{}", remote_file.display()); - - let spec = mk_spec_with_manuals( - spec_dir, - vec![ManualSource { - file: None, - files: Vec::new(), - url: Some(url), - urls: Vec::new(), - sha256: Some("skip".into()), - dest: Some("assets/manual.txt".into()), - }], - ); - - copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); - assert_eq!( - std::fs::read_to_string(build_dir.join("assets/manual.txt")).unwrap(), - "remote-data" - ); - } - - #[test] - fn preflight_manual_sources_accepts_git_url() { - let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); - let spec = mk_spec_with_manuals( - PathBuf::from("."), - vec![ManualSource { - file: None, - files: Vec::new(), - url: Some(format!("{remote_url}#{hashed}")), - urls: Vec::new(), - sha256: None, - dest: None, - }], - ); - let cache_dir = tempfile::tempdir().unwrap(); - - preflight_manual_sources(&spec, cache_dir.path()).unwrap(); - } - - #[test] - fn copy_manual_sources_git_url_mode_checks_out_repository() { - let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); - let spec = mk_spec_with_manuals( - PathBuf::from("."), - vec![ManualSource { - file: None, - files: Vec::new(), - url: Some(format!("{remote_url}#{hashed}")), - urls: Vec::new(), - sha256: None, - dest: None, - }], - ); - let cache_dir = tempfile::tempdir().unwrap(); - let build_dir = tempfile::tempdir().unwrap(); - - copy_manual_sources(&spec, cache_dir.path(), build_dir.path()).unwrap(); - assert_eq!( - std::fs::read_to_string(build_dir.path().join("origin/README")).unwrap(), - "hashed\n" - ); - } - - #[test] - fn copy_manual_sources_multi_files_in_one_block() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let cache_dir = tmp.path().join("cache"); - let build_dir = tmp.path().join("build"); - std::fs::create_dir_all(spec_dir.join("pam")).unwrap(); - std::fs::write(spec_dir.join("pam/other"), "other").unwrap(); - std::fs::write(spec_dir.join("pam/system-auth"), "auth").unwrap(); - - let spec = mk_spec_with_manuals( - spec_dir.clone(), - vec![ManualSource { - file: None, - files: vec!["pam/other".into(), "pam/system-auth".into()], - url: None, - urls: Vec::new(), - sha256: None, - dest: None, - }], - ); - - copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); - assert_eq!( - std::fs::read_to_string(build_dir.join("pam/other")).unwrap(), - "other" - ); - assert_eq!( - std::fs::read_to_string(build_dir.join("pam/system-auth")).unwrap(), - "auth" - ); - } - - #[test] - fn copy_manual_sources_expands_carch_in_files_entries() { - let tmp = tempfile::tempdir().unwrap(); - let spec_dir = tmp.path().join("spec"); - let cache_dir = tmp.path().join("cache"); - let build_dir = tmp.path().join("build"); - std::fs::create_dir_all(&spec_dir).unwrap(); - std::fs::write(spec_dir.join("build.sh"), "#!/bin/sh\necho hi\n").unwrap(); - std::fs::write(spec_dir.join("config.armv7"), "armv7-config").unwrap(); - - let mut spec = mk_spec_with_manuals( - spec_dir.clone(), - vec![ManualSource { - file: None, - files: vec!["build.sh".into(), "config.$CARCH".into()], - url: None, - urls: Vec::new(), - sha256: None, - dest: None, - }], - ); - spec.build.flags.carch = "armv7".into(); - - copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); - assert_eq!( - std::fs::read_to_string(build_dir.join("build.sh")).unwrap(), - "#!/bin/sh\necho hi\n" - ); - assert_eq!( - std::fs::read_to_string(build_dir.join("config.armv7")).unwrap(), - "armv7-config" - ); - } -} +mod tests; diff --git a/src/source/tests.rs b/src/source/tests.rs new file mode 100644 index 0000000..af0224f --- /dev/null +++ b/src/source/tests.rs @@ -0,0 +1,488 @@ +use super::*; +use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, + PackageSpec, Source, +}; +use git2::{Oid, Repository}; +use std::path::Path; + +fn commit_file(repo: &Repository, workdir: &Path, rel: &str, data: &str) -> Oid { + let full_path = workdir.join(rel); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&full_path, data).unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(Path::new(rel)).unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = git2::Signature::now("depot-test", "depot@example.test").unwrap(); + let mut parents = Vec::new(); + if let Ok(head) = repo.head() + && let Some(oid) = head.target() + { + parents.push(repo.find_commit(oid).unwrap()); + } + let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect(); + + repo.commit(Some("HEAD"), &sig, &sig, "test", &tree, &parent_refs) + .unwrap() +} + +fn make_git_source_spec(source_url: String, extract_dir: &str) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: source_url, + sha256: "skip".into(), + extract_dir: extract_dir.into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +fn make_remote_git_repo() -> (tempfile::TempDir, String, Oid, Oid) { + let tmp = tempfile::tempdir().unwrap(); + let remote_dir = tmp.path().join("origin.git"); + let workdir = tmp.path().join("work"); + + Repository::init_bare(&remote_dir).unwrap(); + let repo = Repository::init(&workdir).unwrap(); + let tagged = commit_file(&repo, &workdir, "README", "tagged\n"); + let tag_target = repo.find_object(tagged, None).unwrap(); + repo.tag_lightweight("v1.0.0", &tag_target, false).unwrap(); + let hashed = commit_file(&repo, &workdir, "README", "hashed\n"); + + let branch_ref = repo.head().unwrap().name().unwrap().to_string(); + let mut remote = repo.remote("origin", remote_dir.to_str().unwrap()).unwrap(); + let push_specs = [ + format!("{branch_ref}:{branch_ref}"), + "refs/tags/v1.0.0:refs/tags/v1.0.0".to_string(), + ]; + let push_spec_refs: Vec<&String> = push_specs.iter().collect(); + remote.push(&push_spec_refs, None).unwrap(); + + let remote_url = url::Url::from_file_path(&remote_dir).unwrap().to_string(); + (tmp, remote_url, tagged, hashed) +} + +fn mk_spec_with_manuals(spec_dir: PathBuf, manuals: Vec) -> PackageSpec { + PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: manuals, + source: vec![Source { + url: "https://example.com/src.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "src".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir, + } +} + +#[test] +fn split_git_url_accepts_git_with_rev() { + let (base, rev) = split_git_url("https://example.com/repo.git#v1.2.3").unwrap(); + assert_eq!(base, "https://example.com/repo.git"); + assert_eq!(rev, "v1.2.3"); +} + +#[test] +fn split_git_url_accepts_bare_git_url() { + let (base, rev) = split_git_url("https://example.com/repo.git").unwrap(); + assert_eq!(base, "https://example.com/repo.git"); + assert_eq!(rev, "HEAD"); +} + +#[test] +fn split_git_url_accepts_bare_git_scheme_url() { + let (base, rev) = split_git_url("git://git.suckless.org/ubase").unwrap(); + assert_eq!(base, "git://git.suckless.org/ubase"); + assert_eq!(rev, "HEAD"); +} + +#[test] +fn split_git_url_rejects_archive_urls() { + assert!(split_git_url("https://example.com/foo.tar.gz#deadbeef").is_none()); + assert!(split_git_url("https://example.com/foo.zip#v1").is_none()); +} + +#[test] +fn split_git_url_empty_rev_defaults_to_head() { + let (base, rev) = split_git_url("https://example.com/repo.git#").unwrap(); + assert_eq!(base, "https://example.com/repo.git"); + assert_eq!(rev, "HEAD"); +} + +#[test] +fn split_git_url_accepts_expanded_tag_or_hash_revision() { + let spec = PackageSpec { + package: PackageInfo { + name: "json".into(), + real_name: None, + version: "3.11.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://github.com/nlohmann/json.git#v$version".into(), + sha256: "skip".into(), + extract_dir: "json-$version".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Custom, + flags: BuildFlags::default(), + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + let expanded = spec.expand_vars(&spec.source[0].url); + let (base, rev) = split_git_url(&expanded).unwrap(); + assert_eq!(base, "https://github.com/nlohmann/json.git"); + assert_eq!(rev, "v3.11.3"); + + let (base, rev) = + split_git_url("https://github.com/nlohmann/json.git#0123456789abcdef").unwrap(); + assert_eq!(base, "https://github.com/nlohmann/json.git"); + assert_eq!(rev, "0123456789abcdef"); +} + +#[test] +fn split_hg_url_accepts_revision_and_default_tip() { + let (base, rev) = split_hg_url("hg+https://hg.example.test/repo#v1").unwrap(); + assert_eq!(base, "https://hg.example.test/repo"); + assert_eq!(rev, "v1"); + + let (base, rev) = split_hg_url("hg+https://hg.example.test/repo").unwrap(); + assert_eq!(base, "https://hg.example.test/repo"); + assert_eq!(rev, "tip"); +} + +#[test] +fn prepare_one_rejects_cherry_pick_for_non_git_sources() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + let mut spec = mk_spec_with_manuals(PathBuf::from("."), Vec::new()); + spec.source[0].url = "https://example.com/foo.tar.gz".into(); + spec.source[0].cherry_pick = vec!["deadbeef".into()]; + + let err = prepare_one(&spec, &spec.source[0], &cache_dir, &build_dir) + .expect_err("non-git source with cherry_pick must be rejected"); + assert!( + err.to_string() + .contains("source.cherry_pick is only supported for git sources") + ); +} + +#[test] +fn prepare_one_checks_out_git_tag_revision() { + let (_tmp, remote_url, tagged, _hashed) = make_remote_git_repo(); + let cache_dir = tempfile::tempdir().unwrap(); + let build_dir = tempfile::tempdir().unwrap(); + let spec = make_git_source_spec(format!("{remote_url}#v1.0.0"), "src-tag"); + + let checkout_dir = + prepare_one(&spec, &spec.source[0], cache_dir.path(), build_dir.path()).unwrap(); + let repo = Repository::open(&checkout_dir).unwrap(); + + assert_eq!(repo.head().unwrap().target().unwrap(), tagged); + assert_eq!( + std::fs::read_to_string(checkout_dir.join("README")).unwrap(), + "tagged\n" + ); +} + +#[test] +fn prepare_one_checks_out_git_commit_hash_revision() { + let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); + let cache_dir = tempfile::tempdir().unwrap(); + let build_dir = tempfile::tempdir().unwrap(); + let spec = make_git_source_spec(format!("{remote_url}#{hashed}"), "src-hash"); + + let checkout_dir = + prepare_one(&spec, &spec.source[0], cache_dir.path(), build_dir.path()).unwrap(); + let repo = Repository::open(&checkout_dir).unwrap(); + + assert_eq!(repo.head().unwrap().target().unwrap(), hashed); + assert_eq!( + std::fs::read_to_string(checkout_dir.join("README")).unwrap(), + "hashed\n" + ); +} + +#[test] +fn verify_file_hash_accepts_multiple_algorithms() { + use sha1::Sha1; + use sha2::{Digest, Sha256, Sha512}; + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"abc").unwrap(); + + let sha256_hex = { + let mut h = Sha256::new(); + h.update(b"abc"); + crate::hex::encode_lower(h.finalize()) + }; + let sha512_hex = { + let mut h = Sha512::new(); + h.update(b"abc"); + crate::hex::encode_lower(h.finalize()) + }; + let sha1_hex = { + let mut h = Sha1::new(); + h.update(b"abc"); + crate::hex::encode_lower(h.finalize()) + }; + let md5_hex = format!("{:x}", md5::compute(b"abc")); + let b2_hex = b2sum_rust::Blake2bSum::new(64) + .read(tmp.path()) + .to_ascii_lowercase(); + + assert!(verify_file_hash(tmp.path(), &sha256_hex).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("sha256:{}", sha256_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("sha512:{}", sha512_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("sha1:{}", sha1_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("md5:{}", md5_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("b2:{}", b2_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!("b2sum:{}", b2_hex)).unwrap()); + assert!(verify_file_hash(tmp.path(), &format!(":{}", sha256_hex)).unwrap()); + assert!(!verify_file_hash(tmp.path(), "md5:deadbeef").unwrap()); +} + +#[test] +fn build_blocking_client_with_and_without_timeout() { + use std::time::Duration; + let ua = "depot/test"; + let c1 = build_blocking_client(ua, None).expect("client build failed"); + assert!(c1.get("https://example.com").build().is_ok()); + + let c2 = build_blocking_client(ua, Some(Duration::from_secs(5))).expect("client build failed"); + assert!(c2.get("https://example.com").build().is_ok()); +} + +#[test] +fn copy_manual_sources_local_file_mode() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("manual.patch"), "patch-data").unwrap(); + + let spec = mk_spec_with_manuals( + spec_dir.clone(), + vec![ManualSource { + file: Some("manual.patch".into()), + files: Vec::new(), + url: None, + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("manual.patch")).unwrap(), + "patch-data" + ); +} + +#[test] +fn copy_manual_sources_url_mode_file_scheme() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(&spec_dir).unwrap(); + let remote_file = tmp.path().join("remote-resource.txt"); + std::fs::write(&remote_file, "remote-data").unwrap(); + let url = format!("file://{}", remote_file.display()); + + let spec = mk_spec_with_manuals( + spec_dir, + vec![ManualSource { + file: None, + files: Vec::new(), + url: Some(url), + urls: Vec::new(), + sha256: Some("skip".into()), + dest: Some("assets/manual.txt".into()), + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("assets/manual.txt")).unwrap(), + "remote-data" + ); +} + +#[test] +fn preflight_manual_sources_accepts_git_url() { + let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); + let spec = mk_spec_with_manuals( + PathBuf::from("."), + vec![ManualSource { + file: None, + files: Vec::new(), + url: Some(format!("{remote_url}#{hashed}")), + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + let cache_dir = tempfile::tempdir().unwrap(); + + preflight_manual_sources(&spec, cache_dir.path()).unwrap(); +} + +#[test] +fn copy_manual_sources_git_url_mode_checks_out_repository() { + let (_tmp, remote_url, _tagged, hashed) = make_remote_git_repo(); + let spec = mk_spec_with_manuals( + PathBuf::from("."), + vec![ManualSource { + file: None, + files: Vec::new(), + url: Some(format!("{remote_url}#{hashed}")), + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + let cache_dir = tempfile::tempdir().unwrap(); + let build_dir = tempfile::tempdir().unwrap(); + + copy_manual_sources(&spec, cache_dir.path(), build_dir.path()).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.path().join("origin/README")).unwrap(), + "hashed\n" + ); +} + +#[test] +fn copy_manual_sources_multi_files_in_one_block() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(spec_dir.join("pam")).unwrap(); + std::fs::write(spec_dir.join("pam/other"), "other").unwrap(); + std::fs::write(spec_dir.join("pam/system-auth"), "auth").unwrap(); + + let spec = mk_spec_with_manuals( + spec_dir.clone(), + vec![ManualSource { + file: None, + files: vec!["pam/other".into(), "pam/system-auth".into()], + url: None, + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("pam/other")).unwrap(), + "other" + ); + assert_eq!( + std::fs::read_to_string(build_dir.join("pam/system-auth")).unwrap(), + "auth" + ); +} + +#[test] +fn copy_manual_sources_expands_carch_in_files_entries() { + let tmp = tempfile::tempdir().unwrap(); + let spec_dir = tmp.path().join("spec"); + let cache_dir = tmp.path().join("cache"); + let build_dir = tmp.path().join("build"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("build.sh"), "#!/bin/sh\necho hi\n").unwrap(); + std::fs::write(spec_dir.join("config.armv7"), "armv7-config").unwrap(); + + let mut spec = mk_spec_with_manuals( + spec_dir.clone(), + vec![ManualSource { + file: None, + files: vec!["build.sh".into(), "config.$CARCH".into()], + url: None, + urls: Vec::new(), + sha256: None, + dest: None, + }], + ); + spec.build.flags.carch = "armv7".into(); + + copy_manual_sources(&spec, &cache_dir, &build_dir).unwrap(); + assert_eq!( + std::fs::read_to_string(build_dir.join("build.sh")).unwrap(), + "#!/bin/sh\necho hi\n" + ); + assert_eq!( + std::fs::read_to_string(build_dir.join("config.armv7")).unwrap(), + "armv7-config" + ); +} diff --git a/src/staging/manifest.rs b/src/staging/manifest.rs new file mode 100644 index 0000000..fe877a6 --- /dev/null +++ b/src/staging/manifest.rs @@ -0,0 +1,45 @@ +use super::*; + +/// Manifest containing files and directories for a package +#[derive(Debug, Clone)] +pub struct Manifest { + pub files: Vec, + pub directories: Vec, +} + +/// Generate manifest with both files and directories +pub fn generate_manifest_with_dirs(destdir: &Path) -> Result { + let mut files = Vec::new(); + let mut directories = Vec::new(); + + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + let rel_path = entry + .path() + .strip_prefix(destdir)? + .to_string_lossy() + .to_string(); + + // Skip the root (empty path) + if rel_path.is_empty() { + continue; + } + + if is_skipped_install_path(&rel_path) { + continue; + } + + let file_type = entry.file_type(); + + // Check for symlink first + if file_type.is_symlink() { + // Track symlinks as files (they get removed the same way) + files.push(rel_path); + } else if file_type.is_file() { + files.push(rel_path); + } else if file_type.is_dir() { + directories.push(rel_path); + } + } + + Ok(Manifest { files, directories }) +} diff --git a/src/staging/mod.rs b/src/staging/mod.rs old mode 100755 new mode 100644 index acdaba5..32dcf4e --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -1016,1805 +1016,11 @@ pub fn add_licenses(src_dir: &Path, destdir: &Path, pkgname: &str) -> Result, - created: Vec, - relocated: Vec, - removed: Vec, -} +mod manifest; +mod transaction; -fn is_directory_empty(path: &Path) -> Result { - let mut entries = fs::read_dir(path) - .with_context(|| format!("Failed to read directory {}", path.display()))?; - Ok(entries.next().transpose()?.is_none()) -} - -fn backup_existing_path(src: &Path, backup_path: &Path, rel: &str) -> Result<()> { - let metadata = src - .symlink_metadata() - .with_context(|| format!("Failed to inspect existing path {}", rel))?; - - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create backup dir {}", parent.display()))?; - } - - if metadata.file_type().is_symlink() { - let target = fs::read_link(src) - .with_context(|| format!("Failed to read existing symlink target {}", rel))?; - std::os::unix::fs::symlink(&target, backup_path) - .with_context(|| format!("Failed to backup symlink {}", rel))?; - } else if metadata.file_type().is_dir() { - fs::create_dir_all(backup_path) - .with_context(|| format!("Failed to backup directory {}", rel))?; - apply_unix_mode(backup_path, &metadata)?; - } else { - fs::copy(src, backup_path).with_context(|| format!("Failed to backup file {}", rel))?; - } - - Ok(()) -} - -fn move_directory_contents(src_dir: &Path, dst_dir: &Path) -> Result<()> { - fs::create_dir_all(dst_dir) - .with_context(|| format!("Failed to create directory {}", dst_dir.display()))?; - - for entry in - fs::read_dir(src_dir).with_context(|| format!("Failed to read {}", src_dir.display()))? - { - let entry = entry?; - move_tree_preserving_layout(&entry.path(), &dst_dir.join(entry.file_name()))?; - } - - fs::remove_dir(src_dir).with_context(|| format!("Failed to remove {}", src_dir.display()))?; - Ok(()) -} - -fn copy_tree_preserving_layout_no_overwrite( - src_root: &Path, - dst_root: &Path, - logical_root: &str, - created: &mut Vec, -) -> Result<()> { - for entry in WalkDir::new(src_root).follow_links(false) { - let entry = entry - .with_context(|| format!("Failed to walk relocation tree {}", src_root.display()))?; - let src_path = entry.path(); - let rel = src_path - .strip_prefix(src_root) - .with_context(|| format!("Failed to strip relocation root {}", src_root.display()))?; - if rel.as_os_str().is_empty() { - continue; - } - - let dst_path = dst_root.join(rel); - let metadata = src_path - .symlink_metadata() - .with_context(|| format!("Failed to inspect {}", src_path.display()))?; - let file_type = metadata.file_type(); - - if file_type.is_dir() { - match dst_path.symlink_metadata() { - Ok(dst_meta) => { - if !dst_meta.file_type().is_dir() { - anyhow::bail!( - "Failed to replay relocated directory into {}: destination exists and is not a directory", - dst_path.display() - ); - } - } - Err(err) if err.kind() == io::ErrorKind::NotFound => { - fs::create_dir_all(&dst_path).with_context(|| { - format!("Failed to create directory {}", dst_path.display()) - })?; - apply_unix_mode(&dst_path, &metadata)?; - } - Err(err) => { - return Err(err) - .with_context(|| format!("Failed to inspect {}", dst_path.display())); - } - } - continue; - } - - if let Some(parent) = dst_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } - - if let Ok(dst_metadata) = dst_path.symlink_metadata() { - if duplicate_staged_path_is_equivalent(src_path, &metadata, &dst_path, &dst_metadata)? { - continue; - } - anyhow::bail!( - "Failed to replay relocated path into {}: destination already exists", - dst_path.display() - ); - } - - if file_type.is_symlink() { - let target = fs::read_link(src_path) - .with_context(|| format!("Failed to read symlink {}", src_path.display()))?; - std::os::unix::fs::symlink(&target, &dst_path).with_context(|| { - format!( - "Failed to create relocated symlink {} -> {}", - dst_path.display(), - target.display() - ) - })?; - } else { - fs::copy(src_path, &dst_path).with_context(|| { - format!( - "Failed to copy relocated path {} to {}", - src_path.display(), - dst_path.display() - ) - })?; - apply_unix_mode(&dst_path, &metadata)?; - } - - let logical = Path::new(logical_root).join(rel); - let logical = logical - .to_str() - .context("Relocated install paths must be valid UTF-8")? - .to_string(); - created.push(logical); - } - - Ok(()) -} - -fn symlink_target_path_inside_rootfs( - rootfs: &Path, - link_rel: &str, - target: &Path, -) -> Result> { - let mut normalized = PathBuf::new(); - if target.is_absolute() { - for component in target.components() { - match component { - Component::RootDir => {} - Component::CurDir => {} - Component::Normal(segment) => normalized.push(segment), - Component::ParentDir | Component::Prefix(_) => return Ok(None), - } - } - } else { - if let Some(parent) = Path::new(link_rel).parent() { - normalized.push(parent); - } - for component in target.components() { - match component { - Component::CurDir => {} - Component::Normal(segment) => normalized.push(segment), - Component::ParentDir => { - if !normalized.pop() { - return Ok(None); - } - } - Component::RootDir | Component::Prefix(_) => return Ok(None), - } - } - } - - if normalized.as_os_str().is_empty() { - return Ok(None); - } - - Ok(Some(rootfs.join(normalized))) -} - -fn can_relocate_directory_for_symlink_swap( - rootfs: &Path, - link_rel: &str, - symlink_path: &Path, -) -> Result { - let target = fs::read_link(symlink_path) - .with_context(|| format!("Failed to read symlink {}", symlink_path.display()))?; - let Some(target_path) = symlink_target_path_inside_rootfs(rootfs, link_rel, &target)? else { - return Ok(false); - }; - - match target_path.symlink_metadata() { - Ok(metadata) => Ok(metadata.file_type().is_dir()), - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err) - .with_context(|| format!("Failed to inspect symlink target {}", target_path.display())), - } -} - -fn remove_path_in_place(path: &Path, rel: &str) -> Result<()> { - let metadata = path - .symlink_metadata() - .with_context(|| format!("Failed to inspect existing path {}", rel))?; - - if metadata.file_type().is_dir() { - fs::remove_dir(path) - .with_context(|| format!("Failed to remove obsolete directory {}", rel))?; - } else { - fs::remove_file(path) - .with_context(|| format!("Failed to remove obsolete file/symlink {}", rel))?; - } - - Ok(()) -} - -fn backup_and_remove_obsolete_path( - tx: &mut FsTransaction, - rootfs: &Path, - rel: &str, - require_empty_dir: bool, -) -> Result { - let dest_path = rootfs.join(rel); - let metadata = match dest_path.symlink_metadata() { - Ok(metadata) => metadata, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(err) => { - return Err(err).with_context(|| { - format!("Failed to inspect obsolete path before removal: {}", rel) - }); - } - }; - - if metadata.file_type().is_dir() { - let empty = is_directory_empty(&dest_path)?; - if !empty { - if require_empty_dir { - anyhow::bail!( - "Refusing to replace existing non-empty directory with packaged file/symlink: {}", - rel - ); - } - return Ok(false); - } - } - - let backup_path = tx.removed_backup_path(rel); - backup_existing_path(&dest_path, &backup_path, rel)?; - remove_path_in_place(&dest_path, rel)?; - tx.removed.push(rel.to_string()); - Ok(true) -} - -fn remove_obsolete_children_for_dir( - tx: &mut FsTransaction, - rootfs: &Path, - dir_rel: &str, - remove_paths: &[String], -) -> Result<()> { - let prefix = format!("{dir_rel}/"); - let mut nested_paths: Vec<&str> = remove_paths - .iter() - .filter_map(|path| path.strip_prefix(&prefix).map(|_| path.as_str())) - .collect(); - nested_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); - - for rel in nested_paths { - let _ = backup_and_remove_obsolete_path(tx, rootfs, rel, false)?; - } - - Ok(()) -} - -impl FsTransaction { - fn backup_path(&self, rel: &str) -> PathBuf { - self.tx_dir.join("backup").join(rel) - } - - fn removed_backup_path(&self, rel: &str) -> PathBuf { - self.tx_dir.join("removed").join(rel) - } - - fn relocated_path(&self, rel: &str) -> PathBuf { - self.tx_dir.join("relocated").join(rel) - } - - fn relocate_directory_for_symlink_swap(&mut self, rel: &str) -> Result<()> { - let src = self.rootfs.join(rel); - let relocated = self.relocated_path(rel); - move_directory_contents(&src, &relocated)?; - self.relocated.push(rel.to_string()); - Ok(()) - } - - fn replay_relocated_dir_if_present(&mut self, rel: &str) -> Result<()> { - let relocated = self.relocated_path(rel); - match relocated.symlink_metadata() { - Ok(meta) if meta.file_type().is_dir() => {} - Ok(_) => { - anyhow::bail!( - "Relocation staging path is not a directory: {}", - relocated.display() - ); - } - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(err) => { - return Err(err) - .with_context(|| format!("Failed to inspect {}", relocated.display())); - } - } - - copy_tree_preserving_layout_no_overwrite( - &relocated, - &self.rootfs.join(rel), - rel, - &mut self.created, - ) - } - - fn restore_relocated_dir(&self, rel: &str) -> Result<()> { - let relocated = self.relocated_path(rel); - match relocated.symlink_metadata() { - Ok(meta) if meta.file_type().is_dir() => {} - Ok(_) => { - anyhow::bail!( - "Relocation staging path is not a directory: {}", - relocated.display() - ); - } - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(err) => { - return Err(err) - .with_context(|| format!("Failed to inspect {}", relocated.display())); - } - } - - let dst = self.rootfs.join(rel); - match dst.symlink_metadata() { - Ok(meta) if meta.file_type().is_dir() => {} - Ok(_) => { - anyhow::bail!( - "Failed to restore relocated directory contents into {}: destination is not a directory", - dst.display() - ); - } - Err(err) if err.kind() == io::ErrorKind::NotFound => { - fs::create_dir_all(&dst) - .with_context(|| format!("Failed to create directory {}", dst.display()))?; - } - Err(err) => { - return Err(err).with_context(|| format!("Failed to inspect {}", dst.display())); - } - } - - move_directory_contents(&relocated, &dst)?; - cleanup_empty_parent_dirs(&self.tx_dir, &relocated)?; - Ok(()) - } - - fn restore_backup_entry(&self, src: &Path, dst: &Path) -> Result<()> { - let metadata = src - .symlink_metadata() - .with_context(|| format!("Failed to inspect backup entry {}", src.display()))?; - - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create restore dir {}", parent.display()))?; - } - - if metadata.file_type().is_dir() { - match dst.symlink_metadata() { - Ok(dst_meta) if dst_meta.file_type().is_dir() => {} - Ok(dst_meta) if dst_meta.file_type().is_symlink() => { - fs::remove_file(dst) - .with_context(|| format!("Failed to remove {}", dst.display()))?; - } - Ok(_) => { - fs::remove_file(dst) - .with_context(|| format!("Failed to remove {}", dst.display()))?; - } - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err) - .with_context(|| format!("Failed to inspect {}", dst.display())); - } - } - fs::create_dir_all(dst) - .with_context(|| format!("Failed to restore directory {}", dst.display()))?; - apply_unix_mode(dst, &metadata)?; - return Ok(()); - } - - let _ = fs::remove_file(dst); - match fs::rename(src, dst) { - Ok(()) => Ok(()), - Err(_) if metadata.file_type().is_symlink() => { - let target = fs::read_link(src) - .with_context(|| format!("Failed to read backup symlink {}", src.display()))?; - std::os::unix::fs::symlink(&target, dst) - .with_context(|| format!("Failed to restore symlink {}", dst.display())) - } - Err(_) => { - fs::copy(src, dst).with_context(|| { - format!( - "Failed to restore file {} from {}", - dst.display(), - src.display() - ) - })?; - Ok(()) - } - } - } - - /// Roll back file operations performed by `install_atomic`. - pub fn rollback(&self) -> Result<()> { - // Remove files that were newly created - for rel in &self.created { - let dst = self.rootfs.join(rel); - let _ = fs::remove_file(dst); - } - - // Restore overwritten paths first so relocated and removed children have their parent layout. - for rel in &self.backed_up { - let src = self.backup_path(rel); - let dst = self.rootfs.join(rel); - if src.symlink_metadata().is_ok() { - self.restore_backup_entry(&src, &dst)?; - } - } - - for rel in &self.relocated { - self.restore_relocated_dir(rel)?; - } - - // Restore removed files/directories. - for rel in &self.removed { - let src = self.removed_backup_path(rel); - let dst = self.rootfs.join(rel); - if src.symlink_metadata().is_ok() { - self.restore_backup_entry(&src, &dst)?; - } - } - - Ok(()) - } - - /// Commit the transaction (delete backup directory). - pub fn commit(self) -> Result<()> { - let tx_base_dir = self.tx_dir.parent().map(Path::to_path_buf); - if self.tx_dir.exists() { - fs::remove_dir_all(&self.tx_dir)?; - } - if let Some(tx_base_dir) = tx_base_dir { - match fs::remove_dir(&tx_base_dir) { - Ok(()) => {} - Err(err) - if matches!( - err.kind(), - io::ErrorKind::DirectoryNotEmpty | io::ErrorKind::NotFound - ) => {} - Err(err) => { - return Err(err).with_context(|| { - format!("Failed to remove tx dir {}", tx_base_dir.display()) - }); - } - } - } - Ok(()) - } -} - -/// Install staged files using a rollback-capable transaction. -/// -/// This is used for both first-time installs and updates. For updates, pass a -/// list of relative paths to remove (old manifest minus new manifest). -pub fn install_atomic( - destdir: &Path, - rootfs: &Path, - tx_base_dir: &Path, - remove_paths: &[String], - keep_paths: &[String], -) -> Result { - let tx_base_dir = if rootfs != Path::new("/") && tx_base_dir.starts_with(rootfs) { - rootfs.join(".depot-tx") - } else { - tx_base_dir.to_path_buf() - }; - let keep_rules: Vec = keep_paths - .iter() - .map(|p| KeepMatcher::from_spec(p)) - .collect::>>()?; - let keep_set: HashSet = keep_rules - .iter() - .filter_map(|m| match m { - KeepMatcher::Exact(p) => Some(p.clone()), - KeepMatcher::Pattern(_) => None, - }) - .collect(); - let remove_set: HashSet<&str> = remove_paths.iter().map(String::as_str).collect(); - - fs::create_dir_all(&tx_base_dir) - .with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?; - - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let pid = std::process::id(); - let tx_dir = tx_base_dir.join(format!("tx-{}-{}", ts, pid)); - let backup_dir = tx_dir.join("backup"); - let removed_dir = tx_dir.join("removed"); - fs::create_dir_all(&backup_dir)?; - fs::create_dir_all(&removed_dir)?; - - let mut tx = FsTransaction { - rootfs: rootfs.to_path_buf(), - tx_dir, - backed_up: Vec::new(), - created: Vec::new(), - relocated: Vec::new(), - removed: Vec::new(), - }; - let mut staged_paths = HashSet::new(); - let mut installed_hardlinks: HashMap = HashMap::new(); - - let result: Result<()> = (|| { - // First, create all directories from destdir (for packages with only directories) - for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { - let src_path = entry.path(); - let file_type = entry.file_type(); - - if !file_type.is_dir() || src_path == destdir { - continue; - } - - let rel_path = src_path - .strip_prefix(destdir) - .context("Failed to strip destdir prefix")?; - let rel_path_str = rel_path.to_string_lossy().to_string(); - if is_skipped_install_path(&rel_path_str) { - continue; - } - - let dest_path = rootfs.join(rel_path); - if !dest_path.exists() { - fs::create_dir_all(&dest_path)?; - apply_unix_mode(&dest_path, &src_path.symlink_metadata()?)?; - } - staged_paths.insert(rel_path_str); - } - - // Copy in new files. - for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { - let src_path = entry.path(); - let metadata = src_path - .symlink_metadata() - .context("Failed to get metadata")?; - let file_type = metadata.file_type(); - - // We want to install files AND symlinks (to anything) - if !file_type.is_file() && !file_type.is_symlink() { - continue; - } - - let rel_path = src_path - .strip_prefix(destdir) - .context("Failed to strip destdir prefix")? - .to_string_lossy() - .to_string(); - - if is_skipped_install_path(&rel_path) { - continue; - } - - let keep_match = if keep_set.contains(&rel_path) { - true - } else { - keep_rules.iter().any(|m| m.matches(&rel_path)) - }; - let keep_as_depotnew = keep_match && rootfs.join(&rel_path).exists(); - let install_rel_path = if keep_as_depotnew { - format!("{}.depotnew", rel_path) - } else { - rel_path.clone() - }; - - let dest_path = rootfs.join(&install_rel_path); - - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent)?; - } - - if let Ok(dest_meta) = dest_path.symlink_metadata() { - let backup_path = tx.backup_path(&install_rel_path); - if dest_meta.file_type().is_dir() { - let can_relocate = file_type.is_symlink() - && can_relocate_directory_for_symlink_swap( - rootfs, - &install_rel_path, - src_path, - )?; - if !remove_set.contains(install_rel_path.as_str()) && !can_relocate { - anyhow::bail!( - "Refusing to replace existing directory with packaged file/symlink: {}", - install_rel_path - ); - } - backup_existing_path(&dest_path, &backup_path, &install_rel_path)?; - tx.backed_up.push(install_rel_path.clone()); - remove_obsolete_children_for_dir( - &mut tx, - rootfs, - &install_rel_path, - remove_paths, - )?; - if file_type.is_symlink() { - tx.relocate_directory_for_symlink_swap(&install_rel_path)?; - } - } else { - backup_existing_path(&dest_path, &backup_path, &install_rel_path)?; - tx.backed_up.push(install_rel_path.clone()); - } - } else { - tx.created.push(install_rel_path.clone()); - } - - // Install new file/symlink - // Remove destination if it exists (we backed it up) so we can overwrite - if let Ok(dest_meta) = dest_path.symlink_metadata() { - if dest_meta.file_type().is_dir() { - let relocated_for_symlink_swap = - tx.relocated.iter().any(|rel| rel == &install_rel_path); - if !remove_set.contains(install_rel_path.as_str()) - && !relocated_for_symlink_swap - { - anyhow::bail!( - "Refusing to replace existing directory with packaged file/symlink: {}", - install_rel_path - ); - } - if !is_directory_empty(&dest_path)? { - anyhow::bail!( - "Refusing to replace existing non-empty directory with packaged file/symlink: {}", - install_rel_path - ); - } - fs::remove_dir(&dest_path)?; - } else { - fs::remove_file(&dest_path)?; - } - } - - if file_type.is_symlink() { - let target = fs::read_link(src_path) - .with_context(|| format!("Failed to read staged symlink {}", rel_path))?; - std::os::unix::fs::symlink(target, &dest_path) - .with_context(|| format!("Failed to create symlink: {}", install_rel_path))?; - tx.replay_relocated_dir_if_present(&install_rel_path)?; - } else { - let hardlink_key = if keep_as_depotnew { - None - } else { - hardlink_key(&metadata) - }; - if let Some(first_rel_path) = - hardlink_key.and_then(|key| installed_hardlinks.get(&key)) - { - let first_path = rootfs.join(first_rel_path); - fs::hard_link(&first_path, &dest_path).with_context(|| { - format!( - "Failed to install hardlink: {} -> {}", - install_rel_path, first_rel_path - ) - })?; - } else { - fs::copy(src_path, &dest_path) - .with_context(|| format!("Failed to install: {}", install_rel_path))?; - apply_unix_mode(&dest_path, &metadata)?; - if let Some(key) = hardlink_key { - installed_hardlinks.insert(key, install_rel_path.clone()); - } - } - } - staged_paths.insert(install_rel_path); - } - - // Remove obsolete files/directories left behind by the previous version. - for rel in remove_paths { - if staged_paths.contains(rel) || tx.removed.iter().any(|removed| removed == rel) { - continue; - } - let _ = backup_and_remove_obsolete_path(&mut tx, rootfs, rel, false)?; - } - - Ok(()) - })(); - - if let Err(e) = result { - let _ = tx.rollback(); - return Err(e); - } - - Ok(tx) -} +pub use manifest::*; +pub use transaction::*; #[cfg(test)] -mod tests { - use super::*; - use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; - use std::io::Read; - - fn mk_spec_for_stage_processing() -> PackageSpec { - let flags = BuildFlags { - no_strip: true, - no_compress_man: true, - ..BuildFlags::default() - }; - PackageSpec { - package: PackageInfo { - name: "foo".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "d".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }, - packages: Vec::new(), - alternatives: Default::default(), - manual_sources: Vec::new(), - source: Vec::new(), - build: Build { - build_type: BuildType::Custom, - flags, - }, - dependencies: Dependencies::default(), - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - } - } - - #[test] - fn process_removes_static_archives_by_default() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); - std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); - std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); - std::fs::write(destdir.join("usr/lib/libfoo.so"), "shared").unwrap(); - - let spec = mk_spec_for_stage_processing(); - process(&destdir, &spec).unwrap(); - - assert!(!destdir.join("usr/lib/libfoo.a").exists()); - assert!(!destdir.join("usr/lib/libfoo.la").exists()); - assert!(destdir.join("usr/lib/libfoo.so").exists()); - } - - #[test] - fn process_preserves_static_archives_when_disabled() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); - std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); - std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); - - let mut spec = mk_spec_for_stage_processing(); - spec.build.flags.no_delete_static = true; - process(&destdir, &spec).unwrap(); - - assert!(destdir.join("usr/lib/libfoo.a").exists()); - assert!(!destdir.join("usr/lib/libfoo.la").exists()); - } - - #[test] - fn process_splits_docs_into_docs_output() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/share/doc/foo")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/share/gtk-doc/html/foo")).unwrap(); - std::fs::create_dir_all(destdir.join("opt/foo-docs")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("usr/share/doc/foo/README"), "doc").unwrap(); - std::fs::write(destdir.join("usr/share/gtk-doc/html/foo/index.html"), "gtk").unwrap(); - std::fs::write(destdir.join("opt/foo-docs/guide.txt"), "guide").unwrap(); - std::fs::write(destdir.join("usr/bin/foo"), "bin").unwrap(); - - let mut spec = mk_spec_for_stage_processing(); - spec.build.flags.split_docs = true; - spec.build.flags.doc_dirs = vec!["/opt/foo-docs".to_string()]; - - process(&destdir, &spec).unwrap(); - - let docs_destdir = output_staging_dir(&destdir, "foo-docs"); - assert!(docs_destdir.join("usr/share/doc/foo/README").exists()); - assert!( - docs_destdir - .join("usr/share/gtk-doc/html/foo/index.html") - .exists() - ); - assert!(docs_destdir.join("opt/foo-docs/guide.txt").exists()); - assert!(destdir.join("usr/bin/foo").exists()); - assert!(!destdir.join("usr/share/doc/foo/README").exists()); - assert!( - !destdir - .join("usr/share/gtk-doc/html/foo/index.html") - .exists() - ); - assert!(!destdir.join("opt/foo-docs/guide.txt").exists()); - } - - #[test] - fn process_splits_docs_for_additional_outputs() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - let dev_destdir = output_staging_dir(&destdir, "foo-dev"); - std::fs::create_dir_all(dev_destdir.join("usr/share/doc/foo-dev")).unwrap(); - std::fs::create_dir_all(dev_destdir.join("usr/include")).unwrap(); - std::fs::write(dev_destdir.join("usr/share/doc/foo-dev/README"), "doc").unwrap(); - std::fs::write(dev_destdir.join("usr/include/foo.h"), "header").unwrap(); - - let mut spec = mk_spec_for_stage_processing(); - spec.packages.push(PackageInfo { - name: "foo-dev".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "dev".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }); - spec.build.flags.split_docs = true; - - process(&destdir, &spec).unwrap(); - - let docs_destdir = output_staging_dir(&destdir, "foo-dev-docs"); - assert!(docs_destdir.join("usr/share/doc/foo-dev/README").exists()); - assert!(dev_destdir.join("usr/include/foo.h").exists()); - assert!(!dev_destdir.join("usr/share/doc/foo-dev/README").exists()); - } - - #[test] - fn add_licenses_copies_common_files() { - let tmp = tempfile::tempdir().unwrap(); - let src_dir = tmp.path().join("src"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&src_dir).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - std::fs::write(src_dir.join("LICENSE"), "license text").unwrap(); - std::fs::write(src_dir.join("COPYING.md"), "copying text").unwrap(); - std::fs::write(src_dir.join("README"), "not a license").unwrap(); - - let copied = add_licenses(&src_dir, &destdir, "foo").unwrap(); - assert_eq!(copied, 2); - - let lic_dir = destdir.join("usr/share/licenses/foo"); - assert!(lic_dir.join("LICENSE").exists()); - assert!(lic_dir.join("COPYING.md").exists()); - assert!(!lic_dir.join("README").exists()); - } - - #[test] - fn compress_manpages_zstd_detects_split_output_payload_paths() { - let tmp = tempfile::tempdir().unwrap(); - let dest = tmp.path().join("dest"); - let page = output_staging_dir(&dest, "clang").join("usr/share/man/man1/clang.1"); - std::fs::create_dir_all(page.parent().unwrap()).unwrap(); - std::fs::write(&page, b"clang manpage\n").unwrap(); - - let count = compress_manpages_zstd(&dest).unwrap(); - assert_eq!(count, 1); - assert!(!page.exists()); - - let compressed = page.with_extension("1.zst"); - assert!(compressed.exists()); - let encoded = std::fs::read(&compressed).unwrap(); - let decoded = zstd::stream::decode_all(std::io::Cursor::new(encoded)).unwrap(); - assert_eq!(String::from_utf8(decoded).unwrap(), "clang manpage\n"); - } - - #[test] - fn stage_split_package_licenses_symlinks_matching_outputs_and_copies_distinct_ones() { - let tmp = tempfile::tempdir().unwrap(); - let src_dir = tmp.path().join("src"); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&src_dir).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - std::fs::write(src_dir.join("LICENSE"), "license text").unwrap(); - add_licenses(&src_dir, &destdir, "foo").unwrap(); - - let mut spec = mk_spec_for_stage_processing(); - spec.packages.push(PackageInfo { - name: "foo-dev".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "dev".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["MIT".into()], - }); - spec.packages.push(PackageInfo { - name: "foo-extras".into(), - real_name: None, - version: "1.0".into(), - revision: 1, - description: "extras".into(), - homepage: "h".into(), - abi_breaking: false, - built_against: Vec::new(), - license: vec!["Apache-2.0".into()], - }); - - let dev_dest = output_staging_dir(&destdir, "foo-dev").join("usr/bin"); - let extras_dest = output_staging_dir(&destdir, "foo-extras").join("usr/bin"); - std::fs::create_dir_all(&dev_dest).unwrap(); - std::fs::create_dir_all(&extras_dest).unwrap(); - std::fs::write(dev_dest.join("foo-dev"), "bin").unwrap(); - std::fs::write(extras_dest.join("foo-extras"), "bin").unwrap(); - - stage_split_package_licenses(&src_dir, &destdir, &spec).unwrap(); - - let dev_license = - output_staging_dir(&destdir, "foo-dev").join("usr/share/licenses/foo-dev"); - let dev_meta = std::fs::symlink_metadata(&dev_license).unwrap(); - assert!(dev_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(&dev_license).unwrap(), - PathBuf::from("foo") - ); - - let extras_license = - output_staging_dir(&destdir, "foo-extras").join("usr/share/licenses/foo-extras"); - let extras_meta = std::fs::symlink_metadata(&extras_license).unwrap(); - assert!(extras_meta.is_dir()); - let mut text = String::new(); - std::fs::File::open(extras_license.join("LICENSE")) - .unwrap() - .read_to_string(&mut text) - .unwrap(); - assert_eq!(text, "license text"); - } - - #[test] - fn install_atomic_update_and_rollback_restores_state() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - - // Existing installed files - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::write(rootfs.join("usr/bin/foo"), "old").unwrap(); - std::fs::write(rootfs.join("usr/bin/old_only"), "to_remove").unwrap(); - - // New staged files - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("usr/bin/foo"), "new").unwrap(); - std::fs::write(destdir.join("usr/bin/new_only"), "added").unwrap(); - - let remove_paths = vec!["usr/bin/old_only".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); - - // After install: updated + new present, obsolete removed - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), - "new" - ); - assert!(rootfs.join("usr/bin/new_only").exists()); - assert!(!rootfs.join("usr/bin/old_only").exists()); - - // Roll back should restore old state - tx.rollback().unwrap(); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), - "old" - ); - assert!(!rootfs.join("usr/bin/new_only").exists()); - assert!(rootfs.join("usr/bin/old_only").exists()); - } - - #[test] - fn install_atomic_keep_existing_installs_depotnew() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("etc")).unwrap(); - std::fs::create_dir_all(destdir.join("etc")).unwrap(); - - std::fs::write(rootfs.join("etc/locale.gen"), "existing").unwrap(); - std::fs::write(destdir.join("etc/locale.gen"), "from-package").unwrap(); - - let keep = vec!["etc/locale.gen".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); - - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), - "existing" - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/locale.gen.depotnew")).unwrap(), - "from-package" - ); - - tx.rollback().unwrap(); - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), - "existing" - ); - assert!(!rootfs.join("etc/locale.gen.depotnew").exists()); - } - - #[test] - fn install_atomic_preserves_staged_hardlinks() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - - let coreutils = destdir.join("usr/bin/coreutils"); - let ls = destdir.join("usr/bin/ls"); - std::fs::write(&coreutils, "multicall").unwrap(); - std::fs::hard_link(&coreutils, &ls).unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - let coreutils_meta = rootfs.join("usr/bin/coreutils").metadata().unwrap(); - let ls_meta = rootfs.join("usr/bin/ls").metadata().unwrap(); - assert_eq!(coreutils_meta.ino(), ls_meta.ino()); - assert_eq!(coreutils_meta.nlink(), 2); - assert_eq!(ls_meta.nlink(), 2); - } - - #[test] - fn install_atomic_keep_wildcard_matches_directory_children() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("etc/pam.d")).unwrap(); - std::fs::create_dir_all(destdir.join("etc/pam.d")).unwrap(); - std::fs::create_dir_all(destdir.join("etc/pam.d/subdir")).unwrap(); - - std::fs::write(rootfs.join("etc/pam.d/system-auth"), "existing-auth").unwrap(); - std::fs::write(destdir.join("etc/pam.d/system-auth"), "pkg-auth").unwrap(); - std::fs::write(destdir.join("etc/pam.d/other"), "pkg-other").unwrap(); - std::fs::write(destdir.join("etc/pam.d/subdir/nested"), "pkg-nested").unwrap(); - - let keep = vec!["etc/pam.d/*".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); - - // Existing matched file is preserved and package version becomes .depotnew - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth")).unwrap(), - "existing-auth" - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth.depotnew")).unwrap(), - "pkg-auth" - ); - - // New matched file installs normally because no existing file is present - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/pam.d/other")).unwrap(), - "pkg-other" - ); - - // Single-segment * does not cross '/' - assert_eq!( - std::fs::read_to_string(rootfs.join("etc/pam.d/subdir/nested")).unwrap(), - "pkg-nested" - ); - assert!(!rootfs.join("etc/pam.d/subdir/nested.depotnew").exists()); - - tx.rollback().unwrap(); - } - - #[test] - #[cfg(unix)] - fn install_atomic_replaces_existing_symlink_without_touching_target_directory() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - std::fs::write(rootfs.join("usr/bin/existing"), "keep-me").unwrap(); - std::os::unix::fs::symlink("usr/bin", rootfs.join("bin")).unwrap(); - std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap(); - - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert_eq!( - std::fs::read_link(rootfs.join("bin")).unwrap(), - PathBuf::from("usr/bin") - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/existing")).unwrap(), - "keep-me" - ); - - tx.rollback().unwrap(); - } - - #[test] - #[cfg(unix)] - fn install_atomic_rejects_replacing_directory_with_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr")).unwrap(); - std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); - - let err = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap_err(); - assert!( - err.to_string() - .contains("Refusing to replace existing directory with packaged file/symlink") - ); - } - - #[test] - #[cfg(unix)] - fn install_atomic_relocates_existing_directory_into_packaged_symlink_target() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = rootfs.join("var/cache/depot/build/tx"); - std::fs::create_dir_all(rootfs.join("lib/depot")).unwrap(); - std::fs::write(rootfs.join("lib/depot/lock"), "state").unwrap(); - std::fs::create_dir_all(rootfs.join("usr/lib/depot")).unwrap(); - std::fs::write(rootfs.join("usr/lib/depot/lock"), "state").unwrap(); - std::fs::create_dir_all(destdir.join("usr/lib/misc")).unwrap(); - std::os::unix::fs::symlink("usr/lib", destdir.join("lib")).unwrap(); - - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - let lib_meta = rootfs.join("lib").symlink_metadata().unwrap(); - assert!(lib_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(rootfs.join("lib")).unwrap(), - PathBuf::from("usr/lib") - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/lib/depot/lock")).unwrap(), - "state" - ); - assert!(rootfs.join("usr/lib/misc").is_dir()); - - tx.rollback().unwrap(); - let restored = rootfs.join("lib").symlink_metadata().unwrap(); - assert!(restored.file_type().is_dir()); - assert_eq!( - std::fs::read_to_string(rootfs.join("lib/depot/lock")).unwrap(), - "state" - ); - } - - #[test] - #[cfg(unix)] - fn install_atomic_replaces_obsolete_directory_with_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr")).unwrap(); - std::fs::write(rootfs.join("usr/sbin/legacy"), "old").unwrap(); - std::fs::write(destdir.join("usr/bin/legacy"), "new").unwrap(); - std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); - - let remove_paths = vec!["usr/sbin/legacy".to_string(), "usr/sbin".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); - - let sbin_meta = rootfs.join("usr/sbin").symlink_metadata().unwrap(); - assert!(sbin_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(rootfs.join("usr/sbin")).unwrap(), - PathBuf::from("bin") - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/legacy")).unwrap(), - "new" - ); - - tx.rollback().unwrap(); - let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); - assert!(restored.file_type().is_dir()); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/sbin/legacy")).unwrap(), - "old" - ); - } - - #[test] - #[cfg(unix)] - fn install_atomic_preserves_non_obsolete_directory_contents_when_replacing_with_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr")).unwrap(); - std::fs::write(rootfs.join("usr/sbin/keep"), "keep-me").unwrap(); - std::fs::write(rootfs.join("usr/sbin/legacy"), "old").unwrap(); - std::fs::write(destdir.join("usr/bin/legacy"), "new").unwrap(); - std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); - - let remove_paths = vec!["usr/sbin/legacy".to_string(), "usr/sbin".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); - - let sbin_meta = rootfs.join("usr/sbin").symlink_metadata().unwrap(); - assert!(sbin_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/keep")).unwrap(), - "keep-me" - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), - "keep-me" - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/legacy")).unwrap(), - "new" - ); - - tx.rollback().unwrap(); - let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); - assert!(restored.file_type().is_dir()); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), - "keep-me" - ); - assert!(!rootfs.join("usr/bin/keep").exists()); - } - - #[test] - #[cfg(unix)] - fn install_atomic_rejects_symlink_swap_when_relocated_contents_conflict_with_target() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); - std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr")).unwrap(); - std::fs::write(rootfs.join("usr/sbin/keep"), "keep-me").unwrap(); - std::fs::write(rootfs.join("usr/bin/keep"), "target-conflict").unwrap(); - std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); - - let remove_paths = vec!["usr/sbin".to_string()]; - let err = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap_err(); - assert!( - err.to_string() - .contains("Failed to replay relocated path into") - ); - - let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); - assert!(restored.file_type().is_dir()); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), - "keep-me" - ); - assert_eq!( - std::fs::read_to_string(rootfs.join("usr/bin/keep")).unwrap(), - "target-conflict" - ); - } - - #[test] - fn keep_glob_matches_question_mark_and_not_path_separator() { - assert!(glob_match_path( - "etc/pam.d/system-????", - "etc/pam.d/system-auth" - )); - assert!(!glob_match_path("etc/pam.d/*", "etc/pam.d/subdir/file")); - assert!(glob_match_path("etc/pam.d/*", "etc/pam.d/file")); - assert!(glob_match_path("etc/pam.d/**", "etc/pam.d/subdir/file")); - assert!(glob_match_path("etc/**/file", "etc/pam.d/subdir/file")); - assert!(glob_match_path("etc/pam.d/**", "etc/pam.d")); - } - - #[test] - fn is_manpage_rel_path_detects_uncompressed_manpages() { - assert!(is_manpage_rel_path("usr/share/man/man1/ls.1")); - assert!(is_manpage_rel_path("/usr/share/man/man5/pam.d.5")); - assert!(!is_manpage_rel_path("usr/share/man/man1/ls.1.zst")); - assert!(!is_manpage_rel_path("usr/share/doc/readme")); - } - - #[test] - fn is_elf_file_detects_magic_bytes() { - let tmp = tempfile::tempdir().unwrap(); - let elf = tmp.path().join("elf.bin"); - let text = tmp.path().join("text.txt"); - std::fs::write(&elf, [0x7F, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); - std::fs::write(&text, b"#!/bin/sh\n").unwrap(); - - assert!(is_elf_file(&elf).unwrap()); - assert!(!is_elf_file(&text).unwrap()); - } - - #[test] - fn auto_strip_elf_files_restores_hardlinks_when_strip_replaces_file() { - use std::os::unix::fs::PermissionsExt; - - let tmp = tempfile::tempdir().unwrap(); - let dest = tmp.path().join("dest"); - let bin = dest.join("usr/bin"); - std::fs::create_dir_all(&bin).unwrap(); - - let fake_strip = tmp.path().join("fake-strip"); - std::fs::write( - &fake_strip, - "#!/bin/sh\nfor arg do path=$arg; done\ntmp=\"$path.tmp\"\ncp \"$path\" \"$tmp\"\nprintf stripped >> \"$tmp\"\nmv \"$tmp\" \"$path\"\n", - ) - .unwrap(); - let mut perms = std::fs::metadata(&fake_strip).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&fake_strip, perms).unwrap(); - - let coreutils = bin.join("coreutils"); - let ls = bin.join("ls"); - std::fs::write(&coreutils, [0x7F, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); - std::fs::hard_link(&coreutils, &ls).unwrap(); - - let stripped = auto_strip_elf_files(&dest, fake_strip.to_str().unwrap()).unwrap(); - - let coreutils_meta = coreutils.metadata().unwrap(); - let ls_meta = ls.metadata().unwrap(); - assert_eq!(stripped, 2); - assert_eq!(coreutils_meta.ino(), ls_meta.ino()); - assert_eq!(coreutils_meta.nlink(), 2); - assert_eq!(ls_meta.nlink(), 2); - assert_eq!( - std::fs::read(&coreutils).unwrap(), - std::fs::read(&ls).unwrap() - ); - } - - #[test] - fn compress_manpages_zstd_rewrites_symlinks() { - let tmp = tempfile::tempdir().unwrap(); - let dest = tmp.path().join("dest"); - let man1 = dest.join("usr/share/man/man1"); - std::fs::create_dir_all(&man1).unwrap(); - - let page = man1.join("foo.1"); - std::fs::write(&page, b"foo manpage\n").unwrap(); - std::os::unix::fs::symlink("foo.1", man1.join("bar.1")).unwrap(); - - let count = compress_manpages_zstd(&dest).unwrap(); - assert_eq!(count, 1); - assert!(!man1.join("foo.1").exists()); - assert!(man1.join("foo.1.zst").exists()); - assert!(!man1.join("bar.1").exists()); - - let link_meta = std::fs::symlink_metadata(man1.join("bar.1.zst")).unwrap(); - assert!(link_meta.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(man1.join("bar.1.zst")).unwrap(), - PathBuf::from("foo.1.zst") - ); - - let file = std::fs::File::open(man1.join("foo.1.zst")).unwrap(); - let mut decoder = zstd::stream::read::Decoder::new(file).unwrap(); - let mut out = String::new(); - use std::io::Read as _; - decoder.read_to_string(&mut out).unwrap(); - assert_eq!(out, "foo manpage\n"); - } - - #[test] - fn install_atomic_rejects_unsafe_keep_paths() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("etc")).unwrap(); - std::fs::write(destdir.join("etc/locale.gen"), "x").unwrap(); - - let keep = vec!["../etc/shadow".to_string()]; - let err = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep) - .expect_err("expected keep path traversal to be rejected"); - assert!( - err.to_string() - .contains("keep paths must not contain traversal") - ); - } - - #[test] - fn install_atomic_removes_obsolete_symlink_paths() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(rootfs.join("usr/lib")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("usr/bin/new"), "ok").unwrap(); - - std::os::unix::fs::symlink("../lib/libold.so", rootfs.join("usr/lib/libold.so.link")) - .unwrap(); - assert!( - rootfs - .join("usr/lib/libold.so.link") - .symlink_metadata() - .is_ok() - ); - - let remove_paths = vec!["usr/lib/libold.so.link".to_string()]; - let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); - - assert!( - rootfs - .join("usr/lib/libold.so.link") - .symlink_metadata() - .is_err() - ); - - tx.rollback().unwrap(); - let restored = rootfs - .join("usr/lib/libold.so.link") - .symlink_metadata() - .expect("symlink should be restored"); - assert!(restored.file_type().is_symlink()); - } - - #[test] - fn install_atomic_commit_removes_tx_dir() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("usr/bin/foo"), "x").unwrap(); - - let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - let tx_dir = tx.tx_dir.clone(); - assert!(tx_dir.exists()); - tx.commit().unwrap(); - assert!(!tx_dir.exists()); - } - - #[test] - fn test_install_atomic_symlink_to_directory() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - // Create a symlink bin -> usr/bin in destdir - std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - // Verify rootfs/bin is a symlink, not a directory - let meta = rootfs - .join("bin") - .symlink_metadata() - .expect("bin should exist"); - assert!(meta.file_type().is_symlink(), "bin should be a symlink"); - assert_eq!( - std::fs::read_link(rootfs.join("bin")).unwrap(), - std::path::PathBuf::from("usr/bin") - ); - } - - #[test] - fn test_walkdir_symlink_behavior() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path(); - std::fs::create_dir_all(dir.join("target")).unwrap(); - std::os::unix::fs::symlink("target", dir.join("link")).unwrap(); - - for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { - if entry.path().ends_with("link") { - let ft = entry.file_type(); - assert!( - !ft.is_dir(), - "walkdir should NOT report symlink to dir as a directory" - ); - assert!(ft.is_symlink(), "walkdir SHOULD report it as a symlink"); - } - } - } - - #[test] - fn install_atomic_skips_info_dir_index() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("usr/info")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); - std::fs::write(destdir.join("usr/info/dir"), "legacy index").unwrap(); - std::fs::write(destdir.join("usr/info/dir.bz2"), "legacy index bz2").unwrap(); - std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); - std::fs::write(destdir.join("usr/share/info/dir.gz"), "index gz").unwrap(); - std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!(!rootfs.join("usr/info/dir").exists()); - assert!(!rootfs.join("usr/info/dir.bz2").exists()); - assert!(!rootfs.join("usr/share/info/dir").exists()); - assert!(!rootfs.join("usr/share/info/dir.gz").exists()); - assert!(rootfs.join("usr/share/info/ok.info").exists()); - } - - #[test] - fn install_atomic_skips_packlists_and_pod_files() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/core_perl")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/share/doc/perl-error")).unwrap(); - std::fs::write( - destdir.join("usr/lib/perl5/5.42/core_perl/perllocal.pod"), - "perllocal", - ) - .unwrap(); - std::fs::write( - destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist"), - "packlist", - ) - .unwrap(); - std::fs::write(destdir.join("usr/share/doc/perl-error/Error.pod"), "pod").unwrap(); - std::fs::write(destdir.join("usr/share/doc/perl-error/README"), "readme").unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!( - !rootfs - .join("usr/lib/perl5/5.42/core_perl/perllocal.pod") - .exists() - ); - assert!( - !rootfs - .join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist") - .exists() - ); - assert!(!rootfs.join("usr/share/doc/perl-error/Error.pod").exists()); - assert!(rootfs.join("usr/share/doc/perl-error/README").exists()); - } - - #[test] - fn install_atomic_skips_package_metadata_files() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(&destdir).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); - std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); - std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!(!rootfs.join(".metadata.toml").exists()); - assert!(!rootfs.join(".files.yaml").exists()); - assert!(rootfs.join("usr/bin/ok").exists()); - } - - #[test] - fn install_atomic_skips_package_scripts_dir() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join("scripts")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("scripts/pre_install"), "#!/bin/sh\necho pre\n").unwrap(); - std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!(!rootfs.join("scripts/pre_install").exists()); - assert!(rootfs.join("usr/bin/ok").exists()); - } - - #[test] - fn install_atomic_skips_internal_output_staging_dir() { - let tmp = tempfile::tempdir().unwrap(); - let rootfs = tmp.path().join("root"); - let destdir = tmp.path().join("dest"); - let tx_base = tmp.path().join("tx"); - std::fs::create_dir_all(&rootfs).unwrap(); - std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); - std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); - - let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); - - assert!(rootfs.join("usr/bin/ok").exists()); - assert!(!rootfs.join(".depot").exists()); - } - - #[test] - fn generate_manifest_skips_info_dir_index() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/info")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); - std::fs::write(destdir.join("usr/info/dir"), "legacy index").unwrap(); - std::fs::write(destdir.join("usr/info/dir.zst"), "legacy index zst").unwrap(); - std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); - std::fs::write(destdir.join("usr/share/info/dir.xz"), "index xz").unwrap(); - std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); - - let manifest = generate_manifest_with_dirs(&destdir).unwrap(); - - assert!(!manifest.files.contains(&"usr/info/dir".to_string())); - assert!(!manifest.files.contains(&"usr/info/dir.zst".to_string())); - assert!(!manifest.files.contains(&"usr/share/info/dir".to_string())); - assert!( - !manifest - .files - .contains(&"usr/share/info/dir.xz".to_string()) - ); - assert!( - manifest - .files - .contains(&"usr/share/info/ok.info".to_string()) - ); - } - - #[test] - fn generate_manifest_skips_packlists_and_pod_files() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/core_perl")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/share/doc/perl-error")).unwrap(); - std::fs::write( - destdir.join("usr/lib/perl5/5.42/core_perl/perllocal.pod"), - "perllocal", - ) - .unwrap(); - std::fs::write( - destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist"), - "packlist", - ) - .unwrap(); - std::fs::write(destdir.join("usr/share/doc/perl-error/Error.pod"), "pod").unwrap(); - std::fs::write(destdir.join("usr/share/doc/perl-error/README"), "readme").unwrap(); - - let manifest = generate_manifest_with_dirs(&destdir).unwrap(); - - assert!( - !manifest - .files - .contains(&"usr/lib/perl5/5.42/core_perl/perllocal.pod".to_string()) - ); - assert!( - !manifest - .files - .contains(&"usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist".to_string()) - ); - assert!( - !manifest - .files - .contains(&"usr/share/doc/perl-error/Error.pod".to_string()) - ); - assert!( - manifest - .files - .contains(&"usr/share/doc/perl-error/README".to_string()) - ); - } - - #[test] - fn generate_manifest_skips_package_metadata_files() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(&destdir).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); - std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); - std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); - - let manifest = generate_manifest_with_dirs(&destdir).unwrap(); - - assert!(!manifest.files.contains(&".metadata.toml".to_string())); - assert!(!manifest.files.contains(&".files.yaml".to_string())); - assert!(manifest.files.contains(&"usr/bin/ok".to_string())); - } - - #[test] - fn generate_manifest_skips_package_scripts_dir() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("scripts")).unwrap(); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::write(destdir.join("scripts/pre_install"), "echo pre").unwrap(); - std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); - - let manifest = generate_manifest_with_dirs(&destdir).unwrap(); - - assert!(!manifest.files.contains(&"scripts/pre_install".to_string())); - assert!(manifest.files.contains(&"usr/bin/ok".to_string())); - } - - #[test] - fn generate_manifest_skips_internal_output_staging() { - let tmp = tempfile::tempdir().unwrap(); - let destdir = tmp.path().join("dest"); - std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); - std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); - std::fs::write(destdir.join("usr/bin/llvm-config"), "ok").unwrap(); - std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); - - let manifest = generate_manifest_with_dirs(&destdir).unwrap(); - - assert!(manifest.files.contains(&"usr/bin/llvm-config".to_string())); - assert!( - !manifest - .files - .contains(&".depot/outputs/clang/usr/bin/clang".to_string()) - ); - } -} - -/// Manifest containing files and directories for a package -#[derive(Debug, Clone)] -pub struct Manifest { - pub files: Vec, - pub directories: Vec, -} - -/// Generate manifest with both files and directories -pub fn generate_manifest_with_dirs(destdir: &Path) -> Result { - let mut files = Vec::new(); - let mut directories = Vec::new(); - - for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { - let rel_path = entry - .path() - .strip_prefix(destdir)? - .to_string_lossy() - .to_string(); - - // Skip the root (empty path) - if rel_path.is_empty() { - continue; - } - - if is_skipped_install_path(&rel_path) { - continue; - } - - let file_type = entry.file_type(); - - // Check for symlink first - if file_type.is_symlink() { - // Track symlinks as files (they get removed the same way) - files.push(rel_path); - } else if file_type.is_file() { - files.push(rel_path); - } else if file_type.is_dir() { - directories.push(rel_path); - } - } - - Ok(Manifest { files, directories }) -} +mod tests; diff --git a/src/staging/tests.rs b/src/staging/tests.rs new file mode 100644 index 0000000..3107e1e --- /dev/null +++ b/src/staging/tests.rs @@ -0,0 +1,1037 @@ +use super::*; +use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec}; +use std::io::Read; + +fn mk_spec_for_stage_processing() -> PackageSpec { + let flags = BuildFlags { + no_strip: true, + no_compress_man: true, + ..BuildFlags::default() + }; + PackageSpec { + package: PackageInfo { + name: "foo".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Default::default(), + manual_sources: Vec::new(), + source: Vec::new(), + build: Build { + build_type: BuildType::Custom, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + +#[test] +fn process_removes_static_archives_by_default() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.so"), "shared").unwrap(); + + let spec = mk_spec_for_stage_processing(); + process(&destdir, &spec).unwrap(); + + assert!(!destdir.join("usr/lib/libfoo.a").exists()); + assert!(!destdir.join("usr/lib/libfoo.la").exists()); + assert!(destdir.join("usr/lib/libfoo.so").exists()); +} + +#[test] +fn process_preserves_static_archives_when_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/lib")).unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.a"), "static").unwrap(); + std::fs::write(destdir.join("usr/lib/libfoo.la"), "libtool").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.build.flags.no_delete_static = true; + process(&destdir, &spec).unwrap(); + + assert!(destdir.join("usr/lib/libfoo.a").exists()); + assert!(!destdir.join("usr/lib/libfoo.la").exists()); +} + +#[test] +fn process_splits_docs_into_docs_output() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/share/doc/foo")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/gtk-doc/html/foo")).unwrap(); + std::fs::create_dir_all(destdir.join("opt/foo-docs")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/share/doc/foo/README"), "doc").unwrap(); + std::fs::write(destdir.join("usr/share/gtk-doc/html/foo/index.html"), "gtk").unwrap(); + std::fs::write(destdir.join("opt/foo-docs/guide.txt"), "guide").unwrap(); + std::fs::write(destdir.join("usr/bin/foo"), "bin").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.build.flags.split_docs = true; + spec.build.flags.doc_dirs = vec!["/opt/foo-docs".to_string()]; + + process(&destdir, &spec).unwrap(); + + let docs_destdir = output_staging_dir(&destdir, "foo-docs"); + assert!(docs_destdir.join("usr/share/doc/foo/README").exists()); + assert!( + docs_destdir + .join("usr/share/gtk-doc/html/foo/index.html") + .exists() + ); + assert!(docs_destdir.join("opt/foo-docs/guide.txt").exists()); + assert!(destdir.join("usr/bin/foo").exists()); + assert!(!destdir.join("usr/share/doc/foo/README").exists()); + assert!( + !destdir + .join("usr/share/gtk-doc/html/foo/index.html") + .exists() + ); + assert!(!destdir.join("opt/foo-docs/guide.txt").exists()); +} + +#[test] +fn process_splits_docs_for_additional_outputs() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + let dev_destdir = output_staging_dir(&destdir, "foo-dev"); + std::fs::create_dir_all(dev_destdir.join("usr/share/doc/foo-dev")).unwrap(); + std::fs::create_dir_all(dev_destdir.join("usr/include")).unwrap(); + std::fs::write(dev_destdir.join("usr/share/doc/foo-dev/README"), "doc").unwrap(); + std::fs::write(dev_destdir.join("usr/include/foo.h"), "header").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.packages.push(PackageInfo { + name: "foo-dev".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "dev".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }); + spec.build.flags.split_docs = true; + + process(&destdir, &spec).unwrap(); + + let docs_destdir = output_staging_dir(&destdir, "foo-dev-docs"); + assert!(docs_destdir.join("usr/share/doc/foo-dev/README").exists()); + assert!(dev_destdir.join("usr/include/foo.h").exists()); + assert!(!dev_destdir.join("usr/share/doc/foo-dev/README").exists()); +} + +#[test] +fn add_licenses_copies_common_files() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("src"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + std::fs::write(src_dir.join("LICENSE"), "license text").unwrap(); + std::fs::write(src_dir.join("COPYING.md"), "copying text").unwrap(); + std::fs::write(src_dir.join("README"), "not a license").unwrap(); + + let copied = add_licenses(&src_dir, &destdir, "foo").unwrap(); + assert_eq!(copied, 2); + + let lic_dir = destdir.join("usr/share/licenses/foo"); + assert!(lic_dir.join("LICENSE").exists()); + assert!(lic_dir.join("COPYING.md").exists()); + assert!(!lic_dir.join("README").exists()); +} + +#[test] +fn compress_manpages_zstd_detects_split_output_payload_paths() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("dest"); + let page = output_staging_dir(&dest, "clang").join("usr/share/man/man1/clang.1"); + std::fs::create_dir_all(page.parent().unwrap()).unwrap(); + std::fs::write(&page, b"clang manpage\n").unwrap(); + + let count = compress_manpages_zstd(&dest).unwrap(); + assert_eq!(count, 1); + assert!(!page.exists()); + + let compressed = page.with_extension("1.zst"); + assert!(compressed.exists()); + let encoded = std::fs::read(&compressed).unwrap(); + let decoded = zstd::stream::decode_all(std::io::Cursor::new(encoded)).unwrap(); + assert_eq!(String::from_utf8(decoded).unwrap(), "clang manpage\n"); +} + +#[test] +fn stage_split_package_licenses_symlinks_matching_outputs_and_copies_distinct_ones() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("src"); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::write(src_dir.join("LICENSE"), "license text").unwrap(); + add_licenses(&src_dir, &destdir, "foo").unwrap(); + + let mut spec = mk_spec_for_stage_processing(); + spec.packages.push(PackageInfo { + name: "foo-dev".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "dev".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }); + spec.packages.push(PackageInfo { + name: "foo-extras".into(), + real_name: None, + version: "1.0".into(), + revision: 1, + description: "extras".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["Apache-2.0".into()], + }); + + let dev_dest = output_staging_dir(&destdir, "foo-dev").join("usr/bin"); + let extras_dest = output_staging_dir(&destdir, "foo-extras").join("usr/bin"); + std::fs::create_dir_all(&dev_dest).unwrap(); + std::fs::create_dir_all(&extras_dest).unwrap(); + std::fs::write(dev_dest.join("foo-dev"), "bin").unwrap(); + std::fs::write(extras_dest.join("foo-extras"), "bin").unwrap(); + + stage_split_package_licenses(&src_dir, &destdir, &spec).unwrap(); + + let dev_license = output_staging_dir(&destdir, "foo-dev").join("usr/share/licenses/foo-dev"); + let dev_meta = std::fs::symlink_metadata(&dev_license).unwrap(); + assert!(dev_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(&dev_license).unwrap(), + PathBuf::from("foo") + ); + + let extras_license = + output_staging_dir(&destdir, "foo-extras").join("usr/share/licenses/foo-extras"); + let extras_meta = std::fs::symlink_metadata(&extras_license).unwrap(); + assert!(extras_meta.is_dir()); + let mut text = String::new(); + std::fs::File::open(extras_license.join("LICENSE")) + .unwrap() + .read_to_string(&mut text) + .unwrap(); + assert_eq!(text, "license text"); +} + +#[test] +fn install_atomic_update_and_rollback_restores_state() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + + // Existing installed files + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::write(rootfs.join("usr/bin/foo"), "old").unwrap(); + std::fs::write(rootfs.join("usr/bin/old_only"), "to_remove").unwrap(); + + // New staged files + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/foo"), "new").unwrap(); + std::fs::write(destdir.join("usr/bin/new_only"), "added").unwrap(); + + let remove_paths = vec!["usr/bin/old_only".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + // After install: updated + new present, obsolete removed + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), + "new" + ); + assert!(rootfs.join("usr/bin/new_only").exists()); + assert!(!rootfs.join("usr/bin/old_only").exists()); + + // Roll back should restore old state + tx.rollback().unwrap(); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/foo")).unwrap(), + "old" + ); + assert!(!rootfs.join("usr/bin/new_only").exists()); + assert!(rootfs.join("usr/bin/old_only").exists()); +} + +#[test] +fn install_atomic_keep_existing_installs_depotnew() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("etc")).unwrap(); + std::fs::create_dir_all(destdir.join("etc")).unwrap(); + + std::fs::write(rootfs.join("etc/locale.gen"), "existing").unwrap(); + std::fs::write(destdir.join("etc/locale.gen"), "from-package").unwrap(); + + let keep = vec!["etc/locale.gen".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); + + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), + "existing" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen.depotnew")).unwrap(), + "from-package" + ); + + tx.rollback().unwrap(); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/locale.gen")).unwrap(), + "existing" + ); + assert!(!rootfs.join("etc/locale.gen.depotnew").exists()); +} + +#[test] +fn install_atomic_preserves_staged_hardlinks() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + + let coreutils = destdir.join("usr/bin/coreutils"); + let ls = destdir.join("usr/bin/ls"); + std::fs::write(&coreutils, "multicall").unwrap(); + std::fs::hard_link(&coreutils, &ls).unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + let coreutils_meta = rootfs.join("usr/bin/coreutils").metadata().unwrap(); + let ls_meta = rootfs.join("usr/bin/ls").metadata().unwrap(); + assert_eq!(coreutils_meta.ino(), ls_meta.ino()); + assert_eq!(coreutils_meta.nlink(), 2); + assert_eq!(ls_meta.nlink(), 2); +} + +#[test] +fn install_atomic_keep_wildcard_matches_directory_children() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("etc/pam.d")).unwrap(); + std::fs::create_dir_all(destdir.join("etc/pam.d")).unwrap(); + std::fs::create_dir_all(destdir.join("etc/pam.d/subdir")).unwrap(); + + std::fs::write(rootfs.join("etc/pam.d/system-auth"), "existing-auth").unwrap(); + std::fs::write(destdir.join("etc/pam.d/system-auth"), "pkg-auth").unwrap(); + std::fs::write(destdir.join("etc/pam.d/other"), "pkg-other").unwrap(); + std::fs::write(destdir.join("etc/pam.d/subdir/nested"), "pkg-nested").unwrap(); + + let keep = vec!["etc/pam.d/*".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep).unwrap(); + + // Existing matched file is preserved and package version becomes .depotnew + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth")).unwrap(), + "existing-auth" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/system-auth.depotnew")).unwrap(), + "pkg-auth" + ); + + // New matched file installs normally because no existing file is present + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/other")).unwrap(), + "pkg-other" + ); + + // Single-segment * does not cross '/' + assert_eq!( + std::fs::read_to_string(rootfs.join("etc/pam.d/subdir/nested")).unwrap(), + "pkg-nested" + ); + assert!(!rootfs.join("etc/pam.d/subdir/nested.depotnew").exists()); + + tx.rollback().unwrap(); +} + +#[test] +#[cfg(unix)] +fn install_atomic_replaces_existing_symlink_without_touching_target_directory() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::write(rootfs.join("usr/bin/existing"), "keep-me").unwrap(); + std::os::unix::fs::symlink("usr/bin", rootfs.join("bin")).unwrap(); + std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap(); + + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert_eq!( + std::fs::read_link(rootfs.join("bin")).unwrap(), + PathBuf::from("usr/bin") + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/existing")).unwrap(), + "keep-me" + ); + + tx.rollback().unwrap(); +} + +#[test] +#[cfg(unix)] +fn install_atomic_rejects_replacing_directory_with_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr")).unwrap(); + std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); + + let err = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap_err(); + assert!( + err.to_string() + .contains("Refusing to replace existing directory with packaged file/symlink") + ); +} + +#[test] +#[cfg(unix)] +fn install_atomic_relocates_existing_directory_into_packaged_symlink_target() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = rootfs.join("var/cache/depot/build/tx"); + std::fs::create_dir_all(rootfs.join("lib/depot")).unwrap(); + std::fs::write(rootfs.join("lib/depot/lock"), "state").unwrap(); + std::fs::create_dir_all(rootfs.join("usr/lib/depot")).unwrap(); + std::fs::write(rootfs.join("usr/lib/depot/lock"), "state").unwrap(); + std::fs::create_dir_all(destdir.join("usr/lib/misc")).unwrap(); + std::os::unix::fs::symlink("usr/lib", destdir.join("lib")).unwrap(); + + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + let lib_meta = rootfs.join("lib").symlink_metadata().unwrap(); + assert!(lib_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(rootfs.join("lib")).unwrap(), + PathBuf::from("usr/lib") + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/lib/depot/lock")).unwrap(), + "state" + ); + assert!(rootfs.join("usr/lib/misc").is_dir()); + + tx.rollback().unwrap(); + let restored = rootfs.join("lib").symlink_metadata().unwrap(); + assert!(restored.file_type().is_dir()); + assert_eq!( + std::fs::read_to_string(rootfs.join("lib/depot/lock")).unwrap(), + "state" + ); +} + +#[test] +#[cfg(unix)] +fn install_atomic_replaces_obsolete_directory_with_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr")).unwrap(); + std::fs::write(rootfs.join("usr/sbin/legacy"), "old").unwrap(); + std::fs::write(destdir.join("usr/bin/legacy"), "new").unwrap(); + std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); + + let remove_paths = vec!["usr/sbin/legacy".to_string(), "usr/sbin".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + let sbin_meta = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(sbin_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(rootfs.join("usr/sbin")).unwrap(), + PathBuf::from("bin") + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/legacy")).unwrap(), + "new" + ); + + tx.rollback().unwrap(); + let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(restored.file_type().is_dir()); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/sbin/legacy")).unwrap(), + "old" + ); +} + +#[test] +#[cfg(unix)] +fn install_atomic_preserves_non_obsolete_directory_contents_when_replacing_with_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr")).unwrap(); + std::fs::write(rootfs.join("usr/sbin/keep"), "keep-me").unwrap(); + std::fs::write(rootfs.join("usr/sbin/legacy"), "old").unwrap(); + std::fs::write(destdir.join("usr/bin/legacy"), "new").unwrap(); + std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); + + let remove_paths = vec!["usr/sbin/legacy".to_string(), "usr/sbin".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + let sbin_meta = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(sbin_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/keep")).unwrap(), + "keep-me" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), + "keep-me" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/legacy")).unwrap(), + "new" + ); + + tx.rollback().unwrap(); + let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(restored.file_type().is_dir()); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), + "keep-me" + ); + assert!(!rootfs.join("usr/bin/keep").exists()); +} + +#[test] +#[cfg(unix)] +fn install_atomic_rejects_symlink_swap_when_relocated_contents_conflict_with_target() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/sbin")).unwrap(); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr")).unwrap(); + std::fs::write(rootfs.join("usr/sbin/keep"), "keep-me").unwrap(); + std::fs::write(rootfs.join("usr/bin/keep"), "target-conflict").unwrap(); + std::os::unix::fs::symlink("bin", destdir.join("usr/sbin")).unwrap(); + + let remove_paths = vec!["usr/sbin".to_string()]; + let err = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap_err(); + assert!( + err.to_string() + .contains("Failed to replay relocated path into") + ); + + let restored = rootfs.join("usr/sbin").symlink_metadata().unwrap(); + assert!(restored.file_type().is_dir()); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/sbin/keep")).unwrap(), + "keep-me" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("usr/bin/keep")).unwrap(), + "target-conflict" + ); +} + +#[test] +fn keep_glob_matches_question_mark_and_not_path_separator() { + assert!(glob_match_path( + "etc/pam.d/system-????", + "etc/pam.d/system-auth" + )); + assert!(!glob_match_path("etc/pam.d/*", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/pam.d/*", "etc/pam.d/file")); + assert!(glob_match_path("etc/pam.d/**", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/**/file", "etc/pam.d/subdir/file")); + assert!(glob_match_path("etc/pam.d/**", "etc/pam.d")); +} + +#[test] +fn is_manpage_rel_path_detects_uncompressed_manpages() { + assert!(is_manpage_rel_path("usr/share/man/man1/ls.1")); + assert!(is_manpage_rel_path("/usr/share/man/man5/pam.d.5")); + assert!(!is_manpage_rel_path("usr/share/man/man1/ls.1.zst")); + assert!(!is_manpage_rel_path("usr/share/doc/readme")); +} + +#[test] +fn is_elf_file_detects_magic_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let elf = tmp.path().join("elf.bin"); + let text = tmp.path().join("text.txt"); + std::fs::write(&elf, [0x7F, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); + std::fs::write(&text, b"#!/bin/sh\n").unwrap(); + + assert!(is_elf_file(&elf).unwrap()); + assert!(!is_elf_file(&text).unwrap()); +} + +#[test] +fn auto_strip_elf_files_restores_hardlinks_when_strip_replaces_file() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("dest"); + let bin = dest.join("usr/bin"); + std::fs::create_dir_all(&bin).unwrap(); + + let fake_strip = tmp.path().join("fake-strip"); + std::fs::write( + &fake_strip, + "#!/bin/sh\nfor arg do path=$arg; done\ntmp=\"$path.tmp\"\ncp \"$path\" \"$tmp\"\nprintf stripped >> \"$tmp\"\nmv \"$tmp\" \"$path\"\n", + ) + .unwrap(); + let mut perms = std::fs::metadata(&fake_strip).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&fake_strip, perms).unwrap(); + + let coreutils = bin.join("coreutils"); + let ls = bin.join("ls"); + std::fs::write(&coreutils, [0x7F, b'E', b'L', b'F', 0x02, 0x01]).unwrap(); + std::fs::hard_link(&coreutils, &ls).unwrap(); + + let stripped = auto_strip_elf_files(&dest, fake_strip.to_str().unwrap()).unwrap(); + + let coreutils_meta = coreutils.metadata().unwrap(); + let ls_meta = ls.metadata().unwrap(); + assert_eq!(stripped, 2); + assert_eq!(coreutils_meta.ino(), ls_meta.ino()); + assert_eq!(coreutils_meta.nlink(), 2); + assert_eq!(ls_meta.nlink(), 2); + assert_eq!( + std::fs::read(&coreutils).unwrap(), + std::fs::read(&ls).unwrap() + ); +} + +#[test] +fn compress_manpages_zstd_rewrites_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("dest"); + let man1 = dest.join("usr/share/man/man1"); + std::fs::create_dir_all(&man1).unwrap(); + + let page = man1.join("foo.1"); + std::fs::write(&page, b"foo manpage\n").unwrap(); + std::os::unix::fs::symlink("foo.1", man1.join("bar.1")).unwrap(); + + let count = compress_manpages_zstd(&dest).unwrap(); + assert_eq!(count, 1); + assert!(!man1.join("foo.1").exists()); + assert!(man1.join("foo.1.zst").exists()); + assert!(!man1.join("bar.1").exists()); + + let link_meta = std::fs::symlink_metadata(man1.join("bar.1.zst")).unwrap(); + assert!(link_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(man1.join("bar.1.zst")).unwrap(), + PathBuf::from("foo.1.zst") + ); + + let file = std::fs::File::open(man1.join("foo.1.zst")).unwrap(); + let mut decoder = zstd::stream::read::Decoder::new(file).unwrap(); + let mut out = String::new(); + use std::io::Read as _; + decoder.read_to_string(&mut out).unwrap(); + assert_eq!(out, "foo manpage\n"); +} + +#[test] +fn install_atomic_rejects_unsafe_keep_paths() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("etc")).unwrap(); + std::fs::write(destdir.join("etc/locale.gen"), "x").unwrap(); + + let keep = vec!["../etc/shadow".to_string()]; + let err = install_atomic(&destdir, &rootfs, &tx_base, &[], &keep) + .expect_err("expected keep path traversal to be rejected"); + assert!( + err.to_string() + .contains("keep paths must not contain traversal") + ); +} + +#[test] +fn install_atomic_removes_obsolete_symlink_paths() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(rootfs.join("usr/lib")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/new"), "ok").unwrap(); + + std::os::unix::fs::symlink("../lib/libold.so", rootfs.join("usr/lib/libold.so.link")).unwrap(); + assert!( + rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .is_ok() + ); + + let remove_paths = vec!["usr/lib/libold.so.link".to_string()]; + let tx = install_atomic(&destdir, &rootfs, &tx_base, &remove_paths, &[]).unwrap(); + + assert!( + rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .is_err() + ); + + tx.rollback().unwrap(); + let restored = rootfs + .join("usr/lib/libold.so.link") + .symlink_metadata() + .expect("symlink should be restored"); + assert!(restored.file_type().is_symlink()); +} + +#[test] +fn install_atomic_commit_removes_tx_dir() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/foo"), "x").unwrap(); + + let tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + let tx_dir = tx.tx_dir.clone(); + assert!(tx_dir.exists()); + tx.commit().unwrap(); + assert!(!tx_dir.exists()); +} + +#[test] +fn test_install_atomic_symlink_to_directory() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + // Create a symlink bin -> usr/bin in destdir + std::os::unix::fs::symlink("usr/bin", destdir.join("bin")).unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + // Verify rootfs/bin is a symlink, not a directory + let meta = rootfs + .join("bin") + .symlink_metadata() + .expect("bin should exist"); + assert!(meta.file_type().is_symlink(), "bin should be a symlink"); + assert_eq!( + std::fs::read_link(rootfs.join("bin")).unwrap(), + std::path::PathBuf::from("usr/bin") + ); +} + +#[test] +fn test_walkdir_symlink_behavior() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("target")).unwrap(); + std::os::unix::fs::symlink("target", dir.join("link")).unwrap(); + + for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + if entry.path().ends_with("link") { + let ft = entry.file_type(); + assert!( + !ft.is_dir(), + "walkdir should NOT report symlink to dir as a directory" + ); + assert!(ft.is_symlink(), "walkdir SHOULD report it as a symlink"); + } + } +} + +#[test] +fn install_atomic_skips_info_dir_index() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/info")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); + std::fs::write(destdir.join("usr/info/dir"), "legacy index").unwrap(); + std::fs::write(destdir.join("usr/info/dir.bz2"), "legacy index bz2").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir.gz"), "index gz").unwrap(); + std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join("usr/info/dir").exists()); + assert!(!rootfs.join("usr/info/dir.bz2").exists()); + assert!(!rootfs.join("usr/share/info/dir").exists()); + assert!(!rootfs.join("usr/share/info/dir.gz").exists()); + assert!(rootfs.join("usr/share/info/ok.info").exists()); +} + +#[test] +fn install_atomic_skips_packlists_and_pod_files() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/core_perl")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/doc/perl-error")).unwrap(); + std::fs::write( + destdir.join("usr/lib/perl5/5.42/core_perl/perllocal.pod"), + "perllocal", + ) + .unwrap(); + std::fs::write( + destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist"), + "packlist", + ) + .unwrap(); + std::fs::write(destdir.join("usr/share/doc/perl-error/Error.pod"), "pod").unwrap(); + std::fs::write(destdir.join("usr/share/doc/perl-error/README"), "readme").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!( + !rootfs + .join("usr/lib/perl5/5.42/core_perl/perllocal.pod") + .exists() + ); + assert!( + !rootfs + .join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist") + .exists() + ); + assert!(!rootfs.join("usr/share/doc/perl-error/Error.pod").exists()); + assert!(rootfs.join("usr/share/doc/perl-error/README").exists()); +} + +#[test] +fn install_atomic_skips_package_metadata_files() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); + std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join(".metadata.toml").exists()); + assert!(!rootfs.join(".files.yaml").exists()); + assert!(rootfs.join("usr/bin/ok").exists()); +} + +#[test] +fn install_atomic_skips_package_scripts_dir() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join("scripts")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("scripts/pre_install"), "#!/bin/sh\necho pre\n").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(!rootfs.join("scripts/pre_install").exists()); + assert!(rootfs.join("usr/bin/ok").exists()); +} + +#[test] +fn install_atomic_skips_internal_output_staging_dir() { + let tmp = tempfile::tempdir().unwrap(); + let rootfs = tmp.path().join("root"); + let destdir = tmp.path().join("dest"); + let tx_base = tmp.path().join("tx"); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let _tx = install_atomic(&destdir, &rootfs, &tx_base, &[], &[]).unwrap(); + + assert!(rootfs.join("usr/bin/ok").exists()); + assert!(!rootfs.join(".depot").exists()); +} + +#[test] +fn generate_manifest_skips_info_dir_index() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/info")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/info")).unwrap(); + std::fs::write(destdir.join("usr/info/dir"), "legacy index").unwrap(); + std::fs::write(destdir.join("usr/info/dir.zst"), "legacy index zst").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir"), "index").unwrap(); + std::fs::write(destdir.join("usr/share/info/dir.xz"), "index xz").unwrap(); + std::fs::write(destdir.join("usr/share/info/ok.info"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&"usr/info/dir".to_string())); + assert!(!manifest.files.contains(&"usr/info/dir.zst".to_string())); + assert!(!manifest.files.contains(&"usr/share/info/dir".to_string())); + assert!( + !manifest + .files + .contains(&"usr/share/info/dir.xz".to_string()) + ); + assert!( + manifest + .files + .contains(&"usr/share/info/ok.info".to_string()) + ); +} + +#[test] +fn generate_manifest_skips_packlists_and_pod_files() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/core_perl")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/share/doc/perl-error")).unwrap(); + std::fs::write( + destdir.join("usr/lib/perl5/5.42/core_perl/perllocal.pod"), + "perllocal", + ) + .unwrap(); + std::fs::write( + destdir.join("usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist"), + "packlist", + ) + .unwrap(); + std::fs::write(destdir.join("usr/share/doc/perl-error/Error.pod"), "pod").unwrap(); + std::fs::write(destdir.join("usr/share/doc/perl-error/README"), "readme").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!( + !manifest + .files + .contains(&"usr/lib/perl5/5.42/core_perl/perllocal.pod".to_string()) + ); + assert!( + !manifest + .files + .contains(&"usr/lib/perl5/5.42/vendor_perl/auto/Error/.packlist".to_string()) + ); + assert!( + !manifest + .files + .contains(&"usr/share/doc/perl-error/Error.pod".to_string()) + ); + assert!( + manifest + .files + .contains(&"usr/share/doc/perl-error/README".to_string()) + ); +} + +#[test] +fn generate_manifest_skips_package_metadata_files() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(&destdir).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join(".metadata.toml"), "name='foo'").unwrap(); + std::fs::write(destdir.join(".files.yaml"), "files: []").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&".metadata.toml".to_string())); + assert!(!manifest.files.contains(&".files.yaml".to_string())); + assert!(manifest.files.contains(&"usr/bin/ok".to_string())); +} + +#[test] +fn generate_manifest_skips_package_scripts_dir() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("scripts")).unwrap(); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::write(destdir.join("scripts/pre_install"), "echo pre").unwrap(); + std::fs::write(destdir.join("usr/bin/ok"), "ok").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(!manifest.files.contains(&"scripts/pre_install".to_string())); + assert!(manifest.files.contains(&"usr/bin/ok".to_string())); +} + +#[test] +fn generate_manifest_skips_internal_output_staging() { + let tmp = tempfile::tempdir().unwrap(); + let destdir = tmp.path().join("dest"); + std::fs::create_dir_all(destdir.join("usr/bin")).unwrap(); + std::fs::create_dir_all(destdir.join(".depot/outputs/clang/usr/bin")).unwrap(); + std::fs::write(destdir.join("usr/bin/llvm-config"), "ok").unwrap(); + std::fs::write(destdir.join(".depot/outputs/clang/usr/bin/clang"), "clang").unwrap(); + + let manifest = generate_manifest_with_dirs(&destdir).unwrap(); + + assert!(manifest.files.contains(&"usr/bin/llvm-config".to_string())); + assert!( + !manifest + .files + .contains(&".depot/outputs/clang/usr/bin/clang".to_string()) + ); +} diff --git a/src/staging/transaction.rs b/src/staging/transaction.rs new file mode 100644 index 0000000..60142b0 --- /dev/null +++ b/src/staging/transaction.rs @@ -0,0 +1,717 @@ +use super::*; + +#[derive(Debug)] +pub struct FsTransaction { + rootfs: PathBuf, + pub(super) tx_dir: PathBuf, + backed_up: Vec, + created: Vec, + relocated: Vec, + removed: Vec, +} + +pub(super) fn is_directory_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path) + .with_context(|| format!("Failed to read directory {}", path.display()))?; + Ok(entries.next().transpose()?.is_none()) +} + +pub(super) fn backup_existing_path(src: &Path, backup_path: &Path, rel: &str) -> Result<()> { + let metadata = src + .symlink_metadata() + .with_context(|| format!("Failed to inspect existing path {}", rel))?; + + if let Some(parent) = backup_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create backup dir {}", parent.display()))?; + } + + if metadata.file_type().is_symlink() { + let target = fs::read_link(src) + .with_context(|| format!("Failed to read existing symlink target {}", rel))?; + std::os::unix::fs::symlink(&target, backup_path) + .with_context(|| format!("Failed to backup symlink {}", rel))?; + } else if metadata.file_type().is_dir() { + fs::create_dir_all(backup_path) + .with_context(|| format!("Failed to backup directory {}", rel))?; + apply_unix_mode(backup_path, &metadata)?; + } else { + fs::copy(src, backup_path).with_context(|| format!("Failed to backup file {}", rel))?; + } + + Ok(()) +} + +pub(super) fn move_directory_contents(src_dir: &Path, dst_dir: &Path) -> Result<()> { + fs::create_dir_all(dst_dir) + .with_context(|| format!("Failed to create directory {}", dst_dir.display()))?; + + for entry in + fs::read_dir(src_dir).with_context(|| format!("Failed to read {}", src_dir.display()))? + { + let entry = entry?; + move_tree_preserving_layout(&entry.path(), &dst_dir.join(entry.file_name()))?; + } + + fs::remove_dir(src_dir).with_context(|| format!("Failed to remove {}", src_dir.display()))?; + Ok(()) +} + +pub(super) fn copy_tree_preserving_layout_no_overwrite( + src_root: &Path, + dst_root: &Path, + logical_root: &str, + created: &mut Vec, +) -> Result<()> { + for entry in WalkDir::new(src_root).follow_links(false) { + let entry = entry + .with_context(|| format!("Failed to walk relocation tree {}", src_root.display()))?; + let src_path = entry.path(); + let rel = src_path + .strip_prefix(src_root) + .with_context(|| format!("Failed to strip relocation root {}", src_root.display()))?; + if rel.as_os_str().is_empty() { + continue; + } + + let dst_path = dst_root.join(rel); + let metadata = src_path + .symlink_metadata() + .with_context(|| format!("Failed to inspect {}", src_path.display()))?; + let file_type = metadata.file_type(); + + if file_type.is_dir() { + match dst_path.symlink_metadata() { + Ok(dst_meta) => { + if !dst_meta.file_type().is_dir() { + anyhow::bail!( + "Failed to replay relocated directory into {}: destination exists and is not a directory", + dst_path.display() + ); + } + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(&dst_path).with_context(|| { + format!("Failed to create directory {}", dst_path.display()) + })?; + apply_unix_mode(&dst_path, &metadata)?; + } + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to inspect {}", dst_path.display())); + } + } + continue; + } + + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + + if let Ok(dst_metadata) = dst_path.symlink_metadata() { + if duplicate_staged_path_is_equivalent(src_path, &metadata, &dst_path, &dst_metadata)? { + continue; + } + anyhow::bail!( + "Failed to replay relocated path into {}: destination already exists", + dst_path.display() + ); + } + + if file_type.is_symlink() { + let target = fs::read_link(src_path) + .with_context(|| format!("Failed to read symlink {}", src_path.display()))?; + std::os::unix::fs::symlink(&target, &dst_path).with_context(|| { + format!( + "Failed to create relocated symlink {} -> {}", + dst_path.display(), + target.display() + ) + })?; + } else { + fs::copy(src_path, &dst_path).with_context(|| { + format!( + "Failed to copy relocated path {} to {}", + src_path.display(), + dst_path.display() + ) + })?; + apply_unix_mode(&dst_path, &metadata)?; + } + + let logical = Path::new(logical_root).join(rel); + let logical = logical + .to_str() + .context("Relocated install paths must be valid UTF-8")? + .to_string(); + created.push(logical); + } + + Ok(()) +} + +pub(super) fn symlink_target_path_inside_rootfs( + rootfs: &Path, + link_rel: &str, + target: &Path, +) -> Result> { + let mut normalized = PathBuf::new(); + if target.is_absolute() { + for component in target.components() { + match component { + Component::RootDir => {} + Component::CurDir => {} + Component::Normal(segment) => normalized.push(segment), + Component::ParentDir | Component::Prefix(_) => return Ok(None), + } + } + } else { + if let Some(parent) = Path::new(link_rel).parent() { + normalized.push(parent); + } + for component in target.components() { + match component { + Component::CurDir => {} + Component::Normal(segment) => normalized.push(segment), + Component::ParentDir => { + if !normalized.pop() { + return Ok(None); + } + } + Component::RootDir | Component::Prefix(_) => return Ok(None), + } + } + } + + if normalized.as_os_str().is_empty() { + return Ok(None); + } + + Ok(Some(rootfs.join(normalized))) +} + +pub(super) fn can_relocate_directory_for_symlink_swap( + rootfs: &Path, + link_rel: &str, + symlink_path: &Path, +) -> Result { + let target = fs::read_link(symlink_path) + .with_context(|| format!("Failed to read symlink {}", symlink_path.display()))?; + let Some(target_path) = symlink_target_path_inside_rootfs(rootfs, link_rel, &target)? else { + return Ok(false); + }; + + match target_path.symlink_metadata() { + Ok(metadata) => Ok(metadata.file_type().is_dir()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err) + .with_context(|| format!("Failed to inspect symlink target {}", target_path.display())), + } +} + +pub(super) fn remove_path_in_place(path: &Path, rel: &str) -> Result<()> { + let metadata = path + .symlink_metadata() + .with_context(|| format!("Failed to inspect existing path {}", rel))?; + + if metadata.file_type().is_dir() { + fs::remove_dir(path) + .with_context(|| format!("Failed to remove obsolete directory {}", rel))?; + } else { + fs::remove_file(path) + .with_context(|| format!("Failed to remove obsolete file/symlink {}", rel))?; + } + + Ok(()) +} + +pub(super) fn backup_and_remove_obsolete_path( + tx: &mut FsTransaction, + rootfs: &Path, + rel: &str, + require_empty_dir: bool, +) -> Result { + let dest_path = rootfs.join(rel); + let metadata = match dest_path.symlink_metadata() { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => { + return Err(err).with_context(|| { + format!("Failed to inspect obsolete path before removal: {}", rel) + }); + } + }; + + if metadata.file_type().is_dir() { + let empty = is_directory_empty(&dest_path)?; + if !empty { + if require_empty_dir { + anyhow::bail!( + "Refusing to replace existing non-empty directory with packaged file/symlink: {}", + rel + ); + } + return Ok(false); + } + } + + let backup_path = tx.removed_backup_path(rel); + backup_existing_path(&dest_path, &backup_path, rel)?; + remove_path_in_place(&dest_path, rel)?; + tx.removed.push(rel.to_string()); + Ok(true) +} + +pub(super) fn remove_obsolete_children_for_dir( + tx: &mut FsTransaction, + rootfs: &Path, + dir_rel: &str, + remove_paths: &[String], +) -> Result<()> { + let prefix = format!("{dir_rel}/"); + let mut nested_paths: Vec<&str> = remove_paths + .iter() + .filter_map(|path| path.strip_prefix(&prefix).map(|_| path.as_str())) + .collect(); + nested_paths.sort_by_key(|path| std::cmp::Reverse(path.matches('/').count())); + + for rel in nested_paths { + let _ = backup_and_remove_obsolete_path(tx, rootfs, rel, false)?; + } + + Ok(()) +} + +impl FsTransaction { + fn backup_path(&self, rel: &str) -> PathBuf { + self.tx_dir.join("backup").join(rel) + } + + fn removed_backup_path(&self, rel: &str) -> PathBuf { + self.tx_dir.join("removed").join(rel) + } + + fn relocated_path(&self, rel: &str) -> PathBuf { + self.tx_dir.join("relocated").join(rel) + } + + fn relocate_directory_for_symlink_swap(&mut self, rel: &str) -> Result<()> { + let src = self.rootfs.join(rel); + let relocated = self.relocated_path(rel); + move_directory_contents(&src, &relocated)?; + self.relocated.push(rel.to_string()); + Ok(()) + } + + fn replay_relocated_dir_if_present(&mut self, rel: &str) -> Result<()> { + let relocated = self.relocated_path(rel); + match relocated.symlink_metadata() { + Ok(meta) if meta.file_type().is_dir() => {} + Ok(_) => { + anyhow::bail!( + "Relocation staging path is not a directory: {}", + relocated.display() + ); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to inspect {}", relocated.display())); + } + } + + copy_tree_preserving_layout_no_overwrite( + &relocated, + &self.rootfs.join(rel), + rel, + &mut self.created, + ) + } + + fn restore_relocated_dir(&self, rel: &str) -> Result<()> { + let relocated = self.relocated_path(rel); + match relocated.symlink_metadata() { + Ok(meta) if meta.file_type().is_dir() => {} + Ok(_) => { + anyhow::bail!( + "Relocation staging path is not a directory: {}", + relocated.display() + ); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to inspect {}", relocated.display())); + } + } + + let dst = self.rootfs.join(rel); + match dst.symlink_metadata() { + Ok(meta) if meta.file_type().is_dir() => {} + Ok(_) => { + anyhow::bail!( + "Failed to restore relocated directory contents into {}: destination is not a directory", + dst.display() + ); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(&dst) + .with_context(|| format!("Failed to create directory {}", dst.display()))?; + } + Err(err) => { + return Err(err).with_context(|| format!("Failed to inspect {}", dst.display())); + } + } + + move_directory_contents(&relocated, &dst)?; + cleanup_empty_parent_dirs(&self.tx_dir, &relocated)?; + Ok(()) + } + + fn restore_backup_entry(&self, src: &Path, dst: &Path) -> Result<()> { + let metadata = src + .symlink_metadata() + .with_context(|| format!("Failed to inspect backup entry {}", src.display()))?; + + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create restore dir {}", parent.display()))?; + } + + if metadata.file_type().is_dir() { + match dst.symlink_metadata() { + Ok(dst_meta) if dst_meta.file_type().is_dir() => {} + Ok(dst_meta) if dst_meta.file_type().is_symlink() => { + fs::remove_file(dst) + .with_context(|| format!("Failed to remove {}", dst.display()))?; + } + Ok(_) => { + fs::remove_file(dst) + .with_context(|| format!("Failed to remove {}", dst.display()))?; + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("Failed to inspect {}", dst.display())); + } + } + fs::create_dir_all(dst) + .with_context(|| format!("Failed to restore directory {}", dst.display()))?; + apply_unix_mode(dst, &metadata)?; + return Ok(()); + } + + let _ = fs::remove_file(dst); + match fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(_) if metadata.file_type().is_symlink() => { + let target = fs::read_link(src) + .with_context(|| format!("Failed to read backup symlink {}", src.display()))?; + std::os::unix::fs::symlink(&target, dst) + .with_context(|| format!("Failed to restore symlink {}", dst.display())) + } + Err(_) => { + fs::copy(src, dst).with_context(|| { + format!( + "Failed to restore file {} from {}", + dst.display(), + src.display() + ) + })?; + Ok(()) + } + } + } + + /// Roll back file operations performed by `install_atomic`. + pub fn rollback(&self) -> Result<()> { + // Remove files that were newly created + for rel in &self.created { + let dst = self.rootfs.join(rel); + let _ = fs::remove_file(dst); + } + + // Restore overwritten paths first so relocated and removed children have their parent layout. + for rel in &self.backed_up { + let src = self.backup_path(rel); + let dst = self.rootfs.join(rel); + if src.symlink_metadata().is_ok() { + self.restore_backup_entry(&src, &dst)?; + } + } + + for rel in &self.relocated { + self.restore_relocated_dir(rel)?; + } + + // Restore removed files/directories. + for rel in &self.removed { + let src = self.removed_backup_path(rel); + let dst = self.rootfs.join(rel); + if src.symlink_metadata().is_ok() { + self.restore_backup_entry(&src, &dst)?; + } + } + + Ok(()) + } + + /// Commit the transaction (delete backup directory). + pub fn commit(self) -> Result<()> { + let tx_base_dir = self.tx_dir.parent().map(Path::to_path_buf); + if self.tx_dir.exists() { + fs::remove_dir_all(&self.tx_dir)?; + } + if let Some(tx_base_dir) = tx_base_dir { + match fs::remove_dir(&tx_base_dir) { + Ok(()) => {} + Err(err) + if matches!( + err.kind(), + io::ErrorKind::DirectoryNotEmpty | io::ErrorKind::NotFound + ) => {} + Err(err) => { + return Err(err).with_context(|| { + format!("Failed to remove tx dir {}", tx_base_dir.display()) + }); + } + } + } + Ok(()) + } +} + +/// Install staged files using a rollback-capable transaction. +/// +/// This is used for both first-time installs and updates. For updates, pass a +/// list of relative paths to remove (old manifest minus new manifest). +pub fn install_atomic( + destdir: &Path, + rootfs: &Path, + tx_base_dir: &Path, + remove_paths: &[String], + keep_paths: &[String], +) -> Result { + let tx_base_dir = if rootfs != Path::new("/") && tx_base_dir.starts_with(rootfs) { + rootfs.join(".depot-tx") + } else { + tx_base_dir.to_path_buf() + }; + let keep_rules: Vec = keep_paths + .iter() + .map(|p| KeepMatcher::from_spec(p)) + .collect::>>()?; + let keep_set: HashSet = keep_rules + .iter() + .filter_map(|m| match m { + KeepMatcher::Exact(p) => Some(p.clone()), + KeepMatcher::Pattern(_) => None, + }) + .collect(); + let remove_set: HashSet<&str> = remove_paths.iter().map(String::as_str).collect(); + + fs::create_dir_all(&tx_base_dir) + .with_context(|| format!("Failed to create tx dir: {}", tx_base_dir.display()))?; + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let pid = std::process::id(); + let tx_dir = tx_base_dir.join(format!("tx-{}-{}", ts, pid)); + let backup_dir = tx_dir.join("backup"); + let removed_dir = tx_dir.join("removed"); + fs::create_dir_all(&backup_dir)?; + fs::create_dir_all(&removed_dir)?; + + let mut tx = FsTransaction { + rootfs: rootfs.to_path_buf(), + tx_dir, + backed_up: Vec::new(), + created: Vec::new(), + relocated: Vec::new(), + removed: Vec::new(), + }; + let mut staged_paths = HashSet::new(); + let mut installed_hardlinks: HashMap = HashMap::new(); + + let result: Result<()> = (|| { + // First, create all directories from destdir (for packages with only directories) + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + let src_path = entry.path(); + let file_type = entry.file_type(); + + if !file_type.is_dir() || src_path == destdir { + continue; + } + + let rel_path = src_path + .strip_prefix(destdir) + .context("Failed to strip destdir prefix")?; + let rel_path_str = rel_path.to_string_lossy().to_string(); + if is_skipped_install_path(&rel_path_str) { + continue; + } + + let dest_path = rootfs.join(rel_path); + if !dest_path.exists() { + fs::create_dir_all(&dest_path)?; + apply_unix_mode(&dest_path, &src_path.symlink_metadata()?)?; + } + staged_paths.insert(rel_path_str); + } + + // Copy in new files. + for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { + let src_path = entry.path(); + let metadata = src_path + .symlink_metadata() + .context("Failed to get metadata")?; + let file_type = metadata.file_type(); + + // We want to install files AND symlinks (to anything) + if !file_type.is_file() && !file_type.is_symlink() { + continue; + } + + let rel_path = src_path + .strip_prefix(destdir) + .context("Failed to strip destdir prefix")? + .to_string_lossy() + .to_string(); + + if is_skipped_install_path(&rel_path) { + continue; + } + + let keep_match = if keep_set.contains(&rel_path) { + true + } else { + keep_rules.iter().any(|m| m.matches(&rel_path)) + }; + let keep_as_depotnew = keep_match && rootfs.join(&rel_path).exists(); + let install_rel_path = if keep_as_depotnew { + format!("{}.depotnew", rel_path) + } else { + rel_path.clone() + }; + + let dest_path = rootfs.join(&install_rel_path); + + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + + if let Ok(dest_meta) = dest_path.symlink_metadata() { + let backup_path = tx.backup_path(&install_rel_path); + if dest_meta.file_type().is_dir() { + let can_relocate = file_type.is_symlink() + && can_relocate_directory_for_symlink_swap( + rootfs, + &install_rel_path, + src_path, + )?; + if !remove_set.contains(install_rel_path.as_str()) && !can_relocate { + anyhow::bail!( + "Refusing to replace existing directory with packaged file/symlink: {}", + install_rel_path + ); + } + backup_existing_path(&dest_path, &backup_path, &install_rel_path)?; + tx.backed_up.push(install_rel_path.clone()); + remove_obsolete_children_for_dir( + &mut tx, + rootfs, + &install_rel_path, + remove_paths, + )?; + if file_type.is_symlink() { + tx.relocate_directory_for_symlink_swap(&install_rel_path)?; + } + } else { + backup_existing_path(&dest_path, &backup_path, &install_rel_path)?; + tx.backed_up.push(install_rel_path.clone()); + } + } else { + tx.created.push(install_rel_path.clone()); + } + + // Install new file/symlink + // Remove destination if it exists (we backed it up) so we can overwrite + if let Ok(dest_meta) = dest_path.symlink_metadata() { + if dest_meta.file_type().is_dir() { + let relocated_for_symlink_swap = + tx.relocated.iter().any(|rel| rel == &install_rel_path); + if !remove_set.contains(install_rel_path.as_str()) + && !relocated_for_symlink_swap + { + anyhow::bail!( + "Refusing to replace existing directory with packaged file/symlink: {}", + install_rel_path + ); + } + if !is_directory_empty(&dest_path)? { + anyhow::bail!( + "Refusing to replace existing non-empty directory with packaged file/symlink: {}", + install_rel_path + ); + } + fs::remove_dir(&dest_path)?; + } else { + fs::remove_file(&dest_path)?; + } + } + + if file_type.is_symlink() { + let target = fs::read_link(src_path) + .with_context(|| format!("Failed to read staged symlink {}", rel_path))?; + std::os::unix::fs::symlink(target, &dest_path) + .with_context(|| format!("Failed to create symlink: {}", install_rel_path))?; + tx.replay_relocated_dir_if_present(&install_rel_path)?; + } else { + let hardlink_key = if keep_as_depotnew { + None + } else { + hardlink_key(&metadata) + }; + if let Some(first_rel_path) = + hardlink_key.and_then(|key| installed_hardlinks.get(&key)) + { + let first_path = rootfs.join(first_rel_path); + fs::hard_link(&first_path, &dest_path).with_context(|| { + format!( + "Failed to install hardlink: {} -> {}", + install_rel_path, first_rel_path + ) + })?; + } else { + fs::copy(src_path, &dest_path) + .with_context(|| format!("Failed to install: {}", install_rel_path))?; + apply_unix_mode(&dest_path, &metadata)?; + if let Some(key) = hardlink_key { + installed_hardlinks.insert(key, install_rel_path.clone()); + } + } + } + staged_paths.insert(install_rel_path); + } + + // Remove obsolete files/directories left behind by the previous version. + for rel in remove_paths { + if staged_paths.contains(rel) || tx.removed.iter().any(|removed| removed == rel) { + continue; + } + let _ = backup_and_remove_obsolete_path(&mut tx, rootfs, rel, false)?; + } + + Ok(()) + })(); + + if let Err(e) = result { + let _ = tx.rollback(); + return Err(e); + } + + Ok(tx) +}