diff --git a/Cargo.lock b/Cargo.lock index 6821777..7a95d4f 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -60,9 +60,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -236,9 +236,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -258,18 +258,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.66" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -285,9 +285,9 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clap_mangen" -version = "0.2.31" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" dependencies = [ "clap", "roff", @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.20.1" +version = "0.21.0" dependencies = [ "anyhow", "ar", @@ -1606,9 +1606,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1703,9 +1703,9 @@ dependencies = [ [[package]] name = "roff" -version = "0.2.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" +checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" [[package]] name = "rpassword" diff --git a/Cargo.toml b/Cargo.toml index cbb4536..75339a0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.20.1" +version = "0.21.0" edition = "2024" [lints.rust] @@ -10,7 +10,7 @@ warnings = "deny" anyhow = "1.0.102" ar = "0.9.0" bzip2 = "0.6.1" -clap = { version = "4.5.60", features = ["derive"] } +clap = { version = "4.6.0", features = ["derive"] } flate2 = "1.1.9" git2 = "0.20.4" indicatif = "0.18.4" @@ -37,8 +37,8 @@ petgraph = "0.8.3" fd-lock = "4.0.4" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "native-tls"] } filetime = "0.2.27" -clap_complete = "4.5.66" -clap_mangen = "0.2.31" +clap_complete = "4.6.0" +clap_mangen = "0.2.33" sys-mount = { version = "3.1.0", default-features = false } time = { version = "0.3.47", features = ["formatting", "parsing"] } b2sum-rust = "0.3.0" diff --git a/meson.build b/meson.build index fa4496a..eebbe0d 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.20.1', + version: '0.21.0', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/builder/makefile.rs b/src/builder/makefile.rs index c0438f1..0a69508 100644 --- a/src/builder/makefile.rs +++ b/src/builder/makefile.rs @@ -110,9 +110,21 @@ pub fn build( mod tests { use super::*; use crate::package::{Build, BuildFlags, BuildType, Dependencies, PackageInfo}; + use crate::test_support::TestEnv; use std::fs; use tempfile::tempdir; + #[cfg(unix)] + fn write_executable(path: &std::path::Path, contents: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::write(path, contents)?; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms)?; + Ok(()) + } + fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo { @@ -149,8 +161,24 @@ mod tests { fn test_makefile_build_runs_commands() -> Result<()> { let tmp_src = tempdir()?; let tmp_dest = tempdir()?; + let tmp_tools = tempdir()?; let src_path = tmp_src.path(); let dest_path = tmp_dest.path(); + let tools_path = tmp_tools.path(); + + write_executable( + &tools_path.join("fakeroot"), + r#"#!/bin/sh +if [ "$1" = "--" ]; then + shift +fi +exec "$@" +"#, + )?; + + let mut env = TestEnv::new(); + let old_path = std::env::var("PATH").unwrap_or_default(); + env.set_var("PATH", format!("{}:{}", tools_path.display(), old_path)); let mut spec = mk_spec("test-make", "1.0"); spec.build.flags.makefile_commands = vec![ diff --git a/src/cli.rs b/src/cli.rs index 8ad40cd..425fb38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,23 +1,29 @@ -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; -#[derive(Parser)] -#[command(name = "Depot")] -#[command(about = "Source-based package manager for Linux", long_about = None)] -#[command(version)] -pub struct Cli { +#[derive(Debug, Clone, Args, Default)] +pub struct RootfsArgs { /// Custom root filesystem path - #[arg(long, short = 'r', default_value = "/", global = true)] + #[arg(long, short = 'r', default_value = "/")] pub rootfs: PathBuf, +} +#[derive(Debug, Clone, Args, Default)] +pub struct PromptArgs { + /// Automatically answer yes to prompts and pick the default provider choice + #[arg(long, short = 'y')] + pub yes: bool, +} + +#[derive(Debug, Clone, Args, Default)] +pub struct BuildExecArgs { /// Skip dependency checks - #[arg(long, global = true)] + #[arg(long)] pub no_deps: bool, /// Do not export CFLAGS/CXXFLAGS/LDFLAGS to build commands #[arg( long, - global = true, action = clap::ArgAction::Set, num_args = 0..=1, default_value_t = false, @@ -26,148 +32,256 @@ pub struct Cli { pub no_flags: bool, /// Cross-compilation prefix (e.g., x86_64-linux-musl, aarch64-linux-gnu) - #[arg(long, global = true)] + #[arg(long)] pub cross_prefix: Option, /// Clean build workspace and source cache after successful install/build - #[arg(long, global = true)] + #[arg(long)] pub clean: bool, - /// Automatically answer yes to prompts and pick the default provider choice - #[arg(long, short = 'y', global = true)] - pub yes: bool, - /// Show what would happen without performing builds/installs - #[arg(long, global = true)] + #[arg(long)] pub dry_run: bool, /// Install test dependencies alongside build/runtime dependencies - #[arg(long, global = true)] + #[arg(long)] pub test_deps: bool, +} +#[derive(Debug, Clone, Args, Default)] +pub struct Lib32Args { /// Build/install only the lib32 companion package path (skip primary package output) - #[arg(long, global = true)] + #[arg(long)] pub lib32_only: bool, +} +#[derive(Parser)] +#[command(name = "Depot")] +#[command(about = "Source-based package manager for Linux", long_about = None)] +#[command(version)] +pub struct Cli { #[command(subcommand)] pub command: Commands, } +#[derive(Debug, Clone, Args)] +pub struct InstallArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + #[command(flatten)] + pub prompt_args: PromptArgs, + + #[command(flatten)] + pub build_exec_args: BuildExecArgs, + + #[command(flatten)] + pub lib32_args: Lib32Args, + + /// One or more package names, spec paths (.toml), or package archives (.tar.zst) + #[arg( + value_name = "SPEC_OR_ARCHIVE", + num_args = 1.., + required_unless_present = "spec" + )] + pub spec_or_archive: Vec, + + /// Explicitly specify path to package spec (.toml file) + #[arg( + short, + long = "spec", + visible_alias = "package", + alias = "p", + conflicts_with = "spec_or_archive" + )] + pub spec: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct RemoveArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + #[command(flatten)] + pub prompt_args: PromptArgs, + + /// Package name to remove + pub package: String, +} + +#[derive(Debug, Clone, Args)] +pub struct BuildArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + #[command(flatten)] + pub prompt_args: PromptArgs, + + #[command(flatten)] + pub build_exec_args: BuildExecArgs, + + #[command(flatten)] + pub lib32_args: Lib32Args, + + /// Path to package spec (.toml file) + #[arg(value_name = "SPEC")] + pub spec_pos: Option, + + /// Explicitly specify path to package spec (.toml file) + #[arg(short, long = "spec", visible_alias = "package", alias = "p")] + pub spec: Option, + + /// Install package to rootfs after creating package archive(s) + #[arg(long)] + pub install: bool, + + /// Automatically install missing dependencies before building + #[arg(long)] + pub install_deps: bool, + + /// Remove dependencies auto-installed for this build after the command finishes + #[arg(long)] + pub cleanup_deps: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct UpdateArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + #[command(flatten)] + pub prompt_args: PromptArgs, + + #[command(flatten)] + pub build_exec_args: BuildExecArgs, + + /// Optional package names to update (defaults to all installed packages with upgrades) + #[arg(value_name = "PACKAGE", num_args = 0..)] + pub packages: Vec, +} + +#[derive(Debug, Clone, Args)] +pub struct CheckArgs { + /// Directory to scan recursively for package specs + #[arg(default_value = ".")] + pub dir: PathBuf, +} + +#[derive(Debug, Clone, Args)] +pub struct InfoArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + /// Path to package spec or installed package name + pub package: String, +} + +#[derive(Debug, Clone, Args)] +pub struct SearchArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + /// Search query + pub query: String, + + /// Search repository file lists (binary repo metadata) by path substring + #[arg(long)] + pub files: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct OwnsArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + /// Path to query (absolute or relative to rootfs) + pub path: PathBuf, +} + +#[derive(Debug, Clone, Args)] +pub struct ListArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, +} + +#[derive(Debug, Clone, Args)] +pub struct SignArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, + + /// One or more .zst files to sign + #[arg(value_name = "FILE", required = true, num_args = 1..)] + pub files: Vec, +} + +#[derive(Debug, Clone, Args)] +pub struct RepoArgs { + #[command(subcommand)] + pub command: RepoCommands, +} + +#[derive(Debug, Clone, Args)] +pub struct ConfigArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, +} + +#[derive(Debug, Clone, Args)] +pub struct GenerateArtifactsArgs { + /// Output directory for generated files + #[arg(long, value_name = "DIR")] + pub out_dir: PathBuf, +} + +#[derive(Debug, Clone, Args)] +pub struct MakeSpecArgs { + /// Output file path (defaults to .toml) + #[arg(short, long)] + pub output: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct InternalArgs { + #[command(subcommand)] + pub command: InternalCommands, +} + #[derive(Subcommand)] pub enum Commands { /// Build and install a package from a spec file - Install { - /// One or more package names, spec paths (.toml), or package archives (.tar.zst) - #[arg( - value_name = "SPEC_OR_ARCHIVE", - num_args = 1.., - required_unless_present = "spec" - )] - spec_or_archive: Vec, - - /// Explicitly specify path to package spec (.toml file) - #[arg( - short, - long = "spec", - visible_alias = "package", - alias = "p", - conflicts_with = "spec_or_archive" - )] - spec: Option, - }, + Install(InstallArgs), /// Remove an installed package - Remove { - /// Package name to remove - package: String, - }, + Remove(RemoveArgs), /// Build a package without installing - Build { - /// Path to package spec (.toml file) - #[arg(value_name = "SPEC")] - spec_pos: Option, - - /// Explicitly specify path to package spec (.toml file) - #[arg(short, long = "spec", visible_alias = "package", alias = "p")] - spec: Option, - - /// Install package to rootfs after creating package archive(s) - #[arg(long)] - install: bool, - - /// Automatically install missing dependencies before building - #[arg(long)] - install_deps: bool, - - /// Remove dependencies auto-installed for this build after the command finishes - #[arg(long)] - cleanup_deps: bool, - }, + Build(BuildArgs), /// Update installed packages from configured repositories - Update { - /// Optional package names to update (defaults to all installed packages with upgrades) - #[arg(value_name = "PACKAGE", num_args = 0..)] - packages: Vec, - }, + Update(UpdateArgs), /// Scan package specs for upstream version updates - Check { - /// Directory to scan recursively for package specs - #[arg(default_value = ".")] - dir: PathBuf, - }, + Check(CheckArgs), /// Show information about a package - Info { - /// Path to package spec or installed package name - package: String, - }, + Info(InfoArgs), /// Search configured source and binary repos by package name or provides - Search { - /// Search query - query: String, - /// Search repository file lists (binary repo metadata) by path substring - #[arg(long)] - files: bool, - }, + Search(SearchArgs), /// Show which installed package owns a filesystem path - Owns { - /// Path to query (absolute or relative to rootfs) - path: PathBuf, - }, + Owns(OwnsArgs), /// List installed packages - List, + List(ListArgs), /// Create a detached minisign signature for a .zst file - Sign { - /// One or more .zst files to sign - #[arg(value_name = "FILE", required = true, num_args = 1..)] - files: Vec, - }, + Sign(SignArgs), /// Repository management - Repo { - #[command(subcommand)] - command: RepoCommands, - }, + Repo(RepoArgs), /// Show current configuration - Config, + Config(ConfigArgs), /// Generate shell completion scripts and a man page into an output directory. #[command(hide = true)] - GenerateArtifacts { - /// Output directory for generated files - #[arg(long, value_name = "DIR")] - out_dir: PathBuf, - }, + GenerateArtifacts(GenerateArtifactsArgs), /// Create a new package specification interactively - MakeSpec { - /// Output file path (defaults to .toml) - #[arg(short, long)] - output: Option, - }, + MakeSpec(MakeSpecArgs), #[command(hide = true)] - Internal { - #[command(subcommand)] - command: InternalCommands, - }, + Internal(InternalArgs), } -#[derive(Subcommand)] +#[derive(Subcommand, Debug, Clone)] pub enum InternalCommands { #[command(hide = true)] PythonBuild { @@ -189,23 +303,38 @@ pub enum InternalCommands { }, } -#[derive(Subcommand)] +#[derive(Debug, Clone, Args)] +pub struct RepoCommonArgs { + #[command(flatten)] + pub rootfs_args: RootfsArgs, +} + +#[derive(Subcommand, Debug, Clone)] pub enum RepoCommands { /// Create a repository database from a directory of packages Create { + #[command(flatten)] + args: RepoCommonArgs, /// Directory containing .depot.pkg.tar.zst files #[arg(default_value = ".")] dir: PathBuf, }, /// Sync git mirrors configured in /etc/depot.d/mirrors.toml into /usr/src/depot - Sync, + Sync { + #[command(flatten)] + args: RepoCommonArgs, + }, /// Sync source repos configured in /etc/depot.d/repos.toml into /usr/src/depot Update { + #[command(flatten)] + args: RepoCommonArgs, /// Update only one source repo by name name: Option, }, /// Create/update a source index at the root of a source repo Index { + #[command(flatten)] + args: RepoCommonArgs, /// Source repository root directory #[arg(default_value = ".")] dir: PathBuf, @@ -214,9 +343,14 @@ pub enum RepoCommands { subdirs: Vec, }, /// List configured source and binary repos - List, + List { + #[command(flatten)] + args: RepoCommonArgs, + }, /// Add or update a repo entry in /etc/depot.d/repos.toml Add { + #[command(flatten)] + args: RepoCommonArgs, /// Repo name (e.g. vertex) name: String, /// Source git URL or binary repo base URL @@ -245,6 +379,8 @@ pub enum RepoCommands { }, /// Remove a repo entry from /etc/depot.d/repos.toml Remove { + #[command(flatten)] + args: RepoCommonArgs, /// Repo name name: String, /// Repo kind (auto-detect if unique) @@ -253,6 +389,8 @@ pub enum RepoCommands { }, /// Enable a repo entry in /etc/depot.d/repos.toml Enable { + #[command(flatten)] + args: RepoCommonArgs, /// Repo name name: String, /// Repo kind (auto-detect if unique) @@ -261,6 +399,8 @@ pub enum RepoCommands { }, /// Disable a repo entry in /etc/depot.d/repos.toml Disable { + #[command(flatten)] + args: RepoCommonArgs, /// Repo name name: String, /// Repo kind (auto-detect if unique) @@ -269,11 +409,16 @@ pub enum RepoCommands { }, /// Query binary repo metadata for the package that owns a file path Owns { + #[command(flatten)] + args: RepoCommonArgs, /// Path to query (absolute or relative install path) path: PathBuf, }, /// Show status of configured git mirrors - Status, + Status { + #[command(flatten)] + args: RepoCommonArgs, + }, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -284,18 +429,21 @@ pub enum RepoKindArg { #[cfg(test)] mod tests { - use super::{Cli, Commands}; - use clap::Parser; + use super::{ + BuildArgs, Cli, Commands, InstallArgs, RepoArgs, RepoCommands, SearchArgs, UpdateArgs, + }; + use clap::{CommandFactory, Parser}; use std::path::PathBuf; #[test] fn install_accepts_multiple_positional_targets() { let cli = Cli::try_parse_from(["depot", "install", "base", "linux"]).unwrap(); match cli.command { - Commands::Install { + Commands::Install(InstallArgs { spec_or_archive, spec, - } => { + .. + }) => { assert!(spec.is_none()); assert_eq!( spec_or_archive, @@ -310,10 +458,11 @@ mod tests { fn install_accepts_spec_flag_without_positional_target() { let cli = Cli::try_parse_from(["depot", "install", "--spec", "pkg.toml"]).unwrap(); match cli.command { - Commands::Install { + Commands::Install(InstallArgs { spec_or_archive, spec, - } => { + .. + }) => { assert!(spec_or_archive.is_empty()); assert_eq!(spec, Some(PathBuf::from("pkg.toml"))); } @@ -325,7 +474,7 @@ mod tests { fn update_accepts_no_package_names() { let cli = Cli::try_parse_from(["depot", "update"]).unwrap(); match cli.command { - Commands::Update { packages } => assert!(packages.is_empty()), + Commands::Update(UpdateArgs { packages, .. }) => assert!(packages.is_empty()), _ => panic!("expected update command"), } } @@ -334,7 +483,7 @@ mod tests { fn update_accepts_multiple_package_names() { let cli = Cli::try_parse_from(["depot", "update", "linux", "openssl"]).unwrap(); match cli.command { - Commands::Update { packages } => { + Commands::Update(UpdateArgs { packages, .. }) => { assert_eq!(packages, vec!["linux".to_string(), "openssl".to_string()]) } _ => panic!("expected update command"), @@ -345,22 +494,25 @@ mod tests { fn check_accepts_custom_directory() { let cli = Cli::try_parse_from(["depot", "check", "packages"]).unwrap(); match cli.command { - Commands::Check { dir } => assert_eq!(dir, PathBuf::from("packages")), + Commands::Check(args) => assert_eq!(args.dir, PathBuf::from("packages")), _ => panic!("expected check command"), } } #[test] - fn global_test_deps_flag_is_parsed() { - let cli = Cli::try_parse_from(["depot", "--test-deps", "install", "foo"]).unwrap(); - assert!(cli.test_deps); + fn install_test_deps_flag_is_parsed_for_install() { + let cli = Cli::try_parse_from(["depot", "install", "--test-deps", "foo"]).unwrap(); + match cli.command { + Commands::Install(args) => assert!(args.build_exec_args.test_deps), + _ => panic!("expected install command"), + } } #[test] fn build_install_deps_flag_is_parsed() { let cli = Cli::try_parse_from(["depot", "build", "--install-deps", "pkg.toml"]).unwrap(); match cli.command { - Commands::Build { install_deps, .. } => assert!(install_deps), + Commands::Build(BuildArgs { install_deps, .. }) => assert!(install_deps), _ => panic!("expected build command"), } } @@ -369,8 +521,78 @@ mod tests { fn build_cleanup_deps_flag_is_parsed() { let cli = Cli::try_parse_from(["depot", "build", "--cleanup-deps", "pkg.toml"]).unwrap(); match cli.command { - Commands::Build { cleanup_deps, .. } => assert!(cleanup_deps), + Commands::Build(BuildArgs { cleanup_deps, .. }) => assert!(cleanup_deps), _ => panic!("expected build command"), } } + + #[test] + fn repo_help_does_not_show_build_only_flags() { + let mut cmd = Cli::command(); + let help = cmd + .find_subcommand_mut("repo") + .expect("repo subcommand") + .render_help() + .to_string(); + + assert!(!help.contains("--no-deps")); + assert!(!help.contains("--no-flags")); + assert!(!help.contains("--clean")); + assert!(!help.contains("--test-deps")); + assert!(!help.contains("--lib32-only")); + } + + #[test] + fn repo_create_help_only_shows_repo_options() { + let mut cmd = Cli::command(); + let repo = cmd.find_subcommand_mut("repo").expect("repo subcommand"); + let help = repo + .find_subcommand_mut("create") + .expect("repo create subcommand") + .render_help() + .to_string(); + + assert!(help.contains("--rootfs")); + assert!(!help.contains("--no-deps")); + assert!(!help.contains("--test-deps")); + } + + #[test] + fn search_help_shows_only_search_options() { + let mut cmd = Cli::command(); + let help = cmd + .find_subcommand_mut("search") + .expect("search subcommand") + .render_help() + .to_string(); + + assert!(help.contains("--rootfs")); + assert!(help.contains("--files")); + assert!(!help.contains("--no-deps")); + } + + #[test] + fn repo_command_parses_nested_subcommand() { + let cli = Cli::try_parse_from(["depot", "repo", "status"]).unwrap(); + match cli.command { + Commands::Repo(RepoArgs { + command: RepoCommands::Status { .. }, + }) => {} + _ => panic!("expected repo status command"), + } + } + + #[test] + fn search_parses_rootfs_locally() { + let cli = Cli::try_parse_from(["depot", "search", "-r", "/tmp/root", "llvm"]).unwrap(); + match cli.command { + Commands::Search(SearchArgs { + rootfs_args, query, .. + }) => { + assert_eq!(rootfs_args.rootfs, PathBuf::from("/tmp/root")); + assert_eq!(query, "llvm"); + } + _ => panic!("expected search command"), + } + } } diff --git a/src/commands.rs b/src/commands.rs index 1bb7302..1632eab 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,7 @@ -use crate::cli::{Cli, Commands, InternalCommands, RepoCommands, RepoKindArg}; +use crate::cli::{ + BuildArgs, Cli, Commands, ConfigArgs, InfoArgs, InstallArgs, InternalCommands, ListArgs, + OwnsArgs, RemoveArgs, RepoArgs, RepoCommands, RepoKindArg, SearchArgs, SignArgs, UpdateArgs, +}; use crate::{ builder, cli_assets, config, cross, db, deps, index, install, locking, package, planner, signing, source, staging, ui, @@ -29,13 +32,69 @@ fn rootfs_is_system_root(rootfs: &Path) -> bool { fn command_requires_live_root(command: &Commands) -> bool { matches!( command, - Commands::Install { .. } | Commands::Remove { .. } | Commands::Update { .. } + Commands::Install(_) | Commands::Remove(_) | Commands::Update(_) ) } +fn repo_command_rootfs(command: &RepoCommands) -> &Path { + match command { + RepoCommands::Create { args, .. } + | RepoCommands::Sync { args } + | RepoCommands::Update { args, .. } + | RepoCommands::Index { args, .. } + | RepoCommands::List { args } + | RepoCommands::Add { args, .. } + | RepoCommands::Remove { args, .. } + | RepoCommands::Enable { args, .. } + | RepoCommands::Disable { args, .. } + | RepoCommands::Owns { args, .. } + | RepoCommands::Status { args } => &args.rootfs_args.rootfs, + } +} + +fn command_rootfs(command: &Commands) -> Option<&Path> { + match command { + Commands::Install(args) => Some(&args.rootfs_args.rootfs), + Commands::Remove(args) => Some(&args.rootfs_args.rootfs), + Commands::Build(args) => Some(&args.rootfs_args.rootfs), + Commands::Update(args) => Some(&args.rootfs_args.rootfs), + Commands::Info(args) => Some(&args.rootfs_args.rootfs), + Commands::Search(args) => Some(&args.rootfs_args.rootfs), + Commands::Owns(args) => Some(&args.rootfs_args.rootfs), + Commands::List(args) => Some(&args.rootfs_args.rootfs), + Commands::Sign(args) => Some(&args.rootfs_args.rootfs), + Commands::Repo(args) => Some(repo_command_rootfs(&args.command)), + Commands::Config(args) => Some(&args.rootfs_args.rootfs), + Commands::Check(_) + | Commands::GenerateArtifacts(_) + | Commands::MakeSpec(_) + | Commands::Internal(_) => None, + } +} + +fn command_assume_yes(command: &Commands) -> bool { + match command { + Commands::Install(args) => args.prompt_args.yes, + Commands::Remove(args) => args.prompt_args.yes, + Commands::Build(args) => args.prompt_args.yes, + Commands::Update(args) => args.prompt_args.yes, + Commands::Check(_) + | Commands::Info(_) + | Commands::Search(_) + | Commands::Owns(_) + | Commands::List(_) + | Commands::Sign(_) + | Commands::Repo(_) + | Commands::Config(_) + | Commands::GenerateArtifacts(_) + | Commands::MakeSpec(_) + | Commands::Internal(_) => false, + } +} + fn should_reexec_with_sudo(cli: &Cli) -> bool { !crate::fakeroot::is_root() - && rootfs_is_system_root(&cli.rootfs) + && command_rootfs(&cli.command).is_some_and(rootfs_is_system_root) && command_requires_live_root(&cli.command) } @@ -1081,9 +1140,13 @@ fn build_lib32_companion_package( lib32_destdir.display() ); } - staging::add_licenses(src_dir, &lib32_destdir, &lib32_pkg_spec.package.name)?; install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; staging::process(&lib32_destdir, &lib32_pkg_spec)?; + staging::symlink_package_license( + &lib32_destdir, + &lib32_pkg_spec.package.name, + &pkg_spec.package.name, + )?; Ok(Some((lib32_pkg_spec, lib32_destdir))) } @@ -3969,6 +4032,9 @@ fn run_direct_install_request( if staging_dir.is_none() { // Source-build path: apply staging transforms (strip/compress/static cleanup). 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 { // Binary archive path: install as-packaged without post-build transformations. if !suppress_output { @@ -4019,30 +4085,58 @@ fn run_direct_install_request( } pub fn run(cli: Cli) -> Result<()> { - ui::set_assume_yes(cli.yes); + ui::set_assume_yes(command_assume_yes(&cli.command)); if maybe_reexec_with_sudo(&cli)? { return Ok(()); } - let cli_test_deps = cli.test_deps; + let cli_test_deps = match &cli.command { + Commands::Install(args) => args.build_exec_args.test_deps, + Commands::Build(args) => args.build_exec_args.test_deps, + Commands::Update(args) => args.build_exec_args.test_deps, + Commands::Check(_) + | Commands::Remove(_) + | Commands::Info(_) + | Commands::Search(_) + | Commands::Owns(_) + | Commands::List(_) + | Commands::Sign(_) + | Commands::Repo(_) + | Commands::Config(_) + | Commands::GenerateArtifacts(_) + | Commands::MakeSpec(_) + | Commands::Internal(_) => false, + }; match cli.command { - Commands::Install { + Commands::Install(InstallArgs { + rootfs_args, + prompt_args, + build_exec_args, + lib32_args, spec_or_archive, spec, - } => { + }) => { + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + let clean = build_exec_args.clean; + let dry_run = build_exec_args.dry_run; + let lib32_only = lib32_args.lib32_only; let install_requests = match spec { Some(spec_path) => vec![spec_path], None => spec_or_archive, }; // Load configuration early so we can use configured repos/paths. - let config = config::Config::for_rootfs(&cli.rootfs); + let config = config::Config::for_rootfs(&rootfs); let install_test_deps = install_test_deps_enabled(cli_test_deps, &config); let mut planned_targets = Vec::new(); let mut planned_spec_paths = Vec::new(); let mut direct_requests = Vec::new(); - if cli.no_deps { + if no_deps { direct_requests = install_requests; } else { for request in install_requests { @@ -4065,23 +4159,23 @@ pub fn run(cli: Cli) -> Result<()> { if !planned_targets.is_empty() { ran_plan_mode = true; let planner_opts = planner::PlannerOptions { - assume_yes: cli.yes, + assume_yes: yes, prefer_binary: config.repo_settings.prefer_binary, local_sibling_root: shared_local_sibling_root(&planned_spec_paths), include_test_deps: install_test_deps, - lib32_only_requested_specs: cli.lib32_only, + lib32_only_requested_specs: lib32_only, }; let plan = if planned_targets.len() == 1 { planner::build_install_plan( &config, - &cli.rootfs, + &rootfs, planned_targets[0].clone(), planner_opts, )? } else { planner::build_install_plan_for_targets( &config, - &cli.rootfs, + &rootfs, &planned_targets, planner_opts, )? @@ -4089,15 +4183,15 @@ pub fn run(cli: Cli) -> Result<()> { print_plan_summary(&plan); execute_install_plan_with_child_commands( &plan, - &cli.rootfs, + &rootfs, &config, InstallPlanExecutionOptions { - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, confirm_installation: true, - lib32_only_requested_specs: cli.lib32_only, + lib32_only_requested_specs: lib32_only, install_test_deps, }, )?; @@ -4105,13 +4199,13 @@ pub fn run(cli: Cli) -> Result<()> { let mut ran_direct_install = false; let direct_install_options = DirectInstallOptions { - rootfs: &cli.rootfs, - no_deps: cli.no_deps, - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, - lib32_only: cli.lib32_only, + rootfs: &rootfs, + no_deps, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + lib32_only, install_test_deps, }; if direct_requests.len() > 1 @@ -4132,16 +4226,21 @@ pub fn run(cli: Cli) -> Result<()> { } } if ran_direct_install { - install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?; + install::scripts::run_deferred_hooks_if_possible(&rootfs)?; } - if cli.clean && (ran_plan_mode || ran_direct_install) { + if clean && (ran_plan_mode || ran_direct_install) { clean_build_workspace(&config)?; } } - Commands::Remove { package } => { + Commands::Remove(RemoveArgs { + rootfs_args, + package, + .. + }) => { + let rootfs = rootfs_args.rootfs; ui::info(format!("Removing package: {}", package)); - let config = config::Config::for_rootfs(&cli.rootfs); + let config = config::Config::for_rootfs(&rootfs); let mut remove_lock = locking::open_lock(&config)?; let remove_lock_path = locking::lock_path(&config); let _remove_lock_guard = @@ -4150,21 +4249,33 @@ pub fn run(cli: Cli) -> Result<()> { if !ui::prompt_package_action("removal", &removal_targets, true)? { anyhow::bail!("Aborted"); } - remove_installed_package_with_hooks(&package, &cli.rootfs, &config)?; + remove_installed_package_with_hooks(&package, &rootfs, &config)?; } - Commands::Build { + Commands::Build(BuildArgs { + rootfs_args, + prompt_args, + build_exec_args, + lib32_args, spec_pos, spec, install, install_deps, cleanup_deps, - } => { - warn_if_running_as_root_for_build("build", &cli.rootfs); + }) => { + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + let clean = build_exec_args.clean; + let dry_run = build_exec_args.dry_run; + let lib32_only = lib32_args.lib32_only; + warn_if_running_as_root_for_build("build", &rootfs); let spec_path = spec.or(spec_pos).context("No spec file provided")?; ui::info(format!("Building package from: {}", spec_path.display())); let mut pkg_spec = package::PackageSpec::from_file(&spec_path)?; - let config = config::Config::for_rootfs(&cli.rootfs); + let config = config::Config::for_rootfs(&rootfs); let install_test_deps = install_test_deps_enabled(cli_test_deps, &config); let interrupt_watcher = if cleanup_deps { Some(InterruptWatcher::install()?) @@ -4175,7 +4286,7 @@ pub fn run(cli: Cli) -> Result<()> { let build_result: Result<()> = (|| { // Apply system overrides pkg_spec.apply_config(&config); - let requested_outputs = requested_outputs(&pkg_spec, cli.lib32_only); + let requested_outputs = requested_outputs(&pkg_spec, lib32_only); let build_targets = vec![format!( "{} v{}-{}", @@ -4192,9 +4303,9 @@ pub fn run(cli: Cli) -> Result<()> { config.db_dir.display() ) })?; - let db_path = config.installed_db_path(&cli.rootfs); + let db_path = config.installed_db_path(&rootfs); - if cli.no_deps + if no_deps && should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { let missing_test = @@ -4208,7 +4319,7 @@ pub fn run(cli: Cli) -> Result<()> { { anyhow::bail!("Missing test dependencies: {}", missing_test.join(", ")); } - } else if cli.no_deps + } else if no_deps || !should_install_test_deps(&pkg_spec, install_test_deps, requested_outputs) { maybe_disable_tests_for_missing_deps( @@ -4218,7 +4329,7 @@ pub fn run(cli: Cli) -> Result<()> { )?; } - if !cli.no_deps { + if !no_deps { deps::print_dep_status_for_outputs(&pkg_spec, &db_path, requested_outputs)?; let missing_build = deps::check_build_deps_for_outputs(&pkg_spec, &db_path, requested_outputs)?; @@ -4240,10 +4351,10 @@ pub fn run(cli: Cli) -> Result<()> { .map(Path::to_path_buf); let dep_plan = planner::build_dependency_install_plan( &config, - &cli.rootfs, + &rootfs, &missing_required, planner::PlannerOptions { - assume_yes: cli.yes, + assume_yes: yes, prefer_binary: config.repo_settings.prefer_binary, local_sibling_root, include_test_deps: install_test_deps, @@ -4264,7 +4375,7 @@ pub fn run(cli: Cli) -> Result<()> { } print_plan_summary(&dep_plan); if !install_deps { - if cli.dry_run { + if dry_run { ui::info( "Dry run enabled, stopping before dependency installation/build.", ); @@ -4275,7 +4386,7 @@ pub fn run(cli: Cli) -> Result<()> { missing_required.join(", ") ); } - if cli.dry_run { + if dry_run { ui::info( "Dry run enabled, stopping before dependency installation/build.", ); @@ -4283,13 +4394,13 @@ pub fn run(cli: Cli) -> Result<()> { } execute_install_plan_with_child_commands( &dep_plan, - &cli.rootfs, + &rootfs, &config, InstallPlanExecutionOptions { - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, confirm_installation: false, lib32_only_requested_specs: false, install_test_deps, @@ -4311,10 +4422,10 @@ pub fn run(cli: Cli) -> Result<()> { .map(Path::to_path_buf); let dep_plan = match planner::build_dependency_install_plan( &config, - &cli.rootfs, + &rootfs, &missing_test, planner::PlannerOptions { - assume_yes: cli.yes, + assume_yes: yes, prefer_binary: config.repo_settings.prefer_binary, local_sibling_root, include_test_deps: install_test_deps, @@ -4349,7 +4460,7 @@ pub fn run(cli: Cli) -> Result<()> { )); print_plan_summary(&dep_plan); if !install_deps { - if cli.dry_run { + if dry_run { ui::info( "Dry run enabled, stopping before dependency installation/build.", ); @@ -4365,16 +4476,16 @@ pub fn run(cli: Cli) -> Result<()> { missing_test.join(", ") ); } - } else if !cli.dry_run { + } else if !dry_run { execute_install_plan_with_child_commands( &dep_plan, - &cli.rootfs, + &rootfs, &config, InstallPlanExecutionOptions { - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, confirm_installation: false, lib32_only_requested_specs: false, install_test_deps, @@ -4409,7 +4520,7 @@ pub fn run(cli: Cli) -> Result<()> { } } } - } else if cli.dry_run { + } else if dry_run { ui::info("Dry run enabled, stopping before build."); return Ok(()); } @@ -4419,7 +4530,7 @@ pub fn run(cli: Cli) -> Result<()> { let _build_lock_guard = locking::try_write(&mut build_lock, &build_lock_path, "build")?; - if cli.dry_run { + if dry_run { ui::info("Dry run enabled, stopping before fetch/build."); return Ok(()); } @@ -4437,45 +4548,42 @@ pub fn run(cli: Cli) -> Result<()> { .build_dir .join("destdir") .join(&pkg_spec.package.name); - let cross_config = cli - .cross_prefix + let cross_config = cross_prefix .as_ref() .map(|p| cross::CrossConfig::from_prefix(p)) .transpose()?; - if !cli.lib32_only { + if !lib32_only { builder::build( &pkg_spec, &src_dir, &destdir, cross_config.as_ref(), - !cli.no_flags, + !no_flags, )?; if let Some(watcher) = interrupt_watcher.as_ref() { watcher.check()?; } } - if !cli.lib32_only { + if !lib32_only { staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; staging::process(&destdir, &pkg_spec)?; + staging::stage_split_package_licenses(&src_dir, &destdir, &pkg_spec)?; if let Some(watcher) = interrupt_watcher.as_ref() { watcher.check()?; } } - let arch = cli - .cross_prefix - .as_deref() - .unwrap_or(std::env::consts::ARCH); + let arch = cross_prefix.as_deref().unwrap_or(std::env::consts::ARCH); let mut created_files = Vec::new(); - let staged_outputs = if !cli.lib32_only { + let staged_outputs = if !lib32_only { staged_output_specs(&pkg_spec, &destdir)? } else { Vec::new() }; - if !cli.lib32_only { + if !lib32_only { for (spec_for_out, out_destdir) in &staged_outputs { let packager = package::Packager::new( spec_for_out.clone(), @@ -4483,14 +4591,6 @@ pub fn run(cli: Cli) -> Result<()> { config.clone(), ); let pkg_file = packager.create_package(Path::new("."), arch)?; - if let Some(sig_path) = - signing::auto_sign_zst_file_detached(&cli.rootfs, &pkg_file)? - { - ui::success(format!( - "Created detached signature: {}", - sig_path.display() - )); - } created_files.push(pkg_file); if let Some(watcher) = interrupt_watcher.as_ref() { watcher.check()?; @@ -4504,8 +4604,8 @@ pub fn run(cli: Cli) -> Result<()> { &src_dir, &config, cross_config.as_ref(), - !cli.no_flags, - cli.lib32_only, + !no_flags, + lib32_only, )? { let packager = package::Packager::new( lib32_spec.clone(), @@ -4513,14 +4613,6 @@ pub fn run(cli: Cli) -> Result<()> { config.clone(), ); let pkg_file = packager.create_package(Path::new("."), arch)?; - if let Some(sig_path) = - signing::auto_sign_zst_file_detached(&cli.rootfs, &pkg_file)? - { - ui::success(format!( - "Created detached signature: {}", - sig_path.display() - )); - } created_files.push(pkg_file); lib32_install_bundle = Some((lib32_spec, lib32_destdir)); if let Some(watcher) = interrupt_watcher.as_ref() { @@ -4532,9 +4624,16 @@ pub fn run(cli: Cli) -> Result<()> { ui::success(format!("Build complete. Package created: {}", f.display())); } + for sig_path in signing::auto_sign_zst_files_detached(&rootfs, &created_files)? { + ui::success(format!( + "Created detached signature: {}", + sig_path.display() + )); + } + if install { let mut install_targets = Vec::new(); - if !cli.lib32_only { + if !lib32_only { for (spec_for_out, _) in &staged_outputs { let out = &spec_for_out.package; install_targets @@ -4553,15 +4652,15 @@ pub fn run(cli: Cli) -> Result<()> { if let Some(watcher) = interrupt_watcher.as_ref() { watcher.check()?; } - if should_delegate_live_rootfs_installs(&cli.rootfs) { + if should_delegate_live_rootfs_installs(&rootfs) { run_child_install_command( &created_files, - &cli.rootfs, + &rootfs, InstallPlanExecutionOptions { - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, confirm_installation: false, lib32_only_requested_specs: false, install_test_deps, @@ -4571,22 +4670,15 @@ pub fn run(cli: Cli) -> Result<()> { } let mut transaction_plans = Vec::new(); - if !cli.lib32_only { + if !lib32_only { let output_plans = plan_package_outputs_for_install( - &pkg_spec, - &destdir, - &cli.rootfs, - &config, + &pkg_spec, &destdir, &rootfs, &config, )?; transaction_plans.extend(output_plans); } if let Some((lib32_spec, lib32_destdir)) = &lib32_install_bundle { - let staged = plan_staged_install( - lib32_spec, - lib32_destdir, - &cli.rootfs, - &config, - )?; + let staged = + plan_staged_install(lib32_spec, lib32_destdir, &rootfs, &config)?; transaction_plans.push(PlannedPackageInstall { spec: lib32_spec.clone(), destdir: lib32_destdir.clone(), @@ -4595,27 +4687,27 @@ pub fn run(cli: Cli) -> Result<()> { } run_transaction_hooks_for_plans( - &cli.rootfs, + &rootfs, install::hooks::HookPhase::Pre, &transaction_plans, )?; let installed = install_planned_packages_to_rootfs( &transaction_plans, - &cli.rootfs, + &rootfs, &config, )?; for pkg in installed { log_install_success(&pkg); } run_transaction_hooks_for_plans( - &cli.rootfs, + &rootfs, install::hooks::HookPhase::Post, &transaction_plans, )?; - install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?; + install::scripts::run_deferred_hooks_if_possible(&rootfs)?; } else { - if !cli.lib32_only { + if !lib32_only { for (spec_for_out, _) in &staged_outputs { let out = &spec_for_out.package; ui::success(format!( @@ -4646,13 +4738,13 @@ pub fn run(cli: Cli) -> Result<()> { if cleanup_deps && !auto_installed_deps.is_empty() { cleanup_auto_installed_dependencies( &auto_installed_deps, - &cli.rootfs, + &rootfs, &config, !install, false, )?; } - if cli.clean { + if clean { clean_build_workspace(&config)?; } } @@ -4663,7 +4755,7 @@ pub fn run(cli: Cli) -> Result<()> { } if let Err(clean_err) = cleanup_auto_installed_dependencies( &auto_installed_deps, - &cli.rootfs, + &rootfs, &config, !install, interrupted, @@ -4681,34 +4773,48 @@ pub fn run(cli: Cli) -> Result<()> { } } } - Commands::Update { packages } => { - if cli.lib32_only { - anyhow::bail!("--lib32-only is not supported with 'update'"); - } - let config = config::Config::for_rootfs(&cli.rootfs); + Commands::Update(UpdateArgs { + rootfs_args, + prompt_args, + build_exec_args, + packages, + }) => { + let rootfs = rootfs_args.rootfs; + let yes = prompt_args.yes; + let no_deps = build_exec_args.no_deps; + let no_flags = build_exec_args.no_flags; + let cross_prefix = build_exec_args.cross_prefix; + let clean = build_exec_args.clean; + let dry_run = build_exec_args.dry_run; + let config = config::Config::for_rootfs(&rootfs); run_update_command( &packages, &config, UpdateCommandOptions { - rootfs: &cli.rootfs, - no_deps: cli.no_deps, - no_flags: cli.no_flags, - cross_prefix: cli.cross_prefix.as_deref(), - clean: cli.clean, - dry_run: cli.dry_run, - assume_yes: cli.yes, + rootfs: &rootfs, + no_deps, + no_flags, + cross_prefix: cross_prefix.as_deref(), + clean, + dry_run, + assume_yes: yes, install_test_deps: install_test_deps_enabled(cli_test_deps, &config), }, )?; } - Commands::Check { dir } => { + Commands::Check(args) => { + let dir = args.dir; run_check_command(&dir)?; } - Commands::Info { package } => { + Commands::Info(InfoArgs { + rootfs_args, + package, + }) => { + let rootfs = rootfs_args.rootfs; // Try as file first, then as installed package name let path = PathBuf::from(&package); if path.exists() { - let config = config::Config::for_rootfs(&cli.rootfs); + let config = config::Config::for_rootfs(&rootfs); let info_lock = locking::open_lock(&config)?; let info_lock_path = locking::lock_path(&config); let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; @@ -4716,45 +4822,53 @@ pub fn run(cli: Cli) -> Result<()> { println!("{}", pkg_spec); // Also show dependency status - let db_path = config.installed_db_path(&cli.rootfs); + let db_path = config.installed_db_path(&rootfs); deps::print_dep_status(&pkg_spec, &db_path)?; } else { - let config = config::Config::for_rootfs(&cli.rootfs); + let config = config::Config::for_rootfs(&rootfs); let info_lock = locking::open_lock(&config)?; let info_lock_path = locking::lock_path(&config); let _info_lock_guard = locking::try_read(&info_lock, &info_lock_path, "info")?; - let db_path = config.installed_db_path(&cli.rootfs); + let db_path = config.installed_db_path(&rootfs); db::show_package_info(&db_path, &package)?; } } - Commands::Search { query, files } => { - let config = config::Config::for_rootfs(&cli.rootfs); + Commands::Search(SearchArgs { + rootfs_args, + query, + files, + }) => { + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let search_lock = locking::open_lock(&config)?; let search_lock_path = locking::lock_path(&config); let _search_lock_guard = locking::try_read(&search_lock, &search_lock_path, "search")?; - run_search_command(&query, files, &config, &cli.rootfs)?; + run_search_command(&query, files, &config, &rootfs)?; } - Commands::Owns { path } => { - let config = config::Config::for_rootfs(&cli.rootfs); + Commands::Owns(OwnsArgs { rootfs_args, path }) => { + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let owns_lock = locking::open_lock(&config)?; let owns_lock_path = locking::lock_path(&config); let _owns_lock_guard = locking::try_read(&owns_lock, &owns_lock_path, "owns")?; - let db_path = config.installed_db_path(&cli.rootfs); + let db_path = config.installed_db_path(&rootfs); match db::owns_path(&db_path, &path)? { Some(owner) => ui::info(format!("{} is owned by {}", path.display(), owner)), None => ui::warn(format!("No installed package owns {}", path.display())), } } - Commands::List => { - let config = config::Config::for_rootfs(&cli.rootfs); + Commands::List(ListArgs { rootfs_args }) => { + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let list_lock = locking::open_lock(&config)?; let list_lock_path = locking::lock_path(&config); let _list_lock_guard = locking::try_read(&list_lock, &list_lock_path, "list")?; - let db_path = config.installed_db_path(&cli.rootfs); + let db_path = config.installed_db_path(&rootfs); db::list_packages(&db_path)?; } - Commands::Sign { files } => { - let sig_paths = signing::sign_zst_files_detached(&cli.rootfs, &files)?; + Commands::Sign(SignArgs { rootfs_args, files }) => { + let rootfs = rootfs_args.rootfs; + let sig_paths = signing::sign_zst_files_detached(&rootfs, &files)?; for sig_path in sig_paths { ui::success(format!( "Created detached signature: {}", @@ -4762,17 +4876,17 @@ pub fn run(cli: Cli) -> Result<()> { )); } } - Commands::Repo { command } => match command { - RepoCommands::Create { dir } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + Commands::Repo(RepoArgs { command }) => match command { + RepoCommands::Create { args, dir } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo create")?; let repo = db::repo::RepoManager::new(dir); let db_path = repo.create_repo_db()?; - if let Some(sig_path) = signing::auto_sign_zst_file_detached(&cli.rootfs, &db_path)? - { + if let Some(sig_path) = signing::auto_sign_zst_file_detached(&rootfs, &db_path)? { ui::success(format!( "Created detached signature: {}", sig_path.display() @@ -4783,8 +4897,9 @@ pub fn run(cli: Cli) -> Result<()> { db_path.display() )); } - RepoCommands::Sync => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Sync { args } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = @@ -4805,8 +4920,9 @@ pub fn run(cli: Cli) -> Result<()> { )); } } - RepoCommands::Update { name } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Update { args, name } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = @@ -4834,8 +4950,9 @@ pub fn run(cli: Cli) -> Result<()> { } } } - RepoCommands::Index { dir, subdirs } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Index { args, dir, subdirs } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = @@ -4858,14 +4975,16 @@ pub fn run(cli: Cli) -> Result<()> { stats.ignored_toml_files )); } - RepoCommands::List => { - let config = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::List { args } => { + let rootfs = args.rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let repo_lock = locking::open_lock(&config)?; let repo_lock_path = locking::lock_path(&config); let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo list")?; print_repo_list(&config); } RepoCommands::Add { + args, name, url, kind, @@ -4876,12 +4995,13 @@ pub fn run(cli: Cli) -> Result<()> { repo_db, allow_unsigned, } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo add")?; - let mut repos = config::load_repos_config_file(&cli.rootfs)?; + let mut repos = config::load_repos_config_file(&rootfs)?; match kind { RepoKindArg::Source => { if let Some(existing) = repos.source.get(&name) @@ -4928,7 +5048,7 @@ pub fn run(cli: Cli) -> Result<()> { repos.binary.insert(name.clone(), candidate); } } - let path = config::save_repos_config_file(&cli.rootfs, &repos)?; + let path = config::save_repos_config_file(&rootfs, &repos)?; ui::success(format!( "Saved {} repo '{}' to {}", repo_kind_label(kind), @@ -4936,13 +5056,14 @@ pub fn run(cli: Cli) -> Result<()> { path.display() )); } - RepoCommands::Remove { name, kind } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Remove { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo remove")?; - let mut repos = config::load_repos_config_file(&cli.rootfs)?; + let mut repos = config::load_repos_config_file(&rootfs)?; let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; if !ui::prompt_yes_no( &format!("Remove {} repo '{}'?", repo_kind_label(kind), name), @@ -4959,7 +5080,7 @@ pub fn run(cli: Cli) -> Result<()> { repos.binary.remove(&name); } } - let path = config::save_repos_config_file(&cli.rootfs, &repos)?; + let path = config::save_repos_config_file(&rootfs, &repos)?; ui::success(format!( "Removed {} repo '{}' from {}", repo_kind_label(kind), @@ -4967,13 +5088,14 @@ pub fn run(cli: Cli) -> Result<()> { path.display() )); } - RepoCommands::Enable { name, kind } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Enable { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo enable")?; - let mut repos = config::load_repos_config_file(&cli.rootfs)?; + let mut repos = config::load_repos_config_file(&rootfs)?; let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; match kind { RepoKindArg::Source => { @@ -4985,7 +5107,7 @@ pub fn run(cli: Cli) -> Result<()> { ui::info(format!("Source repo '{}' is already enabled", name)); } else { repo.enabled = true; - let path = config::save_repos_config_file(&cli.rootfs, &repos)?; + let path = config::save_repos_config_file(&rootfs, &repos)?; ui::success(format!( "Enabled source repo '{}' in {}", name, @@ -5002,7 +5124,7 @@ pub fn run(cli: Cli) -> Result<()> { ui::info(format!("Binary repo '{}' is already enabled", name)); } else { repo.enabled = true; - let path = config::save_repos_config_file(&cli.rootfs, &repos)?; + let path = config::save_repos_config_file(&rootfs, &repos)?; ui::success(format!( "Enabled binary repo '{}' in {}", name, @@ -5012,13 +5134,14 @@ pub fn run(cli: Cli) -> Result<()> { } } } - RepoCommands::Disable { name, kind } => { - let cfg = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Disable { args, name, kind } => { + let rootfs = args.rootfs_args.rootfs; + let cfg = config::Config::for_rootfs(&rootfs); let mut repo_lock = locking::open_lock(&cfg)?; let repo_lock_path = locking::lock_path(&cfg); let _repo_lock_guard = locking::try_write(&mut repo_lock, &repo_lock_path, "repo disable")?; - let mut repos = config::load_repos_config_file(&cli.rootfs)?; + let mut repos = config::load_repos_config_file(&rootfs)?; let kind = resolve_repo_kind_for_name(&repos, &name, kind)?; if !ui::prompt_yes_no( &format!("Disable {} repo '{}'?", repo_kind_label(kind), name), @@ -5042,7 +5165,7 @@ pub fn run(cli: Cli) -> Result<()> { repo.enabled = false; } } - let path = config::save_repos_config_file(&cli.rootfs, &repos)?; + let path = config::save_repos_config_file(&rootfs, &repos)?; ui::success(format!( "Disabled {} repo '{}' in {}", repo_kind_label(kind), @@ -5050,8 +5173,9 @@ pub fn run(cli: Cli) -> Result<()> { path.display() )); } - RepoCommands::Owns { path } => { - let config = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Owns { args, path } => { + let rootfs = args.rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let repo_lock = locking::open_lock(&config)?; let repo_lock_path = locking::lock_path(&config); let _repo_lock_guard = locking::try_read(&repo_lock, &repo_lock_path, "repo owns")?; @@ -5069,7 +5193,7 @@ pub fn run(cli: Cli) -> Result<()> { match db::repo::binary_repo_owns_path( name, repo, - &cli.rootfs, + &rootfs, &config.package_cache_dir, &path.to_string_lossy(), ) { @@ -5097,8 +5221,9 @@ pub fn run(cli: Cli) -> Result<()> { )); } } - RepoCommands::Status => { - let config = config::Config::for_rootfs(&cli.rootfs); + RepoCommands::Status { args } => { + let rootfs = args.rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let repo_lock = locking::open_lock(&config)?; let repo_lock_path = locking::lock_path(&config); let _repo_lock_guard = @@ -5137,12 +5262,14 @@ pub fn run(cli: Cli) -> Result<()> { } } }, - Commands::GenerateArtifacts { out_dir } => { + Commands::GenerateArtifacts(args) => { + let out_dir = args.out_dir; cli_assets::generate_cli_assets(&out_dir)?; ui::success(format!("Generated CLI assets in {}", out_dir.display())); } - Commands::Config => { - let config = config::Config::for_rootfs(&cli.rootfs); + Commands::Config(ConfigArgs { rootfs_args }) => { + let rootfs = rootfs_args.rootfs; + let config = config::Config::for_rootfs(&rootfs); let config_lock = locking::open_lock(&config)?; let config_lock_path = locking::lock_path(&config); let _config_lock_guard = locking::try_read(&config_lock, &config_lock_path, "config")?; @@ -5169,7 +5296,8 @@ pub fn run(cli: Cli) -> Result<()> { } } } - Commands::MakeSpec { output } => { + Commands::MakeSpec(args) => { + let output = args.output; let spec = package::create_interactive()?; // Produce a minimal TOML for interactive-created specs (omit defaults) let toml_string = package::spec_to_minimal_toml(&spec)?; @@ -5190,7 +5318,8 @@ pub fn run(cli: Cli) -> Result<()> { output_path.display() )); } - Commands::Internal { command } => { + Commands::Internal(args) => { + let command = args.command; run_internal_command(command)?; } } @@ -5201,11 +5330,40 @@ pub fn run(cli: Cli) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::cli::{ + BuildArgs, BuildExecArgs, Cli, InstallArgs, Lib32Args, PromptArgs, RootfsArgs, SearchArgs, + UpdateArgs, + }; use crate::test_support::TestEnv; use std::sync::Mutex; static ASSUME_YES_TEST_LOCK: Mutex<()> = Mutex::new(()); + fn rootfs_args(rootfs: impl Into) -> RootfsArgs { + RootfsArgs { + rootfs: rootfs.into(), + } + } + + fn prompt_args(yes: bool) -> PromptArgs { + PromptArgs { yes } + } + + fn build_exec_args() -> BuildExecArgs { + BuildExecArgs { + no_deps: false, + no_flags: false, + cross_prefix: None, + clean: false, + dry_run: false, + test_deps: false, + } + } + + fn lib32_args() -> Lib32Args { + Lib32Args { lib32_only: false } + } + fn test_binary_repo_record(name: &str, filename: &str) -> db::repo::BinaryRepoPackageRecord { db::repo::BinaryRepoPackageRecord { repo_name: "core".into(), @@ -6515,27 +6673,43 @@ optional = [] #[test] fn command_requires_live_root_for_install_remove_and_update() { - assert!(command_requires_live_root(&Commands::Install { - spec_or_archive: vec![PathBuf::from("foo")], - spec: None, - })); - assert!(command_requires_live_root(&Commands::Remove { + 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 { + }))); + 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 { + }))); + 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 { + }))); + assert!(!command_requires_live_root(&Commands::Search(SearchArgs { + rootfs_args: rootfs_args("/"), query: "foo".to_string(), files: false, - })); + }))); } #[test] @@ -6727,22 +6901,17 @@ optional = [] .with_context(|| format!("Failed to write {}", dep_spec.display()))?; let result = run(Cli { - rootfs: rootfs.clone(), - no_deps: false, - no_flags: false, - cross_prefix: None, - clean: false, - yes: true, - dry_run: false, - test_deps: false, - lib32_only: false, - command: Commands::Build { + 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); @@ -6793,7 +6962,7 @@ optional = [] assert_eq!(lib32.package.name, "lib32-pkg"); assert_eq!(lib32.dependencies.build, vec!["gcc-multilib"]); - assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib"]); + assert_eq!(lib32.dependencies.runtime, vec!["lib32-zlib", "pkg"]); assert_eq!(lib32.dependencies.optional, vec!["lib32-gtk-doc"]); } } diff --git a/src/package/spec.rs b/src/package/spec.rs index 5826f42..2156654 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -258,9 +258,14 @@ impl PackageSpec { /// Return the effective dependency set used by the generated lib32 companion package. pub fn lib32_dependencies(&self) -> Dependencies { - self.dependencies + let mut deps = self + .dependencies .lib32_dependencies() - .unwrap_or_else(|| self.dependencies.primary_dependencies()) + .unwrap_or_else(|| self.dependencies.primary_dependencies()); + if !deps.runtime.iter().any(|dep| dep == &self.package.name) { + deps.runtime.push(self.package.name.clone()); + } + deps } /// Return local package names/provided features for the selected output set. @@ -1873,7 +1878,7 @@ test = ["bats"] ); assert_eq!( spec.dependencies_for_output("lib32-llvm").runtime, - vec!["lib32-zlib".to_string()] + vec!["lib32-zlib".to_string(), "llvm".to_string()] ); assert_eq!( spec.dependencies_for_output("lib32-llvm").test, diff --git a/src/signing.rs b/src/signing.rs index 373fb83..1f1179e 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; const PUBLIC_KEYS_DIR_REL: &str = "usr/share/depot/keys/public"; const SIGN_KEYS_DIR_REL: &str = "usr/share/depot/keys/sign"; const SIGNING_PASSWORD_ENV: &str = "DEPOT_MINISIGN_PASSWORD"; +const MAX_SIGNING_PASSWORD_ATTEMPTS: usize = 3; #[derive(Debug, Clone, Default)] pub struct KeyLocations { @@ -210,9 +211,13 @@ fn load_signing_material(keys: &KeyLocations) -> Result { } fn load_secret_key(signing_key_path: &Path, secret_key_box: SecretKeyBox) -> Result { - load_secret_key_with_password_override(signing_key_path, secret_key_box, None) + match secret_key_box.clone().into_unencrypted_secret_key() { + Ok(sk) => Ok(sk), + Err(_) => load_secret_key_interactive(signing_key_path, secret_key_box), + } } +#[cfg(test)] fn load_secret_key_with_password_override( signing_key_path: &Path, secret_key_box: SecretKeyBox, @@ -223,7 +228,7 @@ fn load_secret_key_with_password_override( Err(_) => { let password = match password_override { Some(password) => password, - None => signing_key_password(signing_key_path)?, + None => signing_key_password(signing_key_path, 1)?, }; secret_key_box .into_secret_key(Some(password)) @@ -237,9 +242,19 @@ fn load_secret_key_with_password_override( } } -fn signing_key_password(signing_key_path: &Path) -> Result { +fn load_secret_key_interactive( + signing_key_path: &Path, + secret_key_box: SecretKeyBox, +) -> Result { if let Some(password) = std::env::var_os(SIGNING_PASSWORD_ENV) { - return Ok(password.to_string_lossy().into_owned()); + return secret_key_box + .into_secret_key(Some(password.to_string_lossy().into_owned())) + .with_context(|| { + format!( + "Failed to load minisign signing key: {}", + signing_key_path.display() + ) + }); } if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { anyhow::bail!( @@ -249,9 +264,40 @@ fn signing_key_password(signing_key_path: &Path) -> Result { ); } + let mut last_error = None; + for attempt in 1..=MAX_SIGNING_PASSWORD_ATTEMPTS { + let password = signing_key_password(signing_key_path, attempt)?; + match secret_key_box.clone().into_secret_key(Some(password)) { + Ok(secret_key) => return Ok(secret_key), + Err(err) => { + last_error = Some(err); + if attempt < MAX_SIGNING_PASSWORD_ATTEMPTS { + crate::log_warn!( + "Invalid minisign password for {} ({}/{})", + signing_key_path.display(), + attempt, + MAX_SIGNING_PASSWORD_ATTEMPTS + ); + } + } + } + } + + Err(last_error.expect("last minisign password error should exist")).with_context(|| { + format!( + "Failed to load minisign signing key after {} attempt(s): {}", + MAX_SIGNING_PASSWORD_ATTEMPTS, + signing_key_path.display() + ) + }) +} + +fn signing_key_password(signing_key_path: &Path, attempt: usize) -> Result { Password::new(&format!( - "Minisign password for {}:", - signing_key_path.display() + "Minisign password for {} ({}/{}):", + signing_key_path.display(), + attempt, + MAX_SIGNING_PASSWORD_ATTEMPTS )) .without_confirmation() .prompt() @@ -344,7 +390,14 @@ pub fn verify_zst_file_detached_with_public_key( /// Sign one or more `.zst` files with detached minisign signatures written to `.sig`. pub fn sign_zst_files_detached(rootfs: &Path, inputs: &[PathBuf]) -> Result> { - if inputs.is_empty() { + let signable_inputs = collect_signable_inputs(inputs, true)?; + let keys = locate_keys(rootfs)?; + let signing_material = load_signing_material(&keys)?; + sign_signable_inputs_detached(&signable_inputs, &signing_material) +} + +fn collect_signable_inputs(inputs: &[PathBuf], require_any: bool) -> Result> { + if inputs.is_empty() && require_any { anyhow::bail!("No input files provided for signing"); } @@ -364,40 +417,51 @@ pub fn sign_zst_files_detached(rootfs: &Path, inputs: &[PathBuf]) -> Result Result> { + let mut sig_paths = Vec::with_capacity(signable_inputs.len()); for input in signable_inputs { let sig_path = detached_sig_path(input); - sign_detached_with_material(input, &sig_path, &signing_material)?; + sign_detached_with_material(input, &sig_path, signing_material)?; sig_paths.push(sig_path); } - Ok(sig_paths) } +/// Attempt to sign one or more `.zst` files using discovered keys; skip if no signing key exists. +pub fn auto_sign_zst_files_detached(rootfs: &Path, inputs: &[PathBuf]) -> Result> { + let signable_inputs = collect_signable_inputs(inputs, false)?; + if signable_inputs.is_empty() { + return Ok(Vec::new()); + } + + let keys = locate_keys(rootfs)?; + if keys.signing_key.is_none() { + crate::log_info!("No minisign signing key found; skipping detached signatures"); + return Ok(Vec::new()); + } + + let signing_material = load_signing_material(&keys)?; + sign_signable_inputs_detached(&signable_inputs, &signing_material) +} + /// Attempt to sign a `.zst` file using discovered keys; skip if no signing key exists. pub fn auto_sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result> { - if !is_zst_file(input) { - return Ok(None); - } - let keys = locate_keys(rootfs)?; - if keys.signing_key.is_none() { - crate::log_info!( - "No minisign signing key found; skipping detached signature for {}", - input.display() - ); - return Ok(None); - } - let signing_material = load_signing_material(&keys)?; - let sig_path = detached_sig_path(input); - sign_detached_with_material(input, &sig_path, &signing_material)?; - Ok(Some(sig_path)) + Ok( + auto_sign_zst_files_detached(rootfs, &[input.to_path_buf()])? + .into_iter() + .next(), + ) } #[cfg(test)] diff --git a/src/staging/mod.rs b/src/staging/mod.rs index 340e78a..2290b3b 100755 --- a/src/staging/mod.rs +++ b/src/staging/mod.rs @@ -203,6 +203,30 @@ fn has_known_compressed_suffix(path: &str) -> bool { || lower.ends_with(".z") } +fn logical_payload_rel_path(destdir: &Path, path: &Path) -> Result> { + let rel = path + .strip_prefix(destdir) + .context("Failed to strip destdir prefix during path normalization")?; + let output_root = Path::new(INTERNAL_OUTPUTS_DIR); + if !rel.starts_with(output_root) { + let rel = rel.to_string_lossy().to_string(); + if rel.is_empty() { + return Ok(None); + } + return Ok(Some(rel)); + } + + let mut comps = rel.components(); + let _internal = comps.next(); + let _outputs = comps.next(); + let _package = comps.next(); + let logical = comps.as_path(); + if logical.as_os_str().is_empty() { + return Ok(None); + } + Ok(Some(logical.to_string_lossy().to_string())) +} + fn is_manpage_rel_path(rel_path: &str) -> bool { let rel = rel_path.trim_start_matches('/'); rel.starts_with("usr/share/man/") && !has_known_compressed_suffix(rel) @@ -497,11 +521,9 @@ fn compress_manpages_zstd(destdir: &Path) -> Result { for entry in WalkDir::new(destdir).into_iter().filter_map(|e| e.ok()) { let path = entry.path().to_path_buf(); - let rel = path - .strip_prefix(destdir) - .context("Failed to strip destdir prefix during manpage compression")? - .to_string_lossy() - .to_string(); + let Some(rel) = logical_payload_rel_path(destdir, &path)? else { + continue; + }; if !is_manpage_rel_path(&rel) { continue; } @@ -606,6 +628,149 @@ fn compress_manpages_zstd(destdir: &Path) -> Result { Ok(compressed) } +fn normalized_licenses(licenses: &[String]) -> Vec { + let mut normalized: Vec = licenses + .iter() + .map(|license| license.trim().to_string()) + .collect(); + normalized.sort(); + normalized.dedup(); + normalized +} + +fn licenses_match(left: &[String], right: &[String]) -> bool { + normalized_licenses(left) == normalized_licenses(right) +} + +fn supplemental_output_license_targets( + spec: &PackageSpec, + destdir: &Path, +) -> Vec<(crate::package::PackageInfo, PathBuf)> { + let declared_names: HashSet = spec + .outputs() + .into_iter() + .map(|output| output.name) + .collect(); + let mut targets = Vec::new(); + let mut seen = HashSet::new(); + + for output in spec.outputs() { + if output.name != spec.package.name { + let out_destdir = output_staging_dir(destdir, &output.name); + if out_destdir.exists() && seen.insert(output.name.clone()) { + targets.push((output.clone(), out_destdir)); + } + } + + if !spec.build.flags.split_docs || output.name.ends_with("-docs") { + continue; + } + + let docs_pkg = spec.docs_package_for_output(&output); + if declared_names.contains(&docs_pkg.name) && !seen.contains(&docs_pkg.name) { + let docs_destdir = output_staging_dir(destdir, &docs_pkg.name); + if docs_destdir.exists() && seen.insert(docs_pkg.name.clone()) { + targets.push((docs_pkg, docs_destdir)); + } + continue; + } + + let docs_destdir = output_staging_dir(destdir, &docs_pkg.name); + if docs_destdir.exists() && seen.insert(docs_pkg.name.clone()) { + targets.push((docs_pkg, docs_destdir)); + } + } + + targets +} + +/// Stage license payloads for split package outputs. +/// +/// Outputs that share the primary package license reuse the primary license directory via symlink. +/// Outputs with distinct metadata licenses receive copied license files from `src_dir`. +pub fn stage_split_package_licenses( + src_dir: &Path, + destdir: &Path, + spec: &PackageSpec, +) -> Result<()> { + let primary_license_dir = destdir.join("usr/share/licenses").join(&spec.package.name); + if !primary_license_dir.exists() { + return Ok(()); + } + + for (output, output_destdir) in supplemental_output_license_targets(spec, destdir) { + if licenses_match(&output.license, &spec.package.license) { + symlink_package_license(&output_destdir, &output.name, &spec.package.name)?; + } else { + add_licenses(src_dir, &output_destdir, &output.name)?; + } + } + + Ok(()) +} + +/// Stage a package license directory as a symlink to another package's license directory. +pub fn symlink_package_license(destdir: &Path, pkgname: &str, target_pkgname: &str) -> Result<()> { + let licenses_dir = destdir.join("usr/share/licenses"); + fs::create_dir_all(&licenses_dir) + .with_context(|| format!("Failed to create {}", licenses_dir.display()))?; + let link_path = licenses_dir.join(pkgname); + + match link_path.symlink_metadata() { + Ok(metadata) => { + if metadata.file_type().is_symlink() + && fs::read_link(&link_path)? == Path::new(target_pkgname) + { + return Ok(()); + } + if metadata.file_type().is_dir() { + fs::remove_dir_all(&link_path).with_context(|| { + format!( + "Failed to remove existing license dir {}", + link_path.display() + ) + })?; + } else { + fs::remove_file(&link_path).with_context(|| { + format!( + "Failed to remove existing license path {}", + link_path.display() + ) + })?; + } + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err).with_context(|| { + format!( + "Failed to inspect existing license path {}", + link_path.display() + ) + }); + } + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(target_pkgname, &link_path).with_context(|| { + format!( + "Failed to create package license symlink {} -> {}", + link_path.display(), + target_pkgname + ) + })?; + Ok(()) + } + + #[cfg(not(unix))] + { + anyhow::bail!( + "Package license symlinks are only supported on unix hosts: {}", + link_path.display() + ); + } +} + /// Process staged files - remove .la files/static libs, strip binaries, etc. pub fn process(destdir: &Path, spec: &PackageSpec) -> Result<()> { crate::log_info!("Processing staged files..."); @@ -1132,6 +1297,7 @@ pub fn install_atomic( 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 { @@ -1283,6 +1449,83 @@ mod tests { 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(), + version: "1.0".into(), + revision: 1, + description: "dev".into(), + homepage: "h".into(), + license: vec!["MIT".into()], + }); + spec.packages.push(PackageInfo { + name: "foo-extras".into(), + version: "1.0".into(), + revision: 1, + description: "extras".into(), + homepage: "h".into(), + 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();