apt/keyring: download 3 keyrings for sid
Some checks failed
CI / build (push) Failing after 21m24s
CI / snap (push) Has been skipped

This commit is contained in:
2026-03-18 15:23:57 +01:00
parent 5ec675c20b
commit d06e091121
3 changed files with 191 additions and 93 deletions

View File

@@ -16,99 +16,131 @@ struct LaunchpadPpaResponse {
signing_key_fingerprint: String, signing_key_fingerprint: String,
} }
/// Download a keyring to the application cache directory and return the path /// Download keyrings to a shared keyring directory and return the directory path
/// ///
/// 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. /// instead of the system apt keyring directory, allowing non-root usage.
/// The returned path can be passed to mmdebstrap via --keyring. /// The returned directory path can be passed to mmdebstrap via --keyring=.
/// ///
/// 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. /// 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.
///
/// # Arguments /// # Arguments
/// * `ctx` - Optional context to use /// * `ctx` - Optional context to use
/// * `series` - The distribution series (e.g., "noble", "sid") /// * `series` - The distribution series (e.g., "noble", "sid")
/// ///
/// # Returns /// # Returns
/// The path to the downloaded keyring file (in binary GPG format) /// The path to the keyring directory containing all downloaded keyring files
pub async fn download_cache_keyring( pub async fn download_cache_keyrings(
ctx: Option<Arc<context::Context>>, ctx: Option<Arc<context::Context>>,
series: &str, series: &str,
) -> Result<PathBuf, Box<dyn Error>> { ) -> Result<PathBuf, Box<dyn Error>> {
let ctx = ctx.unwrap_or_else(context::current); let ctx = ctx.unwrap_or_else(context::current);
// Obtain keyring URL from distro_info // Obtain keyring URLs from distro_info
let keyring_url = distro_info::get_keyring_url(series).await?; let keyring_urls = distro_info::get_keyring_urls(series).await?;
log::debug!("Downloading keyring from: {}", keyring_url); log::debug!("Downloading keyrings from: {:?}", keyring_urls);
// Get the application cache directory // Use system temp directory for keyrings since it's accessible from unshare mode
let proj_dirs = directories::ProjectDirs::from("com", "pkh", "pkh") // The home directory may not be accessible from mmdebstrap's unshare namespace
.ok_or("Could not determine project directories")?; let temp_dir = std::env::temp_dir();
let cache_dir = proj_dirs.cache_dir(); let keyring_dir = temp_dir.join("pkh-keyrings");
// Create cache directory if it doesn't exist // Create keyring directory if it doesn't exist
if !ctx.exists(cache_dir)? { if !ctx.exists(&keyring_dir)? {
ctx.command("mkdir").arg("-p").arg(cache_dir).status()?; ctx.command("mkdir").arg("-p").arg(&keyring_dir).status()?;
} }
// Extract the original filename from the keyring URL // Make keyring directory world-accessible so mmdebstrap in unshare mode can access it
let filename = keyring_url ctx.command("chmod")
.split('/') .arg("a+rwx")
.next_back() .arg(&keyring_dir)
.unwrap_or("pkh-{}.gpg") .status()?;
.replace("{}", series);
let download_path = cache_dir.join(&filename);
// Download the keyring using curl for keyring_url in keyring_urls {
let mut curl_cmd = ctx.command("curl"); // Extract the original filename from the keyring URL
curl_cmd let filename = keyring_url
.arg("-s") .split('/')
.arg("-f") .next_back()
.arg("-L") .unwrap_or("pkh-{}.gpg")
.arg(&keyring_url) .replace("{}", series);
.arg("--output") let download_path = keyring_dir.join(&filename);
.arg(&download_path);
let status = curl_cmd.status()?; // Determine the binary keyring path
if !status.success() { let binary_path = if filename.ends_with(".asc") {
return Err(format!("Failed to download keyring from {}", keyring_url).into()); // 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()
};
// If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format // Skip download if the binary keyring already exists
// mmdebstrap's --keyring option expects binary GPG keyrings if !ctx.exists(&binary_path)? {
let keyring_path = if filename.ends_with(".asc") { // Download the keyring using curl
let binary_filename = filename.strip_suffix(".asc").unwrap_or(&filename); let mut curl_cmd = ctx.command("curl");
let binary_path = cache_dir.join(format!("{}.gpg", binary_filename)); curl_cmd
.arg("-s")
.arg("-f")
.arg("-L")
.arg(&keyring_url)
.arg("--output")
.arg(&download_path);
log::debug!("Converting ASCII-armored key to binary GPG format"); let status = curl_cmd.status()?;
let mut gpg_cmd = ctx.command("gpg"); if !status.success() {
gpg_cmd return Err(format!("Failed to download keyring from {}", keyring_url).into());
.arg("--dearmor") }
.arg("--output")
.arg(&binary_path)
.arg(&download_path);
let status = gpg_cmd.status()?; // If the downloaded file is an ASCII-armored key (.asc), convert it to binary GPG format
if !status.success() { if filename.ends_with(".asc") {
return Err("Failed to convert keyring to binary format" log::debug!("Converting ASCII-armored key to binary GPG format");
.to_string() let mut gpg_cmd = ctx.command("gpg");
.into()); 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();
}
// Make the keyring file world-readable so mmdebstrap in unshare mode can access it
ctx.command("chmod").arg("a+r").arg(&binary_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()
);
// Ensure existing keyring is world-readable
ctx.command("chmod").arg("a+r").arg(&binary_path).status()?;
} }
}
// Remove the original .asc file
let _ = ctx.command("rm").arg("-f").arg(&download_path).status();
binary_path
} else {
download_path
};
log::info!( log::info!(
"Successfully downloaded keyring for {} to {}", "Keyrings for {} available in {}",
series, series,
keyring_path.display() keyring_dir.display()
); );
Ok(keyring_path)
Ok(keyring_dir)
} }
/// Download and import a PPA key using Launchpad API /// Download and import a PPA key using Launchpad API

