diff --git a/src/db/repo.rs b/src/db/repo.rs index 977dd1b..72d1efc 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -380,6 +380,323 @@ fn copy_file_url_to_path(url: &str, dest: &Path) -> Result { Ok(FileUrlCopyOutcome::Copied) } +fn fetch_url_to_path(client: &reqwest::blocking::Client, url: &str, dest: &Path) -> Result { + match copy_file_url_to_path(url, dest)? { + FileUrlCopyOutcome::Copied => return Ok(true), + FileUrlCopyOutcome::Missing => return Ok(false), + FileUrlCopyOutcome::NotFileUrl => {} + } + + let mut resp = client + .get(url) + .send() + .with_context(|| format!("Failed to fetch {}", url))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch {}: HTTP {}", url, resp.status()); + } + + let mut out = + fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?; + std::io::copy(&mut resp, &mut out) + .with_context(|| format!("Failed to save {}", dest.display()))?; + out.flush() + .with_context(|| format!("Failed to flush {}", dest.display()))?; + Ok(true) +} + +fn extract_html_href_targets(html: &str) -> Vec { + let lower = html.to_ascii_lowercase(); + let lower_bytes = lower.as_bytes(); + let html_bytes = html.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + + while i < lower_bytes.len() { + let Some(rel) = lower[i..].find("href") else { + break; + }; + let mut j = i + rel + 4; + while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { + j += 1; + } + if j >= lower_bytes.len() || lower_bytes[j] != b'=' { + i = j; + continue; + } + j += 1; + while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() { + j += 1; + } + if j >= lower_bytes.len() { + break; + } + + let (start, end) = if lower_bytes[j] == b'"' || lower_bytes[j] == b'\'' { + let quote = lower_bytes[j]; + let start = j + 1; + let mut k = start; + while k < lower_bytes.len() && lower_bytes[k] != quote { + k += 1; + } + (start, k) + } else { + let start = j; + let mut k = start; + while k < lower_bytes.len() + && !lower_bytes[k].is_ascii_whitespace() + && lower_bytes[k] != b'>' + { + k += 1; + } + (start, k) + }; + + if start < end && end <= html_bytes.len() { + out.push(String::from_utf8_lossy(&html_bytes[start..end]).to_string()); + } + i = end.saturating_add(1); + } + + out +} + +fn list_repo_public_key_urls( + base_url: &str, + client: &reqwest::blocking::Client, +) -> Result> { + let parsed = + url::Url::parse(base_url).with_context(|| format!("Invalid repo URL: {base_url}"))?; + if parsed.scheme() == "file" { + let repo_dir = parsed + .to_file_path() + .map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", base_url))?; + let keys_dir = repo_dir.join("keys"); + if !keys_dir.exists() { + return Ok(Vec::new()); + } + if !keys_dir.is_dir() { + anyhow::bail!( + "Binary repo keys path is not a directory: {}", + keys_dir.display() + ); + } + + let mut out = Vec::new(); + for entry in fs::read_dir(&keys_dir) + .with_context(|| format!("Failed to read {}", keys_dir.display()))? + { + let path = entry?.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.to_ascii_lowercase().ends_with(".pub") { + continue; + } + let key_url = url::Url::from_file_path(&path) + .map_err(|_| anyhow::anyhow!("Failed to build file:// URL for {}", path.display()))? + .to_string(); + out.push((name.to_string(), key_url)); + } + out.sort(); + out.dedup(); + return Ok(out); + } + + 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))?; + + let mut out = Vec::new(); + 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())); + } + out.sort(); + out.dedup(); + Ok(out) +} + +fn verify_with_any_trusted_public_key( + rootfs: &Path, + input: &Path, + sig_path: &Path, +) -> Result { + let keys = crate::signing::list_trusted_public_keys(rootfs)?; + if keys.is_empty() { + anyhow::bail!("No trusted minisign public keys found in rootfs or host"); + } + + let mut last_failure: Option<(PathBuf, anyhow::Error)> = None; + for key_path in keys { + match crate::signing::verify_zst_file_detached_with_public_key(input, sig_path, &key_path) { + Ok(()) => return Ok(key_path), + Err(err) => last_failure = Some((key_path, err)), + } + } + + let (key_path, err) = last_failure.expect("non-empty key list must produce a failure"); + Err(err).with_context(|| { + format!( + "Detached signature verification failed with all trusted public keys (last tried {})", + key_path.display() + ) + }) +} + +fn sanitize_filename_component(input: &str) -> String { + input + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch, + _ => '_', + }) + .collect() +} + +fn install_trusted_repo_public_key( + rootfs: &Path, + repo_name: &str, + source_key_path: &Path, + source_name: &str, +) -> Result { + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); + fs::create_dir_all(&trusted_dir) + .with_context(|| format!("Failed to create {}", trusted_dir.display()))?; + + let base_name = source_name + .split('/') + .next_back() + .filter(|name| !name.is_empty()) + .unwrap_or("repo.pub"); + let base_name = sanitize_filename_component(base_name); + let repo_prefix = sanitize_filename_component(repo_name); + + let source_bytes = fs::read(source_key_path) + .with_context(|| format!("Failed to read {}", source_key_path.display()))?; + let mut candidates = Vec::new(); + candidates.push(trusted_dir.join(&base_name)); + if !repo_prefix.is_empty() { + candidates.push(trusted_dir.join(format!("{}-{}", repo_prefix, base_name))); + } + + for candidate in &candidates { + if candidate.exists() { + let existing = fs::read(candidate) + .with_context(|| format!("Failed to read {}", candidate.display()))?; + if existing == source_bytes { + return Ok(candidate.clone()); + } + } else { + fs::write(candidate, &source_bytes) + .with_context(|| format!("Failed to write {}", candidate.display()))?; + return Ok(candidate.clone()); + } + } + + for idx in 1usize.. { + let candidate = trusted_dir.join(format!("{}-{}.{}", repo_prefix, base_name, idx)); + if candidate.exists() { + let existing = fs::read(&candidate) + .with_context(|| format!("Failed to read {}", candidate.display()))?; + if existing == source_bytes { + return Ok(candidate); + } + continue; + } + fs::write(&candidate, &source_bytes) + .with_context(|| format!("Failed to write {}", candidate.display()))?; + return Ok(candidate); + } + + unreachable!("infinite loop returns on first available candidate") +} + +fn try_trust_repo_public_key_for_repo_db( + repo_name: &str, + base_url: &str, + rootfs: &Path, + cache_dir: &Path, + client: &reqwest::blocking::Client, + repo_db_zst_path: &Path, + repo_db_sig_path: &Path, +) -> Result> { + let repo_keys = list_repo_public_key_urls(base_url, client)?; + if repo_keys.is_empty() { + return Ok(None); + } + + let repo_keys_cache_dir = cache_dir.join("repo_keys"); + fs::create_dir_all(&repo_keys_cache_dir) + .with_context(|| format!("Failed to create {}", repo_keys_cache_dir.display()))?; + + for (key_name, key_url) in repo_keys { + let cache_name = sanitize_filename_component(&key_name); + let key_tmp_path = repo_keys_cache_dir.join(&cache_name); + if !fetch_url_to_path(client, &key_url, &key_tmp_path)? { + continue; + } + + if crate::signing::verify_zst_file_detached_with_public_key( + repo_db_zst_path, + repo_db_sig_path, + &key_tmp_path, + ) + .is_err() + { + continue; + } + + let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs); + let prompt = format!( + "Trust repo key '{}' from binary repo '{}' and copy it to {}?", + key_name, + repo_name, + trusted_dir.display() + ); + if !crate::ui::prompt_yes_no(&prompt, false)? { + crate::log_warn!( + "Skipped trusting repo key '{}' for binary repo '{}'", + key_name, + repo_name + ); + continue; + } + + let installed = + install_trusted_repo_public_key(rootfs, repo_name, &key_tmp_path, &key_name)?; + return Ok(Some(installed)); + } + + Ok(None) +} + fn normalize_git_mirror_url(url: &str) -> Result { let parsed = match url::Url::parse(url) { Ok(parsed) => parsed, @@ -552,30 +869,44 @@ pub fn fetch_binary_repo_db( }; if sig_downloaded { - let keys = crate::signing::locate_keys(rootfs)?; - if keys.public_key.is_none() { - if !repo.allow_unsigned { + let 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, + )? { + crate::log_info!( + "Trusted repo key for '{}' installed at {}", + repo_name, + installed_key.display() + ); + } else if !repo.allow_unsigned { anyhow::bail!( - "No minisign public key found for verifying binary repo '{}' (checked rootfs and host)", + "No trusted minisign public key found for binary repo '{}' and no trusted key was accepted from {}/keys/", + repo_name, + base_url.trim_end_matches('/') + ); + } else { + crate::log_warn!( + "No trusted minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true", repo_name ); } - crate::log_warn!( - "No minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true", - repo_name - ); + } + + if crate::signing::list_trusted_public_keys(rootfs)?.is_empty() { + // No key was trusted/installed, and allow_unsigned=true already handled above. } else { - crate::signing::verify_zst_file_detached(rootfs, &tmp_zst, &tmp_sig).with_context( - || { + 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 ) - }, - )?; + })?; crate::log_info!( - "Verified detached signature for binary repo '{}'", - repo_name + "Verified detached signature for binary repo '{}' using {}", + repo_name, + verified_key.display() ); } } @@ -1492,6 +1823,43 @@ license = ["MIT", "Apache-2.0"] assert!(!dst.exists()); } + #[test] + fn test_extract_html_href_targets_parses_common_forms() { + let html = r#" + + alpha + beta + gamma + parent + + "#; + let hrefs = extract_html_href_targets(html); + assert!(hrefs.iter().any(|h| h == "alpha.pub")); + assert!(hrefs.iter().any(|h| h == "nested/beta.pub")); + assert!(hrefs.iter().any(|h| h == "gamma.pub")); + assert!(hrefs.iter().any(|h| h == "../")); + } + + #[test] + fn test_list_repo_public_key_urls_reads_file_repo_keys_dir() { + let tmp = tempfile::tempdir().unwrap(); + let repo_dir = tmp.path().join("repo"); + let keys_dir = repo_dir.join("keys"); + fs::create_dir_all(&keys_dir).unwrap(); + fs::write(keys_dir.join("repo.pub"), b"pubkey").unwrap(); + fs::write(keys_dir.join("ignore.txt"), b"nope").unwrap(); + fs::create_dir_all(keys_dir.join("subdir")).unwrap(); + + let base_url = url::Url::from_directory_path(&repo_dir) + .expect("file URL") + .to_string(); + let client = reqwest::blocking::Client::builder().build().unwrap(); + let keys = list_repo_public_key_urls(&base_url, &client).unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, "repo.pub"); + assert!(keys[0].1.ends_with("/repo.pub")); + } + #[test] fn test_normalize_git_mirror_url_converts_file_scheme() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/signing.rs b/src/signing.rs index 9c95e26..59e67c1 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -36,6 +36,13 @@ fn key_dir_candidates(rootfs: &Path, host_root: &Path, rel: &str) -> Vec bool { + path.is_file() + && path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("pub")) +} + fn pick_key_file(dir: &Path, is_public: bool) -> Result> { if !dir.exists() { return Ok(None); @@ -90,6 +97,27 @@ fn pick_key_file(dir: &Path, is_public: bool) -> Result> { ); } +fn list_public_key_files_in_roots(rootfs: &Path, host_root: &Path) -> Result> { + let mut out = Vec::new(); + for dir in key_dir_candidates(rootfs, host_root, PUBLIC_KEYS_DIR_REL) { + if !dir.exists() { + continue; + } + if !dir.is_dir() { + anyhow::bail!("Key path is not a directory: {}", dir.display()); + } + let mut files: Vec = fs::read_dir(&dir) + .with_context(|| format!("Failed to read {}", dir.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| is_public_key_file(p)) + .collect(); + files.sort(); + out.extend(files); + } + Ok(out) +} + fn locate_keys_in_roots(rootfs: &Path, host_root: &Path) -> Result { let mut keys = KeyLocations::default(); @@ -113,6 +141,16 @@ pub fn locate_keys(rootfs: &Path) -> Result { locate_keys_in_roots(rootfs, Path::new("/")) } +/// Return the directory where trusted minisign public keys are stored under `rootfs`. +pub fn trusted_public_keys_dir(rootfs: &Path) -> PathBuf { + rootfs.join(PUBLIC_KEYS_DIR_REL) +} + +/// List all trusted minisign public keys found in `rootfs` and then the host `/`. +pub fn list_trusted_public_keys(rootfs: &Path) -> Result> { + list_public_key_files_in_roots(rootfs, Path::new("/")) +} + fn detached_sig_path(input: &Path) -> PathBuf { PathBuf::from(format!("{}.sig", input.display())) } @@ -222,6 +260,19 @@ fn verify_detached_with_key_paths( Ok(()) } +/// Verify a `.zst` file using a detached minisign signature and an explicit public key path. +pub fn verify_zst_file_detached_with_public_key( + input: &Path, + sig_path: &Path, + public_key_path: &Path, +) -> Result<()> { + let keys = KeyLocations { + public_key: Some(public_key_path.to_path_buf()), + signing_key: None, + }; + verify_detached_with_key_paths(input, sig_path, &keys) +} + /// Sign a `.zst` file with a detached minisign signature written to `.sig`. pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result { let keys = locate_keys(rootfs)?; @@ -230,12 +281,6 @@ pub fn sign_zst_file_detached(rootfs: &Path, input: &Path) -> Result { Ok(sig_path) } -/// Verify a `.zst` file using a detached minisign signature. -pub fn verify_zst_file_detached(rootfs: &Path, input: &Path, sig_path: &Path) -> Result<()> { - let keys = locate_keys(rootfs)?; - verify_detached_with_key_paths(input, sig_path, &keys) -} - /// 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) { @@ -306,15 +351,33 @@ mod tests { sign_detached_with_key_paths(&file, &sig_path, &keys)?; assert!(sig_path.exists()); - let pk = PublicKey::from_file(pub_path)?; + let pk = PublicKey::from_file(&pub_path)?; let sig_box = SignatureBox::from_file(&sig_path)?; let mut reader = fs::File::open(&file)?; minisign::verify(&pk, &sig_box, &mut reader, true, false, false) .context("signature verification should succeed")?; verify_detached_with_key_paths(&file, &sig_path, &keys)?; + verify_zst_file_detached_with_public_key(&file, &sig_path, &pub_path)?; // Also make sure host/rootfs lookup path version works without touching /. let _ = locate_keys_in_roots(rootfs.path(), host.path())?; Ok(()) } + + #[test] + fn list_trusted_public_keys_finds_multiple_pub_files() -> Result<()> { + let rootfs = tempfile::tempdir()?; + let host = tempfile::tempdir()?; + let public_dir = rootfs.path().join(PUBLIC_KEYS_DIR_REL); + fs::create_dir_all(&public_dir)?; + fs::write(public_dir.join("a.pub"), b"dummy")?; + fs::write(public_dir.join("note.txt"), b"ignore")?; + fs::write(public_dir.join("b.pub"), b"dummy")?; + + let found = list_public_key_files_in_roots(rootfs.path(), host.path())?; + assert_eq!(found.len(), 2); + assert!(found[0].ends_with("a.pub")); + assert!(found[1].ends_with("b.pub")); + Ok(()) + } }