feat: add support for .zst.tmp files in verification and enhance public key URL probing

This commit is contained in:
2026-02-28 17:53:38 -06:00
parent 887f0135d1
commit 5f7402b207
2 changed files with 262 additions and 33 deletions
+227 -31
View File
@@ -661,6 +661,48 @@ fn extract_html_href_targets(html: &str) -> Vec<String> {
out
}
fn default_repo_public_key_candidate_names(base_url: &str) -> Result<Vec<String>> {
let mut names = vec![
"vertex.pub".to_string(),
"depot.pub".to_string(),
"depot.minisign.pub".to_string(),
"minisign.pub".to_string(),
"repo.pub".to_string(),
];
if let Ok(parsed) = url::Url::parse(base_url)
&& let Some(last_segment) = parsed
.path_segments()
.and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
{
names.push(format!("{}.pub", last_segment));
}
names.sort();
names.dedup();
Ok(names)
}
fn probe_repo_public_key_urls(
base_url: &str,
client: &reqwest::blocking::Client,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for key_name in default_repo_public_key_candidate_names(base_url)? {
let key_url = join_repo_url(base_url, &format!("keys/{}", key_name))?;
let resp = client
.get(&key_url)
.send()
.with_context(|| format!("Failed to fetch {}", key_url))?;
if resp.status().is_success() {
out.push((key_name, key_url));
}
}
out.sort();
out.dedup();
Ok(out)
}
fn list_repo_public_key_urls(
base_url: &str,
client: &reqwest::blocking::Client,
@@ -706,36 +748,40 @@ fn list_repo_public_key_urls(
return Ok(out);
}
let mut out = Vec::new();
let keys_url = join_repo_url(base_url, "keys/")?;
let resp = client
.get(&keys_url)
.send()
.with_context(|| format!("Failed to fetch {}", keys_url))?;
if !resp.status().is_success() {
return Ok(Vec::new());
}
let body = resp
.text()
.with_context(|| format!("Failed to read {}", keys_url))?;
let keys_base = url::Url::parse(&keys_url)
.with_context(|| format!("Invalid repo keys URL: {}", keys_url))?;
if resp.status().is_success() {
let body = resp
.text()
.with_context(|| format!("Failed to read {}", keys_url))?;
let keys_base = url::Url::parse(&keys_url)
.with_context(|| format!("Invalid repo keys URL: {}", keys_url))?;
let mut out = Vec::new();
for href in extract_html_href_targets(&body) {
if href.is_empty() || href.starts_with('#') || href.starts_with('?') {
continue;
for href in extract_html_href_targets(&body) {
if href.is_empty() || href.starts_with('#') || href.starts_with('?') {
continue;
}
let Ok(url) = keys_base.join(&href) else {
continue;
};
let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else {
continue;
};
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") {
continue;
}
out.push((name.to_string(), url.to_string()));
}
let Ok(url) = keys_base.join(&href) else {
continue;
};
let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else {
continue;
};
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") {
continue;
}
out.push((name.to_string(), url.to_string()));
}
if out.is_empty() {
out = probe_repo_public_key_urls(base_url, client)?;
}
out.sort();
out.dedup();
Ok(out)
@@ -1067,7 +1113,7 @@ pub fn fetch_binary_repo_db(
};
if sig_downloaded {
let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
let mut trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
if trusted_keys.is_empty() {
if let Some(installed_key) = try_trust_repo_public_key_for_repo_db(
repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig,
@@ -1089,18 +1135,41 @@ pub fn fetch_binary_repo_db(
repo_name
);
}
trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
}
if crate::signing::list_trusted_public_keys(rootfs)?.is_empty() {
if trusted_keys.is_empty() {
// No key was trusted/installed, and allow_unsigned=true already handled above.
} else {
let verified_key = verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
.with_context(|| {
format!(
"Failed to verify detached signature for binary repo '{}'",
repo_name
)
})?;
let verified_key = match verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
{
Ok(key) => key,
Err(initial_err) => {
if let Some(installed_key) = try_trust_repo_public_key_for_repo_db(
repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig,
)? {
crate::log_info!(
"Trusted repo key for '{}' installed at {}",
repo_name,
installed_key.display()
);
verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
.with_context(|| {
format!(
"Failed to verify detached signature for binary repo '{}'",
repo_name
)
})?
} else {
return Err(initial_err).with_context(|| {
format!(
"Failed to verify detached signature for binary repo '{}'",
repo_name
)
});
}
}
};
crate::log_info!(
"Verified detached signature for binary repo '{}' using {}",
repo_name,
@@ -2315,6 +2384,133 @@ license = ["MIT", "Apache-2.0"]
assert!(keys[0].1.ends_with("/repo.pub"));
}
#[test]
fn test_list_repo_public_key_urls_probes_common_names_when_index_missing() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for _ in 0..7 {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
}
let path = request_line
.split_whitespace()
.nth(1)
.unwrap_or_default()
.to_string();
let (status, body) = if path == "/core/keys/vertex.pub" {
("200 OK", "trusted-key")
} else {
("404 Not Found", "missing")
};
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
stream.flush().unwrap();
}
});
let base_url = format!("http://{}/core", addr);
let client = reqwest::blocking::Client::builder().build().unwrap();
let keys = list_repo_public_key_urls(&base_url, &client).unwrap();
server.join().unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].0, "vertex.pub");
assert!(keys[0].1.ends_with("/core/keys/vertex.pub"));
}
#[test]
fn test_fetch_binary_repo_db_can_recover_from_stale_trusted_key() {
use std::io::Write;
struct AssumeYesReset;
impl Drop for AssumeYesReset {
fn drop(&mut self) {
crate::ui::set_assume_yes(false);
}
}
crate::ui::set_assume_yes(true);
let _reset = AssumeYesReset;
let rootfs = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let stale_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
let repo_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
fs::create_dir_all(&trusted_dir).unwrap();
fs::write(
trusted_dir.join("vertex.pub"),
stale_keypair.pk.to_box().unwrap().to_bytes(),
)
.unwrap();
let repo_keys_dir = repo_dir.path().join("keys");
fs::create_dir_all(&repo_keys_dir).unwrap();
fs::write(
repo_keys_dir.join("vertex.pub"),
repo_keypair.pk.to_box().unwrap().to_bytes(),
)
.unwrap();
let repo_db_path = repo_dir.path().join("repo.db.zst");
let mut encoder =
zstd::stream::write::Encoder::new(fs::File::create(&repo_db_path).unwrap(), 3).unwrap();
encoder.write_all(b"repo-db-content").unwrap();
encoder.finish().unwrap();
let sig = minisign::sign(
Some(&repo_keypair.pk),
&repo_keypair.sk,
fs::File::open(&repo_db_path).unwrap(),
None,
Some("repo db signature"),
)
.unwrap();
fs::write(repo_dir.path().join("repo.db.zst.sig"), sig.to_bytes()).unwrap();
let repo_cfg = crate::config::BinaryRepo {
url: url::Url::from_directory_path(repo_dir.path())
.expect("file URL")
.to_string(),
allow_unsigned: false,
..Default::default()
};
let sqlite_db =
fetch_binary_repo_db("core", &repo_cfg, rootfs.path(), cache_dir.path()).unwrap();
assert_eq!(fs::read(sqlite_db).unwrap(), b"repo-db-content");
let installed_key = trusted_dir.join("core-vertex.pub");
assert!(installed_key.exists());
assert_eq!(
fs::read(installed_key).unwrap(),
repo_keypair.pk.to_box().unwrap().to_bytes()
);
}
#[test]
fn test_normalize_git_mirror_url_converts_file_scheme() {
let tmp = tempfile::tempdir().unwrap();
+35 -2
View File
@@ -20,6 +20,15 @@ fn is_zst_file(path: &Path) -> bool {
.unwrap_or(false)
}
fn is_verify_supported_zst_file(path: &Path) -> bool {
path.file_name()
.map(|n| {
let name = n.to_string_lossy();
name.ends_with(".zst") || name.ends_with(".zst.tmp")
})
.unwrap_or(false)
}
fn candidate_roots(rootfs: &Path, host_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
out.push(rootfs.to_path_buf());
@@ -247,9 +256,9 @@ fn verify_detached_with_key_paths(
if !sig_path.exists() {
anyhow::bail!("Detached signature not found: {}", sig_path.display());
}
if !is_zst_file(input) {
if !is_verify_supported_zst_file(input) {
anyhow::bail!(
"Verification currently only supports .zst files: {}",
"Verification currently only supports .zst and .zst.tmp files: {}",
input.display()
);
}
@@ -413,6 +422,30 @@ mod tests {
Ok(())
}
#[test]
fn verify_detached_allows_zst_tmp_inputs() -> Result<()> {
let rootfs = tempfile::tempdir()?;
let (pub_path, sign_path) = write_test_keys(rootfs.path())?;
let file = rootfs.path().join("artifact.tar.zst");
let mut f = fs::File::create(&file)?;
f.write_all(b"test payload")?;
f.flush()?;
let keys = KeyLocations {
public_key: Some(pub_path.clone()),
signing_key: Some(sign_path),
};
let signing_material = load_signing_material(&keys)?;
let sig_path = PathBuf::from(format!("{}.sig", file.display()));
sign_detached_with_material(&file, &sig_path, &signing_material)?;
let tmp_file = rootfs.path().join("artifact.tar.zst.tmp");
fs::rename(&file, &tmp_file)?;
verify_zst_file_detached_with_public_key(&tmp_file, &sig_path, &pub_path)?;
Ok(())
}
#[test]
fn sign_zst_files_detached_signs_multiple_files() -> Result<()> {
let rootfs = tempfile::tempdir()?;