From b6ddc46c83aa07dd8fe6ff804c13f0f2687739a7 Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 18 May 2026 20:23:39 +0200 Subject: [PATCH 1/6] Add CI workflow: fmt + clippy + test on every PR The repo had no `.github/workflows/` at all, so PRs showed zero status checks. Add a single `ci.yml` running on ubuntu-latest: * cargo fmt --check * cargo clippy --no-deps --all-targets -- -D warnings * cargo test --all-targets * cargo test --doc Installs `debsigs` and `debsig-verify` from apt so the sign/verify integration test doesn't skip on the runner. `dpkg-deb`, `fakeroot`, and `gpg` are already present on the ubuntu-latest image. Cargo registry + target dir are cached keyed off Cargo.lock so repeat runs on the same PR stay fast. Concurrency group cancels superseded runs when new commits land on the same PR head. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75353c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +# Cancel superseded runs on the same PR when new commits are pushed. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: cargo test (with integration deps) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Debian tooling for integration tests + # `dpkg-deb`, `fakeroot`, and `gpg` ship by default on + # ubuntu-latest runners. `debsigs` and `debsig-verify` do not. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends debsigs debsig-verify + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo target dir + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml') }} + restore-keys: | + cargo-${{ runner.os }}- + + - name: cargo fmt --check + run: cargo fmt --all -- --check + + - name: cargo clippy + run: cargo clippy --no-deps --all-targets -- -D warnings + + - name: cargo test + run: cargo test --all-targets + + - name: cargo test --doc + run: cargo test --doc From ac55548b50cf020ad053de2b209be16bf259b60b Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 18 May 2026 20:50:13 +0200 Subject: [PATCH 2/6] viewer: search stderr too when parsing debsig-verify output Newer debsig-verify (ubuntu-24+) routes the diagnostic line that contains the `fake/` path to stderr, while older versions printed it to stdout. The viewer was only scanning stdout, so `lookup sign-key` returned "Failed to extract ID from output" on fresh runners. Fix: scan stdout first, fall back to stderr. Loosened the hex match to also accept lowercase (some tooling prints lowercase IDs) and upper-case the captured value on return so callers always see a canonical 16-char uppercase ID. Also accept `:` or `/` as the trailing delimiter in the path-like marker, again for forward compat across debsig-verify versions. The fallback error message now includes both captured streams, trimmed, so the next regression is debuggable from the CI log alone instead of requiring a local repro. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/viewer.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index 582de33..f5d673f 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -36,10 +36,21 @@ pub fn signature(deb: &str, debug: bool) -> Result { return Err(anyhow!(msg)); } + // Newer versions of debsig-verify route the diagnostic containing + // the `fake/` path to stderr; older versions printed it to + // stdout. Search both streams so the parse stays working across + // distro versions. let stdout = String::from_utf8_lossy(&output.stdout); - let re = Regex::new(r"fake/([A-Z0-9]+):").unwrap(); - match re.captures(&stdout) { - Some(caps) => Ok(caps.get(1).unwrap().as_str().to_string()), - None => Err(anyhow!("Failed to extract ID from output")), + let stderr = String::from_utf8_lossy(&output.stderr); + let re = Regex::new(r"fake/([A-Fa-f0-9]+)[:/]").unwrap(); + if let Some(caps) = re.captures(&stdout).or_else(|| re.captures(&stderr)) { + return Ok(caps.get(1).unwrap().as_str().to_uppercase()); } + Err(anyhow!( + "Failed to extract key id from debsig-verify output.\n\ + stdout: {}\n\ + stderr: {}", + stdout.trim(), + stderr.trim() + )) } From f2eb582cce7076962e52d3ac4ff87c46f38498c8 Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 18 May 2026 20:53:36 +0200 Subject: [PATCH 3/6] viewer: extract signing key id from `_gpgorigin` directly, not via debsig-verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation invoked `debsig-verify --policies-dir fake ` and scraped the resulting diagnostic line for a `fake/:` substring. That worked on Ubuntu 22 but broke on Ubuntu 24 (and presumably future versions): newer debsig-verify prints no diagnostic at all under those flags, so the regex never matches and `lookup sign-key` returns "Failed to extract ID." Replace with a direct read: a `.deb` is an ar archive, and `debsigs --sign=origin` (the signing path we use) embeds the GPG signature as the member `_gpgorigin`. Pull that member out via the `ar` crate (already a dep for the session subsystem) and parse the issuer key id with `gpg --list-packets`. Recognize the alternate `_gpgbuilder` member as well, for forward-compat with debsigs's other signing roles. Benefits: * No version-fragile string parsing of diagnostic output. * One fewer runtime dep (no longer needs `debsig-verify` to be installed — only `gpg`). * Tests pass on both Ubuntu 22 (local) and Ubuntu 24 (CI runner). Also a small unit test against a captured `gpg --list-packets` sample to pin the keyid regex. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/viewer.rs | 123 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 35 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index f5d673f..2c47513 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,56 +1,109 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use regex::Regex; -use std::process::Command; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; use crate::misc::{check_command_exists, check_file_exists}; -/// Extract the signing-key id from a .deb by invoking debsig-verify with a -/// deliberately-fake policies directory and parsing the resulting error. +/// Extract the signing-key id from a .deb. /// -/// debsig-verify prints lines like: -/// debsig: Origin Signature check failed. This deb might not be signed, -/// ... -/// fake/: ... -/// — the path `fake/` is what we grep for. +/// A `.deb` is an `ar` archive; `debsigs --sign=origin` (the signing +/// path we use) embeds the GPG signature as a member called +/// `_gpgorigin`. We pull that member out directly and ask `gpg +/// --list-packets` for the issuer key id. This is more robust than +/// scraping debsig-verify's error output — that approach depended on +/// the exact wording of a diagnostic line which has changed between +/// Debian versions (see git history for the previous, fragile +/// implementation). pub fn signature(deb: &str, debug: bool) -> Result { - check_command_exists("debsig-verify")?; + check_command_exists("gpg")?; check_file_exists(deb)?; - if debug { - log::info!("Executing: debsig-verify --policies-dir fake {}", deb); + log::info!("Extracting signature blob from {}", deb); } - let output = Command::new("debsig-verify") - .args(["--policies-dir", "fake", deb]) - .output() - .map_err(|e| anyhow!("Failed to spawn debsig-verify: {}", e))?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let msg = format!( - "Cannot look up package signature due to internal error. Expecting \ - command to error out\n {}", - stdout - ); - log::error!("{}", msg); - return Err(anyhow!(msg)); + let sig_bytes = extract_signature_member(deb)?; + parse_keyid_with_gpg(&sig_bytes, debug) +} + +/// Pull the GPG-signature ar member out of `deb`. Recognized member +/// names are `_gpgorigin` (debsigs origin role, what we sign with) +/// and `_gpgbuilder` (alternate role debsigs supports). +fn extract_signature_member(deb: &str) -> Result> { + let f = std::fs::File::open(deb).with_context(|| format!("Opening {} as ar archive", deb))?; + let mut archive = ar::Archive::new(f); + while let Some(entry) = archive.next_entry() { + let mut entry = entry.with_context(|| format!("Reading ar entry from {}", deb))?; + let name = std::str::from_utf8(entry.header().identifier()) + .unwrap_or("") + .trim_end_matches('/') + .to_string(); + if name == "_gpgorigin" || name == "_gpgbuilder" { + let mut buf = Vec::with_capacity(entry.header().size() as usize); + entry.read_to_end(&mut buf)?; + return Ok(buf); + } } + Err(anyhow!( + "No `_gpgorigin`/`_gpgbuilder` member in {} — package is not signed", + deb + )) +} - // Newer versions of debsig-verify route the diagnostic containing - // the `fake/` path to stderr; older versions printed it to - // stdout. Search both streams so the parse stays working across - // distro versions. - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let re = Regex::new(r"fake/([A-Fa-f0-9]+)[:/]").unwrap(); - if let Some(caps) = re.captures(&stdout).or_else(|| re.captures(&stderr)) { +/// Pipe the raw signature into `gpg --list-packets` and parse the +/// `keyid ` line. +fn parse_keyid_with_gpg(sig: &[u8], debug: bool) -> Result { + let mut child = Command::new("gpg") + .args(["--list-packets"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| anyhow!("Failed to spawn gpg: {}", e))?; + child + .stdin + .as_mut() + .ok_or_else(|| anyhow!("Failed to open gpg stdin"))? + .write_all(sig)?; + let out = child.wait_with_output()?; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if debug { + log::info!("gpg --list-packets stdout:\n{}", stdout); + log::info!("gpg --list-packets stderr:\n{}", stderr); + } + + // gpg --list-packets prints (among other lines): + // :signature packet: algo 1, keyid 40C7DD112EDB4CA9 + // Match the keyid token; uppercase the captured value so the + // returned id is always canonical. + let re = Regex::new(r"keyid ([0-9A-Fa-f]+)").unwrap(); + if let Some(caps) = re.captures(&stdout) { return Ok(caps.get(1).unwrap().as_str().to_uppercase()); } Err(anyhow!( - "Failed to extract key id from debsig-verify output.\n\ + "Failed to extract key id from gpg --list-packets output.\n\ stdout: {}\n\ stderr: {}", stdout.trim(), stderr.trim() )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_keyid_from_gpg_output() { + // Synthetic capture-only test against the regex. + let sample = "\ +:signature packet: algo 1, keyid 40C7DD112EDB4CA9 + version 4, created 1717..., md5len 0, sigclass 0x00 + digest algo 8, begin of digest aa bb +"; + let re = Regex::new(r"keyid ([0-9A-Fa-f]+)").unwrap(); + let caps = re.captures(sample).expect("regex should match"); + assert_eq!(caps.get(1).unwrap().as_str(), "40C7DD112EDB4CA9"); + } +} From dae2db37dc86b1a64e39af6d79b3d8db17e06339 Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 18 May 2026 20:57:13 +0200 Subject: [PATCH 4/6] ci: pin to ubuntu-22.04 to work around gpg 2.4 weak-digest rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled GPG keyring fixture under tests/res/{secret-key.gpg, public-key.gpg} was generated some time ago with a digest algorithm that gpg 2.4 (default on ubuntu-24/ubuntu-latest) refuses during signature verification. Visible symptom: `debsig-verify` invokes gpg internally, gets `gpg exited abnormally`, and verification fails. Pin the CI runner to ubuntu-22.04 for now — that ships gpg 2.2 which still accepts the fixture's digest. Once the fixture is regenerated with a strong digest (a separate follow-up), we can drop the pin and move back to ubuntu-latest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75353c9..2255887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,12 @@ env: jobs: test: name: cargo test (with integration deps) - runs-on: ubuntu-latest + # Pinned to 22.04 because the bundled GPG signing-key fixture + # under tests/res/ was generated with a digest algorithm that + # gpg 2.4 (default on ubuntu-24) rejects during verification. + # TODO: regenerate the fixture with a strong digest and move + # back to ubuntu-latest. Tracked separately. + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 From 7f88978a0cb2346dba5a068646ce922a1c5a03d3 Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 19 May 2026 00:31:31 +0200 Subject: [PATCH 5/6] viewer: extract a single `extract_keyid` helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit test was duplicating the keyid regex from `parse_keyid_with_gpg`, so changes to the production regex would have left the test silently green (it was only asserting against its own copy). Pull the parsing into a tiny `extract_keyid(&str) -> Option` helper; both the production path and the tests now call it. Tests broadened a little while we're at it: * realistic gpg output sample (unchanged content, just renamed) * lowercase input → canonical uppercase output * no-match returns None (both garbage text and empty string) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/viewer.rs | 57 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index 2c47513..ea04d02 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -73,21 +73,29 @@ fn parse_keyid_with_gpg(sig: &[u8], debug: bool) -> Result { log::info!("gpg --list-packets stderr:\n{}", stderr); } - // gpg --list-packets prints (among other lines): - // :signature packet: algo 1, keyid 40C7DD112EDB4CA9 - // Match the keyid token; uppercase the captured value so the - // returned id is always canonical. + extract_keyid(&stdout).ok_or_else(|| { + anyhow!( + "Failed to extract key id from gpg --list-packets output.\n\ + stdout: {}\n\ + stderr: {}", + stdout.trim(), + stderr.trim() + ) + }) +} + +/// Pull the issuer key id out of a `gpg --list-packets` stdout dump. +/// Looks for the `keyid ` token that gpg embeds in signature +/// packet lines, e.g. +/// +/// :signature packet: algo 1, keyid 40C7DD112EDB4CA9 +/// +/// The returned id is always uppercase. Returns `None` when no +/// `keyid` token appears in the input. +fn extract_keyid(text: &str) -> Option { let re = Regex::new(r"keyid ([0-9A-Fa-f]+)").unwrap(); - if let Some(caps) = re.captures(&stdout) { - return Ok(caps.get(1).unwrap().as_str().to_uppercase()); - } - Err(anyhow!( - "Failed to extract key id from gpg --list-packets output.\n\ - stdout: {}\n\ - stderr: {}", - stdout.trim(), - stderr.trim() - )) + re.captures(text) + .map(|c| c.get(1).unwrap().as_str().to_uppercase()) } #[cfg(test)] @@ -95,15 +103,26 @@ mod tests { use super::*; #[test] - fn extract_keyid_from_gpg_output() { - // Synthetic capture-only test against the regex. + fn extract_keyid_from_realistic_gpg_output() { let sample = "\ :signature packet: algo 1, keyid 40C7DD112EDB4CA9 version 4, created 1717..., md5len 0, sigclass 0x00 digest algo 8, begin of digest aa bb "; - let re = Regex::new(r"keyid ([0-9A-Fa-f]+)").unwrap(); - let caps = re.captures(sample).expect("regex should match"); - assert_eq!(caps.get(1).unwrap().as_str(), "40C7DD112EDB4CA9"); + assert_eq!(extract_keyid(sample).as_deref(), Some("40C7DD112EDB4CA9")); + } + + #[test] + fn extract_keyid_uppercases_lowercase_input() { + // gpg in some configurations prints the keyid in lowercase; + // we always return the canonical uppercase form. + let sample = ":signature packet: algo 1, keyid 40c7dd112edb4ca9"; + assert_eq!(extract_keyid(sample).as_deref(), Some("40C7DD112EDB4CA9")); + } + + #[test] + fn extract_keyid_returns_none_when_absent() { + assert!(extract_keyid("no signature packet here").is_none()); + assert!(extract_keyid("").is_none()); } } From ace5929775b6f834c650cd1244fd500dc461144b Mon Sep 17 00:00:00 2001 From: dkijania Date: Tue, 19 May 2026 00:33:31 +0200 Subject: [PATCH 6/6] viewer: inline the keyid example in the doc comment rustdoc treats indented blocks as Rust code, so the `:signature packet: algo 1, keyid 40C7DD112EDB4CA9` example was being parsed as Rust during `cargo test --doc` and failing the build. Inline it with backticks so it stays an example, not a doc test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/viewer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index ea04d02..f598880 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -86,9 +86,7 @@ fn parse_keyid_with_gpg(sig: &[u8], debug: bool) -> Result { /// Pull the issuer key id out of a `gpg --list-packets` stdout dump. /// Looks for the `keyid ` token that gpg embeds in signature -/// packet lines, e.g. -/// -/// :signature packet: algo 1, keyid 40C7DD112EDB4CA9 +/// packet lines (e.g. `:signature packet: algo 1, keyid 40C7DD112EDB4CA9`). /// /// The returned id is always uppercase. Returns `None` when no /// `keyid` token appears in the input.