This repository has no description
16 kB
567 lines
1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use knot_cob::{ChangePayload, CobHome, CobId, CobStore};
5use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef};
6use knot_git::{RefUpdate, Repo};
7use knot_index::{Coverage, IndexError, OfferedKey, Resolved};
8use knot_types::{RefName, RepoName};
9use serde::{Deserialize, Serialize};
10
11mod common;
12use common::{World, acc, at, grant, meta_home, own, registration, repo_did, rkey};
13
14#[derive(Serialize, Deserialize)]
15#[serde(tag = "op", content = "data", rename_all = "snake_case")]
16enum BadMembers {
17 Explode(u8),
18}
19impl ChangePayload for BadMembers {
20 const TYPE: &'static str = "sh.tangled.knot.member";
21}
22
23#[test]
24fn a_concurrent_reader_never_sees_a_net_absent_subject() {
25 let world = World::new();
26 let object = world.seed_members();
27 let index = Arc::new(world.index());
28 index.rebuild().unwrap();
29 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
30
31 let meta = Repo::open(&world.meta_path).unwrap();
32 let store = CobStore::new(&meta);
33 store
34 .update(
35 &meta_home(),
36 object,
37 &MembersChange::Add(grant("teq", "nel", 10)),
38 &world.signer,
39 at(10),
40 )
41 .unwrap();
42 (0..1000).for_each(|i| {
43 store
44 .update(
45 &meta_home(),
46 object,
47 &MembersChange::Add(grant(&format!("f{i}"), "nel", 11 + i)),
48 &world.signer,
49 at(11 + i),
50 )
51 .unwrap();
52 });
53 store
54 .update(
55 &meta_home(),
56 object,
57 &MembersChange::Remove(Removal {
58 subject: acc("teq"),
59 }),
60 &world.signer,
61 at(20_000),
62 )
63 .unwrap();
64
65 let done = Arc::new(AtomicBool::new(false));
66 let leaked = Arc::new(AtomicBool::new(false));
67 std::thread::scope(|scope| {
68 let reader = Arc::clone(&index);
69 let reader_done = Arc::clone(&done);
70 let reader_leaked = Arc::clone(&leaked);
71 scope.spawn(move || {
72 while !reader_done.load(Ordering::Acquire) {
73 if reader.is_member(&acc("teq")) == Resolved::Ready(true) {
74 reader_leaked.store(true, Ordering::Release);
75 }
76 }
77 });
78 index.refresh_members().unwrap();
79 done.store(true, Ordering::Release);
80 });
81
82 assert!(
83 !leaked.load(Ordering::Acquire),
84 "net-absent subject is never written, so no reader can observe it mid-delta"
85 );
86 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
87 assert_eq!(index.is_member(&acc("f0")), Resolved::Ready(true));
88 assert_eq!(index.is_member(&acc("f999")), Resolved::Ready(true));
89}
90
91#[test]
92fn a_concurrent_reader_never_sees_a_collaborator_roster_emptied_mid_refresh() {
93 let world = World::new();
94 let repo = repo_did("squid");
95 let git = world.layout.create(&repo).unwrap();
96 let store = CobStore::new(&git);
97 let object = store
98 .create(
99 &CobHome::from(&repo),
100 &CollaboratorsChange::Add(grant("anchor", "nel", 1)),
101 &world.signer,
102 at(1),
103 )
104 .unwrap()
105 .object;
106 (0..128).for_each(|i| {
107 store
108 .update(
109 &CobHome::from(&repo),
110 object,
111 &CollaboratorsChange::Add(grant(&format!("c{i}"), "nel", 2 + i)),
112 &world.signer,
113 at(2 + i),
114 )
115 .unwrap();
116 });
117
118 let index = Arc::new(world.index());
119 index.rebuild().unwrap();
120 index.ensure_collaborators(&repo).unwrap();
121 assert_eq!(
122 index.is_collaborator(&repo, &acc("anchor")),
123 Resolved::Ready(true)
124 );
125
126 let done = Arc::new(AtomicBool::new(false));
127 let leaked = Arc::new(AtomicBool::new(false));
128 std::thread::scope(|scope| {
129 let reader = Arc::clone(&index);
130 let reader_done = Arc::clone(&done);
131 let reader_leaked = Arc::clone(&leaked);
132 let target = repo.clone();
133 scope.spawn(move || {
134 while !reader_done.load(Ordering::Acquire) {
135 if reader.is_collaborator(&target, &acc("anchor")) != Resolved::Ready(true) {
136 reader_leaked.store(true, Ordering::Release);
137 }
138 }
139 });
140 (0..500).for_each(|_| index.refresh_collaborators(&repo).unwrap());
141 done.store(true, Ordering::Release);
142 });
143
144 assert!(
145 !leaked.load(Ordering::Acquire),
146 "in-place mem::take runs under per-repo lock, so anchor present in both \
147 pre- and post-refresh roster is never observed absent or warming mid-refresh"
148 );
149 assert_eq!(
150 index.is_collaborator(&repo, &acc("anchor")),
151 Resolved::Ready(true)
152 );
153}
154
155fn cob_ref(type_name: &str, object: CobId) -> RefName {
156 RefName::new(format!("refs/cobs/{type_name}/{}", object.oid())).unwrap()
157}
158
159#[test]
160fn a_diverged_collaborators_tip_purges_the_roster_instead_of_serving_it_stale() {
161 let world = World::new();
162 let repo = repo_did("squid");
163 let git = world.layout.create(&repo).unwrap();
164 let store = CobStore::new(&git);
165 let created = store
166 .create(
167 &CobHome::from(&repo),
168 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
169 &world.signer,
170 at(1),
171 )
172 .unwrap();
173 let tip = store
174 .update(
175 &CobHome::from(&repo),
176 created.object,
177 &CollaboratorsChange::Add(grant("bailey", "nel", 2)),
178 &world.signer,
179 at(2),
180 )
181 .unwrap();
182
183 let index = world.index();
184 index.rebuild().unwrap();
185 index.ensure_collaborators(&repo).unwrap();
186 assert_eq!(
187 index.is_collaborator(&repo, &acc("lyna")),
188 Resolved::Ready(true)
189 );
190
191 git.update_ref(&RefUpdate::Update {
192 name: cob_ref(CollaboratorsChange::TYPE, created.object),
193 old: tip.oid(),
194 new: created.object.oid(),
195 })
196 .unwrap();
197
198 assert!(
199 index.refresh_collaborators(&repo).is_err(),
200 "tip that no longer descends from folded tip is structural error"
201 );
202 assert_eq!(
203 index.is_collaborator(&repo, &acc("lyna")),
204 Resolved::Warming,
205 "diverged COB tip purges roster and fails closed, it does not serve \
206 pre-divergence collaborators"
207 );
208}
209
210#[test]
211fn a_diverged_members_tip_fails_closed_to_warming() {
212 let world = World::new();
213 let object = world.seed_members();
214 let meta = Repo::open(&world.meta_path).unwrap();
215 let store = CobStore::new(&meta);
216 let tip = store
217 .update(
218 &meta_home(),
219 object,
220 &MembersChange::Add(grant("teq", "nel", 3)),
221 &world.signer,
222 at(3),
223 )
224 .unwrap();
225
226 let index = world.index();
227 index.rebuild().unwrap();
228 assert_eq!(index.is_member(&acc("nel")), Resolved::Ready(true));
229
230 meta.update_ref(&RefUpdate::Update {
231 name: cob_ref(MembersChange::TYPE, object),
232 old: tip.oid(),
233 new: object.oid(),
234 })
235 .unwrap();
236
237 assert!(
238 index.refresh_members().is_err(),
239 "tip that no longer descends from folded tip is structural error"
240 );
241 assert_eq!(index.coverage().members, Coverage::Warming);
242 assert_eq!(
243 index.is_member(&acc("nel")),
244 Resolved::Warming,
245 "diverged members COB fails projection closed instead of serving stale members"
246 );
247}
248
249#[test]
250fn an_undecodable_change_fails_closed_with_no_partial_apply() {
251 let world = World::new();
252 let object = world.seed_members();
253 let index = world.index();
254 index.rebuild().unwrap();
255
256 let meta = Repo::open(&world.meta_path).unwrap();
257 let store = CobStore::new(&meta);
258 store
259 .update(
260 &meta_home(),
261 object,
262 &MembersChange::Add(grant("teq", "nel", 3)),
263 &world.signer,
264 at(3),
265 )
266 .unwrap();
267 store
268 .update(
269 &meta_home(),
270 object,
271 &BadMembers::Explode(0),
272 &world.signer,
273 at(4),
274 )
275 .unwrap();
276 store
277 .update(
278 &meta_home(),
279 object,
280 &MembersChange::Remove(Removal {
281 subject: acc("teq"),
282 }),
283 &world.signer,
284 at(5),
285 )
286 .unwrap();
287
288 assert!(matches!(
289 index.refresh_members(),
290 Err(IndexError::Decode { .. })
291 ));
292
293 assert_eq!(index.coverage().members, Coverage::Warming);
294 assert_eq!(
295 index.is_member(&acc("teq")),
296 Resolved::Warming,
297 "no partial apply: teq from pre-error change was never committed"
298 );
299 assert_eq!(
300 index.is_member(&acc("nel")),
301 Resolved::Warming,
302 "structurally broken COB fails whole projection closed"
303 );
304
305 assert!(matches!(
306 index.refresh_members(),
307 Err(IndexError::Decode { .. })
308 ));
309 assert_eq!(index.coverage().members, Coverage::Warming);
310}
311
312#[test]
313fn deregister_purges_collaborators_fail_closed() {
314 let world = World::new();
315 let repo = repo_did("clam");
316
317 let meta = Repo::open(&world.meta_path).unwrap();
318 let store = CobStore::new(&meta);
319 let registry = store
320 .create(
321 &meta_home(),
322 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
323 &world.signer,
324 at(1),
325 )
326 .unwrap()
327 .object;
328
329 let git = world.layout.create(&repo).unwrap();
330 let cstore = CobStore::new(&git);
331 cstore
332 .create(
333 &CobHome::from(&repo),
334 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
335 &world.signer,
336 at(1),
337 )
338 .unwrap();
339
340 let index = world.index();
341 index.rebuild().unwrap();
342 index.warm_collaborators();
343 assert_eq!(
344 index.is_collaborator(&repo, &acc("lyna")),
345 Resolved::Ready(true)
346 );
347
348 store
349 .update(
350 &meta_home(),
351 registry,
352 &RegistryChange::Deregister(RepoRef {
353 owner: own("nel"),
354 rkey: rkey("anemone"),
355 }),
356 &world.signer,
357 at(2),
358 )
359 .unwrap();
360 index.refresh_registry().unwrap();
361
362 assert_eq!(
363 index.resolve_repo(&own("nel"), &rkey("anemone")),
364 Resolved::Ready(None)
365 );
366 assert_eq!(
367 index.is_collaborator(&repo, &acc("lyna")),
368 Resolved::Warming,
369 "deregistered repo's collaborators are purged and fail closed, not served stale"
370 );
371}
372
373#[test]
374fn a_renamed_repo_keeps_both_rkeys_and_its_collaborators() {
375 let world = World::new();
376 let repo = repo_did("squid");
377
378 let meta = Repo::open(&world.meta_path).unwrap();
379 let store = CobStore::new(&meta);
380 let registry = store
381 .create(
382 &meta_home(),
383 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
384 &world.signer,
385 at(1),
386 )
387 .unwrap()
388 .object;
389
390 let git = world.layout.create(&repo).unwrap();
391 CobStore::new(&git)
392 .create(
393 &CobHome::from(&repo),
394 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
395 &world.signer,
396 at(1),
397 )
398 .unwrap();
399
400 let index = world.index();
401 index.rebuild().unwrap();
402 index.warm_collaborators();
403 assert_eq!(index.rkey_of(&repo), Resolved::Ready(Some(rkey("anemone"))));
404
405 store
406 .update(
407 &meta_home(),
408 registry,
409 &RegistryChange::Rename(Rename {
410 owner: own("nel"),
411 rkey: rkey("barnacle"),
412 name: RepoName::new("barnacle").unwrap(),
413 repo: repo.clone(),
414 }),
415 &world.signer,
416 at(2),
417 )
418 .unwrap();
419 index.refresh_registry().unwrap();
420
421 assert_eq!(
422 index.resolve_repo(&own("nel"), &rkey("anemone")),
423 Resolved::Ready(Some(repo.clone())),
424 "prior rkey keeps resolving as alias after rename is delta-applied"
425 );
426 assert_eq!(
427 index.resolve_repo(&own("nel"), &rkey("barnacle")),
428 Resolved::Ready(Some(repo.clone()))
429 );
430 assert_eq!(
431 index.rkey_of(&repo),
432 Resolved::Ready(Some(rkey("barnacle"))),
433 "new rkey is canonical"
434 );
435 assert_eq!(
436 index.is_collaborator(&repo, &acc("lyna")),
437 Resolved::Ready(true),
438 "rename never evacuates repo, so its collaborators survive"
439 );
440
441 store
442 .update(
443 &meta_home(),
444 registry,
445 &RegistryChange::Deregister(RepoRef {
446 owner: own("nel"),
447 rkey: rkey("anemone"),
448 }),
449 &world.signer,
450 at(3),
451 )
452 .unwrap();
453 index.refresh_registry().unwrap();
454 assert_eq!(
455 index.resolve_repo(&own("nel"), &rkey("barnacle")),
456 Resolved::Ready(None),
457 "deregistering through retained alias removes repo and every alias"
458 );
459 assert_eq!(
460 index.is_collaborator(&repo, &acc("lyna")),
461 Resolved::Warming,
462 "deregistered repo's collaborators are evacuated"
463 );
464}
465
466#[test]
467fn a_repo_moved_within_one_delta_is_not_evacuated() {
468 let world = World::new();
469 let repo = repo_did("squid");
470
471 let meta = Repo::open(&world.meta_path).unwrap();
472 let store = CobStore::new(&meta);
473 let registry = store
474 .create(
475 &meta_home(),
476 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
477 &world.signer,
478 at(1),
479 )
480 .unwrap()
481 .object;
482
483 let git = world.layout.create(&repo).unwrap();
484 CobStore::new(&git)
485 .create(
486 &CobHome::from(&repo),
487 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
488 &world.signer,
489 at(1),
490 )
491 .unwrap();
492
493 let index = world.index();
494 index.rebuild().unwrap();
495 index.warm_collaborators();
496 assert_eq!(
497 index.is_collaborator(&repo, &acc("lyna")),
498 Resolved::Ready(true)
499 );
500
501 store
502 .update(
503 &meta_home(),
504 registry,
505 &RegistryChange::Deregister(RepoRef {
506 owner: own("nel"),
507 rkey: rkey("anemone"),
508 }),
509 &world.signer,
510 at(2),
511 )
512 .unwrap();
513 store
514 .update(
515 &meta_home(),
516 registry,
517 &RegistryChange::Register(registration("nel", "barnacle", &repo, 3)),
518 &world.signer,
519 at(3),
520 )
521 .unwrap();
522 index.refresh_registry().unwrap();
523
524 assert_eq!(
525 index.resolve_repo(&own("nel"), &rkey("anemone")),
526 Resolved::Ready(None)
527 );
528 assert_eq!(
529 index.resolve_repo(&own("nel"), &rkey("barnacle")),
530 Resolved::Ready(Some(repo.clone()))
531 );
532 assert_eq!(
533 index.is_collaborator(&repo, &acc("lyna")),
534 Resolved::Ready(true),
535 "deregister and re-register within single delta leaves repo hosted, so its collaborators survive"
536 );
537}
538
539#[test]
540fn key_cache_evicts_least_recently_used() {
541 const CAP: u32 = 16_384;
542 let world = World::new();
543 let index = world.index();
544 let key = |i: u32| OfferedKey::from_bytes(i.to_le_bytes().to_vec());
545
546 (0..CAP).for_each(|i| index.cache_key(key(i), &acc("nel")));
547 assert_eq!(
548 index.owner_of_key(&key(0)),
549 Resolved::Ready(Some(acc("nel")))
550 );
551 index.cache_key(key(CAP), &acc("nel"));
552
553 assert_eq!(
554 index.owner_of_key(&key(1)),
555 Resolved::Ready(None),
556 "least-recently-used key is evicted"
557 );
558 assert_eq!(
559 index.owner_of_key(&key(0)),
560 Resolved::Ready(Some(acc("nel"))),
561 "recently-used key survives despite being inserted first"
562 );
563 assert_eq!(
564 index.owner_of_key(&key(CAP)),
565 Resolved::Ready(Some(acc("nel")))
566 );
567}