git-ents.gitmain
⌘K
foforge
redaction.rs108 lines · 3.7 KB · rusthistorycomment on this file
1//! The Redaction entity: a record of a yank, not the yanked content.
2//!
3//! Spec coverage: `model.redaction`.
4
5use ents_attrs as ents;
6use facet::Facet;
7use gix_hash::ObjectId;
8
9/// A record that a specific object was redacted, living at
10/// `refs/meta/redactions/<id>` (`namespace::redaction_ref`).
11///
12/// `model.redaction` requires the target's oid and a human-readable
13/// reason, and forbids carrying the redacted content itself — this struct
14/// has no field that could. It also carries no signature field: "the admin
15/// signature authorizing the yank" is the enclosing mutation commit's own
16/// signature (`receive.redaction-admin-only` is enforced there, by
17/// `ents-receive`, phase 4), the same commit-chain-not-tree-field pattern
18/// `model.comment` and `model.member-revocation` already follow.
19///
20/// `target` is stored as a raw 20-byte SHA-1 array — the one primitive
21/// `facet-git-tree`'s byte-sequence encoding supports directly
22/// (`gix_hash::ObjectId` itself has no `Facet` impl) — the same
23/// representation `facet_git_tree::RawTree` uses internally for its own
24/// wrapped oid. [`Redaction::new`] and [`Redaction::target`] keep the
25/// public API in gitoxide's own type.
26///
27/// # Examples
28///
29/// ```
30/// use ents_model::Redaction;
31///
32/// let target = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
33/// let redaction = Redaction::new(target, "leaked credential");
34/// assert_eq!(redaction.target(), target);
35///
36/// let (id, store) = facet_git_tree::serialize(&redaction).expect("serialize");
37/// let back: Redaction = facet_git_tree::deserialize(&id, &store).expect("deserialize");
38/// assert_eq!(back, redaction);
39/// ```
40// @relation(model.redaction, meta-ref.typed-tree, model.extensibility, scope=file)
41#[derive(Debug, Clone, PartialEq, Eq, Facet)]
42pub struct Redaction {
43 #[facet(ents::skip)]
44 target: [u8; 20],
45 /// A human-readable reason for the redaction.
46 pub reason: String,
47}
48
49impl Redaction {
50 /// Record that `target` was redacted for `reason`.
51 #[must_use]
52 pub fn new(target: ObjectId, reason: impl Into<String>) -> Self {
53 let mut bytes = [0u8; 20];
54 bytes.copy_from_slice(target.as_slice());
55 Self {
56 target: bytes,
57 reason: reason.into(),
58 }
59 }
60
61 /// The redacted object's id.
62 #[must_use]
63 pub fn target(&self) -> ObjectId {
64 ObjectId::from_bytes_or_panic(&self.target)
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 #![allow(
71 clippy::expect_used,
72 clippy::panic,
73 reason = "unit test; the panic is an assertion the type reflects as a struct at all"
74 )]
75
76 use facet::{Facet as _, Type, UserType};
77 use facet_git_tree::{deserialize, serialize};
78 use rstest::rstest;
79
80 use super::*;
81
82 #[rstest]
83 // @relation(model.redaction, meta-ref.typed-tree, scope=function, role=Verifies)
84 fn redaction_round_trips_and_preserves_the_target_oid() {
85 let target = ObjectId::from_bytes_or_panic(&[7u8; 20]);
86 let redaction = Redaction::new(target, "leaked credential");
87
88 let (id, store) = serialize(&redaction).expect("serialize");
89 let back: Redaction = deserialize(&id, &store).expect("deserialize");
90
91 assert_eq!(back, redaction);
92 assert_eq!(back.target(), target);
93 }
94
95 #[rstest]
96 // @relation(model.redaction, scope=function, role=Verifies)
97 fn redaction_never_carries_the_redacted_content() {
98 let Type::User(UserType::Struct(struct_ty)) = Redaction::SHAPE.ty else {
99 panic!("Redaction must reflect as a struct");
100 };
101 let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect();
102 assert_eq!(
103 names,
104 vec!["target", "reason"],
105 "Redaction must carry only the target oid and a reason, never the content itself"
106 );
107 }
108}