Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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)
# 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

- 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
141 changes: 111 additions & 30 deletions src/viewer.rs
Original file line number Diff line number Diff line change
@@ -1,45 +1,126 @@
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/<KEY_ID>: ...
/// — the path `fake/<KEY_ID>` 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<String> {
check_command_exists("debsig-verify")?;
check_command_exists("gpg")?;
check_file_exists(deb)?;
if debug {
log::info!("Extracting signature blob from {}", deb);
}

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<Vec<u8>> {
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
))
}

/// Pipe the raw signature into `gpg --list-packets` and parse the
/// `keyid <HEX>` line.
fn parse_keyid_with_gpg(sig: &[u8], debug: bool) -> Result<String> {
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!("Executing: debsig-verify --policies-dir fake {}", deb);
log::info!("gpg --list-packets stdout:\n{}", stdout);
log::info!("gpg --list-packets stderr:\n{}", stderr);
}

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 <HEX>` 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<String> {
let re = Regex::new(r"keyid ([0-9A-Fa-f]+)").unwrap();
re.captures(text)
.map(|c| c.get(1).unwrap().as_str().to_uppercase())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
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
";
assert_eq!(extract_keyid(sample).as_deref(), Some("40C7DD112EDB4CA9"));
}

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));
#[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"));
}

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")),
#[test]
fn extract_keyid_returns_none_when_absent() {
assert!(extract_keyid("no signature packet here").is_none());
assert!(extract_keyid("").is_none());
}
}
Loading