Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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