refactor command flow and add signed package/hook safety

- split CLI definitions and command execution out of main.rs into new cli.rs and commands.rs modules\n- add transaction hook support via /etc/depot.d/hooks/*.toml with pre/post phases, operation/package/path filters, wildcard negation, and optional affected-path stdin\n- execute transaction hooks around install/update/remove operations and add package-action prompts for install/build/remove flows\n- require and verify detached minisign signatures for binary package archives when allow_unsigned=false, including cache handling and tests\n- persist optional dependencies from package metadata into repo index data and include optional deps in produced metadata\n- update dependency/planner logic to ignore dependencies provided by local split outputs and stop pulling test deps into install planning\n- auto-disable tests when required test dependencies are missing instead of hard-failing build/install paths\n- improve autotools defaults by injecting standard install directory flags when supported and preserving user overrides\n- remove static .a archives during staging by default with new build.flags.no_delete_static escape hatch\n- preserve symlinks and file metadata (permissions/timestamps) in extractor fallback copy path and add regression tests\n- refresh README docs for detached package signatures, optional dependencies, and transaction hooks\n- adjust dependency set: add filetime, disable reqwest default features, and simplify git2/OpenSSL linkage
This commit is contained in:
2026-02-28 00:54:08 -06:00
parent e045ae78fd
commit ce5fefa833
19 changed files with 3863 additions and 2923 deletions
+280 -7
View File
@@ -170,6 +170,7 @@ impl RepoManager {
let mut license = None;
let mut provides = Vec::new();
let mut runtime_dependencies = Vec::new();
let mut optional_dependencies = Vec::new();
let mut archive_files = Vec::new();
for entry in archive.entries()? {
@@ -226,6 +227,17 @@ impl RepoManager {
.map(String::from)
.collect();
}
if let Some(optional_arr) = metadata
.get("dependencies")
.and_then(|v| v.get("optional"))
.and_then(|v| v.as_array())
{
optional_dependencies = optional_arr
.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect();
}
continue;
}
@@ -286,6 +298,12 @@ impl RepoManager {
params![package_id, dep],
)?;
}
for dep in optional_dependencies {
conn.execute(
"INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)",
params![package_id, dep],
)?;
}
for file_path in archive_files {
conn.execute(
@@ -1304,11 +1322,84 @@ fn verify_binary_package_record_checksums(
Ok(())
}
/// Download a binary package archive and verify it against checksums from the
/// signed repository database metadata.
fn fetch_binary_package_signature(
repo_name: &str,
repo: &crate::config::BinaryRepo,
client: &reqwest::blocking::Client,
sig_url: &str,
sig_path: &Path,
) -> Result<bool> {
let found = fetch_url_to_path(client, sig_url, sig_path)?;
if !found {
if !repo.allow_unsigned {
anyhow::bail!(
"Failed to fetch detached signature for binary package in repo '{}' at {}",
repo_name,
sig_url
);
}
crate::log_warn!(
"Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing",
repo_name,
sig_url
);
}
Ok(found)
}
fn verify_binary_package_signature(
repo_name: &str,
repo: &crate::config::BinaryRepo,
rootfs: &Path,
pkg_path: &Path,
sig_path: &Path,
) -> Result<()> {
if !sig_path.exists() {
if repo.allow_unsigned {
return Ok(());
}
anyhow::bail!(
"Detached package signature required but missing for {}",
pkg_path.display()
);
}
let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
if trusted_keys.is_empty() {
if repo.allow_unsigned {
crate::log_warn!(
"No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true",
repo_name
);
return Ok(());
}
anyhow::bail!(
"No trusted minisign public key found for detached package signature verification in binary repo '{}'",
repo_name
);
}
let verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path)
.with_context(|| {
format!(
"Failed to verify detached package signature for {}",
pkg_path.display()
)
})?;
crate::log_info!(
"Verified detached package signature for '{}' using {}",
pkg_path.display(),
verified_key.display()
);
Ok(())
}
/// 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<PathBuf> {
@@ -1324,7 +1415,15 @@ pub fn fetch_binary_package_archive(
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 tmp_path = cache_dir.join(format!("{}.tmp", rec.filename));
let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename));
let pkg_url = join_repo_url(base_url, &rec.filename)?;
let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?;
let client = reqwest::blocking::Client::builder()
.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(|| {
@@ -1333,11 +1432,32 @@ pub fn fetch_binary_package_archive(
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);
}
let pkg_url = join_repo_url(base_url, &rec.filename)?;
crate::log_info!("Fetching binary package: {}", pkg_url);
match copy_file_url_to_path(&pkg_url, &tmp_path)? {
@@ -1346,9 +1466,6 @@ pub fn fetch_binary_package_archive(
anyhow::bail!("Failed to fetch {}: local file not found", pkg_url);
}
FileUrlCopyOutcome::NotFileUrl => {
let client = reqwest::blocking::Client::builder()
.build()
.context("Failed to build HTTP client for binary package fetch")?;
let mut resp = client
.get(&pkg_url)
.send()
@@ -1373,6 +1490,9 @@ pub fn fetch_binary_package_archive(
)
})?;
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 {}",
@@ -1380,6 +1500,29 @@ pub fn fetch_binary_package_archive(
dest_path.display()
)
})?;
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()
)
})?;
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
)
});
}
} else {
let _ = fs::remove_file(&dest_sig_path);
}
Ok(dest_path)
}
@@ -1599,8 +1742,8 @@ license = "MIT"
provides = ["test-feature"]
[dependencies]
build = []
runtime = []
optional = []
"#;
let mut header = tar::Header::new_gnu();
header.set_path(".metadata.toml").unwrap();
@@ -1798,6 +1941,136 @@ license = ["MIT", "Apache-2.0"]
verify_binary_package_record_checksums(&pkg, &rec).unwrap();
}
fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord {
use sha2::{Digest, Sha256, Sha512};
let sha256 = {
let mut h = Sha256::new();
h.update(payload);
format!("{:x}", h.finalize())
};
let sha512 = {
let mut h = Sha512::new();
h.update(payload);
format!("{:x}", h.finalize())
};
BinaryRepoPackageRecord {
repo_name: "repo".into(),
name: "pkg".into(),
version: "1.0".into(),
revision: 1,
filename: filename.to_string(),
size: payload.len() as u64,
sha256,
sha512,
description: None,
provides: Vec::new(),
runtime_dependencies: Vec::new(),
}
}
#[test]
fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"package payload";
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: false,
..Default::default()
};
let err =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.expect_err("missing detached signature should fail");
assert!(err.to_string().to_ascii_lowercase().contains("signature"));
}
#[test]
fn test_fetch_binary_package_archive_verifies_signature_and_checksum() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
std::fs::create_dir_all(&trusted_dir).unwrap();
let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
std::fs::write(
trusted_dir.join("repo.pub"),
keypair.pk.to_box().unwrap().to_bytes(),
)
.unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"signed package payload";
let package_path = repo_dir.path().join(filename);
std::fs::write(&package_path, payload).unwrap();
let sig = minisign::sign(
Some(&keypair.pk),
&keypair.sk,
std::fs::File::open(&package_path).unwrap(),
None,
Some("test signature"),
)
.unwrap();
std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: false,
..Default::default()
};
let fetched =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.unwrap();
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists());
}
#[test]
fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() {
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
let payload = b"unsigned package payload";
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
let rec = test_record_for_payload(filename, payload);
let repo_url = url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string();
let repo_cfg = crate::config::BinaryRepo {
url: repo_url,
allow_unsigned: true,
..Default::default()
};
let fetched =
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
.unwrap();
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists());
}
#[test]
fn test_copy_file_url_to_path_supports_file_scheme() {
let tmp = tempfile::tempdir().unwrap();