Skip to main content

gunnar_core/
forge.rs

1//! **The review half of a pull request: its evidence** (roadmap phase 1).
2//!
3//! Comments and approvals, as **signed append-only events**.
4//!
5//! The roadmap's claim about this phase is not "gunnar grows issues". It is:
6//!
7//! > Every forge event … is an sshsig-signed row under the principal's own key.
8//! > An approval therefore carries the same evidential weight as a commit — and
9//! > becomes a link in the chain: the artifact can show who approved the change.
10//!
11//! That is what this module is. An approval signed by the reviewer's own key is
12//! evidence in exactly the sense a signed commit is: it can be checked by
13//! anybody, later, without trusting the server that stored it. A row in a table
14//! saying `approved_by = alice` is not — it is the server's word.
15//!
16//! # This module does NOT own the pull request
17//!
18//! [`crate::pulls`] does. The record there owns the number, the source and
19//! target refs, the state and the merge oid, under the control store's CAS —
20//! and it is the authority for all of them. This module owns exactly one thing:
21//! **what people said about that pull request**, as signatures.
22//!
23//! The split is one implementation applied to a question with two halves. "Which pull
24//! request is this, and has it merged" is a fact about an object, and an object
25//! needs one writer and a compare-and-swap. "Who approved commit `abc`" is a
26//! claim by a person, and a claim needs a signature, because the value of the
27//! answer is that a third party can check it without trusting this server. A
28//! row saying `approved_by = alice` is the server's word; an `sshsig` is
29//! alice's.
30//!
31//! So [`ForgeEvent::pull`] IS the pull request number. There is no separate
32//! numbering here and no `Opened`, `Merged` or `Closed` event: those are
33//! transitions of the record, and a second copy of them in this log would be a
34//! twin that could disagree with it.
35//!
36//! # Events, not state
37//!
38//! What is signed is what happened, and the current review state is folded from
39//! the events ([`fold`]) rather than stored. Two readers of the same log always
40//! agree, and a state nobody can point at an event for cannot exist.
41//!
42//! It also makes the audit question answerable, which a mutable row does not:
43//! *who approved this, and what exactly did they approve* — the approval names
44//! the commit it approved, so approving and then force-pushing produces an
45//! approval that no longer covers the tip, visibly. `merge_pull` refuses on
46//! exactly that, which is what turns the property into a gate rather than a
47//! display.
48//!
49//! # Where it sits in the chain
50//!
51//! Between links 2 and 3. A commit is signed by its author; the push that
52//! landed it is attested; and an **approval** is a third signature over the
53//! same commit by somebody else. A verifier that wants "was this reviewed"
54//! asks this log, and gets an answer it can check itself.
55
56/// The `sshsig` namespace a forge event is signed under.
57///
58/// Its own namespace, like every other signature in this repository: a review
59/// approval must never be presentable as a commit signature or a tree head, and
60/// `sshsig` binds the namespace into what is signed.
61pub const FORGE_NAMESPACE: &str = "gunnar-forge";
62
63/// What happened.
64///
65/// A closed set on purpose. An open one — a `kind: String` — would let a client
66/// invent an event nobody can fold, and a log that holds events its reader does
67/// not understand is a log whose state depends on who is reading it.
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum Event {
71    /// A comment on a pull request, or — when [`path`](Event::Commented::path)
72    /// is set — on ONE line of ONE file version.
73    ///
74    /// # The anchor follows the code, not the pull request
75    ///
76    /// An empty `path` is the original per-pull-request comment, and its signed
77    /// bytes are byte-for-byte what they were before the anchor existed. A
78    /// non-empty `path` anchors the comment to `(path, line, blob_oid)`, and the
79    /// blob is the load-bearing field: it names the exact file version the line
80    /// lived in. A rebase that does not touch the file leaves that blob's oid
81    /// unchanged, so the comment stays on its line; a rebase that rewrites the
82    /// file gives it a new blob, so the comment stops matching and the line it
83    /// was about is visibly gone. Anchoring to the pull request number instead
84    /// would carry the comment onto a rewritten file it was never made on.
85    Commented {
86        /// What was said.
87        body: String,
88        /// The file the comment is on, as a path within the tree. Empty for a
89        /// comment on the pull request as a whole.
90        #[serde(default)]
91        path: String,
92        /// The one-based line number within `blob_oid`.
93        #[serde(default)]
94        line: u32,
95        /// The full hex oid of the blob the commented line lived in.
96        #[serde(default)]
97        blob_oid: String,
98    },
99    /// **An approval of a specific commit.**
100    ///
101    /// The commit is part of what is signed, so an approval cannot silently
102    /// come to cover a different tip: force-push after a review and the
103    /// approval still names the commit that was reviewed, which is exactly the
104    /// fact an auditor needs and the one a mutable `approved: bool` destroys.
105    Approved {
106        /// The commit that was reviewed.
107        commit: String,
108    },
109    /// A withdrawal of an earlier approval.
110    Rejected {
111        /// The commit the withdrawn approval named.
112        commit: String,
113        /// Why.
114        reason: String,
115    },
116    /// **An issue opened.**
117    ///
118    /// The one place the forge grows a state machine of its own, because an
119    /// issue — unlike a pull request's review — has no [`crate::pulls`] record
120    /// that owns whether it is open. The issue IS the fold of its events, and
121    /// this is the event that says it exists. Its number is not signed: it is
122    /// [`fold_issues`]'s to assign, so that two readers of the same log agree on
123    /// it the way they agree on everything else here.
124    ///
125    /// That the OPENING is signed at all is the property GitHub, GitLab and
126    /// Gitea have no equivalent for: who filed this is the actor's own `sshsig`.
127    IssueOpened {
128        /// A one-line summary.
129        title: String,
130        /// The body, as filed.
131        body: String,
132    },
133    /// A comment on an issue.
134    ///
135    /// The issue number is [`ForgeEvent::pull`] — the subject the event is
136    /// about — so a comment is bound by the signature to its thread.
137    IssueCommented {
138        /// What was said.
139        body: String,
140    },
141    /// **An issue closed**, with the reason it was.
142    ///
143    /// Appended, never deleted: what the fold shows is that the issue is shut,
144    /// and the close — the reason with it — stays readable, which is the fact an
145    /// auditor wants.
146    IssueClosed {
147        /// Why it was closed.
148        reason: String,
149    },
150}
151
152impl Event {
153    fn tag(&self) -> &'static str {
154        match self {
155            Event::Commented { .. } => "commented",
156            Event::Approved { .. } => "approved",
157            Event::Rejected { .. } => "rejected",
158            Event::IssueOpened { .. } => "issue_opened",
159            Event::IssueCommented { .. } => "issue_commented",
160            Event::IssueClosed { .. } => "issue_closed",
161        }
162    }
163}
164
165/// One event on one pull request, before it is signed.
166#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct ForgeEvent {
169    /// `<namespace>/<repo>` this belongs to.
170    pub repo: String,
171    /// **Which pull request** — [`crate::pulls::PullRecord::number`].
172    ///
173    /// Assigned by `PullRequests.Create`, not here: the number is inside the
174    /// signature, so it has to be known before an event is signed, and a second
175    /// numbering authority is exactly the twin this design avoids.
176    pub pull: u64,
177    /// The ACL principal that did it.
178    pub actor: String,
179    /// When.
180    pub at_unix_ms: i64,
181    /// What.
182    pub event: Event,
183}
184
185impl ForgeEvent {
186    /// The exact bytes that are signed.
187    ///
188    /// Line-oriented and fixed-order, for the same reason a tree head and a
189    /// build record are: JSON has no canonical form, so signing a serialisation
190    /// makes the signature depend on the serialiser. The body is length-prefixed
191    /// rather than escaped, so a comment containing a newline cannot be made to
192    /// look like two fields.
193    pub fn signing_bytes(&self) -> Vec<u8> {
194        let mut out = String::new();
195        out.push_str("gunnar-forge-v1\n");
196        out.push_str(&format!("repo {}\n", self.repo));
197        out.push_str(&format!("pull {}\n", self.pull));
198        out.push_str(&format!("actor {}\n", self.actor));
199        out.push_str(&format!("at {}\n", self.at_unix_ms));
200        out.push_str(&format!("kind {}\n", self.event.tag()));
201        let mut field = |name: &str, value: &str| {
202            out.push_str(&format!("{name} {} {value}\n", value.len()));
203        };
204        match &self.event {
205            Event::Commented {
206                body,
207                path,
208                line,
209                blob_oid,
210            } => {
211                field("body", body);
212                // The anchor is bound only when it exists, so a per-pull comment
213                // signs exactly the bytes it did before inline comments were a
214                // thing. A comment with a path binds all three fields, each
215                // length-prefixed like `body`, so none can be moved under the
216                // signature and text in one cannot be read as another.
217                if !path.is_empty() {
218                    field("path", path);
219                    field("line", &line.to_string());
220                    field("blob", blob_oid);
221                }
222            }
223            Event::Approved { commit } => field("commit", commit),
224            Event::Rejected { commit, reason } => {
225                field("commit", commit);
226                field("reason", reason);
227            }
228            Event::IssueOpened { title, body } => {
229                field("title", title);
230                field("body", body);
231            }
232            Event::IssueCommented { body } => field("body", body),
233            Event::IssueClosed { reason } => field("reason", reason),
234        }
235        out.into_bytes()
236    }
237}
238
239/// A [`ForgeEvent`] with the signature that makes it evidence.
240#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct SignedEvent {
243    /// The event the signature is over.
244    #[serde(flatten)]
245    pub event: ForgeEvent,
246    /// `SHA256:…` of the actor's key — or, on an [`attested`](SignedEvent::attested)
247    /// event, of gunnar's own forge-attestation key.
248    pub key_fingerprint: String,
249    /// The `sshsig`, PEM-armoured.
250    pub signature: String,
251    /// **Gunnar's attestation** (GHUNT-19), on an event a person made from a
252    /// session that stepped up, which holds no key of theirs to sign with:
253    /// gunnar verified that session's fresh touch of the person's own passkey and
254    /// signed the event with its own key, naming the actor. `None` on an event
255    /// the actor signed with their own key — byte for byte what it was before.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub attested: Option<Attested>,
258}
259
260/// **What gunnar attests** about an event it signed for a person's session:
261/// the session had stepped up (a fresh, user-verified touch of the person's own
262/// passkey, within the identity core's window) at this time. Inside the
263/// signature, so it cannot be moved onto another event or taken off this one.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct Attested {
267    /// When the session stepped up, Unix milliseconds.
268    pub session_stepped_up_at_unix_ms: i64,
269}
270
271impl Attested {
272    /// The words an operator reads next to an attested event.
273    pub fn words(&self) -> String {
274        format!(
275            "attested for session step-up at {}",
276            crate::flow::iso8601_utc(self.session_stepped_up_at_unix_ms)
277        )
278    }
279}
280
281/// The exact bytes a signed event's signature is over: the event's own bytes,
282/// and gunnar's attestation line when there is one.
283fn signed_bytes(event: &ForgeEvent, attested: Option<&Attested>) -> Vec<u8> {
284    let mut out = event.signing_bytes();
285    if let Some(attested) = attested {
286        out.extend_from_slice(
287            format!(
288                "attested session-step-up {}\n",
289                attested.session_stepped_up_at_unix_ms
290            )
291            .as_bytes(),
292        );
293    }
294    out
295}
296
297/// Why a forge event did not verify.
298///
299/// The one shared [`crate::sshsig::EventProblem`], re-exported here so
300/// `forge::EventProblem` still names it: the forge log and the membership log
301/// verify through one function and so they report a refusal in one vocabulary
302/// (one implementation).
303pub use crate::sshsig::EventProblem;
304
305/// Sign a forge event with the actor's own key.
306///
307/// Through the one signer every signed log in this crate routes through
308/// ([`crate::sshsig::sign`]): the namespace is bound into the signature there,
309/// so a forge approval can never be presented under another log's namespace.
310pub fn sign_event(
311    key: &ssh_key::PrivateKey,
312    event: ForgeEvent,
313) -> Result<SignedEvent, ssh_key::Error> {
314    let (key_fingerprint, signature) =
315        crate::sshsig::sign(key, FORGE_NAMESPACE, &event.signing_bytes())?;
316    Ok(SignedEvent {
317        key_fingerprint,
318        signature,
319        event,
320        attested: None,
321    })
322}
323
324/// **Gunnar signs a person's event with its own key, and says why** (GHUNT-19):
325/// `key` is gunnar's forge-attestation key, never a person's, and the
326/// attestation is inside the signature.
327pub fn attest_event(
328    key: &ssh_key::PrivateKey,
329    event: ForgeEvent,
330    attested: Attested,
331) -> Result<SignedEvent, ssh_key::Error> {
332    let (key_fingerprint, signature) =
333        crate::sshsig::sign(key, FORGE_NAMESPACE, &signed_bytes(&event, Some(&attested)))?;
334    Ok(SignedEvent {
335        key_fingerprint,
336        signature,
337        event,
338        attested: Some(attested),
339    })
340}
341
342/// Verify a signed forge event against a public key (gunnar's attestation
343/// included, when the event carries one).
344pub fn verify_event(signed: &SignedEvent, key: &ssh_key::PublicKey) -> Result<(), EventProblem> {
345    crate::sshsig::verify(
346        key,
347        FORGE_NAMESPACE,
348        &signed_bytes(&signed.event, signed.attested.as_ref()),
349        &signed.key_fingerprint,
350        &signed.signature,
351    )
352}
353
354/// **The review on one pull request**, right now.
355///
356/// Folded, never stored. This is deliberately NOT the pull request: the title,
357/// the refs, whether it is open and what it merged at all live in
358/// [`crate::pulls::PullRecord`], which is their one authority. What is here is
359/// only what cannot be a stored row without losing its value — who approves
360/// what, and how many people have spoken.
361#[derive(Debug, Clone, PartialEq, Eq, Default)]
362pub struct Review {
363    /// The pull request this review belongs to.
364    pub pull: u64,
365    /// Who currently approves which commit.
366    ///
367    /// A map and not a count: *whose* approval, and **of what**, are the two
368    /// things an auditor asks, and a count answers neither. A later `Rejected`
369    /// by the same actor removes their entry; a new `Approved` replaces it.
370    pub approvals: std::collections::BTreeMap<String, String>,
371    /// How many comments have been made.
372    pub comments: usize,
373}
374
375impl Review {
376    /// Who approves `commit` right now.
377    ///
378    /// The commit matters: an approval of an earlier tip is **not** an approval
379    /// of this one, and a forge that treated it as one would let a force-push
380    /// inherit a review.
381    pub fn approvers_of(&self, commit: &str) -> Vec<&str> {
382        self.approvals
383            .iter()
384            .filter(|(_, approved)| approved.as_str() == commit)
385            .map(|(actor, _)| actor.as_str())
386            .collect()
387    }
388}
389
390/// Fold one pull request's review events, in order.
391///
392/// Events for other pull requests are ignored, so a caller may hand in a whole
393/// repository's log.
394///
395/// Always returns a [`Review`], even an empty one: whether the pull request
396/// exists is [`crate::pulls`]'s question, and answering `None` here would be
397/// this module having an opinion about that. "Nobody has said anything yet" and
398/// "there is no such pull request" are different facts with different owners.
399pub fn fold(pull: u64, events: &[ForgeEvent]) -> Review {
400    let mut state = Review {
401        pull,
402        ..Review::default()
403    };
404    for event in events.iter().filter(|e| e.pull == pull) {
405        match &event.event {
406            Event::Commented { .. } => state.comments += 1,
407            Event::Approved { commit } => {
408                state.approvals.insert(event.actor.clone(), commit.clone());
409            }
410            Event::Rejected { .. } => {
411                state.approvals.remove(&event.actor);
412            }
413            // Issue events are a different subject with a different fold
414            // ([`fold_issues`]). A review of pull request #7 and issue #7 share
415            // a number but never a state — the kind keeps them apart, so an
416            // issue comment can never inflate a pull request's comment count.
417            Event::IssueOpened { .. }
418            | Event::IssueCommented { .. }
419            | Event::IssueClosed { .. } => {}
420        }
421    }
422    state
423}
424
425/// **One issue**, folded from its events.
426///
427/// Unlike [`Review`], this owns its whole state — the title, the body, whether
428/// it is open — because unlike a review there is no [`crate::pulls`] record
429/// behind it. An issue is nothing but the fold of its signed events, so the
430/// fold is the only authority for what it says.
431#[derive(Debug, Clone, PartialEq, Eq, Default)]
432pub struct Issue {
433    /// The number the fold assigned: the Nth [`Event::IssueOpened`] in the
434    /// repository is issue N.
435    pub number: u64,
436    /// The one-line summary the opener signed.
437    pub title: String,
438    /// The body the opener signed.
439    pub body: String,
440    /// The ACL principal that opened it — the opening event's actor.
441    pub author: String,
442    /// Whether it is open right now. An [`Event::IssueClosed`] shuts it.
443    pub open: bool,
444    /// How many comments have been made.
445    pub comments: usize,
446    /// When it was opened.
447    pub opened_at_unix_ms: i64,
448}
449
450/// **Fold every issue in a repository**, in the order it was opened.
451///
452/// The caller hands in one repository's events, in log order. Numbering is
453/// positional and therefore reproducible: the first [`Event::IssueOpened`] is
454/// issue 1, the second is issue 2, and a comment or close names the number it
455/// is about in [`ForgeEvent::pull`]. An event naming a number nothing opened is
456/// dropped — it is evidence about nothing — the same way [`fold`] drops another
457/// pull request's events.
458///
459/// Review events (`Commented`/`Approved`/`Rejected`) are ignored: they are a
460/// different subject, so a whole repository's forge log may be handed in.
461pub fn fold_issues(events: &[ForgeEvent]) -> Vec<Issue> {
462    let mut issues: Vec<Issue> = Vec::new();
463    for event in events {
464        match &event.event {
465            Event::IssueOpened { title, body } => {
466                let number = issues.len() as u64 + 1;
467                issues.push(Issue {
468                    number,
469                    title: title.clone(),
470                    body: body.clone(),
471                    author: event.actor.clone(),
472                    open: true,
473                    comments: 0,
474                    opened_at_unix_ms: event.at_unix_ms,
475                });
476            }
477            Event::IssueCommented { .. } => {
478                if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
479                    issue.comments += 1;
480                }
481            }
482            Event::IssueClosed { .. } => {
483                if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
484                    issue.open = false;
485                }
486            }
487            Event::Commented { .. } | Event::Approved { .. } | Event::Rejected { .. } => {}
488        }
489    }
490    issues
491}
492
493/// **One issue**, folded, or `None` if nothing opened that number.
494///
495/// `None` here is a real answer and not [`fold`]'s deliberate always-`Some`: a
496/// review folds to an empty review because whether the pull request exists is
497/// [`crate::pulls`]'s question, but whether an issue exists is exactly THIS
498/// fold's question — there is no other owner to ask — so a number nobody opened
499/// is `None`.
500pub fn fold_issue(number: u64, events: &[ForgeEvent]) -> Option<Issue> {
501    fold_issues(events).into_iter().find(|i| i.number == number)
502}
503
504/// One inline comment, extracted from the review log and shaped for rendering
505/// on a diff.
506///
507/// Deliberately carries its own `blob_oid`: the caller matches it against a
508/// file's blob in the diff it is showing, and that match — not the pull
509/// request's head commit — is what puts the comment on the right line.
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct InlineComment {
512    /// The ACL principal who wrote it.
513    pub actor: String,
514    /// The file it is on.
515    pub path: String,
516    /// The one-based line within `blob_oid`.
517    pub line: u32,
518    /// The blob the commented line lived in.
519    pub blob_oid: String,
520    /// What was said.
521    pub body: String,
522    /// When.
523    pub at_unix_ms: i64,
524}
525
526/// Every INLINE comment on one pull request, in event order.
527///
528/// A `Commented` with an empty `path` is a comment on the pull request as a
529/// whole — it is counted by [`fold`] as a comment and does not appear here,
530/// because it has no line to sit on.
531pub fn inline_comments(pull: u64, events: &[ForgeEvent]) -> Vec<InlineComment> {
532    events
533        .iter()
534        .filter(|e| e.pull == pull)
535        .filter_map(|e| match &e.event {
536            Event::Commented {
537                body,
538                path,
539                line,
540                blob_oid,
541            } if !path.is_empty() => Some(InlineComment {
542                actor: e.actor.clone(),
543                path: path.clone(),
544                line: *line,
545                blob_oid: blob_oid.clone(),
546                body: body.clone(),
547                at_unix_ms: e.at_unix_ms,
548            }),
549            _ => None,
550        })
551        .collect()
552}
553
554/// The inline comments that belong on `path` at blob `blob_oid`.
555///
556/// **Matched by the blob, never by the pull request's head.** The whole point
557/// of anchoring a comment to a blob is that the blob is the exact bytes the
558/// line was written against: give this the blob a file has in the diff being
559/// rendered and it returns only the comments that were made on THAT version of
560/// the file. A rebase that left the file alone hands back the same blob and the
561/// comments still attach; a rebase that rewrote the file hands back a different
562/// blob and they do not, because the line they named is gone.
563pub fn comments_on_blob<'a>(
564    comments: &'a [InlineComment],
565    path: &str,
566    blob_oid: &str,
567) -> Vec<&'a InlineComment> {
568    comments
569        .iter()
570        .filter(|c| c.path == path && c.blob_oid == blob_oid)
571        .collect()
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
579        ForgeEvent {
580            repo: "team/repo".into(),
581            pull,
582            actor: actor.into(),
583            at_unix_ms: at,
584            event,
585        }
586    }
587
588    fn key() -> ssh_key::PrivateKey {
589        ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
590    }
591
592    /// A comment on the pull request as a whole — no inline anchor.
593    fn plain(body: &str) -> Event {
594        Event::Commented {
595            body: body.into(),
596            path: String::new(),
597            line: 0,
598            blob_oid: String::new(),
599        }
600    }
601
602    /// ★ **Gunnar's attestation is inside its signature** (GHUNT-19): an
603    /// attested event verifies against gunnar's key, and taking the attestation
604    /// off, moving its time, or re-naming the actor breaks it.
605    #[test]
606    fn an_attested_event_binds_its_attestation_and_its_actor() {
607        let gunnar = key();
608        let attested = attest_event(
609            &gunnar,
610            event(
611                7,
612                "bob",
613                1_700_000_000_000,
614                Event::Approved {
615                    commit: "a".repeat(40),
616                },
617            ),
618            Attested {
619                session_stepped_up_at_unix_ms: 1_699_999_990_000,
620            },
621        )
622        .expect("attest");
623        assert_eq!(verify_event(&attested, gunnar.public_key()), Ok(()));
624        assert_eq!(
625            attested.attested.expect("carried").words(),
626            "attested for session step-up at 2023-11-14T22:13:10Z"
627        );
628
629        let mut stripped = attested.clone();
630        stripped.attested = None;
631        assert!(
632            verify_event(&stripped, gunnar.public_key()).is_err(),
633            "the attestation came off"
634        );
635        let mut moved = attested.clone();
636        moved.attested = Some(Attested {
637            session_stepped_up_at_unix_ms: 1,
638        });
639        assert!(
640            verify_event(&moved, gunnar.public_key()).is_err(),
641            "the step-up time moved"
642        );
643        let mut renamed = attested;
644        renamed.event.actor = "mallory".into();
645        assert!(
646            verify_event(&renamed, gunnar.public_key()).is_err(),
647            "the actor moved"
648        );
649    }
650
651    /// **An approval is evidence**: signed by the reviewer, over the commit
652    /// they reviewed, and every field bound.
653    #[test]
654    fn an_approval_verifies_and_no_field_can_be_moved_under_it() {
655        let alice = key();
656        let mallory = key();
657        let signed = sign_event(
658            &alice,
659            event(
660                7,
661                "alice",
662                1_700_000_000_000,
663                Event::Approved {
664                    commit: "a".repeat(40),
665                },
666            ),
667        )
668        .expect("sign");
669        assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
670        assert!(matches!(
671            verify_event(&signed, mallory.public_key()),
672            Err(EventProblem::WrongKey { .. })
673        ));
674
675        // The commit above all: an approval that can be re-pointed is an
676        // approval a force-push inherits.
677        for mutate in [
678            |e: &mut ForgeEvent| {
679                e.event = Event::Approved {
680                    commit: "b".repeat(40),
681                }
682            },
683            |e: &mut ForgeEvent| e.actor = "mallory".into(),
684            |e: &mut ForgeEvent| e.pull = 8,
685            |e: &mut ForgeEvent| e.repo = "other/repo".into(),
686            |e: &mut ForgeEvent| e.at_unix_ms += 1,
687        ] {
688            let mut tampered = signed.clone();
689            mutate(&mut tampered.event);
690            assert_eq!(
691                verify_event(&tampered, alice.public_key()),
692                Err(EventProblem::NotVerified),
693                "a field moved under the signature: {:?}",
694                tampered.event
695            );
696        }
697    }
698
699    /// A signature made under another namespace is refused — so a **commit**
700    /// signature can never be presented as an approval.
701    #[test]
702    fn a_commit_signature_cannot_be_presented_as_an_approval() {
703        let alice = key();
704        let event = event(
705            1,
706            "alice",
707            1,
708            Event::Approved {
709                commit: "c".repeat(40),
710            },
711        );
712        let elsewhere = alice
713            .sign("git", ssh_key::HashAlg::Sha512, &event.signing_bytes())
714            .expect("sign");
715        let smuggled = SignedEvent {
716            key_fingerprint: alice
717                .public_key()
718                .fingerprint(ssh_key::HashAlg::Sha256)
719                .to_string(),
720            signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
721            event,
722            attested: None,
723        };
724        assert!(matches!(
725            verify_event(&smuggled, alice.public_key()),
726            Err(EventProblem::Malformed(_))
727        ));
728    }
729
730    /// **A comment with a newline cannot forge a field.** The body is
731    /// length-prefixed, so text that looks like `\nactor mallory` is text.
732    #[test]
733    fn a_body_cannot_smuggle_a_field_past_the_signature() {
734        let honest = event(1, "alice", 1, plain("looks fine"));
735        let sneaky = event(1, "alice", 1, plain("looks fine\nactor mallory"));
736        assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
737        // And the length prefix is what does it: the two bodies differ in
738        // length, so no padding can make them agree.
739        let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
740        assert!(
741            text.contains("body 24 looks fine\nactor mallory"),
742            "the body must be length-prefixed: {text}"
743        );
744    }
745
746    /// The fold is the review, and it answers **who approved what**.
747    #[test]
748    fn the_fold_tracks_approvals_by_actor_and_by_commit() {
749        let old = "1".repeat(40);
750        let new = "2".repeat(40);
751        let events = vec![
752            event(1, "bob", 2, plain("ok")),
753            event(
754                1,
755                "bob",
756                3,
757                Event::Approved {
758                    commit: old.clone(),
759                },
760            ),
761            event(
762                1,
763                "carol",
764                4,
765                Event::Approved {
766                    commit: old.clone(),
767                },
768            ),
769            // Another pull request's events must not leak into this fold.
770            event(
771                2,
772                "dave",
773                5,
774                Event::Approved {
775                    commit: new.clone(),
776                },
777            ),
778        ];
779
780        let review = fold(1, &events);
781        assert_eq!(review.comments, 1);
782        let mut approvers = review.approvers_of(&old);
783        approvers.sort();
784        assert_eq!(approvers, vec!["bob", "carol"]);
785
786        // **A new commit is not approved just because the old one was.**
787        assert!(
788            review.approvers_of(&new).is_empty(),
789            "an approval of an earlier commit was counted for a later one, so a force-push \
790             inherits a review"
791        );
792    }
793
794    /// A withdrawal removes that actor's approval and nobody else's.
795    #[test]
796    fn a_withdrawal_removes_one_approval_and_leaves_the_others() {
797        let commit = "3".repeat(40);
798        let events = vec![
799            event(
800                1,
801                "bob",
802                2,
803                Event::Approved {
804                    commit: commit.clone(),
805                },
806            ),
807            event(
808                1,
809                "carol",
810                3,
811                Event::Approved {
812                    commit: commit.clone(),
813                },
814            ),
815            event(
816                1,
817                "bob",
818                4,
819                Event::Rejected {
820                    commit: commit.clone(),
821                    reason: "second thoughts".into(),
822                },
823            ),
824        ];
825        assert_eq!(fold(1, &events).approvers_of(&commit), vec!["carol"]);
826    }
827
828    /// **A pull request nobody has reviewed folds to an empty review, not to
829    /// nothing.**
830    ///
831    /// Whether the pull request exists is `crate::pulls`'s question. Answering
832    /// `None` here would be this module having an opinion about a record it
833    /// does not own — and "nobody has approved it" is the answer a merge gate
834    /// needs, which is not the same as "there is no such pull request".
835    #[test]
836    fn an_unreviewed_pull_folds_to_an_empty_review() {
837        let none = fold(9, &[]);
838        assert_eq!(none.pull, 9);
839        assert!(none.approvals.is_empty());
840        assert_eq!(none.comments, 0);
841        assert!(none.approvers_of(&"4".repeat(40)).is_empty());
842    }
843
844    /// **An issue opened is evidence**: signed by its author, over the exact
845    /// title and body, every field bound. The mutation loop is what makes this a
846    /// guard and not a round-trip — break any field and the signature must fail.
847    #[test]
848    fn an_issue_open_verifies_and_no_field_can_be_moved_under_it() {
849        let alice = key();
850        let mallory = key();
851        let signed = sign_event(
852            &alice,
853            event(
854                // `pull` is 0 for an opening: the number does not exist yet.
855                0,
856                "alice",
857                1_700_000_000_000,
858                Event::IssueOpened {
859                    title: "the installer wedges on a 0600 kernel".into(),
860                    body: "forge says no kernel to bundle".into(),
861                },
862            ),
863        )
864        .expect("sign");
865        assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
866        // A different key is not this author.
867        assert!(matches!(
868            verify_event(&signed, mallory.public_key()),
869            Err(EventProblem::WrongKey { .. })
870        ));
871
872        for mutate in [
873            |e: &mut ForgeEvent| {
874                e.event = Event::IssueOpened {
875                    title: "something else entirely".into(),
876                    body: "forge says no kernel to bundle".into(),
877                }
878            },
879            |e: &mut ForgeEvent| {
880                e.event = Event::IssueOpened {
881                    title: "the installer wedges on a 0600 kernel".into(),
882                    body: "a body nobody wrote".into(),
883                }
884            },
885            |e: &mut ForgeEvent| e.actor = "mallory".into(),
886            |e: &mut ForgeEvent| e.repo = "other/repo".into(),
887        ] {
888            let mut tampered = signed.clone();
889            mutate(&mut tampered.event);
890            assert_eq!(
891                verify_event(&tampered, alice.public_key()),
892                Err(EventProblem::NotVerified),
893                "a field moved under the signature: {:?}",
894                tampered.event
895            );
896        }
897    }
898
899    /// **The fold is the issue**: numbering is positional, a close shuts it, a
900    /// comment on a number nobody opened is dropped, and one issue's comment
901    /// does not land on another.
902    #[test]
903    fn issues_fold_by_position_and_a_close_shuts_the_right_one() {
904        let events = vec![
905            event(
906                0,
907                "alice",
908                10,
909                Event::IssueOpened {
910                    title: "first".into(),
911                    body: "one".into(),
912                },
913            ),
914            event(
915                0,
916                "bob",
917                11,
918                Event::IssueOpened {
919                    title: "second".into(),
920                    body: "two".into(),
921                },
922            ),
923            event(
924                1,
925                "carol",
926                12,
927                Event::IssueCommented {
928                    body: "on #1".into(),
929                },
930            ),
931            event(
932                2,
933                "carol",
934                13,
935                Event::IssueCommented {
936                    body: "on #2".into(),
937                },
938            ),
939            event(
940                2,
941                "dave",
942                14,
943                Event::IssueCommented {
944                    body: "also #2".into(),
945                },
946            ),
947            // A comment on an issue nobody opened is evidence about nothing.
948            event(
949                9,
950                "eve",
951                15,
952                Event::IssueCommented {
953                    body: "ghost".into(),
954                },
955            ),
956            event(
957                1,
958                "alice",
959                16,
960                Event::IssueClosed {
961                    reason: "fixed".into(),
962                },
963            ),
964        ];
965
966        let issues = fold_issues(&events);
967        assert_eq!(issues.len(), 2, "two openings, two issues");
968
969        let one = fold_issue(1, &events).expect("issue 1");
970        assert_eq!(one.number, 1);
971        assert_eq!(one.title, "first");
972        assert_eq!(one.author, "alice");
973        assert_eq!(one.comments, 1, "only #1's own comment counts for #1");
974        assert!(!one.open, "an IssueClosed shuts it");
975
976        let two = fold_issue(2, &events).expect("issue 2");
977        assert_eq!(two.comments, 2);
978        assert!(two.open, "closing #1 must not touch #2");
979
980        // A number nobody opened is None — this fold IS the authority for
981        // whether the issue exists, so it says so.
982        assert!(fold_issue(9, &events).is_none());
983    }
984
985    /// **A review event never leaks into an issue, and the reverse.** They share
986    /// the subject number but not the kind, and the kind is what the fold keys
987    /// on — so a pull request #1 comment cannot inflate issue #1.
988    #[test]
989    fn a_review_comment_is_not_an_issue_comment() {
990        let events = vec![
991            event(
992                0,
993                "alice",
994                1,
995                Event::IssueOpened {
996                    title: "an issue".into(),
997                    body: "b".into(),
998                },
999            ),
1000            // Same number as the issue, but a REVIEW comment.
1001            event(1, "bob", 2, plain("on the PR")),
1002        ];
1003        assert_eq!(fold_issue(1, &events).expect("issue 1").comments, 0);
1004        // And the review fold sees its own comment but NOT the issue opening:
1005        // one review comment, and the IssueOpened contributes nothing to it.
1006        assert_eq!(fold(1, &events).comments, 1);
1007    }
1008
1009    /// **An inline comment follows the blob, not the pull request.**
1010    ///
1011    /// The comment is made on `src/lib.rs` at line 42, against the blob that
1012    /// file had when it was reviewed. An unrelated commit later in the pull
1013    /// request changes the head but leaves `src/lib.rs` — and therefore its blob
1014    /// oid — alone, so the comment is still on line 42. Rewrite the file and its
1015    /// blob changes, so the comment no longer attaches: the line it named is
1016    /// gone. A matcher keyed on the pull number would have kept it on the
1017    /// rewritten file, which is the bug this anchoring exists to avoid.
1018    #[test]
1019    fn an_inline_comment_follows_the_blob_not_the_pull() {
1020        let reviewed = "a".repeat(40);
1021        let rewritten = "b".repeat(40);
1022        let events = vec![
1023            event(
1024                1,
1025                "alice",
1026                10,
1027                Event::Commented {
1028                    body: "off-by-one here".into(),
1029                    path: "src/lib.rs".into(),
1030                    line: 42,
1031                    blob_oid: reviewed.clone(),
1032                },
1033            ),
1034            // A per-pull comment must not be mistaken for an inline one: it has
1035            // no line to sit on.
1036            event(1, "bob", 11, plain("LGTM overall")),
1037            // Another pull request's inline comment must not leak in.
1038            event(
1039                2,
1040                "carol",
1041                12,
1042                Event::Commented {
1043                    body: "different pr".into(),
1044                    path: "src/lib.rs".into(),
1045                    line: 42,
1046                    blob_oid: reviewed.clone(),
1047                },
1048            ),
1049        ];
1050
1051        let inline = inline_comments(1, &events);
1052        assert_eq!(
1053            inline.len(),
1054            1,
1055            "the per-pull comment and the other pull's comment must not appear as this pull's \
1056             inline comments"
1057        );
1058
1059        // The file was untouched by the unrelated commit: same blob, so the
1060        // comment is still on its line, with its exact author and body.
1061        let stays = comments_on_blob(&inline, "src/lib.rs", &reviewed);
1062        assert_eq!(stays.len(), 1);
1063        assert_eq!(stays[0].line, 42);
1064        assert_eq!(stays[0].actor, "alice");
1065        assert_eq!(stays[0].body, "off-by-one here");
1066
1067        // The file was rewritten: a different blob oid. The comment does NOT
1068        // attach — anchoring to the pull number would have wrongly shown it on
1069        // a file version it was never made on.
1070        assert!(
1071            comments_on_blob(&inline, "src/lib.rs", &rewritten).is_empty(),
1072            "the comment attached to a blob it was never made on: it is keyed on the pull, not \
1073             the code"
1074        );
1075    }
1076
1077    /// **Every field of the inline anchor is bound by the signature**, and a
1078    /// per-pull comment's bytes are unchanged from before the anchor existed.
1079    #[test]
1080    fn an_inline_anchor_is_bound_and_a_plain_comment_is_unchanged() {
1081        let base = event(
1082            1,
1083            "alice",
1084            1,
1085            Event::Commented {
1086                body: "here".into(),
1087                path: "a.rs".into(),
1088                line: 5,
1089                blob_oid: "a".repeat(40),
1090            },
1091        );
1092        for mutate in [
1093            |e: &mut ForgeEvent| {
1094                if let Event::Commented { path, .. } = &mut e.event {
1095                    *path = "b.rs".into();
1096                }
1097            },
1098            |e: &mut ForgeEvent| {
1099                if let Event::Commented { line, .. } = &mut e.event {
1100                    *line = 6;
1101                }
1102            },
1103            |e: &mut ForgeEvent| {
1104                if let Event::Commented { blob_oid, .. } = &mut e.event {
1105                    *blob_oid = "b".repeat(40);
1106                }
1107            },
1108        ] {
1109            let mut moved = base.clone();
1110            mutate(&mut moved);
1111            assert_ne!(
1112                base.signing_bytes(),
1113                moved.signing_bytes(),
1114                "an inline anchor field moved without changing what is signed: {:?}",
1115                moved.event
1116            );
1117        }
1118
1119        // A per-pull comment signs exactly what it did before inline comments
1120        // were a thing: `body` and nothing after it.
1121        let text =
1122            String::from_utf8(event(1, "alice", 1, plain("hi")).signing_bytes()).expect("utf-8");
1123        assert!(
1124            text.ends_with("kind commented\nbody 2 hi\n"),
1125            "a per-pull comment's signed bytes changed: {text}"
1126        );
1127    }
1128}