View File

@@ -143,9 +143,9 @@ impl EphemeralContextGuard {
.arg(lockfile_path.to_string_lossy().to_string()) .arg(lockfile_path.to_string_lossy().to_string())
.status()?; .status()?;
// Download the keyring to the cache directory // Download the keyring(s)
let keyring_path = let keyring_dir =
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 // Use mmdebstrap to download the tarball to the cache directory
let mut cmd = ctx.command("mmdebstrap"); let mut cmd = ctx.command("mmdebstrap");
@@ -153,7 +153,13 @@ impl EphemeralContextGuard {
.arg("--mode=unshare") .arg("--mode=unshare")
.arg("--include=mount,curl,ca-certificates") .arg("--include=mount,curl,ca-certificates")
.arg("--format=tar") .arg("--format=tar")
.arg(format!("--keyring={}", keyring_path.display())); .arg(format!("--keyring={}", keyring_dir.display()))
// Setup hook to copy keyrings into the chroot so apt inside can use them
.arg("--setup-hook=mkdir -p \"$1/etc/apt/trusted.gpg.d\"")
.arg(format!(
"--setup-hook=cp {}/*.gpg \"$1/etc/apt/trusted.gpg.d/\"",
keyring_dir.display()
));
// Add architecture if specified // Add architecture if specified
if let Some(a) = arch { if let Some(a) = arch {

View File

@@ -134,6 +134,18 @@ pub async fn get_ordered_series_name(dist: &str) -> Result<Vec<String>, Box<dyn
/// Get the latest released series for a dist (excluding future releases and special cases like sid) /// Get the latest released series for a dist (excluding future releases and special cases like sid)
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> { pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
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<Vec<String>, Box<dyn Error>> {
let series_info_list = get_ordered_series(dist).await?; let series_info_list = get_ordered_series(dist).await?;
let today = chrono::Local::now().date_naive(); let today = chrono::Local::now().date_naive();
@@ -153,11 +165,11 @@ pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Er
// Sort by release date descending (newest first) // Sort by release date descending (newest first)
released_series.sort_by(|a, b| b.release.cmp(&a.release)); released_series.sort_by(|a, b| b.release.cmp(&a.release));
if let Some(latest) = released_series.first() { Ok(released_series
Ok(latest.series.clone()) .iter()
} else { .take(n)
Err("No released series found".into()) .map(|s| s.series.clone())
} .collect())
} }
/// Obtain the distribution (eg. debian, ubuntu) from a distribution series (eg. noble, bookworm) /// Obtain the distribution (eg. debian, ubuntu) from a distribution series (eg. noble, bookworm)
@@ -202,8 +214,11 @@ pub fn get_base_url(dist: &str) -> String {
DATA.dist.get(dist).unwrap().base_url.clone() DATA.dist.get(dist).unwrap().base_url.clone()
} }
/// Obtain the URL for the archive keyring of a distribution series /// Obtain the URLs for the archive keyrings of a distribution series
pub async fn get_keyring_url(series: &str) -> Result<String, Box<dyn Error>> { ///
/// 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<Vec<String>, Box<dyn Error>> {
let dist = get_dist_from_series(series).await?; let dist = get_dist_from_series(series).await?;
let dist_data = DATA let dist_data = DATA
.dist .dist
@@ -212,24 +227,36 @@ pub async fn get_keyring_url(series: &str) -> Result<String, Box<dyn Error>> {
// For Debian, we need the series number to form the keyring URL // For Debian, we need the series number to form the keyring URL
if dist == "debian" { 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" { if series == "sid" || series == "experimental" {
let latest_released = get_latest_released_series("debian").await?; let latest_released = get_n_latest_released_series("debian", 3).await?;
let series_num = get_debian_series_number(&latest_released).await?.unwrap(); let mut urls = Vec::new();
// Replace {series_num} placeholder with the latest released series number for released_series in latest_released {
Ok(dist_data if let Some(series_num) = get_debian_series_number(&released_series).await? {
.archive_keyring urls.push(
.replace("{series_num}", &series_num)) 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 { } else {
let series_num = get_debian_series_number(series).await?.unwrap(); let series_num = get_debian_series_number(series).await?.unwrap();
// Replace {series_num} placeholder with the actual series number // Replace {series_num} placeholder with the actual series number
Ok(dist_data Ok(vec![
.archive_keyring dist_data
.replace("{series_num}", &series_num)) .archive_keyring
.replace("{series_num}", &series_num),
])
} }
} else { } else {
// For other distributions like Ubuntu, use the keyring directly // 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] #[tokio::test]
async fn test_get_keyring_url_sid() { async fn test_get_keyring_urls_sid() {
// Test that 'sid' uses the latest released version for keyring URL // Test that 'sid' returns keyrings from the 3 latest released versions
let sid_keyring = get_keyring_url("sid").await.unwrap(); let sid_keyrings = get_keyring_urls("sid").await.unwrap();
let latest_released = get_latest_released_series("debian").await.unwrap();
let latest_keyring = get_keyring_url(&latest_released).await.unwrap();
// The keyring URL for 'sid' should be the same as the latest released version // Should have keyring URLs for sid
assert_eq!(sid_keyring, latest_keyring); 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] #[tokio::test]