diff --git a/src/apt/keyring.rs b/src/apt/keyring.rs index fd6ad96..254c97a 100644 --- a/src/apt/keyring.rs +++ b/src/apt/keyring.rs @@ -16,99 +16,201 @@ struct LaunchpadPpaResponse { signing_key_fingerprint: String, } -/// Download a keyring to the application cache directory and return the path +/// Download keyrings and combine them into a single keyring file for the series /// -/// This function downloads the keyring to a user-writable cache directory +/// This function downloads keyrings to a user-writable cache directory /// instead of the system apt keyring directory, allowing non-root usage. -/// The returned path can be passed to mmdebstrap via --keyring. +/// All keyrings are combined into a single file that can be passed to mmdebstrap +/// via --keyring= or using signed-by in the mirror specification. /// -/// For Debian keyrings (which are ASCII-armored .asc files), the key is +/// For Debian keyrings (which are ASCII-armored .asc files), the keys are /// converted to binary GPG format using gpg --dearmor. /// +/// For 'sid' and 'experimental', this downloads keyrings from the 3 latest +/// releases since sid needs keys from all recent releases, and combines them +/// into a single keyring file using gpg. +/// /// # Arguments /// * `ctx` - Optional context to use /// * `series` - The distribution series (e.g., "noble", "sid") /// /// # Returns -/// The path to the downloaded keyring file (in binary GPG format) -pub async fn download_cache_keyring( +/// The path to the combined keyring file for the series +pub async fn download_cache_keyrings( ctx: Option>, series: &str, ) -> Result> { let ctx = ctx.unwrap_or_else(context::current); - // Obtain keyring URL from distro_info - let keyring_url = distro_info::get_keyring_url(series).await?; - log::debug!("Downloading keyring from: {}", keyring_url); + // Obtain keyring URLs from distro_info + let keyring_urls = distro_info::get_keyring_urls(series).await?; + log::debug!("Downloading keyrings from: {:?}", keyring_urls); // Get the application cache directory let proj_dirs = directories::ProjectDirs::from("com", "pkh", "pkh") .ok_or("Could not determine project directories")?; let cache_dir = proj_dirs.cache_dir(); + let keyring_dir = cache_dir.join("keyrings"); - // Create cache directory if it doesn't exist + // Create cache and keyring directories if they don't exist if !ctx.exists(cache_dir)? { ctx.command("mkdir").arg("-p").arg(cache_dir).status()?; } - - // Extract the original filename from the keyring URL - let filename = keyring_url - .split('/') - .next_back() - .unwrap_or("pkh-{}.gpg") - .replace("{}", series); - let download_path = cache_dir.join(&filename); - - // Download the keyring using curl - let mut curl_cmd = ctx.command("curl"); - curl_cmd - .arg("-s") - .arg("-f") - .arg("-L") - .arg(&keyring_url) - .arg("--output") - .arg(&download_path); - - let status = curl_cmd.status()?; - if !status.success() { - return Err(format!("Failed to download keyring from {}", keyring_url).into()); + if !ctx.exists(&keyring_dir)? { + ctx.command("mkdir").arg("-p").arg(&keyring_dir).status()?; } - // If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format - // mmdebstrap's --keyring option expects binary GPG keyrings - let keyring_path = if filename.ends_with(".asc") { - let binary_filename = filename.strip_suffix(".asc").unwrap_or(&filename); - let binary_path = cache_dir.join(format!("{}.gpg", binary_filename)); + // Path for the combined keyring file for this series + let combined_keyring_path = keyring_dir.join(format!("{}-archive-keyring.gpg", series)); - log::debug!("Converting ASCII-armored key to binary GPG format"); - let mut gpg_cmd = ctx.command("gpg"); - gpg_cmd - .arg("--dearmor") - .arg("--output") - .arg(&binary_path) - .arg(&download_path); + // Collect paths to individual keyring files + let mut individual_keyring_paths = Vec::new(); - let status = gpg_cmd.status()?; - if !status.success() { - return Err("Failed to convert keyring to binary format" - .to_string() - .into()); + for keyring_url in keyring_urls { + // Extract the original filename from the keyring URL + let filename = keyring_url + .split('/') + .next_back() + .unwrap_or("pkh-{}.gpg") + .replace("{}", series); + let download_path = keyring_dir.join(&filename); + + // Determine the binary keyring path + let binary_path = if filename.ends_with(".asc") { + // ASCII-armored key: convert to .gpg + let binary_filename = filename + .strip_suffix(".asc") + .unwrap_or(&filename); + keyring_dir.join(format!("{}.gpg", binary_filename)) + } else { + download_path.clone() + }; + + // Skip download if the binary keyring already exists + if !ctx.exists(&binary_path)? { + // Download the keyring using curl + let mut curl_cmd = ctx.command("curl"); + curl_cmd + .arg("-s") + .arg("-f") + .arg("-L") + .arg(&keyring_url) + .arg("--output") + .arg(&download_path); + + let status = curl_cmd.status()?; + if !status.success() { + return Err(format!("Failed to download keyring from {}", keyring_url).into()); + } + + // If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format + if filename.ends_with(".asc") { + log::debug!("Converting ASCII-armored key to binary GPG format"); + let mut gpg_cmd = ctx.command("gpg"); + gpg_cmd + .arg("--dearmor") + .arg("--output") + .arg(&binary_path) + .arg(&download_path); + + let status = gpg_cmd.status()?; + if !status.success() { + return Err("Failed to convert keyring to binary format" + .to_string() + .into()); + } + + // Remove the original .asc file + let _ = ctx.command("rm").arg("-f").arg(&download_path).status(); + } + + log::info!( + "Successfully downloaded keyring for {} to {}", + series, + binary_path.display() + ); + } else { + log::debug!( + "Keyring already exists at {}, skipping download", + binary_path.display() + ); } - // Remove the original .asc file - let _ = ctx.command("rm").arg("-f").arg(&download_path).status(); + individual_keyring_paths.push(binary_path); + } - binary_path + // Combine all keyrings into a single file using gpg + if individual_keyring_paths.len() == 1 { + // If only one keyring, just use it directly (or copy if needed) + let single_path = individual_keyring_paths.remove(0); + if single_path != combined_keyring_path { + // Copy to the combined path + let _ = ctx + .command("cp") + .arg(&single_path) + .arg(&combined_keyring_path) + .status()?; + } } else { - download_path - }; + // Multiple keyrings: combine using gpg + log::debug!( + "Combining {} keyrings into {}", + individual_keyring_paths.len(), + combined_keyring_path.display() + ); + + // Remove existing combined keyring if it exists + if ctx.exists(&combined_keyring_path)? { + let _ = ctx + .command("rm") + .arg("-f") + .arg(&combined_keyring_path) + .status()?; + } + + // Create an empty keyring first + let _status = ctx + .command("gpg") + .arg("--no-default-keyring") + .arg("--keyring") + .arg(&combined_keyring_path) + .arg("--fingerprint") + .status()?; + + // gpg --fingerprint on empty keyring may fail, but it creates the keyring file + // Check if the file was created + if !ctx.exists(&combined_keyring_path)? { + return Err("Failed to create empty keyring".into()); + } + + // Use gpg to combine keyrings by importing each one + for keyring_path in &individual_keyring_paths { + let status = ctx + .command("gpg") + .arg("--no-default-keyring") + .arg("--keyring") + .arg(&combined_keyring_path) + .arg("--import") + .arg(keyring_path) + .status()?; + + if !status.success() { + return Err(format!( + "Failed to import keyring {} into combined keyring", + keyring_path.display() + ) + .into()); + } + } + } log::info!( - "Successfully downloaded keyring for {} to {}", + "Combined keyring for {} available at {}", series, - keyring_path.display() + combined_keyring_path.display() ); - Ok(keyring_path) + + Ok(combined_keyring_path) } /// Download and import a PPA key using Launchpad API diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 1bb02cd..775512e 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -143,9 +143,9 @@ impl EphemeralContextGuard { .arg(lockfile_path.to_string_lossy().to_string()) .status()?; - // Download the keyring to the cache directory + // Download the keyring(s) let keyring_path = - crate::apt::keyring::download_cache_keyring(Some(ctx.clone()), series).await?; + crate::apt::keyring::download_cache_keyrings(Some(ctx.clone()), series).await?; // Use mmdebstrap to download the tarball to the cache directory let mut cmd = ctx.command("mmdebstrap"); diff --git a/src/distro_info.rs b/src/distro_info.rs index 65dd029..d2ea4d5 100644 --- a/src/distro_info.rs +++ b/src/distro_info.rs @@ -134,6 +134,18 @@ pub async fn get_ordered_series_name(dist: &str) -> Result, Box Result> { + let latest = get_n_latest_released_series(dist, 1).await?; + latest + .first() + .cloned() + .ok_or("No released series found".into()) +} + +/// Get the N latest released series for a dist (excluding future releases and special cases like sid) +pub async fn get_n_latest_released_series( + dist: &str, + n: usize, +) -> Result, Box> { let series_info_list = get_ordered_series(dist).await?; let today = chrono::Local::now().date_naive(); @@ -153,11 +165,11 @@ pub async fn get_latest_released_series(dist: &str) -> Result String { DATA.dist.get(dist).unwrap().base_url.clone() } -/// Obtain the URL for the archive keyring of a distribution series -pub async fn get_keyring_url(series: &str) -> Result> { +/// Obtain the URLs for the archive keyrings of a distribution series +/// +/// For 'sid' and 'experimental', returns keyrings from the 3 latest releases +/// since sid needs keys from all recent releases. +pub async fn get_keyring_urls(series: &str) -> Result, Box> { let dist = get_dist_from_series(series).await?; let dist_data = DATA .dist @@ -212,24 +227,36 @@ pub async fn get_keyring_url(series: &str) -> Result> { // For Debian, we need the series number to form the keyring URL if dist == "debian" { - // Special case for 'sid' - use the latest released version + // Special case for 'sid' - use keyrings from the 3 latest released versions if series == "sid" || series == "experimental" { - let latest_released = get_latest_released_series("debian").await?; - let series_num = get_debian_series_number(&latest_released).await?.unwrap(); - // Replace {series_num} placeholder with the latest released series number - Ok(dist_data - .archive_keyring - .replace("{series_num}", &series_num)) + let latest_released = get_n_latest_released_series("debian", 3).await?; + let mut urls = Vec::new(); + for released_series in latest_released { + if let Some(series_num) = get_debian_series_number(&released_series).await? { + urls.push( + dist_data + .archive_keyring + .replace("{series_num}", &series_num), + ); + } + } + if urls.is_empty() { + Err("No keyring URLs found for sid/experimental".into()) + } else { + Ok(urls) + } } else { let series_num = get_debian_series_number(series).await?.unwrap(); // Replace {series_num} placeholder with the actual series number - Ok(dist_data - .archive_keyring - .replace("{series_num}", &series_num)) + Ok(vec![ + dist_data + .archive_keyring + .replace("{series_num}", &series_num), + ]) } } else { // For other distributions like Ubuntu, use the keyring directly - Ok(dist_data.archive_keyring.clone()) + Ok(vec![dist_data.archive_keyring.clone()]) } } @@ -347,14 +374,47 @@ mod tests { } #[tokio::test] - async fn test_get_keyring_url_sid() { - // Test that 'sid' uses the latest released version for keyring URL - let sid_keyring = get_keyring_url("sid").await.unwrap(); - let latest_released = get_latest_released_series("debian").await.unwrap(); - let latest_keyring = get_keyring_url(&latest_released).await.unwrap(); + async fn test_get_keyring_urls_sid() { + // Test that 'sid' returns keyrings from the 3 latest released versions + let sid_keyrings = get_keyring_urls("sid").await.unwrap(); - // The keyring URL for 'sid' should be the same as the latest released version - assert_eq!(sid_keyring, latest_keyring); + // Should have keyring URLs for sid + assert!(!sid_keyrings.is_empty()); + assert!(sid_keyrings.len() <= 3); + + // Each URL should be a valid Debian keyring URL + for url in &sid_keyrings { + assert!( + url.contains("ftp-master.debian.org/keys"), + "URL '{}' does not contain expected pattern", + url + ); + } + } + + #[tokio::test] + async fn test_get_keyring_url_regular_series() { + // Test that regular series (like bookworm) returns a single keyring URL + let bookworm_keyring = &get_keyring_urls("bookworm").await.unwrap()[0]; + assert!( + bookworm_keyring.contains("ftp-master.debian.org/keys"), + "URL '{}' does not contain expected pattern", + bookworm_keyring + ); + } + + #[tokio::test] + async fn test_get_n_latest_released_series() { + // Test getting 3 latest released series + let latest_3 = get_n_latest_released_series("debian", 3).await.unwrap(); + + // Should have at most 3 series + assert!(!latest_3.is_empty()); + assert!(latest_3.len() <= 3); + + // Should not contain 'sid' or 'experimental' + assert!(!latest_3.contains(&"sid".to_string())); + assert!(!latest_3.contains(&"experimental".to_string())); } #[tokio::test]