use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use knot_cob::{ChangePayload, CobHome, CobId, CobStore}; use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef}; use knot_git::{RefUpdate, Repo}; use knot_index::{Coverage, IndexError, OfferedKey, Resolved}; use knot_types::{ClonePath, RefName, RepoName}; use serde::{Deserialize, Serialize}; mod common; use common::{ World, acc, at, grant, meta_home, named_registration, own, registration, repo_did, rkey, }; #[derive(Serialize, Deserialize)] #[serde(tag = "op", content = "data", rename_all = "snake_case")] enum BadMembers { Explode(u8), } impl ChangePayload for BadMembers { const TYPE: &'static str = "sh.tangled.knot.member"; } #[test] fn a_concurrent_reader_never_sees_a_net_absent_subject() { let world = World::new(); let object = world.seed_members(); let index = Arc::new(world.index()); index.rebuild().unwrap(); assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false)); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); store .update( &meta_home(), object, &MembersChange::Add(grant("teq", "nel", 10)), &world.signer, at(10), ) .unwrap(); (0..1000).for_each(|i| { store .update( &meta_home(), object, &MembersChange::Add(grant(&format!("f{i}"), "nel", 11 + i)), &world.signer, at(11 + i), ) .unwrap(); }); store .update( &meta_home(), object, &MembersChange::Remove(Removal { subject: acc("teq"), }), &world.signer, at(20_000), ) .unwrap(); let done = Arc::new(AtomicBool::new(false)); let leaked = Arc::new(AtomicBool::new(false)); std::thread::scope(|scope| { let reader = Arc::clone(&index); let reader_done = Arc::clone(&done); let reader_leaked = Arc::clone(&leaked); scope.spawn(move || { while !reader_done.load(Ordering::Acquire) { if reader.is_member(&acc("teq")) == Resolved::Ready(true) { reader_leaked.store(true, Ordering::Release); } } }); index.refresh_members().unwrap(); done.store(true, Ordering::Release); }); assert!( !leaked.load(Ordering::Acquire), "net-absent subject is never written, so no reader can observe it mid-delta" ); assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false)); assert_eq!(index.is_member(&acc("f0")), Resolved::Ready(true)); assert_eq!(index.is_member(&acc("f999")), Resolved::Ready(true)); } #[test] fn a_concurrent_reader_never_sees_a_collaborator_roster_emptied_mid_refresh() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); let store = CobStore::new(&git); let object = store .create( &CobHome::from(&repo), &CollaboratorsChange::Add(grant("anchor", "nel", 1)), &world.signer, at(1), ) .unwrap() .object; (0..128).for_each(|i| { store .update( &CobHome::from(&repo), object, &CollaboratorsChange::Add(grant(&format!("c{i}"), "nel", 2 + i)), &world.signer, at(2 + i), ) .unwrap(); }); let index = Arc::new(world.index()); index.rebuild().unwrap(); index.ensure_collaborators(&repo).unwrap(); assert_eq!( index.is_collaborator(&repo, &acc("anchor")), Resolved::Ready(true) ); let done = Arc::new(AtomicBool::new(false)); let leaked = Arc::new(AtomicBool::new(false)); std::thread::scope(|scope| { let reader = Arc::clone(&index); let reader_done = Arc::clone(&done); let reader_leaked = Arc::clone(&leaked); let target = repo.clone(); scope.spawn(move || { while !reader_done.load(Ordering::Acquire) { if reader.is_collaborator(&target, &acc("anchor")) != Resolved::Ready(true) { reader_leaked.store(true, Ordering::Release); } } }); (0..500).for_each(|_| index.refresh_collaborators(&repo).unwrap()); done.store(true, Ordering::Release); }); assert!( !leaked.load(Ordering::Acquire), "in-place mem::take runs under per-repo lock, so anchor present in both \ pre- and post-refresh roster is never observed absent or warming mid-refresh" ); assert_eq!( index.is_collaborator(&repo, &acc("anchor")), Resolved::Ready(true) ); } fn cob_ref(type_name: &str, object: CobId) -> RefName { RefName::new(format!("refs/cobs/{type_name}/{}", object.oid())).unwrap() } #[test] fn a_diverged_collaborators_tip_purges_the_roster_instead_of_serving_it_stale() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); let store = CobStore::new(&git); let created = store .create( &CobHome::from(&repo), &CollaboratorsChange::Add(grant("lyna", "nel", 1)), &world.signer, at(1), ) .unwrap(); let tip = store .update( &CobHome::from(&repo), created.object, &CollaboratorsChange::Add(grant("bailey", "nel", 2)), &world.signer, at(2), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); index.ensure_collaborators(&repo).unwrap(); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Ready(true) ); git.update_ref(&RefUpdate::Update { name: cob_ref(CollaboratorsChange::TYPE, created.object), old: tip.oid(), new: created.object.oid(), }) .unwrap(); assert!( index.refresh_collaborators(&repo).is_err(), "tip that no longer descends from folded tip is structural error" ); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Warming, "diverged COB tip purges roster and fails closed, it does not serve \ pre-divergence collaborators" ); } #[test] fn a_diverged_members_tip_fails_closed_to_warming() { let world = World::new(); let object = world.seed_members(); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let tip = store .update( &meta_home(), object, &MembersChange::Add(grant("teq", "nel", 3)), &world.signer, at(3), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!(index.is_member(&acc("nel")), Resolved::Ready(true)); meta.update_ref(&RefUpdate::Update { name: cob_ref(MembersChange::TYPE, object), old: tip.oid(), new: object.oid(), }) .unwrap(); assert!( index.refresh_members().is_err(), "tip that no longer descends from folded tip is structural error" ); assert_eq!(index.coverage().members, Coverage::Warming); assert_eq!( index.is_member(&acc("nel")), Resolved::Warming, "diverged members COB fails projection closed instead of serving stale members" ); } #[test] fn an_undecodable_change_fails_closed_with_no_partial_apply() { let world = World::new(); let object = world.seed_members(); let index = world.index(); index.rebuild().unwrap(); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); store .update( &meta_home(), object, &MembersChange::Add(grant("teq", "nel", 3)), &world.signer, at(3), ) .unwrap(); store .update( &meta_home(), object, &BadMembers::Explode(0), &world.signer, at(4), ) .unwrap(); store .update( &meta_home(), object, &MembersChange::Remove(Removal { subject: acc("teq"), }), &world.signer, at(5), ) .unwrap(); assert!(matches!( index.refresh_members(), Err(IndexError::Decode { .. }) )); assert_eq!(index.coverage().members, Coverage::Warming); assert_eq!( index.is_member(&acc("teq")), Resolved::Warming, "no partial apply: teq from pre-error change was never committed" ); assert_eq!( index.is_member(&acc("nel")), Resolved::Warming, "structurally broken COB fails whole projection closed" ); assert!(matches!( index.refresh_members(), Err(IndexError::Decode { .. }) )); assert_eq!(index.coverage().members, Coverage::Warming); } #[test] fn deregister_purges_collaborators_fail_closed() { let world = World::new(); let repo = repo_did("clam"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let registry = store .create( &meta_home(), &RegistryChange::Register(registration("nel", "anemone", &repo, 1)), &world.signer, at(1), ) .unwrap() .object; let git = world.layout.create(&repo).unwrap(); let cstore = CobStore::new(&git); cstore .create( &CobHome::from(&repo), &CollaboratorsChange::Add(grant("lyna", "nel", 1)), &world.signer, at(1), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); index.warm_collaborators(); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Ready(true) ); store .update( &meta_home(), registry, &RegistryChange::Deregister(RepoRef { owner: own("nel"), rkey: rkey("anemone"), }), &world.signer, at(2), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_repo(&own("nel"), &rkey("anemone")), Resolved::Ready(None) ); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Warming, "deregistered repo's collaborators are purged and fail closed, not served stale" ); } #[test] fn a_renamed_repo_keeps_both_rkeys_and_its_collaborators() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let registry = store .create( &meta_home(), &RegistryChange::Register(registration("nel", "anemone", &repo, 1)), &world.signer, at(1), ) .unwrap() .object; let git = world.layout.create(&repo).unwrap(); CobStore::new(&git) .create( &CobHome::from(&repo), &CollaboratorsChange::Add(grant("lyna", "nel", 1)), &world.signer, at(1), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); index.warm_collaborators(); assert_eq!(index.rkey_of(&repo), Resolved::Ready(Some(rkey("anemone")))); store .update( &meta_home(), registry, &RegistryChange::Rename(Rename { owner: own("nel"), rkey: rkey("barnacle"), name: RepoName::new("barnacle").unwrap(), repo: repo.clone(), }), &world.signer, at(2), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_repo(&own("nel"), &rkey("anemone")), Resolved::Ready(Some(repo.clone())), "prior rkey keeps resolving as alias after rename is delta-applied" ); assert_eq!( index.resolve_repo(&own("nel"), &rkey("barnacle")), Resolved::Ready(Some(repo.clone())) ); assert_eq!( index.rkey_of(&repo), Resolved::Ready(Some(rkey("barnacle"))), "new rkey is canonical" ); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Ready(true), "rename never evacuates repo, so its collaborators survive" ); store .update( &meta_home(), registry, &RegistryChange::Deregister(RepoRef { owner: own("nel"), rkey: rkey("anemone"), }), &world.signer, at(3), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_repo(&own("nel"), &rkey("barnacle")), Resolved::Ready(None), "deregistering through retained alias removes repo and every alias" ); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Warming, "deregistered repo's collaborators are evacuated" ); } #[test] fn a_repo_moved_within_one_delta_is_not_evacuated() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let registry = store .create( &meta_home(), &RegistryChange::Register(registration("nel", "anemone", &repo, 1)), &world.signer, at(1), ) .unwrap() .object; let git = world.layout.create(&repo).unwrap(); CobStore::new(&git) .create( &CobHome::from(&repo), &CollaboratorsChange::Add(grant("lyna", "nel", 1)), &world.signer, at(1), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); index.warm_collaborators(); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Ready(true) ); store .update( &meta_home(), registry, &RegistryChange::Deregister(RepoRef { owner: own("nel"), rkey: rkey("anemone"), }), &world.signer, at(2), ) .unwrap(); store .update( &meta_home(), registry, &RegistryChange::Register(registration("nel", "barnacle", &repo, 3)), &world.signer, at(3), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_repo(&own("nel"), &rkey("anemone")), Resolved::Ready(None) ); assert_eq!( index.resolve_repo(&own("nel"), &rkey("barnacle")), Resolved::Ready(Some(repo.clone())) ); assert_eq!( index.is_collaborator(&repo, &acc("lyna")), Resolved::Ready(true), "deregister and re-register within single delta leaves repo hosted, so its collaborators survive" ); } #[test] fn key_cache_evicts_least_recently_used() { const CAP: u32 = 16_384; let world = World::new(); let index = world.index(); let key = |i: u32| OfferedKey::from_bytes(i.to_le_bytes().to_vec()); (0..CAP).for_each(|i| index.cache_key(key(i), &acc("nel"))); assert_eq!( index.owner_of_key(&key(0)), Resolved::Ready(Some(acc("nel"))) ); index.cache_key(key(CAP), &acc("nel")); assert_eq!( index.owner_of_key(&key(1)), Resolved::Ready(None), "least-recently-used key is evicted" ); assert_eq!( index.owner_of_key(&key(0)), Resolved::Ready(Some(acc("nel"))), "recently-used key survives despite being inserted first" ); assert_eq!( index.owner_of_key(&key(CAP)), Resolved::Ready(Some(acc("nel"))) ); } fn path(raw: &str) -> ClonePath { ClonePath::parse(raw).unwrap() } #[test] fn a_clone_path_resolves_by_name_when_the_record_key_is_a_tid() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3mizfnpxii522", "substratum.cloud", &repo, 1, )), &world.signer, at(1), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("substratum.cloud")), Resolved::Ready(Some(repo.clone())), "a PDS-native record key leaves the display name as the only human clone path" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), Resolved::Ready(Some(repo.clone())), "the record key still resolves" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("substratum.cloud.git")), Resolved::Ready(Some(repo)), "the conventional .git suffix strips before the name lookup" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("periwinkle")), Resolved::Ready(None) ); } #[test] fn a_record_key_outranks_another_repos_name() { let world = World::new(); let by_name = repo_did("limpet"); let by_rkey = repo_did("mussel"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration("nel", "limpet", "mussel", &by_name, 1)), &world.signer, at(1), ) .unwrap() .object; store .update( &meta_home(), object, &RegistryChange::Register(named_registration("nel", "mussel", "scallop", &by_rkey, 2)), &world.signer, at(2), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("mussel")), Resolved::Ready(Some(by_rkey)), "a record key match wins over another repo holding that string as its name" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("scallop")), Resolved::Ready(Some(repo_did("mussel"))) ); } #[test] fn a_name_two_repos_share_resolves_to_the_older_registration() { let world = World::new(); let first = repo_did("whelk"); let second = repo_did("conch"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3lubrptx57d22", "kelp", &first, 1, )), &world.signer, at(1), ) .unwrap() .object; store .update( &meta_home(), object, &RegistryChange::Register(named_registration( "nel", "3mqydma3re27z", "kelp", &second, 2, )), &world.signer, at(2), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(first.clone())), "a contested name resolves to whichever repo registered first, ordered by \ created_at so a cold seed and an incremental replay agree" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), Resolved::Ready(Some(first)), "each record key stays unambiguous" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), Resolved::Ready(Some(second)), "the repo that lost the name is still reachable by its record key" ); } #[test] fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { let world = World::new(); let holder = repo_did("whelk"); let latecomer = repo_did("conch"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3lubrptx57d22", "kelp", &holder, 1, )), &world.signer, at(1), ) .unwrap() .object; store .update( &meta_home(), object, &RegistryChange::Register(named_registration( "nel", "3mqydma3re27z", "uni", &latecomer, 2, )), &world.signer, at(2), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); store .update( &meta_home(), object, &RegistryChange::Rename(Rename { owner: own("nel"), rkey: rkey("3mqydma3re27z"), name: RepoName::new("kelp").unwrap(), repo: latecomer.clone(), }), &world.signer, at(3), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(holder)), "a rename keeps the repo's original created_at, so renaming onto a name \ another repo registered earlier cannot take it" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("uni")), Resolved::Ready(None), "the renamed repo's previous name stops resolving" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), Resolved::Ready(Some(latecomer)) ); } #[test] fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() { let world = World::new(); let first = repo_did("whelk"); let survivor = repo_did("conch"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3lubrptx57d22", "kelp", &first, 1, )), &world.signer, at(1), ) .unwrap() .object; store .update( &meta_home(), object, &RegistryChange::Register(named_registration( "nel", "3mqydma3re27z", "kelp", &survivor, 2, )), &world.signer, at(2), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(first)), "the name resolves to the older registration while both exist" ); store .update( &meta_home(), object, &RegistryChange::Deregister(RepoRef { owner: own("nel"), rkey: rkey("3lubrptx57d22"), }), &world.signer, at(3), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(survivor)), "the name resolves to the remaining repo once the older one is deregistered" ); } #[test] fn a_rename_moves_name_resolution_off_the_old_name() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3mizfnpxii522", "anemone", &repo, 1, )), &world.signer, at(1), ) .unwrap() .object; let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("anemone")), Resolved::Ready(Some(repo.clone())) ); store .update( &meta_home(), object, &RegistryChange::Rename(Rename { owner: own("nel"), rkey: rkey("3mizfnpxii522"), name: RepoName::new("barnacle").unwrap(), repo: repo.clone(), }), &world.signer, at(2), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("barnacle")), Resolved::Ready(Some(repo.clone())), "the new name resolves after a rename that keeps the record key" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("anemone")), Resolved::Ready(None), "the superseded name stops resolving" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), Resolved::Ready(Some(repo)) ); } #[test] fn a_rename_that_changes_only_the_record_key_keeps_the_name_resolving() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration("nel", "3lubrptx57d22", "kelp", &repo, 1)), &world.signer, at(1), ) .unwrap() .object; let index = world.index(); index.rebuild().unwrap(); store .update( &meta_home(), object, &RegistryChange::Rename(Rename { owner: own("nel"), rkey: rkey("3mqydma3re27z"), name: RepoName::new("kelp").unwrap(), repo: repo.clone(), }), &world.signer, at(2), ) .unwrap(); index.refresh_registry().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("kelp")), Resolved::Ready(Some(repo.clone())), "the unchanged name survives a rename that swaps the record key" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), Resolved::Ready(Some(repo.clone())) ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), Resolved::Ready(Some(repo)), "the superseded record key keeps resolving through its retained alias" ); } #[test] fn seeding_and_replaying_agree_on_name_resolution() { let world = World::new(); let first = repo_did("whelk"); let second = repo_did("conch"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); let object = store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "3lubrptx57d22", "kelp", &first, 1, )), &world.signer, at(1), ) .unwrap() .object; let replayed = world.index(); replayed.rebuild().unwrap(); store .update( &meta_home(), object, &RegistryChange::Register(named_registration( "nel", "3mqydma3re27z", "uni", &second, 2, )), &world.signer, at(2), ) .unwrap(); replayed.refresh_registry().unwrap(); let seeded = world.index(); seeded.rebuild().unwrap(); ["kelp", "uni", "3lubrptx57d22", "3mqydma3re27z", "nautilus"] .into_iter() .for_each(|segment| { assert_eq!( replayed.resolve_clone_path(&own("nel"), &path(segment)), seeded.resolve_clone_path(&own("nel"), &path(segment)), "incremental replay and a cold seed disagree on {segment}" ); }); } #[test] fn a_name_differing_from_its_record_key_only_by_case_resolves() { let world = World::new(); let repo = repo_did("squid"); let meta = Repo::open(&world.meta_path).unwrap(); let store = CobStore::new(&meta); store .create( &meta_home(), &RegistryChange::Register(named_registration( "nel", "runic_lang", "Runic_lang", &repo, 1, )), &world.signer, at(1), ) .unwrap(); let index = world.index(); index.rebuild().unwrap(); assert_eq!( index.resolve_clone_path(&own("nel"), &path("Runic_lang")), Resolved::Ready(Some(repo.clone())), "the appview lowercases the record key but keeps the display name's case, \ so the mixed-case path resolves by name" ); assert_eq!( index.resolve_clone_path(&own("nel"), &path("runic_lang")), Resolved::Ready(Some(repo)), "the lowercased record key still resolves" ); }