From 82600b1d2227cd2380ed481a5732ceefbc6ee423 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 3 Oct 2026 01:52:04 +0200 Subject: [PATCH] apt/release: type the file-verification and armor errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_file returns FileVerifyError (no Release file, unlisted path, checksum mismatch, size mismatch — the structured pieces carried, so the by-hash retry in package_info can match on the failure instead of parsing text) and the in-process armor/base64 decoders return ArmorError; the gpgv staging and keyring-download paths keep their diagnostic strings, rendered at the boundary. The existing VerifyError(String) keeps its name and downcast role for suite signature verification. Display texts are byte-identical to the old format! strings. --- src/apt/release.rs | 161 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 121 insertions(+), 40 deletions(-) diff --git a/src/apt/release.rs b/src/apt/release.rs index 0a98702..35d1482 100644 --- a/src/apt/release.rs +++ b/src/apt/release.rs @@ -39,7 +39,9 @@ //! the missing authenticity only produces a `warn!`. //! //! Only actual tampering evidence — an invalid signature, or an index whose -//! hash does not match the signed Release — is reported as [`VerifyError`]. +//! hash does not match the signed Release — is reported as an error: +//! [`VerifyError`] for the suite signature, [`FileVerifyError`] for an +//! index that fails its Release checksums. //! //! ASCII-armored keyrings (Debian distributes `.asc` keyrings, and //! keyserver responses are armored) are dearmored in-process, so no `gpg` @@ -227,48 +229,101 @@ impl VerifiedRelease { /// or, for flat repositories, 'Sources.xz'). Both the digest and the /// size are checked; a file not listed in the Release file is refused, /// as it is not covered by the repository signature. - pub fn verify_file(&self, rel_path: &str, data: &[u8]) -> Result<(), String> { + pub fn verify_file(&self, rel_path: &str, data: &[u8]) -> Result<(), FileVerifyError> { if !self.available { - return Err(format!( - "no Release file was found for '{}': cannot checksum-verify '{rel_path}'", - self.suite_url - )); + return Err(FileVerifyError::NoReleaseFile { + suite_url: self.suite_url.clone(), + path: rel_path.to_string(), + }); } - let entry = self.checksums.get(rel_path).ok_or_else(|| { - format!( - "'{rel_path}' is not listed in the Release file of '{}'; refusing \ - to use an index that is not covered by the repository metadata", - self.suite_url - ) - })?; + let entry = self + .checksums + .get(rel_path) + .ok_or_else(|| FileVerifyError::NotListed { + path: rel_path.to_string(), + suite_url: self.suite_url.clone(), + })?; let digest = entry.kind.digest(data); if !digest.eq_ignore_ascii_case(&entry.hash) { - return Err(format!( - "checksum mismatch for '{rel_path}' of '{}': the Release file \ - expects {} {} but the downloaded file hashes to {}; the index \ - does not match the signed repository metadata", - self.suite_url, - entry.kind.name(), - entry.hash, - digest - )); + return Err(FileVerifyError::ChecksumMismatch { + path: rel_path.to_string(), + suite_url: self.suite_url.clone(), + algorithm: entry.kind.name().to_string(), + expected: entry.hash.clone(), + actual: digest, + }); } if entry.size > 0 && entry.size != data.len() as u64 { - return Err(format!( - "size mismatch for '{rel_path}' of '{}': expected {} bytes, got {}", - self.suite_url, - entry.size, - data.len() - )); + return Err(FileVerifyError::SizeMismatch { + path: rel_path.to_string(), + suite_url: self.suite_url.clone(), + expected: entry.size, + actual: data.len(), + }); } Ok(()) } } +/// Failure of [`VerifiedRelease::verify_file`]: the downloaded index is not +/// covered by the suite's signed Release metadata. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FileVerifyError { + /// No Release file was seen on the suite, so there is nothing to + /// verify against. + #[error("no Release file was found for '{suite_url}': cannot checksum-verify '{path}'")] + NoReleaseFile { + /// URL of the suite directory. + suite_url: String, + /// The path that was to be verified. + path: String, + }, + /// The Release file does not list the path: it is not covered by the + /// repository signature. + #[error( + "'{path}' is not listed in the Release file of '{suite_url}'; refusing to use an index that is not covered by the repository metadata" + )] + NotListed { + /// The unlisted path. + path: String, + /// URL of the suite directory. + suite_url: String, + }, + /// The downloaded file does not hash to the Release entry. + #[error( + "checksum mismatch for '{path}' of '{suite_url}': the Release file expects {algorithm} {expected} but the downloaded file hashes to {actual}; the index does not match the signed repository metadata" + )] + ChecksumMismatch { + /// The verified path. + path: String, + /// URL of the suite directory. + suite_url: String, + /// Name of the checksum algorithm (`SHA256`, ...). + algorithm: String, + /// The digest the Release file lists. + expected: String, + /// The digest of the downloaded bytes. + actual: String, + }, + /// The downloaded file's length differs from the Release entry (a + /// zeroed entry skips the check). + #[error("size mismatch for '{path}' of '{suite_url}': expected {expected} bytes, got {actual}")] + SizeMismatch { + /// The verified path. + path: String, + /// URL of the suite directory. + suite_url: String, + /// The size the Release file lists. + expected: u64, + /// The size of the downloaded bytes. + actual: usize, + }, +} + /// Parse a Release file body (the signed payload of an InRelease, or a /// plain Release file) into a [`VerifiedRelease`] fn parse_release_body(suite_url: &str, body: &str, authenticated: bool) -> VerifiedRelease { @@ -913,9 +968,33 @@ pub async fn ppa_keyring_bytes( Ok(keyring) } +/// Failure of the OpenPGP armor and base64 decoders ([`dearmor`], +/// [`base64_decode`]). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ArmorError { + /// The armored input is not valid UTF-8. + #[error("the armored keyring is not valid UTF-8")] + NotUtf8, + /// The armor CRC-24 does not match the decoded payload. + #[error("the armor CRC-24 checksum does not match")] + CrcMismatch, + /// An armor marker was present but no payload followed it. + #[error("no armored OpenPGP data found")] + NoArmoredData, + /// A byte outside the base64 alphabet. + #[error("invalid base64 character '{c}'")] + InvalidBase64 { + /// The offending character. + c: char, + }, + /// The base64 payload ends mid-group. + #[error("truncated base64 data")] + Truncated, +} + /// Decode an OpenPGP ASCII-armored keyring into its binary form (the /// equivalent of 'gpg --dearmor'). Binary input passes through unchanged. -fn dearmor(data: &[u8]) -> Result, String> { +fn dearmor(data: &[u8]) -> Result, ArmorError> { // Only input starting with an armor marker is armored; anything else is // assumed to already be a binary keyring let first = data @@ -926,8 +1005,7 @@ fn dearmor(data: &[u8]) -> Result, String> { return Ok(data.to_vec()); } - let text = std::str::from_utf8(data) - .map_err(|_| "the armored keyring is not valid UTF-8".to_string())?; + let text = std::str::from_utf8(data).map_err(|_| ArmorError::NotUtf8)?; #[derive(PartialEq)] enum State { @@ -963,7 +1041,7 @@ fn dearmor(data: &[u8]) -> Result, String> { | (u32::from(expected[1]) << 8) | u32::from(expected[2]); if value != crc24(&decoded) { - return Err("the armor CRC-24 checksum does not match".to_string()); + return Err(ArmorError::CrcMismatch); } } } @@ -981,21 +1059,21 @@ fn dearmor(data: &[u8]) -> Result, String> { } if out.is_empty() { - return Err("no armored OpenPGP data found".to_string()); + return Err(ArmorError::NoArmoredData); } Ok(out) } /// Decode standard base64, ignoring whitespace; '=' padding is optional -fn base64_decode(input: &str) -> Result, String> { - fn value(byte: u8) -> Result { +fn base64_decode(input: &str) -> Result, ArmorError> { + fn value(byte: u8) -> Result { match byte { b'A'..=b'Z' => Ok(u32::from(byte - b'A')), b'a'..=b'z' => Ok(u32::from(byte - b'a' + 26)), b'0'..=b'9' => Ok(u32::from(byte - b'0' + 52)), b'+' => Ok(62), b'/' => Ok(63), - _ => Err(format!("invalid base64 character '{}'", byte as char)), + _ => Err(ArmorError::InvalidBase64 { c: byte as char }), } } @@ -1015,7 +1093,7 @@ fn base64_decode(input: &str) -> Result, String> { } 3 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12) | (value(chunk[2])? << 6), 2 => (value(chunk[0])? << 18) | (value(chunk[1])? << 12), - _ => return Err("truncated base64 data".to_string()), + _ => return Err(ArmorError::Truncated), }; out.push((n >> 16) as u8); if chunk.len() >= 3 { @@ -1154,20 +1232,23 @@ garbage // Tampered content fails with a checksum mismatch let err = verified .verify_file("main/source/Sources.gz", b"tampered!") - .unwrap_err(); + .unwrap_err() + .to_string(); assert!(err.contains("checksum mismatch"), "got: {err}"); // A correct hash with a wrong size also fails let err = verified .verify_file("main/source/Sizes.gz", b"test") - .unwrap_err(); + .unwrap_err() + .to_string(); assert!(err.contains("size mismatch"), "got: {err}"); // A file not listed in the Release file is refused: it is not // covered by the repository signature let err = verified .verify_file("evil/source/Sources.gz", b"test") - .unwrap_err(); + .unwrap_err() + .to_string(); assert!(err.contains("not listed"), "got: {err}"); // Without a Release file nothing can be verified