crates/gunnar-ui-server/src/forge.rs
//! The bytes a forge event is signed over, mirrored from gunnar.
//!
//! An approval is not a row the console can ask gunnar to write. gunnar's
//! `Forge` service has no `Sign` RPC on purpose — "a server that could sign an
//! approval is a server that could manufacture one" — so every event arrives
//! already signed by the caller's own key, and `PullRequests.Merge` refuses
//! unless a **verified** approval names the exact commit it is about to
//! fast-forward to.
//!
//! So this is the console's half of that: the canonical signing bytes and the
//! `sshsig` namespace, so [`crate::signer::Signer`] can produce an event
//! gunnar's `verify_event` accepts.
//!
//! # Why it is a copy
//!
//! Same reason the credential constants in [`crate::signer`] are: the crate
//! that owns them (`gunnar-core`, via `gunnar-grpc`) pulls in `gunnar-proto`,
//! whose build script requires `protoc` and pins tonic 0.13. `tests/contract.rs`
//! asserts this copy has not drifted from gunnar's `crates/gunnar-core/src/forge.rs`
//! whenever gunnar is checked out beside this repo.
//!
//! # What the console can and cannot claim
//!
//! gunnar sees exactly one principal here — the key this process signs with —
//! and `Forge.Append` refuses an event whose `actor` is not the caller's own
//! principal. **An approval made from the console is therefore the console's
//! approval**, not the browser user's: the console has no access to a human's
//! ssh key and must not invent one. What the console *can* do is say, inside
//! the same log, which of its own users asked for it — see
//! [`attribution`] — and that is a claim by the console, checkable only as far
//! as the console is trusted. The signature is the console's; the name is its
//! testimony.
/// The `sshsig` namespace a forge event is signed under.
///
/// Its own namespace: a review approval must never be presentable as a commit
/// signature, and `sshsig` binds the namespace into what is signed.
pub const FORGE_NAMESPACE: &str = "gunnar-forge";
/// The first line of the signed bytes — domain separation.
pub const SIGNING_PREAMBLE: &str = "gunnar-forge-v1";
/// What happened, in the closed set gunnar folds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
/// A comment on a pull request, or — when `path` is set — on ONE line of
/// ONE file version.
///
/// An empty `path` is the per-pull 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)` — the blob it was
/// made on, so it follows the code across a rebase that leaves the file
/// alone. Mirrors `gunnar_core::forge::Event::Commented`.
Commented {
body: String,
/// The file the comment is on. Empty for a per-pull comment.
path: String,
/// The one-based line within `blob_oid`.
line: u32,
/// The blob the commented line lived in.
blob_oid: String,
},
/// An approval of a **specific** commit.
Approved { commit: String },
/// **An issue opened.** The console produces these, unlike `Rejected`,
/// because the issues page can file one; the number is the fold's to assign.
IssueOpened { title: String, body: String },
/// A comment on an issue.
IssueCommented { body: String },
/// **An issue closed**, with the reason it was.
IssueClosed { reason: String },
}
impl Event {
fn tag(&self) -> &'static str {
match self {
Event::Commented { .. } => "commented",
Event::Approved { .. } => "approved",
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)]
pub struct ForgeEvent {
/// `<namespace>/<repo>`.
pub repo: String,
/// `PullRequest.number`, assigned by `PullRequests.Create`.
pub pull: u64,
/// The identity-table principal this is attributed to. `Forge.Append` refuses anything
/// but the caller's own.
pub actor: String,
pub at_unix_ms: i64,
pub event: Event,
}
impl ForgeEvent {
/// The exact bytes gunnar verifies against.
///
/// Line-oriented and fixed-order, because JSON has no canonical form. 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(SIGNING_PREAMBLE);
out.push('\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);
if !path.is_empty() {
field("path", path);
field("line", &line.to_string());
field("blob", blob_oid);
}
}
Event::Approved { commit } => field("commit", commit),
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()
}
}
/// The line the console writes into the forge log naming **which of its own
/// users** asked for an approval.
///
/// The approval itself is signed by the console's key and attributed to the
/// console's principal, because that is the only key this process has. Without
/// this line gunnar's log would record that the console approved something and
/// nothing about who drove it, and "who approved this" is the question the
/// whole forge log exists to answer.
///
/// It is deliberately a `Commented` event and not a field on the approval:
/// inventing a field would mean a second review model, and the console's word
/// about its own session is a comment, not evidence.
pub fn attribution(user: &str, role: &str, commit: &str) -> String {
format!(
"Approved from the Gunnar console by {user:?} ({role}) for commit {commit}. This \
approval is signed by the console's own key: the console has no access to \
{user:?}'s ssh key, so the signature is the console's and this line is the \
console's word about who asked for it."
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The exact bytes, spelled out. If gunnar's writer and this one ever
/// disagree by one character, every approval the console files is rejected
/// as "the event's signature does not verify" — a message that reads like a
/// key problem and is not.
#[test]
fn the_signing_bytes_are_what_gunnar_writes() {
let event = ForgeEvent {
repo: "team/proposed".into(),
pull: 7,
actor: "console".into(),
at_unix_ms: 1_700_000_000_000,
event: Event::Approved {
commit: "a".repeat(40),
},
};
assert_eq!(
String::from_utf8(event.signing_bytes()).expect("utf-8"),
format!(
"gunnar-forge-v1\nrepo team/proposed\npull 7\nactor console\nat \
1700000000000\nkind approved\ncommit 40 {}\n",
"a".repeat(40)
)
);
}
/// The issue-open bytes, spelled out. Same drift hazard as the approval:
/// one character apart from gunnar's writer and every issue the console
/// opens is refused as "the event's signature does not verify". `pull` is 0
/// because an opening has no number yet — the fold assigns it.
#[test]
fn the_issue_open_bytes_are_what_gunnar_writes() {
let event = ForgeEvent {
repo: "team/proposed".into(),
pull: 0,
actor: "console".into(),
at_unix_ms: 1_700_000_000_000,
event: Event::IssueOpened {
title: "the boot is dark".into(),
body: "PID 1 to a dead ttyS0".into(),
},
};
assert_eq!(
String::from_utf8(event.signing_bytes()).expect("utf-8"),
"gunnar-forge-v1\nrepo team/proposed\npull 0\nactor console\nat 1700000000000\n\
kind issue_opened\ntitle 16 the boot is dark\nbody 21 PID 1 to a dead ttyS0\n"
);
}
/// A comment's body is length-prefixed, so text that looks like a field is
/// text. The attribution line is operator-supplied through `user`, which is
/// exactly the value that would otherwise be worth smuggling a newline in.
#[test]
fn a_body_cannot_smuggle_a_field_past_the_signature() {
let event = ForgeEvent {
repo: "team/proposed".into(),
pull: 1,
actor: "console".into(),
at_unix_ms: 1,
event: Event::Commented {
body: "looks fine\nactor mallory".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
},
};
let text = String::from_utf8(event.signing_bytes()).expect("utf-8");
assert!(
text.contains("body 24 looks fine\nactor mallory"),
"the body is not length-prefixed: {text}"
);
}
/// **The inline anchor is appended, length-prefixed, only when the comment
/// has a path** — and the exact bytes are spelled out, because this copy and
/// gunnar's `verify_event` must agree to the character or every inline
/// comment the console files is refused as unverifiable.
#[test]
fn an_inline_comment_signs_the_anchor_and_a_plain_one_does_not() {
let base = |event| ForgeEvent {
repo: "team/proposed".into(),
pull: 7,
actor: "console".into(),
at_unix_ms: 1_700_000_000_000,
event,
};
let plain = base(Event::Commented {
body: "hi".into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
});
assert!(
String::from_utf8(plain.signing_bytes())
.expect("utf-8")
.ends_with("kind commented\nbody 2 hi\n"),
"a per-pull comment must sign exactly what it did before the anchor existed"
);
let inline = base(Event::Commented {
body: "hi".into(),
path: "a.rs".into(),
line: 5,
blob_oid: "a".repeat(40),
});
assert_eq!(
String::from_utf8(inline.signing_bytes()).expect("utf-8"),
format!(
"gunnar-forge-v1\nrepo team/proposed\npull 7\nactor console\nat \
1700000000000\nkind commented\nbody 2 hi\npath 4 a.rs\nline 1 5\nblob 40 {}\n",
"a".repeat(40)
)
);
}
/// Every field is inside the signature, so none of them can be moved.
#[test]
fn no_field_can_be_moved_without_changing_the_bytes() {
let base = ForgeEvent {
repo: "team/proposed".into(),
pull: 7,
actor: "console".into(),
at_unix_ms: 1_700_000_000_000,
event: Event::Approved {
commit: "a".repeat(40),
},
};
for mutate in [
|e: &mut ForgeEvent| e.repo = "other/repo".into(),
|e: &mut ForgeEvent| e.pull = 8,
|e: &mut ForgeEvent| e.actor = "mallory".into(),
|e: &mut ForgeEvent| e.at_unix_ms += 1,
|e: &mut ForgeEvent| {
e.event = Event::Approved {
commit: "b".repeat(40),
}
},
] {
let mut moved = base.clone();
mutate(&mut moved);
assert_ne!(
base.signing_bytes(),
moved.signing_bytes(),
"a field moved without changing what is signed: {moved:?}"
);
}
}
/// The attribution names the human, the role and the commit — all three,
/// because a line that says only "approved from the console" answers none
/// of the questions an auditor asks.
#[test]
fn the_attribution_names_the_user_the_role_and_the_commit() {
let line = attribution("bob", "admin", &"c".repeat(40));
assert!(line.contains("bob"));
assert!(line.contains("admin"));
assert!(line.contains(&"c".repeat(40)));
// And it must not read as if bob signed it.
assert!(line.contains("signed by the console's own key"));
}
}