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

nordisk/gunnar

crates/gunnar-core/src/forge.rs

//! **The review half of a pull request: its evidence** (roadmap phase 1).
//!
//! Comments and approvals, as **signed append-only events**.
//!
//! The roadmap's claim about this phase is not "gunnar grows issues". It is:
//!
//! > Every forge event … is an sshsig-signed row under the principal's own key.
//! > An approval therefore carries the same evidential weight as a commit — and
//! > becomes a link in the chain: the artifact can show who approved the change.
//!
//! That is what this module is. An approval signed by the reviewer's own key is
//! evidence in exactly the sense a signed commit is: it can be checked by
//! anybody, later, without trusting the server that stored it. A row in a table
//! saying `approved_by = alice` is not — it is the server's word.
//!
//! # This module does NOT own the pull request
//!
//! [`crate::pulls`] does. The record there owns the number, the source and
//! target refs, the state and the merge oid, under the control store's CAS —
//! and it is the authority for all of them. This module owns exactly one thing:
//! **what people said about that pull request**, as signatures.
//!
//! The split is one implementation applied to a question with two halves. "Which pull
//! request is this, and has it merged" is a fact about an object, and an object
//! needs one writer and a compare-and-swap. "Who approved commit `abc`" is a
//! claim by a person, and a claim needs a signature, because the value of the
//! answer is that a third party can check it without trusting this server. A
//! row saying `approved_by = alice` is the server's word; an `sshsig` is
//! alice's.
//!
//! So [`ForgeEvent::pull`] IS the pull request number. There is no separate
//! numbering here and no `Opened`, `Merged` or `Closed` event: those are
//! transitions of the record, and a second copy of them in this log would be a
//! twin that could disagree with it.
//!
//! # Events, not state
//!
//! What is signed is what happened, and the current review state is folded from
//! the events ([`fold`]) rather than stored. Two readers of the same log always
//! agree, and a state nobody can point at an event for cannot exist.
//!
//! It also makes the audit question answerable, which a mutable row does not:
//! *who approved this, and what exactly did they approve* — the approval names
//! the commit it approved, so approving and then force-pushing produces an
//! approval that no longer covers the tip, visibly. `merge_pull` refuses on
//! exactly that, which is what turns the property into a gate rather than a
//! display.
//!
//! # Where it sits in the chain
//!
//! Between links 2 and 3. A commit is signed by its author; the push that
//! landed it is attested; and an **approval** is a third signature over the
//! same commit by somebody else. A verifier that wants "was this reviewed"
//! asks this log, and gets an answer it can check itself.

/// The `sshsig` namespace a forge event is signed under.
///
/// Its own namespace, like every other signature in this repository: a review
/// approval must never be presentable as a commit signature or a tree head, and
/// `sshsig` binds the namespace into what is signed.
pub const FORGE_NAMESPACE: &str = "gunnar-forge";

/// What happened.
///
/// A closed set on purpose. An open one — a `kind: String` — would let a client
/// invent an event nobody can fold, and a log that holds events its reader does
/// not understand is a log whose state depends on who is reading it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
    /// A comment on a pull request, or — when [`path`](Event::Commented::path)
    /// is set — on ONE line of ONE file version.
    ///
    /// # The anchor follows the code, not the pull request
    ///
    /// An empty `path` is the original per-pull-request comment, and its signed
    /// bytes are byte-for-byte what they were before the anchor existed. A
    /// non-empty `path` anchors the comment to `(path, line, blob_oid)`, and the
    /// blob is the load-bearing field: it names the exact file version the line
    /// lived in. A rebase that does not touch the file leaves that blob's oid
    /// unchanged, so the comment stays on its line; a rebase that rewrites the
    /// file gives it a new blob, so the comment stops matching and the line it
    /// was about is visibly gone. Anchoring to the pull request number instead
    /// would carry the comment onto a rewritten file it was never made on.
    Commented {
        /// What was said.
        body: String,
        /// The file the comment is on, as a path within the tree. Empty for a
        /// comment on the pull request as a whole.
        #[serde(default)]
        path: String,
        /// The one-based line number within `blob_oid`.
        #[serde(default)]
        line: u32,
        /// The full hex oid of the blob the commented line lived in.
        #[serde(default)]
        blob_oid: String,
    },
    /// **An approval of a specific commit.**
    ///
    /// The commit is part of what is signed, so an approval cannot silently
    /// come to cover a different tip: force-push after a review and the
    /// approval still names the commit that was reviewed, which is exactly the
    /// fact an auditor needs and the one a mutable `approved: bool` destroys.
    Approved {
        /// The commit that was reviewed.
        commit: String,
    },
    /// A withdrawal of an earlier approval.
    Rejected {
        /// The commit the withdrawn approval named.
        commit: String,
        /// Why.
        reason: String,
    },
    /// **An issue opened.**
    ///
    /// The one place the forge grows a state machine of its own, because an
    /// issue — unlike a pull request's review — has no [`crate::pulls`] record
    /// that owns whether it is open. The issue IS the fold of its events, and
    /// this is the event that says it exists. Its number is not signed: it is
    /// [`fold_issues`]'s to assign, so that two readers of the same log agree on
    /// it the way they agree on everything else here.
    ///
    /// That the OPENING is signed at all is the property GitHub, GitLab and
    /// Gitea have no equivalent for: who filed this is the actor's own `sshsig`.
    IssueOpened {
        /// A one-line summary.
        title: String,
        /// The body, as filed.
        body: String,
    },
    /// A comment on an issue.
    ///
    /// The issue number is [`ForgeEvent::pull`] — the subject the event is
    /// about — so a comment is bound by the signature to its thread.
    IssueCommented {
        /// What was said.
        body: String,
    },
    /// **An issue closed**, with the reason it was.
    ///
    /// Appended, never deleted: what the fold shows is that the issue is shut,
    /// and the close — the reason with it — stays readable, which is the fact an
    /// auditor wants.
    IssueClosed {
        /// Why it was closed.
        reason: String,
    },
}

