crates/forge/ents-forge/tests/disjointness.rs
disjointness.rshistorycomment 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 | |
| 32 | use ents_forge::comment::Comment; |
| 33 | use ents_forge::issue::Issue; |
| 34 | use ents_forge::review::Review; |
| 35 | use ents_model::{Effect, Member, MemberId, Provenance, ResultRecord, Status}; |
| 36 | use facet::{Type, UserType}; |
| 37 | use facet_git_tree::ObjectStore; |
| 38 | use gix_hash::ObjectId; |
| 39 | use 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. |
| 43 | struct 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. |
| 51 | fn 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). |
| 130 | fn 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. |
| 154 | fn 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. |
| 169 | type 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. |
| 174 | fn 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) |
| 190 | fn 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 | } |