crates/gunnar-ui-server/src/api/blame.rs
//! Blame: who wrote each line of a file at a revision (`Refs.Blame`), and
//! whether the commit that wrote it is signed (`Admin.VerifyChain`).
//!
//! Two of gunnar's own walks, joined. `Refs.Blame` runs the same walk `gunnar
//! blame` runs — against the RUNNING server's open store, so an operator can
//! blame a sealed appliance with no shell — and answers, for every line, the
//! full oid of the commit that last touched it and that commit's author. That
//! is the "which commit wrote each line" half.
//!
//! The other half is the signature. A blame that names the author names the
//! server's word for who wrote a line; gunnar's promise is stronger — the
//! commit is *signed*, and a third party can check it. So each DISTINCT commit
//! in the blame is run through `Admin.VerifyChain` (the same verdict the chain
//! view shows) once, at one of its own lines, and its `signature` link's
//! verdict is folded back onto every line that commit wrote. A commit whose
//! signature is `proven` names its signer; one that is `unproven` or `broken`
//! is a **named hole** — the oid is real and attributed, and the page says the
//! line's provenance stops there rather than drawing a tick it cannot earn.
//!
//! This is why the module doc calls blame "half of the chain view": link 1 is
//! which commit wrote the line, link 2 is the signature on it, and that is
//! exactly what a per-file blame is when every line carries its own verdict.
use std::collections::BTreeMap;
use axum::extract::{Path, Query};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{repo_path, ApiError, ApiResult, Reader};
use crate::pb;
/// **How many distinct commits one blame will verify the signature of.**
///
/// The amplification factor of this route, made a constant instead of a hope.
/// Every distinct commit costs one `Admin.VerifyChain`, which is a walk of
/// gunnar's history with its own budget, and the request that names the file is
/// a `GET` a visitor with no session may make. 64 covers what a human reads: a
/// file whose lines come from more than 64 different commits is one where the
/// page's colours have stopped meaning anything anyway.
pub const MAX_VERIFIED_COMMITS: usize = 64;
/// How long one blame may spend verifying before it serves what it has.
///
/// The count is not the whole bound — 64 slow walks are still 64 slow walks —
/// so the loop also watches the clock. It is checked BETWEEN calls: the latency
/// of any single RPC is gunnar's to bound, and a deadline this side could only
/// abandon a call gunnar is still paying for.
const VERIFY_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
/// `?rev=` and `?path=` on a blame request.
#[derive(Debug, Clone, Deserialize)]
pub struct BlameQuery {
/// A ref name, `HEAD`, or a hex oid. Empty means the default branch.
#[serde(default)]
pub rev: Option<String>,
/// The file to blame, as a path within the revision's tree.
pub path: String,
}
/// The signature verdict on the commit that wrote a line.
///
/// Three-way on purpose, exactly as the chain view keeps it: `unproven` is not
/// `proven`, and collapsing them would make an unsigned commit render like a
/// signed one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Signature {
/// A registered key signed the commit; `signer` names gunnar's finding.
Proven,
/// The commit is unsigned, or its signature did not check out. A named hole.
Unproven,
/// gunnar could not walk to the signature at all.
Broken,
}
/// One line's attribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BlameLine {
/// The one-based line number, so the page can render a gutter without
/// counting.
pub number: usize,
/// The FULL hex oid of the commit that last touched this line. The page
/// colours by this, and it is what the red-first test asserts — not that a
/// row rendered, but that the row is attributed to this exact commit.
pub commit: String,
/// The commit author's display name.
pub author: String,
/// The signature verdict on `commit`.
pub signature: Signature,
/// gunnar's word about who signed it (or why it could not be proven),
/// verbatim from the chain's `signature` link. `None` until the commit is
/// verified.
pub signer: Option<String>,
/// The line's bytes at the blamed revision, without its trailing newline.
pub text: String,
/// True when the line's bytes are not valid UTF-8, so `text` is empty.
pub binary: bool,
}
/// A file's blame.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Blame {
pub rev: String,
pub path: String,
pub lines: Vec<BlameLine>,
/// The distinct commits, in first-appearance order, so the page can assign
/// a stable colour per commit without a second pass.
pub commits: Vec<String>,
/// How many of `commits` were run through `Admin.VerifyChain`.
pub verified: usize,
/// **True when this answer stopped verifying before it ran out of
/// commits.** The lines of an unverified commit keep the safe default —
/// `unproven`, a named hole — so a truncated answer under-claims rather
/// than over-claims, and this says which kind of hole it is: "nobody proved
/// it" or "this console did not look".
pub truncated: bool,
/// **The file is kept in Git LFS**: the tree holds a pointer, and blaming
/// its three lines would say who last moved the pointer, as if they had
/// written the file. The lines are left out; `commits` still names the
/// commits that changed it.
pub lfs: bool,
}
/// True when the blamed lines ARE a Git LFS pointer — the file is in LFS.
fn is_lfs_pointer(blame: &Blame) -> bool {
if blame.lines.is_empty() || blame.lines.len() > 8 || blame.lines.iter().any(|l| l.binary) {
return false;
}
let text: String = blame
.lines
.iter()
.map(|l| format!("{}\n", l.text))
.collect();
crate::api::browse::LfsPointer::parse(text.as_bytes()).is_some()
}
/// Turn gunnar's per-line rows into the page's model, WITHOUT signatures yet.
///
/// Pure, so the attribution is testable without a server: line N carries the
/// exact oid gunnar attributed it to, and the distinct-commit list is in
/// first-appearance order.
fn fold(rev: String, path: String, rows: Vec<pb::BlameLine>) -> Blame {
let mut lines = Vec::with_capacity(rows.len());
let mut commits: Vec<String> = Vec::new();
for (i, row) in rows.into_iter().enumerate() {
if !commits.contains(&row.commit) {
commits.push(row.commit.clone());
}
let text = String::from_utf8(row.line);
lines.push(BlameLine {
number: i + 1,
commit: row.commit,
author: row.author,
signature: Signature::Unproven,
signer: None,
binary: text.is_err(),
text: text.unwrap_or_default(),
});
}
Blame {
rev,
path,
lines,
commits,
verified: 0,
truncated: false,
lfs: false,
}
}
/// Fold each distinct commit's signature verdict onto every line it wrote.
///
/// Pure and separate from the calls that produce `verdicts`, so the join is
/// testable: a commit proven signed names its signer on every one of its lines,
/// and a commit that is not proven leaves a NAMED hole — the oid stays, the
/// tick does not.
fn apply_signatures(blame: &mut Blame, verdicts: &BTreeMap<String, (Signature, String)>) {
for line in &mut blame.lines {
if let Some((signature, signer)) = verdicts.get(&line.commit) {
line.signature = *signature;
line.signer = Some(signer.clone());
}
}
}
/// The `signature` link of one commit's chain, reshaped to `(verdict, detail)`.
fn signature_of(chain: &pb::VerifyChainResponse) -> (Signature, String) {
let link = chain.links.iter().find(|l| l.id == "signature");
match link {
Some(link) => {
let signature = match pb::ChainLinkVerdict::try_from(link.verdict) {
Ok(pb::ChainLinkVerdict::ChainLinkProven) => Signature::Proven,
Ok(pb::ChainLinkVerdict::ChainLinkBroken) => Signature::Broken,
_ => Signature::Unproven,
};
(signature, link.detail.clone())
}
// No signature link at all is a hole, not a pass.
None => (
Signature::Broken,
"Gunnar returned no signature link for this commit".to_owned(),
),
}
}
/// `GET /api/repos/{namespace}/{name}/blame?rev=&path=`
///
/// Not cached: an operator asking who wrote a line is re-checking provenance,
/// and a stale answer about that is worse than the call.
pub async fn blame(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
Query(query): Query<BlameQuery>,
) -> ApiResult<Blame> {
let rev = query.rev.unwrap_or_default();
let response = reader
.gunnar
.refs()
.blame(pb::BlameRequest {
repo: Some(repo_path(&namespace, &name)),
rev: rev.clone(),
path: query.path.clone(),
})
.await
.map_err(ApiError::from)?
.into_inner();
let mut blame = fold(rev, query.path.clone(), response.lines);
if is_lfs_pointer(&blame) {
blame.lfs = true;
blame.lines.clear();
return Ok(Json(blame));
}
// One VerifyChain per DISTINCT commit, at one of its own lines, and **at
// most [`MAX_VERIFIED_COMMITS`] of them within [`VERIFY_BUDGET`]**. The
// target is `<path>:<line>` for a line that commit actually wrote, so
// gunnar's link 1 resolves to the same commit and link 2 is its signature.
//
// The bound used to be the sentence "a file has few distinct commits",
// which is an assumption about other people's repositories and not a
// check — and `blame` is on `is_anonymous_read`, so the caller who picks
// the file need not be logged in. N is theirs to choose: one GET of the
// longest-lived file in a public repository became N `Admin.VerifyChain`
// walks of gunnar's history, each with its own WALK_BUDGET.
let mut verdicts: BTreeMap<String, (Signature, String)> = BTreeMap::new();
let started = std::time::Instant::now();
for commit in &blame.commits {
if blame.verified >= MAX_VERIFIED_COMMITS || started.elapsed() > VERIFY_BUDGET {
blame.truncated = true;
break;
}
let Some(line) = blame.lines.iter().find(|l| &l.commit == commit) else {
continue;
};
let chain = reader
.gunnar
.admin()
.verify_chain(pb::VerifyChainRequest {
repo: Some(repo_path(&namespace, &name)),
rev: blame.rev.clone(),
target: format!("{}:{}", blame.path, line.number),
})
.await
.map_err(ApiError::from)?
.into_inner();
verdicts.insert(commit.clone(), signature_of(&chain));
blame.verified += 1;
}
if blame.truncated {
tracing::info!(
repo = %format!("{namespace}/{name}"),
path = %blame.path,
commits = blame.commits.len(),
verified = blame.verified,
"a blame stopped verifying at this console's ceiling; the rest of its lines are \
served as unproven"
);
}
apply_signatures(&mut blame, &verdicts);
Ok(Json(blame))
}
#[cfg(test)]
mod tests {
use super::*;
fn row(commit: &str, author: &str, line: &str) -> pb::BlameLine {
pb::BlameLine {
commit: commit.to_owned(),
author: author.to_owned(),
line: line.as_bytes().to_vec(),
}
}
/// ★ **A file kept in Git LFS is not blamed line by line** (2026-10-15):
/// its lines are the pointer's, and naming who last moved the pointer as
/// the author of each "line" of a PDF is wrong. A file that merely talks
/// about LFS is blamed as usual.
#[test]
fn an_lfs_pointer_is_recognised_and_a_file_about_lfs_is_not() {
let a = "a".repeat(40);
let pointer = fold(
"HEAD".into(),
"docs/idiot_guide.pdf".into(),
vec![
row(&a, "alice", "version https://git-lfs.github.com/spec/v1\n"),
row(&a, "alice", &format!("oid sha256:{}\n", "fd".repeat(32))),
row(&a, "alice", "size 189950\n"),
],
);
assert!(is_lfs_pointer(&pointer));
let about = fold(
"HEAD".into(),
"docs/lfs.md".into(),
vec![
row(&a, "alice", "version https://git-lfs.github.com/spec/v1"),
row(&a, "alice", "is the spec"),
],
);
assert!(!is_lfs_pointer(&about));
}
/// **Each line is attributed to the exact commit gunnar blamed it on**, and
/// the distinct commits come out in first-appearance order for a stable
/// colour. The assertion is the oid, not that a row exists.
#[test]
fn a_line_carries_the_oid_of_the_commit_that_wrote_it() {
let a = "a".repeat(40);
let b = "b".repeat(40);
let blame = fold(
"HEAD".into(),
"src/lib.rs".into(),
vec![
row(&a, "alice", "fn main() {"),
row(&b, "bob", " work();"),
row(&a, "alice", "}"),
],
);
assert_eq!(blame.lines[0].commit, a);
assert_eq!(blame.lines[0].number, 1);
assert_eq!(blame.lines[0].author, "alice");
assert_eq!(blame.lines[1].commit, b);
assert_eq!(blame.lines[2].commit, a);
// First-appearance order: a before b, and a not repeated.
assert_eq!(blame.commits, vec![a, b]);
}
/// **A signed commit names its signer on every line it wrote; an unsigned
/// one leaves a named hole.** The oid stays either way — the hole is
/// attributed, not blank.
#[test]
fn a_signed_commit_names_its_signer_and_an_unsigned_one_is_a_named_hole() {
let signed = "1".repeat(40);
let unsigned = "2".repeat(40);
let mut blame = fold(
"HEAD".into(),
"x.rs".into(),
vec![
row(&signed, "alice", "a"),
row(&unsigned, "mallory", "b"),
row(&signed, "alice", "c"),
],
);
let mut verdicts = BTreeMap::new();
verdicts.insert(
signed.clone(),
(Signature::Proven, "signed by alice's key".to_owned()),
);
verdicts.insert(
unsigned.clone(),
(Signature::Unproven, "the commit is not signed".to_owned()),
);
apply_signatures(&mut blame, &verdicts);
// The signer is named on BOTH of the signed commit's lines.
assert_eq!(blame.lines[0].signature, Signature::Proven);
assert_eq!(
blame.lines[0].signer.as_deref(),
Some("signed by alice's key")
);
assert_eq!(blame.lines[2].signature, Signature::Proven);
assert_eq!(
blame.lines[2].signer.as_deref(),
Some("signed by alice's key")
);
// The unsigned commit is a NAMED hole: its oid is still `unsigned`, and
// its verdict is not proven. Asserting the oid, not that it rendered.
assert_eq!(blame.lines[1].commit, unsigned);
assert_eq!(blame.lines[1].signature, Signature::Unproven);
assert_eq!(
blame.lines[1].signer.as_deref(),
Some("the commit is not signed")
);
}
/// A line whose bytes are not UTF-8 is marked binary rather than lost.
#[test]
fn a_non_utf8_line_is_binary() {
let mut rows = vec![row(&"c".repeat(40), "carol", "ok")];
rows.push(pb::BlameLine {
commit: "c".repeat(40),
author: "carol".into(),
line: vec![0xff, 0xfe],
});
let blame = fold("HEAD".into(), "b".into(), rows);
assert!(!blame.lines[0].binary);
assert!(blame.lines[1].binary);
assert_eq!(blame.lines[1].text, "");
}
}