impl Event {
    fn tag(&self) -> &'static str {
        match self {
            Event::Commented { .. } => "commented",
            Event::Approved { .. } => "approved",
            Event::Rejected { .. } => "rejected",
            Event::IssueOpened { .. } => "issue_opened",
            Event::IssueCommented { .. } => "issue_commented",
            Event::IssueClosed { .. } => "issue_closed",
        }
    }
}

/// One event on one pull request, before it is signed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ForgeEvent {
    /// `<namespace>/<repo>` this belongs to.
    pub repo: String,
    /// **Which pull request** — [`crate::pulls::PullRecord::number`].
    ///
    /// Assigned by `PullRequests.Create`, not here: the number is inside the
    /// signature, so it has to be known before an event is signed, and a second
    /// numbering authority is exactly the twin this design avoids.
    pub pull: u64,
    /// The ACL principal that did it.
    pub actor: String,
    /// When.
    pub at_unix_ms: i64,
    /// What.
    pub event: Event,
}

impl ForgeEvent {
    /// The exact bytes that are signed.
    ///
    /// Line-oriented and fixed-order, for the same reason a tree head and a
    /// build record are: JSON has no canonical form, so signing a serialisation
    /// makes the signature depend on the serialiser. The body is length-prefixed
    /// rather than escaped, so a comment containing a newline cannot be made to
    /// look like two fields.
    pub fn signing_bytes(&self) -> Vec<u8> {
        let mut out = String::new();
        out.push_str("gunnar-forge-v1\n");
        out.push_str(&format!("repo {}\n", self.repo));
        out.push_str(&format!("pull {}\n", self.pull));
        out.push_str(&format!("actor {}\n", self.actor));
        out.push_str(&format!("at {}\n", self.at_unix_ms));
        out.push_str(&format!("kind {}\n", self.event.tag()));
        let mut field = |name: &str, value: &str| {
            out.push_str(&format!("{name} {} {value}\n", value.len()));
        };
        match &self.event {
            Event::Commented {
                body,
                path,
                line,
                blob_oid,
            } => {
                field("body", body);
                // The anchor is bound only when it exists, so a per-pull comment
                // signs exactly the bytes it did before inline comments were a
                // thing. A comment with a path binds all three fields, each
                // length-prefixed like `body`, so none can be moved under the
                // signature and text in one cannot be read as another.
                if !path.is_empty() {
                    field("path", path);
                    field("line", &line.to_string());
                    field("blob", blob_oid);
                }
            }
            Event::Approved { commit } => field("commit", commit),
            Event::Rejected { commit, reason } => {
                field("commit", commit);
                field("reason", reason);
            }
            Event::IssueOpened { title, body } => {
                field("title", title);
                field("body", body);
            }
            Event::IssueCommented { body } => field("body", body),
            Event::IssueClosed { reason } => field("reason", reason),
        }
        out.into_bytes()
    }
}

