diff --git a/Cargo.lock b/Cargo.lock index d6d4c86..2ac41ec 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,7 +382,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index c525ad6..4feaadd 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.5.0" +version = "0.6.0" edition = "2024" [lints.rust] diff --git a/src/cli.rs b/src/cli.rs index b334064..773b3d8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; #[derive(Parser)] #[command(name = "Depot")] -#[command(about = "Depot - Source-based package manager for Linux", long_about = None)] +#[command(about = "Source-based package manager for Linux", long_about = None)] #[command(version)] pub struct Cli { /// Custom root filesystem path diff --git a/src/commands.rs b/src/commands.rs index cd757d7..c1e2d88 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -4,6 +4,7 @@ use crate::{ signing, source, staging, ui, }; use anyhow::{Context, Result}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -35,6 +36,205 @@ fn parse_dependency_list(metadata: &toml::Value, kind: &str) -> Vec { .unwrap_or_default() } +fn load_package_archive_into_staging( + archive_path: &Path, +) -> Result<(package::PackageSpec, tempfile::TempDir)> { + let tmp_dir = tempfile::TempDir::new().with_context(|| { + format!( + "Failed to create staging dir for {}", + archive_path.display() + ) + })?; + let extract_dir = tmp_dir.path().to_path_buf(); + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + + let mut metadata_content = String::new(); + for entry in archive.entries().with_context(|| { + format!( + "Failed to read archive entries from {}", + archive_path.display() + ) + })? { + let mut entry = entry.with_context(|| { + format!( + "Failed to read archive entry from {}", + archive_path.display() + ) + })?; + if entry.path()?.to_string_lossy() == ".metadata.toml" { + use std::io::Read; + entry + .read_to_string(&mut metadata_content) + .with_context(|| { + format!( + "Failed to read .metadata.toml in {}", + archive_path.display() + ) + })?; + break; + } + } + + if metadata_content.is_empty() { + anyhow::bail!( + "Package archive does not contain .metadata.toml: {}", + archive_path.display() + ); + } + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + archive.unpack(&extract_dir).with_context(|| { + format!( + "Failed to extract package archive {} into {}", + archive_path.display(), + extract_dir.display() + ) + })?; + + let metadata: toml::Value = toml::from_str(&metadata_content).with_context(|| { + format!( + "Failed to parse .metadata.toml in {}", + archive_path.display() + ) + })?; + + let mut spec = package::PackageSpec { + package: package::PackageInfo { + name: metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + 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, + description: metadata + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + homepage: metadata + .get("homepage") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + license: parse_licenses_from_toml(&metadata), + }, + 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 { + build: Vec::new(), + runtime: parse_dependency_list(&metadata, "runtime"), + test: Vec::new(), + optional: parse_dependency_list(&metadata, "optional"), + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + }; + + if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { + spec.alternatives.provides = provides + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } + + Ok((spec, tmp_dir)) +} + +fn extract_package_archive_to_staging(archive_path: &Path) -> Result { + let tmp_dir = tempfile::TempDir::new().with_context(|| { + format!( + "Failed to create staging dir for {}", + archive_path.display() + ) + })?; + let extract_dir = tmp_dir.path().to_path_buf(); + + let file = fs::File::open(archive_path) + .with_context(|| format!("Failed to open archive {}", archive_path.display()))?; + let zstd_decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("Failed to read zstd stream {}", archive_path.display()))?; + let mut archive = tar::Archive::new(zstd_decoder); + archive.unpack(&extract_dir).with_context(|| { + format!( + "Failed to extract package archive {} into {}", + archive_path.display(), + extract_dir.display() + ) + })?; + Ok(tmp_dir) +} + +fn parse_license_list_from_repo(license: &Option) -> Vec { + let Some(raw) = license.as_ref() else { + return Vec::new(); + }; + raw.split(',') + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .map(String::from) + .collect() +} + +fn package_spec_from_repo_record( + record: &db::repo::BinaryRepoPackageRecord, +) -> package::PackageSpec { + package::PackageSpec { + package: package::PackageInfo { + name: record.name.clone(), + version: record.version.clone(), + revision: record.revision, + description: record.description.clone().unwrap_or_default(), + homepage: record.homepage.clone().unwrap_or_default(), + license: parse_license_list_from_repo(&record.license), + }, + packages: Vec::new(), + alternatives: package::Alternatives { + provides: record.provides.clone(), + replaces: Vec::new(), + }, + manual_sources: Vec::new(), + source: Vec::new(), + build: package::Build { + build_type: package::BuildType::Bin, + flags: package::BuildFlags::default(), + }, + dependencies: package::Dependencies { + build: Vec::new(), + runtime: record.runtime_dependencies.clone(), + test: Vec::new(), + optional: record.optional_dependencies.clone(), + }, + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } +} + fn build_type_runs_automatic_tests(build_type: package::BuildType) -> bool { matches!( build_type, @@ -405,6 +605,26 @@ fn install_staged_to_rootfs( Ok(()) } +fn install_package_outputs_to_rootfs( + pkg_spec: &package::PackageSpec, + destdir: &Path, + rootfs: &Path, + config: &config::Config, +) -> Result> { + let mut installed = Vec::new(); + for out in pkg_spec.outputs() { + let mut spec_for_out = pkg_spec.clone(); + let output_name = out.name.clone(); + spec_for_out.package = out; + spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); + spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); + let out_destdir = output_destdir_for(destdir, &pkg_spec.package.name, &output_name); + install_staged_to_rootfs(&spec_for_out, &out_destdir, rootfs, config)?; + installed.push(spec_for_out.package); + } + Ok(installed) +} + fn repo_kind_label(kind: RepoKindArg) -> &'static str { match kind { RepoKindArg::Source => "source", @@ -711,6 +931,9 @@ fn print_plan_summary(plan: &planner::ExecutionPlan) { summary.skipped_installed, human_bytes(summary.known_download_bytes) )); + 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()), @@ -755,8 +978,15 @@ fn execute_install_plan_with_child_commands( dry_run: bool, config: &config::Config, ) -> Result<()> { + #[derive(Clone)] + struct BinaryPhaseItem { + repo_name: String, + record: db::repo::BinaryRepoPackageRecord, + } + let summary = plan.summary(); - if plan.actionable_steps().next().is_none() { + let actionable_steps: Vec<_> = plan.actionable_steps().collect(); + if actionable_steps.is_empty() { ui::info("Nothing to do."); return Ok(()); } @@ -767,8 +997,8 @@ fn execute_install_plan_with_child_commands( summary.source_build_installs )); } - let planned_packages: Vec = plan - .actionable_steps() + let planned_packages: Vec = actionable_steps + .iter() .map(|step| step.package.clone()) .collect(); if !ui::prompt_package_action("installation", &planned_packages, true)? { @@ -780,53 +1010,185 @@ fn execute_install_plan_with_child_commands( return Ok(()); } - let exe = std::env::current_exe().context("Failed to locate depot executable")?; + let mut binary_phase_items = Vec::new(); + for step in &actionable_steps { + if let planner::PlanOrigin::Binary { repo_name, record } = &step.origin { + binary_phase_items.push(BinaryPhaseItem { + repo_name: repo_name.clone(), + record: (**record).clone(), + }); + } + } - for step in plan.actionable_steps() { - let input_path = match &step.origin { - planner::PlanOrigin::Source { path, .. } => 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))?; - db::repo::fetch_binary_package_archive( - repo_name, - repo_cfg, - rootfs, - record, - &config.package_cache_dir, - )? - } - planner::PlanOrigin::Installed => continue, - }; + let mut binary_archives: HashMap<(String, String), db::repo::BinaryRepoCachedArchive> = + HashMap::new(); + if !binary_phase_items.is_empty() { + ui::info(format!( + "Downloading {} binary package(s) and detached signatures...", + binary_phase_items.len() + )); + for (idx, item) in binary_phase_items.iter().enumerate() { + ui::info(format!( + " [{}/{}] {}-{} ({})", + idx + 1, + binary_phase_items.len(), + item.record.name, + item.record.version, + item.repo_name + )); + let repo_cfg = config + .binary_repos + .get(&item.repo_name) + .with_context(|| format!("Binary repo '{}' not found in config", item.repo_name))?; + let cached = db::repo::cache_binary_package_archive( + &item.repo_name, + repo_cfg, + &item.record, + &config.package_cache_dir, + ) + .with_context(|| { + format!( + "Failed to cache binary package '{}' from repo '{}'", + item.record.filename, item.repo_name + ) + })?; + binary_archives.insert( + (item.repo_name.clone(), item.record.filename.clone()), + cached, + ); + } ui::info(format!( - "Executing planned step: {} ({})", - step.package, - input_path.display() + "Verifying checksums for {} binary package(s)...", + binary_phase_items.len() )); + for (idx, item) in binary_phase_items.iter().enumerate() { + ui::info(format!( + " [{}/{}] {}-{}", + idx + 1, + binary_phase_items.len(), + item.record.name, + item.record.version + )); + 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_checksums(&cached.package_path, &item.record) + .with_context(|| { + format!( + "Checksum verification failed for {} from repo '{}'", + item.record.filename, item.repo_name + ) + })?; + } - let mut cmd = std::process::Command::new(&exe); - cmd.arg("-r").arg(rootfs); - cmd.arg("--no-deps"); - cmd.arg("--yes"); - if no_flags { - cmd.arg("--no-flags"); + ui::info(format!( + "Verifying detached signatures for {} binary package(s)...", + binary_phase_items.len() + )); + for (idx, item) in binary_phase_items.iter().enumerate() { + ui::info(format!( + " [{}/{}] {}-{}", + idx + 1, + binary_phase_items.len(), + item.record.name, + item.record.version + )); + 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_signature( + &item.repo_name, + repo_cfg, + rootfs, + &cached.package_path, + &cached.signature_path, + ) + .with_context(|| { + format!( + "Detached signature verification failed for {} from repo '{}'", + item.record.filename, item.repo_name + ) + })?; } - if let Some(p) = cross_prefix { - cmd.arg("--cross-prefix").arg(p); - } - if clean { - cmd.arg("--clean"); - } - cmd.arg("install").arg(input_path); + } - let status = cmd - .status() - .context("Failed to spawn planned install step")?; - if !status.success() { - anyhow::bail!("Planned install step for '{}' failed", step.package); + let exe = std::env::current_exe().context("Failed to locate depot executable")?; + let total_steps = actionable_steps.len(); + for (idx, step) in actionable_steps.into_iter().enumerate() { + match &step.origin { + planner::PlanOrigin::Source { path, .. } => { + ui::info(format!( + "[{}/{}] building+installing {} from source", + idx + 1, + total_steps, + step.package + )); + + let mut cmd = std::process::Command::new(&exe); + cmd.arg("-r").arg(rootfs); + cmd.arg("--no-deps"); + cmd.arg("--yes"); + if no_flags { + cmd.arg("--no-flags"); + } + if let Some(p) = cross_prefix { + cmd.arg("--cross-prefix").arg(p); + } + if clean { + cmd.arg("--clean"); + } + cmd.arg("install").arg(path); + + let status = cmd + .status() + .context("Failed to spawn planned install step")?; + if !status.success() { + anyhow::bail!("Planned install step for '{}' failed", step.package); + } + } + planner::PlanOrigin::Binary { repo_name, record } => { + ui::info(format!( + "[{}/{}] installing {} {}-{} from binary:{}", + idx + 1, + total_steps, + record.name, + record.version, + record.revision, + repo_name + )); + + 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(&cached.package_path)?; + let spec = package_spec_from_repo_record(record); + let installed = + install_package_outputs_to_rootfs(&spec, staged.path(), rootfs, config)?; + for pkg in installed { + ui::success(format!("Installed {} v{}", pkg.name, pkg.version)); + } + } + planner::PlanOrigin::Installed => {} } } Ok(()) @@ -965,94 +1327,7 @@ pub fn run(cli: Cli) -> Result<()> { if spec_path.to_string_lossy().ends_with(".tar.zst") { // Install from archive ui::info(format!("Detected package archive: {}", spec_path.display())); - let tmp_dir = tempfile::TempDir::new()?; - let extract_dir = tmp_dir.path().to_path_buf(); - - // Extract metadata.toml first to get spec - let file = fs::File::open(&spec_path)?; - let zstd_decoder = zstd::stream::read::Decoder::new(file)?; - let mut archive = tar::Archive::new(zstd_decoder); - - let mut metadata_content = String::new(); - for entry in archive.entries()? { - let mut entry = entry?; - if entry.path()?.to_string_lossy() == ".metadata.toml" { - use std::io::Read; - entry.read_to_string(&mut metadata_content)?; - break; - } - } - - if metadata_content.is_empty() { - anyhow::bail!( - "Package archive does not contain .metadata.toml: {}", - spec_path.display() - ); - } - - let file = fs::File::open(&spec_path)?; - let zstd_decoder = zstd::stream::read::Decoder::new(file)?; - let mut archive = tar::Archive::new(zstd_decoder); - archive.unpack(&extract_dir)?; - - let metadata: toml::Value = toml::from_str(&metadata_content)?; - - // Create a minimal spec from metadata - let mut spec = package::PackageSpec { - package: package::PackageInfo { - name: metadata - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - 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, - description: metadata - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - homepage: metadata - .get("homepage") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - license: parse_licenses_from_toml(&metadata), - }, - 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 { - build: Vec::new(), - runtime: parse_dependency_list(&metadata, "runtime"), - test: Vec::new(), - optional: parse_dependency_list(&metadata, "optional"), - }, - package_alternatives: Default::default(), - package_dependencies: Default::default(), - spec_dir: PathBuf::from("."), - }; - - if let Some(provides) = metadata.get("provides").and_then(|v| v.as_array()) { - spec.alternatives.provides = provides - .iter() - .filter_map(|v| v.as_str()) - .map(String::from) - .collect(); - } - + let (spec, tmp_dir) = load_package_archive_into_staging(&spec_path)?; (spec, Some(tmp_dir)) } else { // Install from spec (normal build) @@ -1224,23 +1499,20 @@ pub fn run(cli: Cli) -> Result<()> { }; if !cli.lib32_only { - // 4. Stage (clean .la files, etc.) - staging::process(&destdir, &pkg_spec)?; - - // 5-6. Install/update to rootfs and register in DB - for out in pkg_spec.outputs() { - let mut spec_for_out = pkg_spec.clone(); - let output_name = out.name.clone(); - spec_for_out.package = out; - spec_for_out.alternatives = pkg_spec.alternatives_for_output(&output_name); - spec_for_out.dependencies = pkg_spec.dependencies_for_output(&output_name); - let out_destdir = - output_destdir_for(&destdir, &pkg_spec.package.name, &output_name); - install_staged_to_rootfs(&spec_for_out, &out_destdir, &cli.rootfs, &config)?; + if staging_dir.is_none() { + // Source-build path: apply staging transforms (strip/compress/static cleanup). + staging::process(&destdir, &pkg_spec)?; + } else { + // Binary archive path: install as-packaged without post-build transformations. + ui::info("Installing binary archive payload without staging transforms"); + } + let installed = + install_package_outputs_to_rootfs(&pkg_spec, &destdir, &cli.rootfs, &config)?; + for pkg in installed { ui::success(format!( "Successfully installed {} v{}", - spec_for_out.package.name, spec_for_out.package.version + pkg.name, pkg.version )); // TODO(snapper): create post-install snapshot after install commit succeeds. } @@ -2106,4 +2378,67 @@ mod tests { 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(&archive_path)?; + let record = db::repo::BinaryRepoPackageRecord { + repo_name: "core".into(), + name: "pkg".into(), + version: "1.0".into(), + revision: 1, + filename: archive_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default() + .to_string(), + size: payload.len() as u64, + sha256: String::new(), + sha512: String::new(), + description: Some("test package".into()), + homepage: Some("https://example.test".into()), + license: Some("MIT".into()), + provides: vec!["pkg-virtual".into()], + runtime_dependencies: vec!["glibc".into()], + optional_dependencies: vec!["manpages".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].name, "pkg"); + assert!(rootfs.path().join("usr/bin/hello").exists()); + + let db_path = cfg.db_dir.join("packages.db"); + assert_eq!( + db::get_package_version(&db_path, "pkg")?, + Some("1.0".into()) + ); + Ok(()) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 0142de4..9bfd954 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -151,11 +151,6 @@ pub fn register_package(db_path: &Path, spec: &PackageSpec, destdir: &Path) -> R tx.commit()?; - crate::log_info!( - "Registered {} files and {} directories in database", - manifest.files.len(), - manifest.directories.len() - ); Ok(()) } diff --git a/src/db/repo.rs b/src/db/repo.rs index 444d6d0..d99de69 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -112,8 +112,18 @@ pub struct BinaryRepoPackageRecord { pub sha256: String, pub sha512: String, pub description: Option, + pub homepage: Option, + pub license: Option, pub provides: Vec, pub runtime_dependencies: Vec, + pub optional_dependencies: Vec, +} + +/// Local cache paths for a binary package archive and its detached signature. +#[derive(Debug, Clone)] +pub struct BinaryRepoCachedArchive { + pub package_path: PathBuf, + pub signature_path: PathBuf, } /// File search hit returned from a cached binary repo database. @@ -1463,6 +1473,28 @@ fn query_package_runtime_deps(conn: &Connection, package_id: i64) -> Result 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 find_cached_binary_repo_packages( repo_name: &str, db_path: &Path, @@ -1481,7 +1513,9 @@ fn find_cached_binary_repo_packages( p.size, p.sha256, p.sha512, - p.description + p.description, + p.homepage, + p.license FROM packages p WHERE lower(p.name) = lower(?1) OR EXISTS ( @@ -1508,8 +1542,11 @@ fn find_cached_binary_repo_packages( sha256: row.get(6)?, sha512: row.get(7)?, description: row.get(8)?, + homepage: row.get(9)?, + license: row.get(10)?, provides: Vec::new(), runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), }, )) })?; @@ -1519,6 +1556,7 @@ fn find_cached_binary_repo_packages( let (package_id, mut rec) = row?; rec.provides = query_package_provides(&conn, package_id)?; rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; out.push(rec); } Ok(out) @@ -1619,6 +1657,36 @@ fn verify_binary_package_record_checksums( Ok(()) } +fn download_binary_package_archive( + client: &reqwest::blocking::Client, + pkg_url: &str, + tmp_path: &Path, +) -> Result<()> { + match copy_file_url_to_path(pkg_url, tmp_path)? { + FileUrlCopyOutcome::Copied => {} + 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 mut out = fs::File::create(tmp_path) + .with_context(|| format!("Failed to create {}", tmp_path.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", tmp_path.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; + } + } + Ok(()) +} + fn fetch_binary_package_signature( repo_name: &str, repo: &crate::config::BinaryRepo, @@ -1691,15 +1759,14 @@ fn verify_binary_package_signature( Ok(()) } -/// Download a binary package archive and verify it against detached signatures -/// and checksums from signed repository metadata. -pub fn fetch_binary_package_archive( +/// 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, - rootfs: &Path, rec: &BinaryRepoPackageRecord, package_cache_dir: &Path, -) -> Result { +) -> Result { let machine_arch = std::env::consts::ARCH; let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { format!( @@ -1711,8 +1778,8 @@ pub fn fetch_binary_package_archive( 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 dest_path = cache_dir.join(&rec.filename); - let dest_sig_path = cache_dir.join(format!("{}.sig", rec.filename)); + 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)?; @@ -1722,105 +1789,94 @@ pub fn fetch_binary_package_archive( .build() .context("Failed to build HTTP client for binary package fetch")?; - if dest_path.exists() { - verify_binary_package_record_checksums(&dest_path, rec).with_context(|| { - format!( - "Cached binary package failed checksum verification: {}", - dest_path.display() - ) - })?; - if !dest_sig_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, &dest_sig_path).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_sig_path.display(), - dest_sig_path.display() - ) - })?; - } else { - let _ = fs::remove_file(&dest_sig_path); - } - } - verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path) - .with_context(|| { - format!( - "Cached binary package failed signature verification: {}", - dest_path.display() - ) - })?; - crate::log_info!("Using cached binary package: {}", dest_path.display()); - return Ok(dest_path); - } - - crate::log_info!("Fetching binary package: {}", pkg_url); - - match copy_file_url_to_path(&pkg_url, &tmp_path)? { - FileUrlCopyOutcome::Copied => {} - 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 mut out = fs::File::create(&tmp_path) - .with_context(|| format!("Failed to create {}", tmp_path.display()))?; - std::io::copy(&mut resp, &mut out) - .with_context(|| format!("Failed to save {}", tmp_path.display()))?; - out.flush() - .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; - } - } - - verify_binary_package_record_checksums(&tmp_path, rec).with_context(|| { - format!( - "Downloaded binary package failed checksum verification: {}", - rec.filename - ) - })?; - - let sig_downloaded = - fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?; - - fs::rename(&tmp_path, &dest_path).with_context(|| { - format!( - "Failed to move {} to {}", - tmp_path.display(), - dest_path.display() - ) - })?; - if sig_downloaded { - fs::rename(&tmp_sig_path, &dest_sig_path).with_context(|| { + let package_downloaded = if !package_path.exists() { + crate::log_info!("Fetching binary package: {}", pkg_url); + download_binary_package_archive(&client, &pkg_url, &tmp_path)?; + fs::rename(&tmp_path, &package_path).with_context(|| { format!( "Failed to move {} to {}", - tmp_sig_path.display(), - dest_sig_path.display() + tmp_path.display(), + package_path.display() ) })?; - if let Err(err) = - verify_binary_package_signature(repo_name, repo, rootfs, &dest_path, &dest_sig_path) - { - let _ = fs::remove_file(&dest_path); - let _ = fs::remove_file(&dest_sig_path); - return Err(err).with_context(|| { - format!( - "Downloaded binary package failed signature verification: {}", - rec.filename - ) - }); - } + true } else { - let _ = fs::remove_file(&dest_sig_path); + crate::log_info!("Using cached binary package: {}", package_path.display()); + 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(dest_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) +} + +/// Verify a cached/downloaded package archive against its detached signature. +pub fn verify_binary_package_archive_signature( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_path: &Path, + signature_path: &Path, +) -> Result<()> { + verify_binary_package_signature(repo_name, repo, rootfs, package_path, signature_path) +} + +/// 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)?; + verify_binary_package_archive_checksums(&cached.package_path, rec).with_context(|| { + format!( + "Binary package failed checksum verification: {}", + cached.package_path.display() + ) + })?; + verify_binary_package_archive_signature( + repo_name, + repo, + rootfs, + &cached.package_path, + &cached.signature_path, + ) + .with_context(|| { + format!( + "Binary package failed signature verification: {}", + cached.package_path.display() + ) + })?; + Ok(cached.package_path) } /// Synchronize git mirrors into /usr/src/depot/ @@ -2233,8 +2289,11 @@ license = ["MIT", "Apache-2.0"] sha256, sha512, description: None, + homepage: None, + license: None, provides: Vec::new(), runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), }; verify_binary_package_record_checksums(&pkg, &rec).unwrap(); @@ -2264,8 +2323,11 @@ license = ["MIT", "Apache-2.0"] sha256, sha512, description: None, + homepage: None, + license: None, provides: Vec::new(), runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), } } @@ -2343,6 +2405,64 @@ license = ["MIT", "Apache-2.0"] assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists()); } + #[test] + fn test_cache_binary_package_archive_supports_phased_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"); + verify_binary_package_archive_signature( + "repo", + &repo_cfg, + rootfs.path(), + &cached.package_path, + &cached.signature_path, + ) + .expect("signature verification should succeed"); + } + #[test] fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() { let rootfs = tempfile::tempdir().unwrap(); diff --git a/src/install/scripts.rs b/src/install/scripts.rs index f4d5bb2..39fbf2a 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -263,11 +263,11 @@ fn run_script_with_rootfs_context( .arg(rootfs) .arg("/bin/sh") .arg("-c") - .arg("cd / && exec /bin/sh \"$1\"") + .arg("export DEPOT_PACKAGE DEPOT_ROOTFS DEPOT_ACTION DEPOT_PHASE; cd / && exec /bin/sh \"$1\"") .arg("sh") .arg(rel_script) .env("DEPOT_PACKAGE", pkg_name) - .env("DEPOT_ROOTFS", rootfs) + .env("DEPOT_ROOTFS", "/") .env("DEPOT_ACTION", hook.action()) .env("DEPOT_PHASE", hook.phase()) .status()