git-ents.gitmain
⌘K
foforge
disjointness.rs209 lines · 8.1 KB · rusthistorycomment on this file
1//! Workspace-level pairwise schema-disjointness test (`gate.identity-binding`):
2//! "entity structs MUST stay pairwise disjoint under this decode, a
3//! property held by test rather than by a stored marker" — so one signed
4//! genesis commit can never be admitted under two different meta-ref
5//! namespaces. Every genesis-borne entity struct — [`Comment`], [`Issue`],
6//! [`Member`], [`Effect`], [`Review`], [`ResultRecord`] — lives here rather
7//! than in `ents-testutil`: that crate is kernel-side and must never
8//! depend on `ents-forge` (`ents-forge`'s own crate doc), but `Comment` and
9//! `Review` are defined in `ents-forge`, so this workspace-wide check can
10//! only be written once this crate compiles against every entity it needs.
11//!
12//! This is *not* the same claim `model.extensibility`'s
13//! `every_entity_shape_name_tracks_its_struct_declaration` test makes
14//! (each type reflects under its own Rust name): a type could carry a
15//! distinct name and still accidentally decode another type's tree if
16//! their field shapes lined up. This test instead builds one real tree per
17//! entity and asserts every *other* entity's decoder refuses it.
18//!
19//! Plain `facet_git_tree::deserialize` alone is *not* the mechanism this
20//! proves: it fills declared fields by name and leaves an absent one at
21//! its default (`None` for an `Option`), silently ignoring any tree entry
22//! that names no field of `T` — so it is not by itself strict. The actual
23//! disjointness mechanism is `ents_gate::verify`'s private `strict_decode`,
24//! which layers "every tree entry must name one of `T`'s fields, an
25//! unknown one refusing" on top of that lenient decode before trusting it
26//! (`gate.identity-binding`). That helper is kernel-private, so this test
27//! mirrors its exact two-step check rather than reimplementing a third,
28//! looser one.
29
30#![allow(clippy::expect_used, reason = "integration test")]
31
32use ents_forge::comment::Comment;
33use ents_forge::issue::Issue;
34use ents_forge::review::Review;
35use ents_model::{Effect, Member, MemberId, Provenance, ResultRecord, Status};
36use facet::{Type, UserType};
37use facet_git_tree::ObjectStore;
38use gix_hash::ObjectId;
39use gix_object::{Find, Kind, TreeRef};
40
41/// One entity's own tree oid, tagged with the type name it was serialized
42/// from — used only to report which pair failed to stay disjoint.
43struct Sample {
44 name: &'static str,
45 tree: ObjectId,
46}
47
48/// One tree per entity type this phase's genesis/composite binding covers,
49/// all written into the same `store` so every decoder below can read every
50/// tree.
51fn samples(store: &ObjectStore) -> Vec<Sample> {
52 let target = ObjectId::null(gix_hash::Kind::Sha1);
53 vec![
54 Sample {
55 name: "Comment",
56 tree: facet_git_tree::serialize_into(
57 &Comment {
58 body: "looks off by one".to_owned(),
59 state: "open".to_owned(),
60 anchor: None,
61 context: Some("issues/abc".to_owned()),
62 parent: None,
63 },
64 store,
65 )
66 .expect("serialize Comment"),
67 },
68 Sample {
69 name: "Issue",
70 tree: facet_git_tree::serialize_into(
71 &Issue {
72 title: "gate rejects a valid signature".to_owned(),
73 body: "steps to reproduce".to_owned(),
74 state: "open".to_owned(),
75 assignees: vec![MemberId::new("jdc")],
76 labels: vec!["bug".to_owned()],
77 },
78 store,
79 )
80 .expect("serialize Issue"),
81 },
82 Sample {
83 name: "Member",
84 tree: facet_git_tree::serialize_into(
85 &Member::new(
86 "jdc",
87 "ssh-ed25519 AAAA... jdc",
88 Provenance::AdminRegistered,
89 ),
90 store,
91 )
92 .expect("serialize Member"),
93 },
94 Sample {
95 name: "Effect",
96 tree: facet_git_tree::serialize_into(
97 &Effect {
98 name: "unit".to_owned(),
99 trigger: "rev(refs/heads/main)".to_owned(),
100 toolchains: vec!["rust-stable".to_owned()],
101 run: "cargo nextest run".to_owned(),
102 },
103 store,
104 )
105 .expect("serialize Effect"),
106 },
107 Sample {
108 name: "Review",
109 tree: facet_git_tree::serialize_into(
110 &Review::new(target, ents_forge::review::Verdict::Approve, "looks good"),
111 store,
112 )
113 .expect("serialize Review"),
114 },
115 Sample {
116 name: "ResultRecord",
117 tree: facet_git_tree::serialize_into(
118 &ResultRecord::new("unit", target, Status::Pass),
119 store,
120 )
121 .expect("serialize ResultRecord"),
122 },
123 ]
124}
125
126/// The names of every top-level entry in `tree` — mirrors
127/// `ents_gate::object::tree_entry_names` exactly (that helper is
128/// `pub(crate)` to the kernel gate crate, so this test cannot call it
129/// directly).
130fn tree_entry_names(tree: &ObjectId, store: &ObjectStore) -> Vec<String> {
131 let mut buf = Vec::new();
132 let Ok(Some(data)) = store.try_find(tree, &mut buf) else {
133 return Vec::new();
134 };
135 if data.kind != Kind::Tree {
136 return Vec::new();
137 }
138 let Ok(parsed) = TreeRef::from_bytes(data.data, tree.kind()) else {
139 return Vec::new();
140 };
141 parsed
142 .entries
143 .iter()
144 .map(|e| String::from_utf8_lossy(e.filename).into_owned())
145 .collect()
146}
147
148/// Whether `tree` strictly decodes as `T` — mirrors
149/// `ents_gate::verify::strict_decode` exactly: every top-level tree entry
150/// must name one of `T`'s declared fields (an unknown one refuses), and
151/// only then does the ordinary (lenient) `facet_git_tree::deserialize`
152/// get to run. This, not the bare decode alone, is the mechanism
153/// `gate.identity-binding` names.
154fn decodes_as<T: for<'facet> facet::Facet<'facet>>(tree: &ObjectId, store: &ObjectStore) -> bool {
155 let Type::User(UserType::Struct(st)) = T::SHAPE.ty else {
156 return false;
157 };
158 let fields: Vec<&str> = st.fields.iter().map(|f| f.name).collect();
159 if tree_entry_names(tree, store)
160 .iter()
161 .any(|entry| !fields.contains(&entry.as_str()))
162 {
163 return false;
164 }
165 facet_git_tree::deserialize::<T>(tree, store).is_ok()
166}
167
168/// One entity type's [`decodes_as`] instantiation.
169type Decoder = fn(&ObjectId, &ObjectStore) -> bool;
170
171/// One named decoder per entity type — a `fn` pointer table so the test
172/// below can loop every (sample, decoder) pair generically instead of one
173/// hand-written assertion per combination.
174fn decoders() -> Vec<(&'static str, Decoder)> {
175 vec![
176 ("Comment", decodes_as::<Comment>),
177 ("Issue", decodes_as::<Issue>),
178 ("Member", decodes_as::<Member>),
179 ("Effect", decodes_as::<Effect>),
180 ("Review", decodes_as::<Review>),
181 ("ResultRecord", decodes_as::<ResultRecord>),
182 ]
183}
184
185/// `gate.identity-binding`: every sample decodes as its own type and
186/// refuses every other type in this list — pairwise disjointness held by
187/// this test, not a stored `.schema` marker.
188#[test]
189// @relation(gate.identity-binding, meta-ref.typed-tree, model.comment, model.issue, model.review, model.result-identity, scope=function, role=Verifies)
190fn entity_schemas_stay_pairwise_disjoint_under_strict_decode() {
191 let store = ObjectStore::default();
192 let samples = samples(&store);
193 let decoders = decoders();
194
195 for sample in &samples {
196 for (decoder_name, decode) in &decoders {
197 let decodes = decode(&sample.tree, &store);
198 let should_decode = *decoder_name == sample.name;
199 assert_eq!(
200 decodes,
201 should_decode,
202 "a {}'s tree {} decode as {decoder_name} (expected {should_decode}) — entity \
203 schemas must stay pairwise disjoint under strict decode (gate.identity-binding)",
204 sample.name,
205 if decodes { "does" } else { "does not" },
206 );
207 }
208 }
209}