/// A [`ForgeEvent`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedEvent {
    /// The event the signature is over.
    #[serde(flatten)]
    pub event: ForgeEvent,
    /// `SHA256:…` of the actor's key — or, on an [`attested`](SignedEvent::attested)
    /// event, of gunnar's own forge-attestation key.
    pub key_fingerprint: String,
    /// The `sshsig`, PEM-armoured.
    pub signature: String,
    /// **Gunnar's attestation** (GHUNT-19), on an event a person made from a
    /// session that stepped up, which holds no key of theirs to sign with:
    /// gunnar verified that session's fresh touch of the person's own passkey and
    /// signed the event with its own key, naming the actor. `None` on an event
    /// the actor signed with their own key — byte for byte what it was before.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attested: Option<Attested>,
}

/// **What gunnar attests** about an event it signed for a person's session:
/// the session had stepped up (a fresh, user-verified touch of the person's own
/// passkey, within the identity core's window) at this time. Inside the
/// signature, so it cannot be moved onto another event or taken off this one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Attested {
    /// When the session stepped up, Unix milliseconds.
    pub session_stepped_up_at_unix_ms: i64,
}

impl Attested {
    /// The words an operator reads next to an attested event.
    pub fn words(&self) -> String {
        format!(
            "attested for session step-up at {}",
            crate::flow::iso8601_utc(self.session_stepped_up_at_unix_ms)
        )
    }
}

/// The exact bytes a signed event's signature is over: the event's own bytes,
/// and gunnar's attestation line when there is one.
fn signed_bytes(event: &ForgeEvent, attested: Option<&Attested>) -> Vec<u8> {
    let mut out = event.signing_bytes();
    if let Some(attested) = attested {
        out.extend_from_slice(
            format!(
                "attested session-step-up {}\n",
                attested.session_stepped_up_at_unix_ms
            )
            .as_bytes(),
        );
    }
    out
}

/// Why a forge event did not verify.
///
/// The one shared [`crate::sshsig::EventProblem`], re-exported here so
/// `forge::EventProblem` still names it: the forge log and the membership log
/// verify through one function and so they report a refusal in one vocabulary
/// (one implementation).
pub use crate::sshsig::EventProblem;

/// Sign a forge event with the actor's own key.
///
/// Through the one signer every signed log in this crate routes through
/// ([`crate::sshsig::sign`]): the namespace is bound into the signature there,
/// so a forge approval can never be presented under another log's namespace.
pub fn sign_event(
    key: &ssh_key::PrivateKey,
    event: ForgeEvent,
) -> Result<SignedEvent, ssh_key::Error> {
    let (key_fingerprint, signature) =
        crate::sshsig::sign(key, FORGE_NAMESPACE, &event.signing_bytes())?;
    Ok(SignedEvent {
        key_fingerprint,
        signature,
        event,
        attested: None,
    })
}

/// **Gunnar signs a person's event with its own key, and says why** (GHUNT-19):
/// `key` is gunnar's forge-attestation key, never a person's, and the
/// attestation is inside the signature.
pub fn attest_event(
    key: &ssh_key::PrivateKey,
    event: ForgeEvent,
    attested: Attested,
) -> Result<SignedEvent, ssh_key::Error> {
    let (key_fingerprint, signature) =
        crate::sshsig::sign(key, FORGE_NAMESPACE, &signed_bytes(&event, Some(&attested)))?;
    Ok(SignedEvent {
        key_fingerprint,
        signature,
        event,
        attested: Some(attested),
    })
}

/// Verify a signed forge event against a public key (gunnar's attestation
/// included, when the event carries one).
pub fn verify_event(signed: &SignedEvent, key: &ssh_key::PublicKey) -> Result<(), EventProblem> {
    crate::sshsig::verify(
        key,
        FORGE_NAMESPACE,
        &signed_bytes(&signed.event, signed.attested.as_ref()),
        &signed.key_fingerprint,
        &signed.signature,
    )
}

/// **The review on one pull request**, right now.
///
/// Folded, never stored. This is deliberately NOT the pull request: the title,
/// the refs, whether it is open and what it merged at all live in
/// [`crate::pulls::PullRecord`], which is their one authority. What is here is
/// only what cannot be a stored row without losing its value — who approves
/// what, and how many people have spoken.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Review {
    /// The pull request this review belongs to.
    pub pull: u64,
    /// Who currently approves which commit.
    ///
    /// A map and not a count: *whose* approval, and **of what**, are the two
    /// things an auditor asks, and a count answers neither. A later `Rejected`
    /// by the same actor removes their entry; a new `Approved` replaces it.
    pub approvals: std::collections::BTreeMap<String, String>,
    /// How many comments have been made.
    pub comments: usize,
}

