git-ents.gitmain
⌘K
foforge
transfer.rs441 lines · 14.0 KB · rusthistorycomment on this file
1//! Forge transfer: fetch and push over `refs/meta/*` (`sync.forge-transfer`,
2//! and the push side of `sync.pre-flight` / `sync.inbox-routing`).
3//!
4//! Strategy: **integration harness** — a "remote" and a "local" are each a
5//! ref-store / object-store pair, and the properties are end-to-end: after a
6//! fetch the destination can verify a signed tip entirely on its own (so
7//! history and signatures came across), a divergence is reported rather than
8//! silently overwritten, and a push gates the *remote* write on the same
9//! gate while routing an unauthorized canonical push to the inbox.
10
11#![expect(
12 clippy::unwrap_used,
13 clippy::expect_used,
14 clippy::panic,
15 reason = "tests"
16)]
17
18use ents_gate::{Config, Update, Verdict, verify};
19use ents_model::{MemberId, Provenance, namespace};
20use ents_sync::transfer::{Pushed, fetch, push};
21use ents_testutil::{
22 CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
23};
24use gix::refs::FullName;
25use gix_hash::ObjectId;
26use gix_ref_store::RefStoreRead;
27
28/// A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on
29/// `ents-forge`): any multi-field entity exercises the same transfer
30/// machinery, which is generic over the typed tree.
31#[derive(Debug, Clone, PartialEq, Eq, facet::Facet)]
32struct Issue {
33 title: String,
34 body: String,
35 state: String,
36}
37
38fn issue(state: &str) -> Issue {
39 Issue {
40 title: "t".into(),
41 body: "b".into(),
42 state: state.into(),
43 }
44}
45
46/// Write a parentless, signed genesis issue commit, set its oid-keyed ref
47/// (`meta-ref.identity-binding`), and return `(name, oid)`.
48fn genesis(
49 refs: &MemRefStore,
50 objects: &ObjectStore,
51 state: &str,
52 signer: &Keypair,
53 seconds: i64,
54) -> (FullName, ObjectId) {
55 let tree = facet_git_tree::serialize_into(&issue(state), objects).unwrap();
56 let tip = write_commit(
57 objects,
58 &CommitSpec {
59 tree,
60 parents: vec![],
61 message: "Open issue".into(),
62 seconds,
63 },
64 Some(signer),
65 );
66 let name: FullName = format!("refs/meta/issues/{tip}").try_into().unwrap();
67 refs.set(name.as_ref(), tip);
68 (name, tip)
69}
70
71/// Enroll `admin` (and optionally `bob`) and record the epoch.
72fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair, bob: Option<&Keypair>) {
73 enroll_member(
74 refs,
75 objects,
76 "admin",
77 admin,
78 Provenance::AdminRegistered,
79 100,
80 );
81 let config: FullName = namespace::CONFIG_REF.try_into().unwrap();
82 write_meta_entity(
83 refs,
84 objects,
85 config,
86 &Config { epoch: Some(200) },
87 Some(admin),
88 200,
89 );
90 if let Some(bob) = bob {
91 enroll_member(refs, objects, "bob", bob, Provenance::SelfAttested, 250);
92 }
93}
94
95/// Fetch moves the whole forge: every meta-ref, its full history, and the
96/// signatures needed to verify it, so the destination verifies a tip on its
97/// own with nothing left behind (`sync.forge-transfer`).
98// @relation(sync.forge-transfer, scope=function, role=Verifies)
99#[test]
100fn fetch_moves_the_whole_forge_with_verifiable_signatures() {
101 let remote_refs = MemRefStore::default();
102 let remote_objects = ObjectStore::default();
103 let admin = Keypair::from_seed(1);
104 boot(&remote_refs, &remote_objects, &admin, None);
105
106 // An issue with two commits of history; its id is the genesis oid.
107 let (name, parent) = genesis(&remote_refs, &remote_objects, "open", &admin, 300);
108 let tip = write_meta_entity(
109 &remote_refs,
110 &remote_objects,
111 name.clone(),
112 &issue("closed"),
113 Some(&admin),
114 400,
115 );
116
117 let local_refs = MemRefStore::default();
118 let local_objects = ObjectStore::default();
119 let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap();
120
121 // member, config, and the issue all arrived.
122 assert!(
123 report
124 .updated
125 .iter()
126 .any(|n| n.as_bstr() == "refs/meta/member/admin")
127 );
128 assert!(
129 report
130 .updated
131 .iter()
132 .any(|n| n.as_bstr() == "refs/meta/config")
133 );
134 assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(tip));
135
136 // The full history came with it, not just the tip.
137 assert!(
138 local_objects.get(&parent).is_some(),
139 "the parent commit must transfer too"
140 );
141
142 // The signatures and policy transferred: the destination verifies the
143 // tip against its *own* fetched state, offline.
144 let snapshot = local_refs.fetched_copy();
145 snapshot.remove(name.as_ref());
146 let verdict = verify(
147 &snapshot,
148 &local_objects,
149 &Update {
150 name,
151 new: Some(tip),
152 },
153 )
154 .unwrap();
155 assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}");
156}
157
158/// When a local meta-ref has moved out from under the remote — neither tip
159/// descends from the other — fetch reports the divergence for the merge
160/// machinery to resolve, and does not clobber the local ref.
161// @relation(sync.forge-transfer, scope=function, role=Verifies)
162#[test]
163fn fetch_reports_divergence_instead_of_overwriting() {
164 let name: FullName = "refs/meta/issues/1".try_into().unwrap();
165
166 let remote_refs = MemRefStore::default();
167 let remote_objects = ObjectStore::default();
168 let key = Keypair::from_seed(1);
169 let remote_tip = write_meta_entity(
170 &remote_refs,
171 &remote_objects,
172 name.clone(),
173 &issue("open"),
174 Some(&key),
175 300,
176 );
177
178 // The local ref points at an independent-root commit: no descent either
179 // way.
180 let local_refs = MemRefStore::default();
181 let local_objects = ObjectStore::default();
182 let local_tip = {
183 let tree = facet_git_tree::serialize_into(&issue("closed"), &local_objects).unwrap();
184 write_commit(
185 &local_objects,
186 &CommitSpec {
187 tree,
188 parents: vec![],
189 message: "local".into(),
190 seconds: 300,
191 },
192 Some(&key),
193 )
194 };
195 local_refs.set(name.as_ref(), local_tip);
196
197 let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap();
198 assert!(report.updated.is_empty());
199 let diverged = report
200 .diverged
201 .iter()
202 .find(|d| d.name == name)
203 .expect("divergence reported");
204 assert_eq!(diverged.local, local_tip);
205 assert_eq!(diverged.remote, remote_tip);
206 // The local ref is untouched.
207 assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(local_tip));
208}
209
210/// Push pre-flights against the remote's own policy: an authorized push is
211/// transferred and advances the remote ref (`sync.pre-flight`,
212/// `sync.forge-transfer`).
213// @relation(sync.pre-flight, sync.forge-transfer, scope=function, role=Verifies)
214#[test]
215fn push_advances_the_remote_on_an_authorized_ref() {
216 let admin = Keypair::from_seed(1);
217 let remote_refs = MemRefStore::default();
218 let remote_objects = ObjectStore::default();
219 boot(&remote_refs, &remote_objects, &admin, None);
220
221 let local_refs = MemRefStore::default();
222 let local_objects = ObjectStore::default();
223 boot(&local_refs, &local_objects, &admin, None);
224
225 let (name, tip) = genesis(&local_refs, &local_objects, "open", &admin, 300);
226
227 let pushed = push(
228 &remote_refs,
229 &remote_objects,
230 &local_objects,
231 &name,
232 tip,
233 &MemberId::new("admin"),
234 )
235 .unwrap();
236 assert_eq!(pushed, Pushed::Advanced(name.clone()));
237 assert_eq!(remote_refs.get(name.as_ref()).unwrap(), Some(tip));
238}
239
240/// A self-attested contributor's canonical push is predicted to fail, so
241/// push routes it to the contributor's own inbox segment and leaves the
242/// remote canonical ref untouched (`sync.inbox-routing`).
243// @relation(sync.inbox-routing, sync.pre-flight, scope=function, role=Verifies)
244#[test]
245fn push_routes_an_unauthorized_canonical_push_to_the_inbox() {
246 let admin = Keypair::from_seed(1);
247 let bob = Keypair::from_seed(2);
248 let remote_refs = MemRefStore::default();
249 let remote_objects = ObjectStore::default();
250 boot(&remote_refs, &remote_objects, &admin, Some(&bob));
251
252 let local_refs = MemRefStore::default();
253 let local_objects = ObjectStore::default();
254 boot(&local_refs, &local_objects, &admin, Some(&bob));
255
256 let name: FullName = "refs/meta/issues/1".try_into().unwrap();
257 let tip = write_meta_entity(
258 &local_refs,
259 &local_objects,
260 name.clone(),
261 &issue("open"),
262 Some(&bob),
263 300,
264 );
265
266 let pushed = push(
267 &remote_refs,
268 &remote_objects,
269 &local_objects,
270 &name,
271 tip,
272 &MemberId::new("bob"),
273 )
274 .unwrap();
275 match pushed {
276 Pushed::Inbox(route) => assert_eq!(route.as_bstr(), "refs/meta/inbox/bob/issues/1"),
277 other => panic!("expected inbox routing, got {other:?}"),
278 }
279 assert_eq!(
280 remote_refs.get(name.as_ref()).unwrap(),
281 None,
282 "canonical ref must be untouched"
283 );
284}
285
286/// A ref store that simulates a racing writer: the first transaction it
287/// receives is preceded by a competing ref move, landing exactly in the
288/// window between a caller's read (or pre-flight) and its CAS.
289struct RacingStore<'a> {
290 inner: &'a MemRefStore,
291 race: std::sync::Mutex<Option<(FullName, gix_hash::ObjectId)>>,
292}
293
294impl<'a> RacingStore<'a> {
295 fn new(inner: &'a MemRefStore, name: FullName, oid: gix_hash::ObjectId) -> Self {
296 Self {
297 inner,
298 race: std::sync::Mutex::new(Some((name, oid))),
299 }
300 }
301}
302
303impl gix_ref_store::RefStoreRead for RacingStore<'_> {
304 fn get(
305 &self,
306 name: &gix::refs::FullNameRef,
307 ) -> gix_ref_store::Result<Option<gix_hash::ObjectId>> {
308 self.inner.get(name)
309 }
310
311 fn iter_prefix(&self, prefix: &str) -> gix_ref_store::Result<gix_ref_store::RefIter> {
312 self.inner.iter_prefix(prefix)
313 }
314}
315
316impl gix_ref_store::RefStore for RacingStore<'_> {
317 #[expect(
318 clippy::unwrap_in_result,
319 reason = "test fixture: a poisoned mutex is a broken test, not a condition under test"
320 )]
321 fn transaction(
322 &self,
323 edits: &[gix_ref_store::RefEdit],
324 ) -> gix_ref_store::Result<gix_ref_store::TxOutcome> {
325 if let Some((name, oid)) = self.race.lock().unwrap().take() {
326 self.inner.set(name.as_ref(), oid);
327 }
328 self.inner.transaction(edits)
329 }
330}
331
332/// The staleness race pre-flight admits (`sync.pre-flight`: "a prediction
333/// that can only be stale"): another writer advances the remote ref between
334/// the passing verdict and the CAS. The rejected transaction must surface
335/// as [`Pushed::Stale`] — never as a fabricated success — and the racing
336/// writer's tip must survive untouched.
337// @relation(sync.pre-flight, scope=function, role=Verifies)
338#[test]
339fn push_reports_a_lost_cas_race_as_stale_not_success() {
340 let admin = Keypair::from_seed(1);
341 let remote_refs = MemRefStore::default();
342 let remote_objects = ObjectStore::default();
343 boot(&remote_refs, &remote_objects, &admin, None);
344
345 let local_refs = MemRefStore::default();
346 let local_objects = ObjectStore::default();
347 boot(&local_refs, &local_objects, &admin, None);
348
349 let (name, ours) = genesis(&local_refs, &local_objects, "open", &admin, 300);
350
351 // The racing writer's competing tip, landed on the remote the instant
352 // push's transaction begins — after pre-flight has already passed.
353 let racer = {
354 let tree = facet_git_tree::serialize_into(&issue("closed"), &remote_objects).unwrap();
355 write_commit(
356 &remote_objects,
357 &CommitSpec {
358 tree,
359 parents: vec![],
360 message: "racer".into(),
361 seconds: 310,
362 },
363 Some(&admin),
364 )
365 };
366 let racing = RacingStore::new(&remote_refs, name.clone(), racer);
367
368 let pushed = push(
369 &racing,
370 &remote_objects,
371 &local_objects,
372 &name,
373 ours,
374 &MemberId::new("admin"),
375 )
376 .unwrap();
377
378 assert_eq!(
379 pushed,
380 Pushed::Stale(name.clone()),
381 "a lost CAS race must not be reported as Advanced"
382 );
383 assert_eq!(
384 remote_refs.get(name.as_ref()).unwrap(),
385 Some(racer),
386 "the racing writer's tip must survive; nothing was written"
387 );
388}
389
390/// The same race on the fetch side: a local writer moves the ref between
391/// fetch's read and its transaction. The ref must land in
392/// [`ents_sync::transfer::FetchReport::stale`], never in `updated`, and the
393/// concurrent writer's tip must survive.
394// @relation(sync.forge-transfer, scope=function, role=Verifies)
395#[test]
396fn fetch_reports_a_lost_cas_race_as_stale_not_updated() {
397 let key = Keypair::from_seed(1);
398 let remote_refs = MemRefStore::default();
399 let remote_objects = ObjectStore::default();
400 let name: FullName = "refs/meta/issues/1".try_into().unwrap();
401 write_meta_entity(
402 &remote_refs,
403 &remote_objects,
404 name.clone(),
405 &issue("open"),
406 Some(&key),
407 300,
408 );
409
410 let local_refs = MemRefStore::default();
411 let local_objects = ObjectStore::default();
412 // A concurrent local writer creates the same ref mid-fetch, defeating
413 // the MustNotExist precondition fetch read moments earlier.
414 let racer = {
415 let tree = facet_git_tree::serialize_into(&issue("closed"), &local_objects).unwrap();
416 write_commit(
417 &local_objects,
418 &CommitSpec {
419 tree,
420 parents: vec![],
421 message: "racer".into(),
422 seconds: 310,
423 },
424 Some(&key),
425 )
426 };
427 let racing = RacingStore::new(&local_refs, name.clone(), racer);
428
429 let report = fetch(&remote_refs, &remote_objects, &racing, &local_objects).unwrap();
430
431 assert!(
432 report.updated.is_empty(),
433 "a rejected CAS must not be reported as updated: {report:?}"
434 );
435 assert_eq!(report.stale, vec![name.clone()]);
436 assert_eq!(
437 local_refs.get(name.as_ref()).unwrap(),
438 Some(racer),
439 "the concurrent writer's tip must survive; nothing was written"
440 );
441}