feat: add support for .zst.tmp files in verification and enhance public key URL probing
This commit is contained in:
+204
-8
@@ -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,21 +748,19 @@ 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());
|
||||
}
|
||||
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;
|
||||
@@ -736,6 +776,12 @@ fn list_repo_public_key_urls(
|
||||
}
|
||||
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)
|
||||
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
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user