impl Review {
    /// Who approves `commit` right now.
    ///
    /// The commit matters: an approval of an earlier tip is **not** an approval
    /// of this one, and a forge that treated it as one would let a force-push
    /// inherit a review.
    pub fn approvers_of(&self, commit: &str) -> Vec<&str> {
        self.approvals
            .iter()
            .filter(|(_, approved)| approved.as_str() == commit)
            .map(|(actor, _)| actor.as_str())
            .collect()
    }
}

/// Fold one pull request's review events, in order.
///
/// Events for other pull requests are ignored, so a caller may hand in a whole
/// repository's log.
///
/// Always returns a [`Review`], even an empty one: whether the pull request
/// exists is [`crate::pulls`]'s question, and answering `None` here would be
/// this module having an opinion about that. "Nobody has said anything yet" and
/// "there is no such pull request" are different facts with different owners.
pub fn fold(pull: u64, events: &[ForgeEvent]) -> Review {
    let mut state = Review {
        pull,
        ..Review::default()
    };
    for event in events.iter().filter(|e| e.pull == pull) {
        match &event.event {
            Event::Commented { .. } => state.comments += 1,
            Event::Approved { commit } => {
                state.approvals.insert(event.actor.clone(), commit.clone());
            }
            Event::Rejected { .. } => {
                state.approvals.remove(&event.actor);
            }
            // Issue events are a different subject with a different fold
            // ([`fold_issues`]). A review of pull request #7 and issue #7 share
            // a number but never a state — the kind keeps them apart, so an
            // issue comment can never inflate a pull request's comment count.
            Event::IssueOpened { .. }
            | Event::IssueCommented { .. }
            | Event::IssueClosed { .. } => {}
        }
    }
    state
}

/// **One issue**, folded from its events.
///
/// Unlike [`Review`], this owns its whole state — the title, the body, whether
/// it is open — because unlike a review there is no [`crate::pulls`] record
/// behind it. An issue is nothing but the fold of its signed events, so the
/// fold is the only authority for what it says.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Issue {
    /// The number the fold assigned: the Nth [`Event::IssueOpened`] in the
    /// repository is issue N.
    pub number: u64,
    /// The one-line summary the opener signed.
    pub title: String,
    /// The body the opener signed.
    pub body: String,
    /// The ACL principal that opened it — the opening event's actor.
    pub author: String,
    /// Whether it is open right now. An [`Event::IssueClosed`] shuts it.
    pub open: bool,
    /// How many comments have been made.
    pub comments: usize,
    /// When it was opened.
    pub opened_at_unix_ms: i64,
}

/// **Fold every issue in a repository**, in the order it was opened.
///
/// The caller hands in one repository's events, in log order. Numbering is
/// positional and therefore reproducible: the first [`Event::IssueOpened`] is
/// issue 1, the second is issue 2, and a comment or close names the number it
/// is about in [`ForgeEvent::pull`]. An event naming a number nothing opened is
/// dropped — it is evidence about nothing — the same way [`fold`] drops another
/// pull request's events.
///
/// Review events (`Commented`/`Approved`/`Rejected`) are ignored: they are a
/// different subject, so a whole repository's forge log may be handed in.
pub fn fold_issues(events: &[ForgeEvent]) -> Vec<Issue> {
    let mut issues: Vec<Issue> = Vec::new();
    for event in events {
        match &event.event {
            Event::IssueOpened { title, body } => {
                let number = issues.len() as u64 + 1;
                issues.push(Issue {
                    number,
                    title: title.clone(),
                    body: body.clone(),
                    author: event.actor.clone(),
                    open: true,
                    comments: 0,
                    opened_at_unix_ms: event.at_unix_ms,
                });
            }
            Event::IssueCommented { .. } => {
                if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
                    issue.comments += 1;
                }
            }
            Event::IssueClosed { .. } => {
                if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
                    issue.open = false;
                }
            }
            Event::Commented { .. } | Event::Approved { .. } | Event::Rejected { .. } => {}
        }
    }
    issues
}

/// **One issue**, folded, or `None` if nothing opened that number.
///
/// `None` here is a real answer and not [`fold`]'s deliberate always-`Some`: a
/// review folds to an empty review because whether the pull request exists is
/// [`crate::pulls`]'s question, but whether an issue exists is exactly THIS
/// fold's question — there is no other owner to ask — so a number nobody opened
/// is `None`.
pub fn fold_issue(number: u64, events: &[ForgeEvent]) -> Option<Issue> {
    fold_issues(events).into_iter().find(|i| i.number == number)
}

