git-ents.gitmain
⌘K
foforge
commit 585e1a4
receive: split Identity into signing committer and attributed author

Identity.actor stays the committer and signer; a new optional author field carries an attributed member (receive.attributed-author), so a hosted web edit lands as "member via the web" while the gate keeps judging the signature alone. author: None at every existing construction site, a signed_commit test proving the override lands in the author slot only, and rustfmt on layering.rs.

Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-lens/src/lens.rs @@ -263,6 +263,7 @@ let sign = |payload: &[u8]| signer.sign(payload); let identity = Identity { actor: signer.actor(), + author: None, sign: &sign, }; comment::resolve( @@ -394,6 +395,7 @@ let sign = |payload: &[u8]| signer.sign(payload); let identity = Identity { actor: signer.actor(), + author: None, sign: &sign, }; if let Some(parent) = composed.target.parent {
crates/cli/ents-lens/tests/lens.rs @@ -88,6 +88,7 @@ let sign = |payload: &[u8]| key.sign(payload); let identity = Identity { actor: self.actor(), + author: None, sign: &sign, }; let (id, _outcome) = comment::add( @@ -203,6 +204,7 @@ let sign = |payload: &[u8]| key.sign(payload); let identity = Identity { actor: fixture.actor(), + author: None, sign: &sign, }; comment::reply(
crates/cli/git-ents/src/lib.rs @@ -61,7 +61,7 @@ //! name: "jdc".into(), email: "jdc@ents.test".into(), //! time: gix::date::Time { seconds: 1_000, offset: 0 }, //! }; -//! let identity = Identity { actor, sign: &|payload| signer.sign(payload) }; +//! let identity = Identity { actor, author: None, sign: &|payload| signer.sign(payload) }; //! //! let member = ents_model::Member::new("jdc", signer.public_openssh(), Provenance::AdminRegistered); //! let name = ents_model::namespace::member_ref(&MemberId::new("jdc")).expect("valid");
crates/cli/git-ents/tests/layering.rs @@ -162,7 +162,10 @@ continue; } assert!( - package.dependencies.iter().all(|dependency| dependency.name != "ents-verify"), + package + .dependencies + .iter() + .all(|dependency| dependency.name != "ents-verify"), "{} must not depend on ents-verify: verify/ is a sink layer nothing else may depend on", package.name );
crates/forge/ents-forge/tests/conversations.rs @@ -72,6 +72,7 @@ offset: 0, }, }, + author: None, sign: &*self.sign, } }
crates/kernel/ents-receive/src/propose.rs @@ -35,11 +35,18 @@ use crate::sink::EventSink; /// Everything [`propose_entity`] needs about the acting identity: the -/// commit author/committer signature and a signing function producing the -/// `gpgsig` header's armored payload. +/// commit committer signature, an optional attributed author, and a +/// signing function producing the `gpgsig` header's armored payload. +// @relation(receive.attributed-author, scope=type) pub struct Identity<'a> { - /// The author and committer signature every mutation commit carries. + /// The committer signature every mutation commit carries — always the + /// signing identity (`receive.attributed-author`). pub actor: gix::actor::Signature, + /// A distinct attributed author, so history reads "member via the + /// web" (`receive.attributed-author`, `roots.web-signing`); `None` + /// means the author is `actor`, the unattributed common case. The + /// gate keys authorization off the signer, never this field. + pub author: Option<gix::actor::Signature>, /// Signs a commit payload, returning the armored SSHSIG PEM block git /// stores in the `gpgsig` header — a closure rather than a concrete /// signer type so this crate never depends on how a caller loads or @@ -92,6 +99,7 @@ /// email: "admin@ents.test".into(), /// time: gix::date::Time { seconds: 300, offset: 0 }, /// }, +/// author: None, /// sign: &|payload| admin.sign(payload), /// }; /// @@ -195,6 +203,7 @@ /// email: "admin@ents.test".into(), /// time: gix::date::Time { seconds: 300, offset: 0 }, /// }, +/// author: None, /// sign: &|payload| admin.sign(payload), /// }; /// @@ -288,6 +297,7 @@ /// email: "admin@ents.test".into(), /// time: gix::date::Time { seconds: 300, offset: 0 }, /// }, +/// author: None, /// sign: &|payload| admin.sign(payload), /// }; /// @@ -393,6 +403,7 @@ /// email: "admin@ents.test".into(), /// time: gix::date::Time { seconds: 300, offset: 0 }, /// }, +/// author: None, /// sign: &|payload| admin.sign(payload), /// }; /// @@ -466,7 +477,10 @@ let mut commit = Commit { tree, parents: parents.into(), - author: identity.actor.clone(), + author: identity + .author + .clone() + .unwrap_or_else(|| identity.actor.clone()), committer: identity.actor.clone(), encoding: None, message: message.into(),
crates/kernel/ents-receive/tests/receive.rs @@ -454,6 +454,7 @@ offset: 0, }, }, + author: None, sign: &|payload| forge.admin.sign(payload), }; let rounds = chain_commits(&forge.objects, 2, 250); @@ -530,6 +531,7 @@ offset: 0, }, }, + author: None, sign: &|payload| forge.guest.sign(payload), }; let reviewed = chain_commits(&forge.objects, 1, 250)[0]; @@ -675,6 +677,7 @@ offset: 0, }, }, + author: None, sign: &|payload| forge.guest.sign(payload), }; @@ -699,3 +702,67 @@ ); assert!(forge.refs.get(landed.as_ref()).expect("readable").is_some()); } + +/// An attributed mutation ("member via the web") carries the attributed +/// member in the commit's author slot while the committer — and the +/// signature the gate judges — stays the signing identity; the mandatory +/// gate admits it exactly as it would the unattributed form +/// (`receive.attributed-author`). +// @relation(receive.attributed-author, scope=function, role=Verifies) +#[test] +fn an_attributed_author_lands_in_the_author_slot_and_the_gate_judges_the_signer() { + use gix_object::Find as _; + + let forge = forge(); + let identity = Identity { + actor: gix::actor::Signature { + name: "admin".into(), + email: "admin@ents.test".into(), + time: gix::date::Time { + seconds: 300, + offset: 0, + }, + }, + author: Some(gix::actor::Signature { + name: "guest".into(), + email: "guest@ents.test".into(), + time: gix::date::Time { + seconds: 290, + offset: 0, + }, + }), + sign: &|payload| forge.admin.sign(payload), + }; + let refname = namespace::redaction_ref("r-attr").expect("valid"); + let redaction = Redaction::new(ObjectId::null(gix_hash::Kind::Sha1), "leaked credential"); + + let outcome = ents_receive::propose_entity( + &forge.refs, + &forge.objects, + &NullEventSink, + refname.clone(), + &redaction, + &identity, + "Redact, attributed", + Mode::Mandatory, + ) + .expect("reaches an outcome"); + assert_eq!(outcome.result, TxResult::Applied, "{:?}", outcome.verdicts); + + let tip = forge + .refs + .get(refname.as_ref()) + .expect("readable") + .expect("written"); + let mut buf = Vec::new(); + let data = forge + .objects + .try_find(&tip, &mut buf) + .expect("readable") + .expect("present"); + let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses"); + let author = commit.author().expect("author parses"); + let committer = commit.committer().expect("committer parses"); + assert_eq!(author.name, "guest", "attributed author"); + assert_eq!(committer.name, "admin", "signing committer"); +}
crates/cli/git-ents/src/commands/account.rs @@ -76,6 +76,7 @@ .expect("fixed, valid refname"); let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = propose_entity(
crates/cli/git-ents/src/commands/comment.rs @@ -59,6 +59,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let (id, outcome) = comment::add( @@ -89,6 +90,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let (id, outcome) = comment::reply( @@ -119,6 +121,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = if resolve {
crates/cli/git-ents/src/commands/effect.rs @@ -65,6 +65,7 @@ let ref_name = namespace::effect_ref(name)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = propose_entity(
crates/cli/git-ents/src/commands/issue.rs @@ -64,6 +64,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let new = NewIssue { @@ -103,6 +104,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let edit = EditIssue {
crates/cli/git-ents/src/commands/redact.rs @@ -60,6 +60,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = propose_entity(
crates/cli/git-ents/src/commands/review.rs @@ -33,6 +33,7 @@ let member = reviewer_member_id(root, &signer)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let (target, outcome) = review::new( @@ -65,7 +66,9 @@ /// Propagates a ref-store or object read failure. fn reviewer_member_id(root: &LocalRoot, signer: &crate::sign::Signer) -> Result<MemberId> { let pubkey = signer.public_openssh(); - if let Some((username, _state)) = super::members::find_by_key(root, &pubkey)? { + if let Some((username, _state)) = + super::members::find_by_key(&root.refs, &root.objects, &pubkey)? + { return Ok(MemberId::new(username)); } Ok(MemberId::new(super::short_fingerprint(signer)))
crates/cli/git-ents/src/commands/toolchain.rs @@ -37,6 +37,7 @@ let signer = signer(root, key)?; let identity = Identity { actor: actor(&signer), + author: None, sign: &|payload| signer.sign(payload), }; let outcome = toolchain::import(