/// One inline comment, extracted from the review log and shaped for rendering
/// on a diff.
///
/// Deliberately carries its own `blob_oid`: the caller matches it against a
/// file's blob in the diff it is showing, and that match — not the pull
/// request's head commit — is what puts the comment on the right line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineComment {
    /// The ACL principal who wrote it.
    pub actor: String,
    /// The file it is on.
    pub path: String,
    /// The one-based line within `blob_oid`.
    pub line: u32,
    /// The blob the commented line lived in.
    pub blob_oid: String,
    /// What was said.
    pub body: String,
    /// When.
    pub at_unix_ms: i64,
}

/// Every INLINE comment on one pull request, in event order.
///
/// A `Commented` with an empty `path` is a comment on the pull request as a
/// whole — it is counted by [`fold`] as a comment and does not appear here,
/// because it has no line to sit on.
pub fn inline_comments(pull: u64, events: &[ForgeEvent]) -> Vec<InlineComment> {
    events
        .iter()
        .filter(|e| e.pull == pull)
        .filter_map(|e| match &e.event {
            Event::Commented {
                body,
                path,
                line,
                blob_oid,
            } if !path.is_empty() => Some(InlineComment {
                actor: e.actor.clone(),
                path: path.clone(),
                line: *line,
                blob_oid: blob_oid.clone(),
                body: body.clone(),
                at_unix_ms: e.at_unix_ms,
            }),
            _ => None,
        })
        .collect()
}

/// The inline comments that belong on `path` at blob `blob_oid`.
///
/// **Matched by the blob, never by the pull request's head.** The whole point
/// of anchoring a comment to a blob is that the blob is the exact bytes the
/// line was written against: give this the blob a file has in the diff being
/// rendered and it returns only the comments that were made on THAT version of
/// the file. A rebase that left the file alone hands back the same blob and the
/// comments still attach; a rebase that rewrote the file hands back a different
/// blob and they do not, because the line they named is gone.
pub fn comments_on_blob<'a>(
    comments: &'a [InlineComment],
    path: &str,
    blob_oid: &str,
) -> Vec<&'a InlineComment> {
    comments
        .iter()
        .filter(|c| c.path == path && c.blob_oid == blob_oid)
        .collect()
}

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

    fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
        ForgeEvent {
            repo: "team/repo".into(),
            pull,
            actor: actor.into(),
            at_unix_ms: at,
            event,
        }
    }

    fn key() -> ssh_key::PrivateKey {
        ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
    }

    /// A comment on the pull request as a whole — no inline anchor.
    fn plain(body: &str) -> Event {
        Event::Commented {
            body: body.into(),
            path: String::new(),
            line: 0,
            blob_oid: String::new(),
        }
    }

    /// ★ **Gunnar's attestation is inside its signature** (GHUNT-19): an
    /// attested event verifies against gunnar's key, and taking the attestation
    /// off, moving its time, or re-naming the actor breaks it.
    #[test]
    fn an_attested_event_binds_its_attestation_and_its_actor() {
        let gunnar = key();
        let attested = attest_event(
            &gunnar,
            event(
                7,
                "bob",
                1_700_000_000_000,
                Event::Approved {
                    commit: "a".repeat(40),
                },
            ),
            Attested {
                session_stepped_up_at_unix_ms: 1_699_999_990_000,
            },
        )
        .expect("attest");
        assert_eq!(verify_event(&attested, gunnar.public_key()), Ok(()));
        assert_eq!(
            attested.attested.expect("carried").words(),
            "attested for session step-up at 2023-11-14T22:13:10Z"
        );

        let mut stripped = attested.clone();
        stripped.attested = None;
        assert!(
            verify_event(&stripped, gunnar.public_key()).is_err(),
            "the attestation came off"
        );
        let mut moved = attested.clone();
        moved.attested = Some(Attested {
            session_stepped_up_at_unix_ms: 1,
        });
        assert!(
            verify_event(&moved, gunnar.public_key()).is_err(),
            "the step-up time moved"
        );
        let mut renamed = attested;
        renamed.event.actor = "mallory".into();
        assert!(
            verify_event(&renamed, gunnar.public_key()).is_err(),
            "the actor moved"
        );
    }

    /// **An approval is evidence**: signed by the reviewer, over the commit
    /// they reviewed, and every field bound.
    #[test]
    fn an_approval_verifies_and_no_field_can_be_moved_under_it() {
        let alice = key();
        let mallory = key();
        let signed = sign_event(
            &alice,
            event(
                7,
                "alice",
                1_700_000_000_000,
                Event::Approved {
                    commit: "a".repeat(40),
                },
            ),
        )
        .expect("sign");
        assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
        assert!(matches!(
            verify_event(&signed, mallory.public_key()),
            Err(EventProblem::WrongKey { .. })
        ));

        // The commit above all: an approval that can be re-pointed is an
        // approval a force-push inherits.
        for mutate in [
            |e: &mut ForgeEvent| {
                e.event = Event::Approved {
                    commit: "b".repeat(40),
                }
            },
            |e: &mut ForgeEvent| e.actor = "mallory".into(),
            |e: &mut ForgeEvent| e.pull = 8,
            |e: &mut ForgeEvent| e.repo = "other/repo".into(),
            |e: &mut ForgeEvent| e.at_unix_ms += 1,
        ] {
            let mut tampered = signed.clone();
            mutate(&mut tampered.event);
            assert_eq!(
                verify_event(&tampered, alice.public_key()),
                Err(EventProblem::NotVerified),
                "a field moved under the signature: {:?}",
                tampered.event
            );
        }
    }

    /// A signature made under another namespace is refused — so a **commit**
    /// signature can never be presented as an approval.
    #[test]
    fn a_commit_signature_cannot_be_presented_as_an_approval() {
        let alice = key();
        let event = event(
            1,
            "alice",
            1,
            Event::Approved {
                commit: "c".repeat(40),
            },
        );
        let elsewhere = alice
            .sign("git", ssh_key::HashAlg::Sha512, &event.signing_bytes())
            .expect("sign");
        let smuggled = SignedEvent {
            key_fingerprint: alice
                .public_key()
                .fingerprint(ssh_key::HashAlg::Sha256)
                .to_string(),
            signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
            event,
            attested: None,
        };
        assert!(matches!(
            verify_event(&smuggled, alice.public_key()),
            Err(EventProblem::Malformed(_))
        ));
    }

    /// **A comment with a newline cannot forge a field.** The body is
    /// length-prefixed, so text that looks like `\nactor mallory` is text.
    #[test]
    fn a_body_cannot_smuggle_a_field_past_the_signature() {
        let honest = event(1, "alice", 1, plain("looks fine"));
        let sneaky = event(1, "alice", 1, plain("looks fine\nactor mallory"));
        assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
        // And the length prefix is what does it: the two bodies differ in
        // length, so no padding can make them agree.
        let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
        assert!(
            text.contains("body 24 looks fine\nactor mallory"),
            "the body must be length-prefixed: {text}"
        );
    }

    /// The fold is the review, and it answers **who approved what**.
    #[test]
    fn the_fold_tracks_approvals_by_actor_and_by_commit() {
        let old = "1".repeat(40);
        let new = "2".repeat(40);
        let events = vec![
            event(1, "bob", 2, plain("ok")),
            event(
                1,
                "bob",
                3,
                Event::Approved {
                    commit: old.clone(),
                },
            ),
            event(
                1,
                "carol",
                4,
                Event::Approved {
                    commit: old.clone(),
                },
            ),
            // Another pull request's events must not leak into this fold.
            event(
                2,
                "dave",
                5,
                Event::Approved {
                    commit: new.clone(),
                },
            ),
        ];

        let review = fold(1, &events);
        assert_eq!(review.comments, 1);
        let mut approvers = review.approvers_of(&old);
        approvers.sort();
        assert_eq!(approvers, vec!["bob", "carol"]);

        // **A new commit is not approved just because the old one was.**
        assert!(
            review.approvers_of(&new).is_empty(),
            "an approval of an earlier commit was counted for a later one, so a force-push \
             inherits a review"
        );
    }

    /// A withdrawal removes that actor's approval and nobody else's.
    #[test]
    fn a_withdrawal_removes_one_approval_and_leaves_the_others() {
        let commit = "3".repeat(40);
        let events = vec![
            event(
                1,
                "bob",
                2,
                Event::Approved {
                    commit: commit.clone(),
                },
            ),
            event(
                1,
                "carol",
                3,
                Event::Approved {
                    commit: commit.clone(),
                },
            ),
            event(
                1,
                "bob",
                4,
                Event::Rejected {
                    commit: commit.clone(),
                    reason: "second thoughts".into(),
                },
            ),
        ];
        assert_eq!(fold(1, &events).approvers_of(&commit), vec!["carol"]);
    }

    /// **A pull request nobody has reviewed folds to an empty review, not to
    /// nothing.**
    ///
    /// Whether the pull request exists is `crate::pulls`'s question. Answering
    /// `None` here would be this module having an opinion about a record it
    /// does not own — and "nobody has approved it" is the answer a merge gate
    /// needs, which is not the same as "there is no such pull request".
    #[test]
    fn an_unreviewed_pull_folds_to_an_empty_review() {
        let none = fold(9, &[]);
        assert_eq!(none.pull, 9);
        assert!(none.approvals.is_empty());
        assert_eq!(none.comments, 0);
        assert!(none.approvers_of(&"4".repeat(40)).is_empty());
    }

    /// **An issue opened is evidence**: signed by its author, over the exact
    /// title and body, every field bound. The mutation loop is what makes this a
    /// guard and not a round-trip — break any field and the signature must fail.
    #[test]
    fn an_issue_open_verifies_and_no_field_can_be_moved_under_it() {
        let alice = key();
        let mallory = key();
        let signed = sign_event(
            &alice,
            event(
                // `pull` is 0 for an opening: the number does not exist yet.
                0,
                "alice",
                1_700_000_000_000,
                Event::IssueOpened {
                    title: "the installer wedges on a 0600 kernel".into(),
                    body: "forge says no kernel to bundle".into(),
                },
            ),
        )
        .expect("sign");
        assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
        // A different key is not this author.
        assert!(matches!(
            verify_event(&signed, mallory.public_key()),
            Err(EventProblem::WrongKey { .. })
        ));

        for mutate in [
            |e: &mut ForgeEvent| {
                e.event = Event::IssueOpened {
                    title: "something else entirely".into(),
                    body: "forge says no kernel to bundle".into(),
                }
            },
            |e: &mut ForgeEvent| {
                e.event = Event::IssueOpened {
                    title: "the installer wedges on a 0600 kernel".into(),
                    body: "a body nobody wrote".into(),
                }
            },
            |e: &mut ForgeEvent| e.actor = "mallory".into(),
            |e: &mut ForgeEvent| e.repo = "other/repo".into(),
        ] {
            let mut tampered = signed.clone();
            mutate(&mut tampered.event);
            assert_eq!(
                verify_event(&tampered, alice.public_key()),
                Err(EventProblem::NotVerified),
                "a field moved under the signature: {:?}",
                tampered.event
            );
        }
    }

    /// **The fold is the issue**: numbering is positional, a close shuts it, a
    /// comment on a number nobody opened is dropped, and one issue's comment
    /// does not land on another.
    #[test]
    fn issues_fold_by_position_and_a_close_shuts_the_right_one() {
        let events = vec![
            event(
                0,
                "alice",
                10,
                Event::IssueOpened {
                    title: "first".into(),
                    body: "one".into(),
                },
            ),
            event(
                0,
                "bob",
                11,
                Event::IssueOpened {
                    title: "second".into(),
                    body: "two".into(),
                },
            ),
            event(
                1,
                "carol",
                12,
                Event::IssueCommented {
                    body: "on #1".into(),
                },
            ),
            event(
                2,
                "carol",
                13,
                Event::IssueCommented {
                    body: "on #2".into(),
                },
            ),
            event(
                2,
                "dave",
                14,
                Event::IssueCommented {
                    body: "also #2".into(),
                },
            ),
            // A comment on an issue nobody opened is evidence about nothing.
            event(
                9,
                "eve",
                15,
                Event::IssueCommented {
                    body: "ghost".into(),
                },
            ),
            event(
                1,
                "alice",
                16,
                Event::IssueClosed {
                    reason: "fixed".into(),
                },
            ),
        ];

        let issues = fold_issues(&events);
        assert_eq!(issues.len(), 2, "two openings, two issues");

        let one = fold_issue(1, &events).expect("issue 1");
        assert_eq!(one.number, 1);
        assert_eq!(one.title, "first");
        assert_eq!(one.author, "alice");
        assert_eq!(one.comments, 1, "only #1's own comment counts for #1");
        assert!(!one.open, "an IssueClosed shuts it");

        let two = fold_issue(2, &events).expect("issue 2");
        assert_eq!(two.comments, 2);
        assert!(two.open, "closing #1 must not touch #2");

        // A number nobody opened is None — this fold IS the authority for
        // whether the issue exists, so it says so.
        assert!(fold_issue(9, &events).is_none());
    }

    /// **A review event never leaks into an issue, and the reverse.** They share
    /// the subject number but not the kind, and the kind is what the fold keys
    /// on — so a pull request #1 comment cannot inflate issue #1.
    #[test]
    fn a_review_comment_is_not_an_issue_comment() {
        let events = vec![
            event(
                0,
                "alice",
                1,
                Event::IssueOpened {
                    title: "an issue".into(),
                    body: "b".into(),
                },
            ),
            // Same number as the issue, but a REVIEW comment.
            event(1, "bob", 2, plain("on the PR")),
        ];
        assert_eq!(fold_issue(1, &events).expect("issue 1").comments, 0);
        // And the review fold sees its own comment but NOT the issue opening:
        // one review comment, and the IssueOpened contributes nothing to it.
        assert_eq!(fold(1, &events).comments, 1);
    }

    /// **An inline comment follows the blob, not the pull request.**
    ///
    /// The comment is made on `src/lib.rs` at line 42, against the blob that
    /// file had when it was reviewed. An unrelated commit later in the pull
    /// request changes the head but leaves `src/lib.rs` — and therefore its blob
    /// oid — alone, so the comment is still on line 42. Rewrite the file and its
    /// blob changes, so the comment no longer attaches: the line it named is
    /// gone. A matcher keyed on the pull number would have kept it on the
    /// rewritten file, which is the bug this anchoring exists to avoid.
    #[test]
    fn an_inline_comment_follows_the_blob_not_the_pull() {
        let reviewed = "a".repeat(40);
        let rewritten = "b".repeat(40);
        let events = vec![
            event(
                1,
                "alice",
                10,
                Event::Commented {
                    body: "off-by-one here".into(),
                    path: "src/lib.rs".into(),
                    line: 42,
                    blob_oid: reviewed.clone(),
                },
            ),
            // A per-pull comment must not be mistaken for an inline one: it has
            // no line to sit on.
            event(1, "bob", 11, plain("LGTM overall")),
            // Another pull request's inline comment must not leak in.
            event(
                2,
                "carol",
                12,
                Event::Commented {
                    body: "different pr".into(),
                    path: "src/lib.rs".into(),
                    line: 42,
                    blob_oid: reviewed.clone(),
                },
            ),
        ];

        let inline = inline_comments(1, &events);
        assert_eq!(
            inline.len(),
            1,
            "the per-pull comment and the other pull's comment must not appear as this pull's \
             inline comments"
        );

        // The file was untouched by the unrelated commit: same blob, so the
        // comment is still on its line, with its exact author and body.
        let stays = comments_on_blob(&inline, "src/lib.rs", &reviewed);
        assert_eq!(stays.len(), 1);
        assert_eq!(stays[0].line, 42);
        assert_eq!(stays[0].actor, "alice");
        assert_eq!(stays[0].body, "off-by-one here");

        // The file was rewritten: a different blob oid. The comment does NOT
        // attach — anchoring to the pull number would have wrongly shown it on
        // a file version it was never made on.
        assert!(
            comments_on_blob(&inline, "src/lib.rs", &rewritten).is_empty(),
            "the comment attached to a blob it was never made on: it is keyed on the pull, not \
             the code"
        );
    }

    /// **Every field of the inline anchor is bound by the signature**, and a
    /// per-pull comment's bytes are unchanged from before the anchor existed.
    #[test]
    fn an_inline_anchor_is_bound_and_a_plain_comment_is_unchanged() {
        let base = event(
            1,
            "alice",
            1,
            Event::Commented {
                body: "here".into(),
                path: "a.rs".into(),
                line: 5,
                blob_oid: "a".repeat(40),
            },
        );
        for mutate in [
            |e: &mut ForgeEvent| {
                if let Event::Commented { path, .. } = &mut e.event {
                    *path = "b.rs".into();
                }
            },
            |e: &mut ForgeEvent| {
                if let Event::Commented { line, .. } = &mut e.event {
                    *line = 6;
                }
            },
            |e: &mut ForgeEvent| {
                if let Event::Commented { blob_oid, .. } = &mut e.event {
                    *blob_oid = "b".repeat(40);
                }
            },
        ] {
            let mut moved = base.clone();
            mutate(&mut moved);
            assert_ne!(
                base.signing_bytes(),
                moved.signing_bytes(),
                "an inline anchor field moved without changing what is signed: {:?}",
                moved.event
            );
        }

        // A per-pull comment signs exactly what it did before inline comments
        // were a thing: `body` and nothing after it.
        let text =
            String::from_utf8(event(1, "alice", 1, plain("hi")).signing_bytes()).expect("utf-8");
        assert!(
            text.ends_with("kind commented\nbody 2 hi\n"),
            "a per-pull comment's signed bytes changed: {text}"
        );
    }
}