diff --git a/docs/src/content/docs/reference/records.md b/docs/src/content/docs/reference/records.md index 018767558..e069fc451 100644 --- a/docs/src/content/docs/reference/records.md +++ b/docs/src/content/docs/reference/records.md @@ -25,6 +25,7 @@ Location key: - **User PDS**: the author's own account. - **Repo account**: the repository DID's account, hosted by its knot. +- **Knot account**: the Knot has an overarching DID, hosting the Knot-level records such as membership (offers). - **Not a record**: a schema for an object or event that is never stored in a PDS. @@ -37,6 +38,10 @@ Location key: | `org.tangled.graph.collaborator` | Repo account | Invitee DID | Collaboration offer. Indexed by bobbin. Collaborator access itself is granted on the knot with `sh.tangled.repo.addCollaborator`. | | `org.tangled.label.definition` | Repo account | any | A label in the repository's catalogue: `{name, color}`. | +## Knots + +- **`org.tangled.knot.member`**: a membership offer record, under the holistic Knot-level "account", where the rkey is the member's DID. Member access itself is granted on the knot with `sh.tangled.knot.addMember`, subject to change when we figure out the best way to use the more atproto-native way of`createRecord` of a member record, with a knot-level session. + ## Issues and pull requests | NSID | Stored in | Record key | Purpose | diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index 3e1a76cdd..2bf9e16ea 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -913,7 +913,7 @@ mod tests { assert_eq!( pushes(&index, &acl, &squid, &acc("lyna")).await, Decision::Deny, - "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push" + "rebuild materializes collaborators only for registered repos, so a collaborator COB on an unregistered repo never warms and never grants a push" ); } } diff --git a/knot2/crates/knot-bench/benches/cob.rs b/knot2/crates/knot-bench/benches/cob.rs index 4d4cc3743..d915215e7 100644 --- a/knot2/crates/knot-bench/benches/cob.rs +++ b/knot2/crates/knot-bench/benches/cob.rs @@ -13,11 +13,11 @@ fn main() { } #[divan::bench(args = GRADES)] -fn fold(bencher: Bencher, changes: u32) { +fn replay(bencher: Bencher, changes: u32) { let cob = build_linear_cob(ChangeCount::new(changes)); bencher .counter(ItemsCount::new(changes as usize)) - .bench_local(|| cob.fold()); + .bench_local(|| cob.replay()); } #[divan::bench(args = REPO_GRADES)] diff --git a/knot2/crates/knot-bench/benches/projection.rs b/knot2/crates/knot-bench/benches/projection.rs index eabb16590..fc51b70aa 100644 --- a/knot2/crates/knot-bench/benches/projection.rs +++ b/knot2/crates/knot-bench/benches/projection.rs @@ -1,8 +1,8 @@ use divan::Bencher; use divan::counter::ItemsCount; use knot_bench::{ - IssueCount, RepoCount, RepoIndex, RollCount, build_collaborator_roll, build_issue_tracker, - build_registry, + GrantListCount, IssueCount, RepoCount, RepoIndex, build_collaborator_grant_list, + build_issue_tracker, build_registry, }; use knot_cob::CHECKPOINTS_PREFIX; use knot_types::RefName; @@ -10,7 +10,7 @@ use knot_types::RefName; #[global_allocator] static ALLOC: divan::AllocProfiler = divan::AllocProfiler::system(); -const ROSTER_GRADES: &[u32] = &[64, 512, 4096]; +const GRANT_LIST_GRADES: &[u32] = &[64, 512, 4096]; const REGISTRY_GRADES: &[u64] = &[64, 4096]; const TRACKER_GRADES: &[u32] = &[100, 1000]; @@ -18,14 +18,14 @@ fn main() { divan::main(); } -#[divan::bench(args = ROSTER_GRADES)] +#[divan::bench(args = GRANT_LIST_GRADES)] fn collaborator_refresh(bencher: Bencher, collaborators: u32) { - let built = build_collaborator_roll(RollCount::new(collaborators)); + let built = build_collaborator_grant_list(GrantListCount::new(collaborators)); let index = built.index(); index.rebuild().expect("rebuild"); index .ensure_collaborators(built.repo()) - .expect("first fold"); + .expect("first materialize"); bencher .counter(ItemsCount::new(collaborators as usize)) .bench_local(|| index.refresh_collaborators(built.repo()).expect("refresh")); diff --git a/knot2/crates/knot-bench/src/fixtures.rs b/knot2/crates/knot-bench/src/fixtures.rs index 1fb48d944..3282893cc 100644 --- a/knot2/crates/knot-bench/src/fixtures.rs +++ b/knot2/crates/knot-bench/src/fixtures.rs @@ -40,7 +40,7 @@ at_least_one! { ChangeCount of u32 RepoCount of u64 RefCount of u32 - RollCount of u32 + GrantListCount of u32 IssueCount of u32 } @@ -352,14 +352,14 @@ pub fn build_registry(repos: RepoCount) -> BuiltRegistry { } } -pub struct BuiltRoll { +pub struct BuiltGrantList { _dir: TempDir, meta_path: PathBuf, layout: Layout, repo: RepoDid, } -impl BuiltRoll { +impl BuiltGrantList { pub fn index(&self) -> Index { Index::new(&self.meta_path, self.layout.clone()) } @@ -368,7 +368,7 @@ impl BuiltRoll { } } -pub fn build_collaborator_roll(collaborators: RollCount) -> BuiltRoll { +pub fn build_collaborator_grant_list(collaborators: GrantListCount) -> BuiltGrantList { let dir = tempfile::tempdir().expect("tempdir"); let meta_path = dir.path().join("meta.git"); Repo::create(&meta_path).expect("create meta"); @@ -402,7 +402,7 @@ pub fn build_collaborator_roll(collaborators: RollCount) -> BuiltRoll { .expect("add collaborator"); }); - BuiltRoll { + BuiltGrantList { _dir: dir, meta_path, layout, @@ -623,15 +623,15 @@ impl BuiltRegistryWriter { &self.signer, UnixSeconds::new(GENESIS_SECONDS), ) - .expect("idempotent re-register folds registry"); + .expect("idempotent re-register refreshes the registry"); } - pub fn full_fold(&self) { + pub fn full_replay(&self) { let meta = Repo::open(&self.meta_path).expect("reopen meta"); let store = CobStore::new(&meta); store .get::(self.object) - .expect("full fold of registry"); + .expect("full replay of the registry"); } } @@ -690,27 +690,27 @@ impl BuiltMembersWriter { self.object, &self.signer, UnixSeconds::new(GENESIS_SECONDS), - |roll| { - Ok(if roll.contains(&grant(0).subject) { + |list| { + Ok(if list.contains(&grant(0).subject) { None } else { Some(MembersChange::Add(grant(0))) }) }, ) - .expect("idempotent re-add folds the bounded suffix"); + .expect("idempotent re-add refreshes the bounded suffix"); } - pub fn full_fold(&self) { + pub fn full_replay(&self) { let meta = Repo::open(&self.meta_path).expect("reopen meta"); let store = CobStore::new(&meta); store .get::(self.object) - .expect("full fold of members"); + .expect("full replay of members"); } } -pub fn build_members_checkpointed(members: RollCount) -> BuiltMembersWriter { +pub fn build_members_checkpointed(members: GrantListCount) -> BuiltMembersWriter { let dir = tempfile::tempdir().expect("tempdir"); let meta_path = dir.path().join("meta.git"); Repo::create(&meta_path).expect("create meta"); @@ -754,11 +754,11 @@ pub struct BuiltLinearCob { } impl BuiltLinearCob { - pub fn fold(&self) -> usize { + pub fn replay(&self) -> usize { let store = CobStore::new(&self.repo); store .get::(self.object) - .expect("fold registry") + .expect("full replay of registry") .state() .len() } diff --git a/knot2/crates/knot-bench/src/latency.rs b/knot2/crates/knot-bench/src/latency.rs index 5bf4b7999..3de004efc 100644 --- a/knot2/crates/knot-bench/src/latency.rs +++ b/knot2/crates/knot-bench/src/latency.rs @@ -21,12 +21,12 @@ impl OpenLatency { } pub fn replay_boot(index: &Index, repos: &[RepoDid], per_open: OpenLatency) { - index.refresh_members().expect("fold members"); - index.refresh_registry().expect("fold registry"); + index.refresh_members().expect("refresh members"); + index.refresh_registry().expect("refresh registry"); repos.iter().for_each(|repo| { per_open.stall(); index .refresh_collaborators(repo) - .expect("fabric-boot repo must fold, otherwise bench measures nothing"); + .expect("fabric-boot repo must materialize, or the bench measures garbage"); }); } diff --git a/knot2/crates/knot-bench/src/lib.rs b/knot2/crates/knot-bench/src/lib.rs index 74b12090c..01756c406 100644 --- a/knot2/crates/knot-bench/src/lib.rs +++ b/knot2/crates/knot-bench/src/lib.rs @@ -2,10 +2,11 @@ mod fixtures; mod latency; pub use fixtures::{ - BuiltHistory, BuiltLinearCob, BuiltMembersWriter, BuiltRefs, BuiltRegistry, - BuiltRegistryWriter, BuiltRoll, BuiltTracker, ChangeCount, ChurnCount, CommitCount, - HistorySpec, IssueCount, PathCount, RefCount, RepoCount, RepoIndex, RollCount, - build_collaborator_roll, build_history, build_issue_tracker, build_linear_cob, build_many_refs, - build_members_checkpointed, build_registry, build_registry_checkpointed, write_history, + BuiltGrantList, BuiltHistory, BuiltLinearCob, BuiltMembersWriter, BuiltRefs, BuiltRegistry, + BuiltRegistryWriter, BuiltTracker, ChangeCount, ChurnCount, CommitCount, GrantListCount, + HistorySpec, IssueCount, PathCount, RefCount, RepoCount, RepoIndex, + build_collaborator_grant_list, build_history, build_issue_tracker, build_linear_cob, + build_many_refs, build_members_checkpointed, build_registry, build_registry_checkpointed, + write_history, }; pub use latency::{OpenLatency, replay_boot}; diff --git a/knot2/crates/knot-bench/tests/boot.rs b/knot2/crates/knot-bench/tests/boot.rs index ba2b92d91..aba35ab1d 100644 --- a/knot2/crates/knot-bench/tests/boot.rs +++ b/knot2/crates/knot-bench/tests/boot.rs @@ -2,7 +2,7 @@ use knot_bench::{RepoCount, build_registry}; use knot_index::Resolved; #[test] -fn the_registry_fixture_boots_without_folding_collaborators() { +fn the_registry_fixture_boots_without_materializing_collaborators() { let registry = build_registry(RepoCount::new(8)); let index = registry.index(); index.rebuild().unwrap(); @@ -11,7 +11,7 @@ fn the_registry_fixture_boots_without_folding_collaborators() { assert_eq!( index.effective_collaborator(repo, &knot_types::AccountDid::new("did:plc:nel").unwrap()), Resolved::Warming, - "rebuild must not fold any collaborator COB, so every repo reads warming until first access" + "rebuild mustn't materialize any collaborator COB, so every repo reads warming until first access" ); }); @@ -21,6 +21,6 @@ fn the_registry_fixture_boots_without_folding_collaborators() { !index .effective_collaborator(first, &knot_types::AccountDid::new("did:plc:nel").unwrap()) .is_warming(), - "repo folds on first access" + "a repo materializes on first access" ); } diff --git a/knot2/crates/knot-bench/tests/members_gate.rs b/knot2/crates/knot-bench/tests/members_gate.rs index 167dd6ced..01f60f838 100644 --- a/knot2/crates/knot-bench/tests/members_gate.rs +++ b/knot2/crates/knot-bench/tests/members_gate.rs @@ -1,27 +1,27 @@ #![cfg(feature = "instrument")] -use knot_bench::{RollCount, build_members_checkpointed}; +use knot_bench::{GrantListCount, build_members_checkpointed}; use knot_cob::instrument::measure; #[test] fn a_checkpointed_member_write_reads_a_bounded_object_set() { - let small = build_members_checkpointed(RollCount::new(512)); - let large = build_members_checkpointed(RollCount::new(1024)); + let small = build_members_checkpointed(GrantListCount::new(512)); + let large = build_members_checkpointed(GrantListCount::new(1024)); - let (_, full_small) = measure(|| small.full_fold()); - let (_, full_large) = measure(|| large.full_fold()); + let (_, full_small) = measure(|| small.full_replay()); + let (_, full_large) = measure(|| large.full_replay()); let (_, write_small) = measure(|| small.probe()); let (_, write_large) = measure(|| large.probe()); assert_eq!( full_small.get(), 2 * 512, - "full fold reads every change's commit and payload, so twice the member count" + "a full replay reads every change's commit and payload, so twice the member count" ); assert_eq!( full_large.get(), 2 * 1024, - "full fold the checkpoint replaces is linear in member count" + "the full replay the checkpoint replaces is linear in member count" ); assert_eq!( write_small.get(), @@ -37,7 +37,7 @@ fn a_checkpointed_member_write_reads_a_bounded_object_set() { ); assert!( write_large.get() < full_large.get() / 3, - "checkpoint cuts per-write object reads far below the full fold, {} vs {}", + "checkpoint cuts per-write object reads far below the full replay, {} vs {}", write_large.get(), full_large.get() ); diff --git a/knot2/crates/knot-bench/tests/registry_gate.rs b/knot2/crates/knot-bench/tests/registry_gate.rs index 4763706cd..fe2b83e57 100644 --- a/knot2/crates/knot-bench/tests/registry_gate.rs +++ b/knot2/crates/knot-bench/tests/registry_gate.rs @@ -8,20 +8,20 @@ fn a_checkpointed_registry_write_reads_a_bounded_object_set() { let small = build_registry_checkpointed(RepoCount::new(512)); let large = build_registry_checkpointed(RepoCount::new(1024)); - let (_, full_small) = measure(|| small.full_fold()); - let (_, full_large) = measure(|| large.full_fold()); + let (_, full_small) = measure(|| small.full_replay()); + let (_, full_large) = measure(|| large.full_replay()); let (_, write_small) = measure(|| small.probe()); let (_, write_large) = measure(|| large.probe()); assert_eq!( full_small.get(), 2 * 512, - "full fold reads every change's commit and payload, so twice the change count" + "a full replay reads every change's commit and payload, so twice the change count" ); assert_eq!( full_large.get(), 2 * 1024, - "full fold the checkpoint replaces is linear in repo count" + "the full replay the checkpoint replaces is linear in repo count" ); assert_eq!( write_small.get(), @@ -37,7 +37,7 @@ fn a_checkpointed_registry_write_reads_a_bounded_object_set() { ); assert!( write_large.get() < full_large.get() / 3, - "checkpoint cuts per-write object reads far below the full fold, {} vs {}", + "checkpoint cuts per-write object reads far below the full replay, {} vs {}", write_large.get(), full_large.get() ); diff --git a/knot2/crates/knot-cob/src/error.rs b/knot2/crates/knot-cob/src/error.rs index 8223f0b66..2140c2607 100644 --- a/knot2/crates/knot-cob/src/error.rs +++ b/knot2/crates/knot-cob/src/error.rs @@ -31,7 +31,7 @@ pub enum CobError { #[error("object {object} tip doesn't descend from indexed tip {since}")] DivergedTip { object: CobId, since: ChangeId }, #[error( - "folding change {change}, of object {object}, into that mr tip, which it doesn't descend from, would count changes the index already has" + "applying change {change}, of object {object}, to that mr tip, which it doesn't descend from, would count changes the index already has" )] DeltaNotDescended { object: CobId, change: ChangeId }, #[error("change's {blob} blob, of {bytes} bytes, is over {limit} byte limit")] diff --git a/knot2/crates/knot-cob/src/lib.rs b/knot2/crates/knot-cob/src/lib.rs index da23f7197..56ede26b6 100644 --- a/knot2/crates/knot-cob/src/lib.rs +++ b/knot2/crates/knot-cob/src/lib.rs @@ -807,8 +807,8 @@ impl<'r> CobStore<'r> { let stride = checkpoint_stride(E::SNAPSHOT_STRIDE, E::checkpoint_size(&state)); if suffix.saturating_add(1) >= stride { let author = ActorId::from_secp256k1(signer.public_key().as_bytes()); - let folded = E::apply(state, materialized.change, &author); - if let Err(error) = self.write_checkpoint::(object, tip, &folded) { + let applied = E::apply(state, materialized.change, &author); + if let Err(error) = self.write_checkpoint::(object, tip, &applied) { tracing::warn!( cob = type_name.as_str(), object = %object.oid().to_hex(), @@ -851,8 +851,8 @@ impl<'r> CobStore<'r> { Some((checkpoint_tip, state)) => { match self.changes_since::(object, Some(checkpoint_tip)) { Ok(delta) => { - let folded = object::fold_changes::(state, &delta.changes, &type_name)?; - Ok((folded, tip, delta.changes.len())) + let applied = object::apply_changes::(state, &delta.changes, &type_name)?; + Ok((applied, tip, delta.changes.len())) } Err( unusable @ (CobError::DivergedTip { .. } @@ -861,7 +861,7 @@ impl<'r> CobStore<'r> { tracing::warn!( %object, error = %unusable, - "checkpoint no longer bounds this object's history, fold starts over from its root change" + "checkpoint no longer bounds this object's history, the replay starts over from its root change" ); self.full_state::(object) .map(|state| (state, tip, usize::MAX)) @@ -1233,7 +1233,7 @@ mod tests { } #[test] - fn checkpointed_writes_match_a_full_fold_and_leave_a_snapshot() { + fn checkpointed_writes_match_a_full_replay_and_leave_a_snapshot() { let (_dir, repo) = fixture(); let store = CobStore::new(&repo); let key = signer(1); @@ -1258,11 +1258,11 @@ mod tests { )); }); - let folded = store.get::(object).unwrap(); + let replayed = store.get::(object).unwrap(); assert_eq!( - folded.state().len(), + replayed.state().len(), total, - "every checkpointed write landed and full fold agrees" + "a full replay counts every checkpointed write" ); let snapshot = backend::checkpoint_ref_name(&NameChange::type_name(), object).unwrap(); assert!( @@ -1293,7 +1293,7 @@ mod tests { assert_eq!( checkpoint_stride(SnapshotStride::new(256), StateSize::new(4_000_000)), backend::MAX_GRAPH_CHANGES, - "the stride stops at the serving limit so the boot tail always folds incrementally, never overflowing into a full rebuild" + "the stride stops at the serving limit so the boot tail always replays incrementally, never overflowing into a full rebuild" ); } @@ -1327,7 +1327,7 @@ mod tests { assert_eq!( store.get::(object).unwrap().state().len(), total, - "full fold still agrees after adaptive snapshotting" + "a full replay still counts every write after adaptive snapshotting" ); let (_state, _tip, suffix) = store.checkpointed_state::(object).unwrap(); @@ -1424,7 +1424,7 @@ mod tests { assert_eq!( &state, store.get::(object).unwrap().state(), - "healed snapshot matches the full fold" + "healed snapshot matches the full replay" ); }); } @@ -1507,7 +1507,7 @@ mod tests { let one_gib = backend::rebuild_change_limit_for(available(1024 * 1024 * 1024)); assert!( (100_000..150_000).contains(&one_gib), - "on a 1 GiB host the fold budget tracks the measured per-change cost near the serving limit, was {one_gib}" + "on a 1 GiB host the rebuild budget tracks the measured per-change cost near the serving limit, was {one_gib}" ); let roomy = backend::rebuild_change_limit_for(available(64 * 1024 * 1024 * 1024)); assert!( @@ -2191,7 +2191,7 @@ mod tests { CobStore::new(&diverged_repo).changes_since::(diverged, Some(stranger)), Err(CobError::DivergedTip { .. }) ), - "a since that doesn't descend from the indexed tip is refused, not re-folded onto stale state" + "a since that doesn't descend from the indexed tip is refused, not applied onto stale state" ); let (_forked_dir, forked_repo) = fixture(); @@ -2209,11 +2209,11 @@ mod tests { forked_store.changes_since::(forked, None), Err(CobError::ForkedHistory { .. }) ), - "changes_since refuses the same fork instead of folding it for the index" + "changes_since rejects the same fork instead of replaying it for the index" ); assert!( forked_store.changes_since::(forked, None).is_ok(), - "convergent class still folds the same graph" + "convergent class still replays the same graph" ); } @@ -2353,7 +2353,7 @@ mod tests { #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })] #[test] - fn prop_concurrent_changes_converge_to_the_causal_fold((ops, keys) in ops_and_perm()) { + fn prop_concurrent_changes_converge_to_the_causal_order((ops, keys) in ops_and_perm()) { let identity: Vec = (0..ops.len()).collect(); let model = model_state(&ops); @@ -2830,7 +2830,7 @@ mod tests { } #[test] - fn two_edits_from_one_folded_state_end_as_one_write_and_one_conflict() { + fn two_edits_from_one_materialized_state_end_as_one_write_and_one_conflict() { let dir = tempfile::tempdir().unwrap(); let layout = Layout::new(dir.path()); let did = squid(); @@ -2921,7 +2921,7 @@ mod tests { } #[test] - fn a_forked_social_history_folds_through_the_convergent_arm() { + fn a_forked_social_history_replays_through_the_convergent_arm() { let (_dir, repo) = fixture(); let key = signer(16); let store = CobStore::new(&repo); @@ -2949,22 +2949,22 @@ mod tests { }) .unwrap(); - let folded = store.get::(object).unwrap(); + let replayed = store.get::(object).unwrap(); assert_eq!( - folded.state().text, + replayed.state().text, vec!["kelp", "left", "right", "merged"], "Causal order puts fork's branches in timestamp order under merge" ); assert_eq!( - folded.state().version, + replayed.state().version, merge_change.change.emits().version(), - "the current version is the merge's, which fold order reaches last" + "the current version is the merge's, which replay order reaches last" ); let again = store.get::(object).unwrap(); assert_eq!( - serde_ipld_dagcbor::to_vec(folded.state()).unwrap(), + serde_ipld_dagcbor::to_vec(replayed.state()).unwrap(), serde_ipld_dagcbor::to_vec(again.state()).unwrap(), - "Second fold of one forked history reaches byte-identical state" + "a second replay of one forked history reaches byte-identical state" ); } @@ -3010,7 +3010,7 @@ mod tests { #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })] #[test] - fn prop_a_social_fork_folds_the_same_whatever_order_its_changes_arrived( + fn prop_a_social_fork_replays_the_same_whatever_order_its_changes_arrived( keys in prop::collection::vec(any::(), 1..8usize) ) { let identity: Vec = (0..keys.len()).collect(); @@ -3033,18 +3033,18 @@ mod tests { } #[test] - fn a_fork_of_one_edit_folds_root_edit_and_merge_once_each() { + fn a_fork_of_one_edit_replays_root_edit_and_merge_once_each() { let (_dir, repo, object) = note_fan(&[0]); - let folded = CobStore::new(&repo).get::(object).unwrap(); + let replayed = CobStore::new(&repo).get::(object).unwrap(); assert_eq!( - folded.state().text, + replayed.state().text, vec!["kelp", "edit0", "merged"], - "Fold order is causal sort, not the order git took objects in" + "replay order is causal sort, not the order git took objects in" ); } #[test] - fn a_merge_that_forks_below_a_checkpoint_folds_from_the_root_instead_of_double_counting() { + fn a_merge_that_forks_below_a_checkpoint_replays_from_the_root_instead_of_double_counting() { let (_dir, repo, created, mut live) = opened_note(17); let key = signer(17); let store = CobStore::new(&repo); @@ -3082,11 +3082,11 @@ mod tests { .unwrap(); let (materialized, tip) = store.materialize::(object).unwrap(); - let folded = store.get::(object).unwrap(); + let replayed = store.get::(object).unwrap(); assert_eq!(tip, merge); assert_eq!( materialized, - *folded.state(), + *replayed.state(), "checkpoint is abandoned, rather than trusted, delta under that mr checkpoint would count its prefix twice" ); assert_eq!( diff --git a/knot2/crates/knot-cob/src/object.rs b/knot2/crates/knot-cob/src/object.rs index 47efc1472..15d7905ed 100644 --- a/knot2/crates/knot-cob/src/object.rs +++ b/knot2/crates/knot-cob/src/object.rs @@ -87,7 +87,7 @@ pub trait Checkpoint: Evaluate { fn checkpoint_size(state: &Self::State) -> StateSize; } -pub(crate) fn fold_changes( +pub(crate) fn apply_changes( state: E::State, changes: &[Change], expected: &TypeName, @@ -115,7 +115,7 @@ pub(crate) fn evaluate( ) -> Result<(E::State, History), CobError> { let root = ChangeId::new(graph.root().oid()); let ordered = graph.into_ordered(); - let state = fold_changes::(E::initial(), &ordered, expected)?; + let state = apply_changes::(E::initial(), &ordered, expected)?; Ok((state, History::new(root, ordered))) } diff --git a/knot2/crates/knot-cobs/src/blocklist.rs b/knot2/crates/knot-cobs/src/blocklist.rs index 56a4effba..d8067ed36 100644 --- a/knot2/crates/knot-cobs/src/blocklist.rs +++ b/knot2/crates/knot-cobs/src/blocklist.rs @@ -1,6 +1,6 @@ -use crate::grant::roll_cob; +use crate::grant::grant_cob; -roll_cob! { +grant_cob! { change = BlocklistChange, ops = { Add(Grant), Remove(Removal) }, cob = BlocklistCob, diff --git a/knot2/crates/knot-cobs/src/collaborators.rs b/knot2/crates/knot-cobs/src/collaborators.rs index 6af709c71..01c3a8cad 100644 --- a/knot2/crates/knot-cobs/src/collaborators.rs +++ b/knot2/crates/knot-cobs/src/collaborators.rs @@ -1,6 +1,6 @@ -use crate::grant::roll_cob; +use crate::grant::grant_cob; -roll_cob! { +grant_cob! { change = CollaboratorsChange, ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = CollaboratorsCob, diff --git a/knot2/crates/knot-cobs/src/grant.rs b/knot2/crates/knot-cobs/src/grant.rs index 014898b7a..562ac18aa 100644 --- a/knot2/crates/knot-cobs/src/grant.rs +++ b/knot2/crates/knot-cobs/src/grant.rs @@ -31,11 +31,11 @@ pub struct Entry { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Roll { +pub struct GrantList { entries: BTreeMap, } -impl Roll { +impl GrantList { pub fn empty() -> Self { Self { entries: BTreeMap::new(), @@ -125,7 +125,7 @@ grant_change! { Removal(removal) => Effect::Revoke(&removal.subject), } -macro_rules! roll_cob { +macro_rules! grant_cob { ( change = $change:ident, ops = { $($variant:ident($payload:ident)),+ $(,)? }, @@ -153,7 +153,7 @@ macro_rules! roll_cob { impl ::knot_cob::KnotAuthored for $change {} - pub type $state = $crate::grant::Roll; + pub type $state = $crate::grant::GrantList; pub struct $cob; @@ -164,7 +164,7 @@ macro_rules! roll_cob { const HISTORY: ::knot_cob::HistoryModel = ::knot_cob::HistoryModel::Linear; fn initial() -> Self::State { - $crate::grant::Roll::empty() + $crate::grant::GrantList::empty() } fn apply( @@ -186,7 +186,7 @@ macro_rules! roll_cob { #[cfg(test)] #[test] - fn the_change_declares_its_lexicon_type_and_folds_through_the_cob() { + fn the_change_declares_its_lexicon_type_and_applies_through_the_cob() { use ::knot_cob::{ChangePayload, Evaluate}; let did = |value: &str| ::knot_types::AccountDid::new(value).unwrap(); @@ -200,18 +200,18 @@ macro_rules! roll_cob { assert_eq!( $change::decode(&change.encode().unwrap()).unwrap(), change, - "Change must decode from dag-cbor as itself, or its replay folds another roll" + "Change must decode from dag-cbor as itself, or its replay builds another grant list" ); assert_eq!( $cob::apply($cob::initial(), change, &actor).len(), 1, - "Cob's fold has to write change's subject into its roll" + "Cob's `apply` has to write the change's subject into its grant list" ); } }; } -pub(crate) use roll_cob; +pub(crate) use grant_cob; #[cfg(test)] mod tests { @@ -245,8 +245,10 @@ mod tests { } } - fn fold(changes: Vec) -> Roll { - changes.iter().fold(Roll::empty(), Roll::apply_change) + fn fold(changes: Vec) -> GrantList { + changes + .iter() + .fold(GrantList::empty(), GrantList::apply_change) } fn entry(added_by: &str, at: i64) -> Option { @@ -256,7 +258,7 @@ mod tests { }) } - fn history() -> Roll { + fn history() -> GrantList { fold(vec![ change(b'g', "granted", "olaren", 1), change(b'i', "invited", "olaren", 1), @@ -293,8 +295,8 @@ mod tests { } #[test] - fn a_fold_keeps_the_first_admission_until_a_removal_clears_it() { - let roll = history(); + fn replay_keeps_the_first_admission_until_a_removal_clears_it() { + let list = history(); [ ("nobody", None), ("granted", entry("olaren", 1)), @@ -315,25 +317,25 @@ mod tests { .into_iter() .for_each(|(subject, expected)| { assert_eq!( - roll.get(&did(subject)).cloned(), + list.get(&did(subject)).cloned(), expected, "the first admission sets an entry's provenance, and only a removal clears it: \ {subject}" ); }); assert!( - roll.entries().all(|(subject, _)| roll.contains(subject)), - "every entry the roll holds is a member, there is no pending state left to be in" + list.entries().all(|(subject, _)| list.contains(subject)), + "every entry on the grant list is a member, and pending isn't a state an entry can end up in" ); assert!( !fold(vec![]).contains(&did("nel")), - "an empty change log admits nobody" + "an empty change log doesn't admit anybody" ); } #[test] - fn a_legacy_invite_admits_and_its_acceptance_folds_to_nothing() { - let invited = Roll::empty().apply_change(&change(b'i', "nel", "olaren", 1)); + fn a_legacy_invite_admits_and_apply_skips_its_acceptance() { + let invited = GrantList::empty().apply_change(&change(b'i', "nel", "olaren", 1)); assert_eq!( invited.get(&did("nel")).cloned(), entry("olaren", 1), @@ -348,8 +350,8 @@ mod tests { invited.would_change(&change(b'a', "nel", "olaren", 7)) ), (invited.clone(), false), - "an acceptance left over from the consent model must fold to nothing, and never \ - append a change of its own" + "an acceptance from the consent model mustn't change the grant list or append a \ + change" ); } @@ -390,14 +392,14 @@ mod tests { serde_ipld_dagcbor::from_slice::(&under_consent).unwrap(), entry("olaren", 7).unwrap(), "a checkpoint written under the consent model has to keep decoding, or a live knot \ - loses its roll on upgrade" + loses its grant list on upgrade" ); let mixed = history(); - let reread: Roll = + let reread: GrantList = serde_ipld_dagcbor::from_slice(&serde_ipld_dagcbor::to_vec(&mixed).unwrap()).unwrap(); assert_eq!( reread, mixed, - "the checkpoint is the roll's own encoding, so it has to come back out of one" + "the checkpoint is the grant list's encoding, so it has to decode back into the same list" ); } @@ -405,11 +407,11 @@ mod tests { #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] #[test] - fn would_change_agrees_with_the_fold_over_the_whole_alphabet( + fn would_change_agrees_with_replay_over_the_whole_alphabet( ops in prop::collection::vec((0u8..4, 0u8..3), 0..12), (op, who) in (0u8..4, 0u8..3), ) { - let roll = fold( + let list = fold( ops.iter() .enumerate() .map(|(index, (op, who))| { @@ -420,9 +422,9 @@ mod tests { ); let next = change(ALPHABET[op as usize], &format!("s{who}"), "olaren", 99); prop_assert_eq!( - roll.would_change(&next), - roll.clone().apply_change(&next) != roll, - "the append-time predicate and the fold disagree on {:?}", + list.would_change(&next), + list.clone().apply_change(&next) != list, + "the append-time predicate and the replay disagree on {:?}", next ); } diff --git a/knot2/crates/knot-cobs/src/lib.rs b/knot2/crates/knot-cobs/src/lib.rs index dde68d043..dc0013fa8 100644 --- a/knot2/crates/knot-cobs/src/lib.rs +++ b/knot2/crates/knot-cobs/src/lib.rs @@ -17,7 +17,7 @@ pub use manifest::{Manifest, ManifestChange, ManifestCob}; pub use collaborators::{Collaborators, CollaboratorsChange, CollaboratorsCob}; pub use comment::{Comment, CommentChange, CommentCob}; -pub use grant::{Accept, Effect, Entry, Grant, GrantChange, Invite, Removal, Roll}; +pub use grant::{Accept, Effect, Entry, Grant, GrantChange, GrantList, Invite, Removal}; pub use import::{ImportError, verify_cob_ref}; pub use members::{Members, MembersChange, MembersCob}; pub use pull::{Patch, PatchChange, PatchCob}; diff --git a/knot2/crates/knot-cobs/src/members.rs b/knot2/crates/knot-cobs/src/members.rs index ba00f5f4a..410294ce5 100644 --- a/knot2/crates/knot-cobs/src/members.rs +++ b/knot2/crates/knot-cobs/src/members.rs @@ -1,6 +1,6 @@ -use crate::grant::roll_cob; +use crate::grant::grant_cob; -roll_cob! { +grant_cob! { change = MembersChange, ops = { Add(Grant), Invite(Invite), Accept(Accept), Remove(Removal) }, cob = MembersCob, diff --git a/knot2/crates/knot-cobs/src/registry.rs b/knot2/crates/knot-cobs/src/registry.rs index d96d812da..2afb67e91 100644 --- a/knot2/crates/knot-cobs/src/registry.rs +++ b/knot2/crates/knot-cobs/src/registry.rs @@ -582,7 +582,7 @@ mod tests { ]); assert_eq!(state.resolve(&owner("nel"), &rkey("anemone")), None); assert_eq!(state.resolve(&owner("nel"), &rkey("barnacle")), None); - assert!(state.is_empty(), "replaying a deregister folds as a no-op"); + assert!(state.is_empty(), "replaying a deregister is a no-op"); assert_eq!(state, Registry::default()); } @@ -655,7 +655,7 @@ mod tests { } #[test] - fn a_registration_written_before_the_policy_field_still_folds() { + fn a_registration_written_before_the_policy_field_still_applies() { #[derive(Serialize)] struct Before<'a> { owner: &'a str, @@ -680,10 +680,10 @@ mod tests { })) .unwrap(); let decoded = RegistryChange::decode(&bytes) - .expect("Change from before this field existed still folds"); - let folded = fold(vec![decoded]); + .expect("Change from before this field existed still applies"); + let state = fold(vec![decoded]); assert_eq!( - folded.record_of(&repo("squid")).unwrap().policy, + state.record_of(&repo("squid")).unwrap().policy, ContributionPolicy::Collaborators, "Repository registered before this field existed takes the safe policy, not the open one" ); diff --git a/knot2/crates/knot-cobs/src/social.rs b/knot2/crates/knot-cobs/src/social.rs index c1067afb1..e16b30063 100644 --- a/knot2/crates/knot-cobs/src/social.rs +++ b/knot2/crates/knot-cobs/src/social.rs @@ -166,7 +166,7 @@ impl Evaluate for SocialCob { } fn apply(state: Self::State, change: Self::Change, _author: &ActorId) -> Self::State { - state.fold(&change) + state.apply(&change) } } @@ -312,7 +312,7 @@ impl SocialState { } } - pub fn fold(self, change: &impl Social) -> Self { + pub fn apply(self, change: &impl Social) -> Self { match (self, change.emission()) { (Self::Unopened, Emission::Open(creation)) => Self::Live(Box::new(SocialObject { address: creation.address.clone(), @@ -561,28 +561,28 @@ mod tests { } } - fn folded(changes: &[AnySocialChange]) -> SocialState { + fn replayed(changes: &[AnySocialChange]) -> SocialState { changes .iter() - .fold(SocialState::Unopened, |state, change| state.fold(change)) + .fold(SocialState::Unopened, |state, change| state.apply(change)) } #[test] - fn a_change_before_the_root_folds_to_an_unopened_object() { - assert_eq!(folded(&[edited(1, 1, 2, 2)]), SocialState::Unopened); - assert_eq!(folded(&[erased(1, 1, 2)]), SocialState::Unopened); + fn a_change_before_the_root_leaves_the_object_unopened() { + assert_eq!(replayed(&[edited(1, 1, 2, 2)]), SocialState::Unopened); + assert_eq!(replayed(&[erased(1, 1, 2)]), SocialState::Unopened); assert!( - folded(&[opened(1, 1), edited(1, 1, 2, 2)]) + replayed(&[opened(1, 1), edited(1, 1, 2, 2)]) .object() .is_some_and(|object| object.version() == Some(version(2))), - "Fold follows the version the latest change produced" + "the state follows the version the latest change produced" ); } #[test] fn a_second_root_never_moves_an_object_that_is_already_open() { - let once = folded(&[opened(1, 1)]); - let twice = folded(&[opened(1, 1), opened(2, 9)]); + let once = replayed(&[opened(1, 1)]); + let twice = replayed(&[opened(1, 1), opened(2, 9)]); assert_eq!( once, twice, "The address the root fixed, and the author that mr root named, both outlive any later root" @@ -591,7 +591,7 @@ mod tests { #[test] fn an_erasure_clears_the_version_and_keeps_the_authorship() { - let state = folded(&[opened(1, 1), erased(1, 1, 5)]); + let state = replayed(&[opened(1, 1), erased(1, 1, 5)]); let object = state.object().expect("Erased object still exists"); assert!(object.is_erased()); assert_eq!(object.author(), &who(1)); @@ -599,9 +599,9 @@ mod tests { } #[test] - fn an_edit_made_from_an_older_version_never_reaches_the_fold() { - let live = folded(&[opened(1, 1), edited(1, 1, 2, 2)]); - let stale = folded(&[opened(1, 1), edited(1, 1, 2, 2), edited(1, 1, 3, 3)]); + fn an_edit_made_from_an_older_version_never_reaches_the_state() { + let live = replayed(&[opened(1, 1), edited(1, 1, 2, 2)]); + let stale = replayed(&[opened(1, 1), edited(1, 1, 2, 2), edited(1, 1, 3, 3)]); assert_eq!( live, stale, "the projection ought to pin the version Edit states it was made from" @@ -611,13 +611,13 @@ mod tests { #[test] fn only_one_of_two_changes_from_a_shared_version_is_stored() { assert!( - folded(&[opened(1, 1), erased(1, 1, 2), edited(1, 1, 3, 3)]) + replayed(&[opened(1, 1), erased(1, 1, 2), edited(1, 1, 3, 3)]) .object() .is_some_and(SocialObject::is_erased), "Edit from version erasure retired doesn't republish the record" ); assert_eq!( - folded(&[opened(1, 1), edited(1, 1, 3, 3), erased(1, 1, 2)]) + replayed(&[opened(1, 1), edited(1, 1, 3, 3), erased(1, 1, 2)]) .object() .and_then(SocialObject::version), Some(version(3)), @@ -691,7 +691,7 @@ mod tests { #[test] fn the_blocklist_outranks_an_edit_of_an_account_s_own_object() { - let live = folded(&[opened(1, 1)]); + let live = replayed(&[opened(1, 1)]); let edit = edited(1, 1, 2, 2); let erase = erased(1, 1, 2); assert_eq!( @@ -742,7 +742,7 @@ mod tests { #[test] fn an_author_erases_its_own_text_after_the_policy_closed_behind_it() { - let live = folded(&[opened(1, 1)]); + let live = replayed(&[opened(1, 1)]); let closed = knot_types::ContributionPermission::new( Blocked::No, RepoRole::Stranger, @@ -880,7 +880,7 @@ mod tests { #[test] fn edit_and_erasure_belong_to_author_or_moderator() { - let live = folded(&[opened(1, 1)]); + let live = replayed(&[opened(1, 1)]); let open = opened(2, 5); let good = permission(RepoRole::Stranger, Blocked::No); let collaborator = permission(RepoRole::Collaborator, Blocked::No); @@ -939,7 +939,7 @@ mod tests { #[test] fn label_openings_decide_who_edits_and_erases() { use knot_types::Membership; - let live = folded(&[opened(1, 1)]); + let live = replayed(&[opened(1, 1)]); let owner = permission(RepoRole::Owner, Blocked::No); let admin = permission(RepoRole::KnotAdmin(Membership::Outsider), Blocked::No); let collaborator = permission(RepoRole::Collaborator, Blocked::No); @@ -1023,7 +1023,7 @@ mod tests { #[test] fn an_edit_from_a_version_that_moved_on_reads_the_current_one() { - let live = folded(&[opened(1, 1), edited(1, 1, 2, 2)]); + let live = replayed(&[opened(1, 1), edited(1, 1, 2, 2)]); let good = permission(RepoRole::Stranger, Blocked::No); assert_eq!( decide(Opening::Contribution, &live, 1, good, &edited(1, 1, 3, 3)), @@ -1036,7 +1036,7 @@ mod tests { decide(Opening::Contribution, &live, 1, good, &edited(1, 2, 3, 3)), Append::Allow ); - let erased_state = folded(&[opened(1, 1), erased(1, 1, 2)]); + let erased_state = replayed(&[opened(1, 1), erased(1, 1, 2)]); assert_eq!( decide( Opening::Contribution, @@ -1099,24 +1099,24 @@ mod tests { #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] #[test] - fn replayed_changes_fold_to_first_pass_state(changes in changes()) { - let once = folded(&changes); + fn replayed_changes_match_the_first_pass_state(changes in changes()) { + let once = replayed(&changes); let doubled: Vec = changes .iter() .flat_map(|change| [change.clone(), change.clone()]) .collect(); prop_assert_eq!( once, - folded(&doubled), - "Replayed change set folds to the state the first pass reached" + replayed(&doubled), + "Replayed change set gives the state the first pass reached" ); } #[test] fn author_is_fixed_at_root_whatever_follows(changes in changes()) { prop_assume!(changes.len() > 1); - let whole = folded(&changes); - let prefix = folded(&changes[..changes.len().saturating_sub(1)]); + let whole = replayed(&changes); + let prefix = replayed(&changes[..changes.len().saturating_sub(1)]); prop_assert_eq!( prefix.object().map(SocialObject::author), whole.object().map(SocialObject::author), diff --git a/knot2/crates/knot-cobs/src/ticket.rs b/knot2/crates/knot-cobs/src/ticket.rs index a1ab48b95..1aaf6832e 100644 --- a/knot2/crates/knot-cobs/src/ticket.rs +++ b/knot2/crates/knot-cobs/src/ticket.rs @@ -74,7 +74,7 @@ mod tests { assert_eq!( TicketChange::decode(&change.encode().unwrap()).unwrap(), change, - "the state the write path signed is the state the fold reads back" + "the state the write path signed is the state the replay reads back" ); } } diff --git a/knot2/crates/knot-cobs/tests/cobs.rs b/knot2/crates/knot-cobs/tests/cobs.rs index 24649ef18..55ba2456a 100644 --- a/knot2/crates/knot-cobs/tests/cobs.rs +++ b/knot2/crates/knot-cobs/tests/cobs.rs @@ -53,23 +53,23 @@ fn members_roundtrip_and_reload_is_identical() { .unwrap(); let object = store.get::(created.object).unwrap(); - let roll = object.state(); - assert!(roll.contains(&account("nel"))); + let list = object.state(); + assert!(list.contains(&account("nel"))); assert_eq!( - roll.get(&account("olaren")).unwrap().added_by, + list.get(&account("olaren")).unwrap().added_by, account("nel") ); - assert!(!roll.contains(&account("teq"))); - assert_eq!(roll.len(), 2); + assert!(!list.contains(&account("teq"))); + assert_eq!(list.len(), 2); - let listed: Vec<&AccountDid> = roll.entries().map(|(subject, _)| subject).collect(); + let listed: Vec<&AccountDid> = list.entries().map(|(subject, _)| subject).collect(); assert_eq!(listed, vec![&account("nel"), &account("olaren")]); let reopened = reopen(repo); let reloaded = CobStore::new(&reopened) .get::(created.object) .unwrap(); - assert_eq!(roll, reloaded.state()); + assert_eq!(list, reloaded.state()); let (_dup_dir, dup_repo) = fixture(); let dup_store = CobStore::new(&dup_repo); diff --git a/knot2/crates/knot-cobs/tests/invariants.rs b/knot2/crates/knot-cobs/tests/invariants.rs index 8ebe32b4d..7d1a7bd37 100644 --- a/knot2/crates/knot-cobs/tests/invariants.rs +++ b/knot2/crates/knot-cobs/tests/invariants.rs @@ -44,7 +44,7 @@ fn accepted(subject: &str, seconds: i64) -> MembersChange { } #[test] -fn an_acceptance_left_in_a_live_chain_folds_to_nothing() { +fn an_acceptance_without_an_admission_stays_off_the_grant_list() { let uninvited = build_members( 3, &[ @@ -54,7 +54,7 @@ fn an_acceptance_left_in_a_live_chain_folds_to_nothing() { ); assert!( !uninvited.contains(&account("nel")), - "nel has an entry from an acceptance, and the roll admits nobody it wasn't told to" + "an acceptance without an admission doesn't put nel on the grant list" ); let admitted = build_members( @@ -512,11 +512,11 @@ proptest! { #![proptest_config(ProptestConfig { cases: 96, ..ProptestConfig::default() })] #[test] - fn a_subject_is_on_the_roll_only_because_an_admission_put_it_there( + fn a_subject_is_on_the_grant_list_only_because_an_admission_put_it_there( ops in prop::collection::vec((0u8..3, 0u8..3), 1..24) ) { let author = ActorId::from_secp256k1(&[0x02; 33]); - let folded = ops + let list = ops .iter() .enumerate() .fold(MembersCob::initial(), |state, (step, (op, who))| { @@ -525,12 +525,12 @@ proptest! { }); let unearned: Vec = (0u8..3) - .filter(|who| folded.contains(&account(&format!("s{who}")))) + .filter(|who| list.contains(&account(&format!("s{who}")))) .filter(|who| !ops.iter().any(|(op, subject)| *op != 1 && subject == who)) .collect(); prop_assert!( unearned.is_empty(), - "subjects on the roll that no admission ever named: {unearned:?}" + "subjects on the grant list that an admission didn't put there: {unearned:?}" ); } } diff --git a/knot2/crates/knot-cobs/tests/social.rs b/knot2/crates/knot-cobs/tests/social.rs index 59da2c619..cde9fe0a6 100644 --- a/knot2/crates/knot-cobs/tests/social.rs +++ b/knot2/crates/knot-cobs/tests/social.rs @@ -145,7 +145,7 @@ fn a_social_object_opens_edits_and_erases_through_the_approved_doors_only() { assert_eq!( live(store.get::(created.object).unwrap().state()), produced, - "Version edit named is version fold ends on" + "Version edit named is version the replay ends on" ); let stranger = context(&olaren, &squid, RepoRole::Stranger); @@ -209,7 +209,7 @@ fn a_social_object_opens_edits_and_erases_through_the_approved_doors_only() { } #[test] -fn a_social_object_past_the_snapshot_stride_writes_a_checkpoint_that_folds_to_the_same_state() { +fn a_social_object_past_the_snapshot_stride_writes_a_checkpoint_that_replays_to_the_same_state() { let (_dir, repo) = fixture(); let store = CobStore::new(&repo); let key = signer(32); @@ -250,12 +250,12 @@ fn a_social_object_past_the_snapshot_stride_writes_a_checkpoint_that_folds_to_th assert_eq!( serde_ipld_dagcbor::to_vec(&materialized).unwrap(), serde_ipld_dagcbor::to_vec(store.get::(object).unwrap().state()).unwrap(), - "Checkpointed fold and fold from root are byte-identical" + "Checkpointed replay and replay from root are byte-identical" ); } #[test] -fn a_forked_social_history_written_outside_the_knot_folds_through_the_convergent_arm() { +fn a_forked_social_history_written_outside_the_knot_replays_through_the_convergent_arm() { let (_dir, repo) = fixture(); let nsid = NoteChange::type_name(); let actor = owner_of(33); @@ -290,20 +290,20 @@ fn a_forked_social_history_written_outside_the_knot_folds_through_the_convergent }) .unwrap(); - let folded = CobStore::new(&repo).get::(object).unwrap(); + let replayed = CobStore::new(&repo).get::(object).unwrap(); let left_version = edited(&nel, opened_at, "left", 2) .change .emits() .version() .unwrap(); assert_eq!( - live(folded.state()), + live(replayed.state()), left_version, "First edit in causal order takes version, and two that named root's \ version after it are stale" ); assert_eq!( - folded.state().object().map(SocialObject::updated_at), + replayed.state().object().map(SocialObject::updated_at), Some(UnixSeconds::new(2)) ); } diff --git a/knot2/crates/knot-index/src/intern.rs b/knot2/crates/knot-index/src/intern.rs index c0e35b9d0..d68bf3830 100644 --- a/knot2/crates/knot-index/src/intern.rs +++ b/knot2/crates/knot-index/src/intern.rs @@ -70,6 +70,7 @@ impl Interner { .expect("A minted record key is a TID of thirteen bytes"), ), RecordRkey::Composed(key) => RecordKey::Slug(self.0.get_or_intern(key.as_str())), + RecordRkey::Subject(key) => RecordKey::Slug(self.0.get_or_intern(key.as_str())), } } @@ -80,6 +81,7 @@ impl Interner { .expect("A minted record key is a TID of thirteen bytes"), )), RecordRkey::Composed(key) => self.0.get(key.as_str()).map(RecordKey::Slug), + RecordRkey::Subject(key) => self.0.get(key.as_str()).map(RecordKey::Slug), } } diff --git a/knot2/crates/knot-index/src/lib.rs b/knot2/crates/knot-index/src/lib.rs index c75f96744..c3b626482 100644 --- a/knot2/crates/knot-index/src/lib.rs +++ b/knot2/crates/knot-index/src/lib.rs @@ -146,17 +146,17 @@ struct Granted { hosted: HostedCoverage, } -enum Folded { - Seated(Vec), +enum RepoGrants { + Ready(Vec), Pending, Unreadable, } -impl Folded { - fn into_seats(self) -> Option> { +impl RepoGrants { + fn into_accounts(self) -> Option> { match self { - Folded::Seated(seats) => Some(seats), - Folded::Pending | Folded::Unreadable => None, + RepoGrants::Ready(accounts) => Some(accounts), + RepoGrants::Pending | RepoGrants::Unreadable => None, } } } @@ -504,7 +504,7 @@ impl Index { } pub fn ensure_collaborators(&self, repo: &RepoDid) -> Result<(), IndexError> { - if self.collaborators.is_folded(&self.interner, repo) { + if self.collaborators.is_materialized(&self.interner, repo) { return Ok(()); } self.refresh_collaborators(repo) @@ -512,7 +512,7 @@ impl Index { pub fn refresh_collaborators(&self, repo: &RepoDid) -> Result<(), IndexError> { let repo_key = self.interner.intern_repo(repo); - self.fold_collaborators(repo, repo_key) + self.materialize_collaborators(repo, repo_key) .inspect(|()| { self.unreadable.remove_sync(&repo_key); }) @@ -521,7 +521,7 @@ impl Index { }) } - fn fold_collaborators(&self, repo: &RepoDid, repo_key: RepoKey) -> Result<(), IndexError> { + fn materialize_collaborators(&self, repo: &RepoDid, repo_key: RepoKey) -> Result<(), IndexError> { let git = self.layout.open(repo)?; let store = CobStore::new(&git); match store.list::()?.as_slice() { @@ -542,7 +542,7 @@ impl Index { } pub fn ensure_social(&self, repo: &RepoDid) -> Result<(), IndexError> { - if self.social.is_folded(&self.interner, repo) { + if self.social.is_materialized(&self.interner, repo) { return Ok(()); } let git = self.layout.open(repo)?; @@ -774,24 +774,24 @@ impl Index { match self.registry.coverage() { Coverage::Warming => Resolved::Warming, Coverage::Ready => { - let folded: Vec = self + let repos: Vec = self .hosted_repos() .iter() - .map(|repo| self.seats_of(repo)) + .map(|repo| self.grants_of(repo)) .collect(); let hosted = HostedCoverage::over( - folded + repos .iter() - .filter(|repo| matches!(repo, Folded::Pending)) + .filter(|repo| matches!(repo, RepoGrants::Pending)) .count(), ); - let grants = folded.into_iter().filter_map(Folded::into_seats).fold( - Vec::new(), - |mut grants, seats| { - grants.extend(seats); + let grants = repos + .into_iter() + .filter_map(RepoGrants::into_accounts) + .fold(Vec::new(), |mut grants, accounts| { + grants.extend(accounts); grants - }, - ); + }); Resolved::Ready(Granted { subjects: sorted(grants), hosted, @@ -800,17 +800,17 @@ impl Index { } } - fn seats_of(&self, repo: &RepoDid) -> Folded { + fn grants_of(&self, repo: &RepoDid) -> RepoGrants { match (self.owner_of(repo), self.effective_collaborators(repo)) { - (Resolved::Ready(owner), Resolved::Ready(granted)) => Folded::Seated( + (Resolved::Ready(owner), Resolved::Ready(granted)) => RepoGrants::Ready( owner .map(AccountDid::from) .into_iter() .chain(granted) .collect(), ), - _ if self.unreadable_repo(repo) => Folded::Unreadable, - _ => Folded::Pending, + _ if self.unreadable_repo(repo) => RepoGrants::Unreadable, + _ => RepoGrants::Pending, } } diff --git a/knot2/crates/knot-index/src/projections.rs b/knot2/crates/knot-index/src/projections.rs index 6dc372c5e..4afe12431 100644 --- a/knot2/crates/knot-index/src/projections.rs +++ b/knot2/crates/knot-index/src/projections.rs @@ -6,8 +6,8 @@ use std::sync::{Arc, Mutex}; use knot_cob::{Change, ChangeId, ChangePayload, Checkpoint, CobId, CobStore, Evaluate}; use knot_cobs::{ - CollaboratorsChange, CollaboratorsCob, Effect, Entry, Grant, GrantChange, Policy, Registration, - Registry, RegistryChange, Rename, RepoRef, RepoRegistryCob, Roll, + CollaboratorsChange, CollaboratorsCob, Effect, Entry, Grant, GrantChange, GrantList, Policy, + Registration, Registry, RegistryChange, Rename, RepoRef, RepoRegistryCob, }; use knot_types::{ AccountDid, ClonePath, ContributionPolicy, OfferedKey, OwnerDid, RepoDid, RepoRkey, UnixSeconds, @@ -159,7 +159,7 @@ where object: CobId, ) -> Result<(), IndexError> where - Cob: Checkpoint + Evaluate, + Cob: Checkpoint + Evaluate, { let mut tip = self .tip @@ -167,10 +167,10 @@ where .unwrap_or_else(|poisoned| poisoned.into_inner()); match *tip { None => { - let (roll, seeded) = store + let (list, seeded) = store .materialize::(object) .inspect_err(|_| self.coverage.set(Coverage::Warming))?; - self.seed(interner, &roll); + self.seed(interner, &list); *tip = Some(seeded); } Some(prev) => { @@ -187,9 +187,9 @@ where Ok(()) } - fn seed(&self, interner: &Interner, roll: &Roll) { + fn seed(&self, interner: &Interner, list: &GrantList) { self.membership.clear_sync(); - roll.entries().for_each(|(subject, entry)| { + list.entries().for_each(|(subject, entry)| { let _ = self.membership.insert_sync( interner.intern_account(subject), Provenance::seeded(interner, entry), @@ -231,7 +231,7 @@ where } } -struct RepoRoll { +struct RepoGrantList { tip: Option, entries: BTreeMap, } @@ -239,14 +239,14 @@ struct RepoRoll { const REPO_LOCK_STRIPES: usize = 256; pub(crate) struct CollaboratorsProjection { - rolls: scc::HashMap, + grant_lists: scc::HashMap, locks: [Mutex<()>; REPO_LOCK_STRIPES], } impl CollaboratorsProjection { pub(crate) fn new() -> Self { Self { - rolls: scc::HashMap::new(), + grant_lists: scc::HashMap::new(), locks: std::array::from_fn(|_| Mutex::new(())), } } @@ -261,10 +261,10 @@ impl CollaboratorsProjection { Coverage::Ready } - pub(crate) fn is_folded(&self, interner: &Interner, repo: &RepoDid) -> bool { + pub(crate) fn is_materialized(&self, interner: &Interner, repo: &RepoDid) -> bool { interner .repo(repo) - .is_some_and(|repo| self.rolls.contains_sync(&repo)) + .is_some_and(|repo| self.grant_lists.contains_sync(&repo)) } pub(crate) fn holds( @@ -276,10 +276,10 @@ impl CollaboratorsProjection { let Some(repo) = interner.repo(repo) else { return Resolved::Warming; }; - match self.rolls.read_sync(&repo, |_, roll| { + match self.grant_lists.read_sync(&repo, |_, list| { interner .account(did) - .is_some_and(|account| roll.entries.contains_key(&account)) + .is_some_and(|account| list.entries.contains_key(&account)) }) { Some(value) => Resolved::Ready(value), None => Resolved::Warming, @@ -295,8 +295,8 @@ impl CollaboratorsProjection { let Some(repo) = interner.repo(repo) else { return Resolved::Warming; }; - match self.rolls.read_sync(&repo, |_, roll| { - roll.entries + match self.grant_lists.read_sync(&repo, |_, list| { + list.entries .iter() .map(|(&subject, slot)| { let did = interner.resolve_account(subject); @@ -332,7 +332,7 @@ impl CollaboratorsProjection { let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); self.install( repo, - RepoRoll { + RepoGrantList { tip: None, entries: BTreeMap::new(), }, @@ -348,22 +348,25 @@ impl CollaboratorsProjection { ) -> Result<(), IndexError> { let lock = self.repo_lock(repo); let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - self.fold_repo(interner, store, repo, object) + self.materialize_repo(interner, store, repo, object) .inspect_err(|_| self.purge_repo(repo)) } - fn fold_repo( + fn materialize_repo( &self, interner: &Interner, store: &CobStore, repo: RepoKey, object: CobId, ) -> Result<(), IndexError> { - let prev = self.rolls.read_sync(&repo, |_, roll| roll.tip).flatten(); + let prev = self + .grant_lists + .read_sync(&repo, |_, list| list.tip) + .flatten(); match prev { None => { - let (roll, tip) = store.materialize::(object)?; - let entries = roll + let (list, tip) = store.materialize::(object)?; + let entries = list .entries() .map(|(subject, entry)| { ( @@ -374,7 +377,7 @@ impl CollaboratorsProjection { .collect(); self.install( repo, - RepoRoll { + RepoGrantList { tip: Some(tip), entries, }, @@ -383,17 +386,20 @@ impl CollaboratorsProjection { Some(prev) => { let delta = store.changes_since::(object, Some(prev))?; let decoded = decode_delta::(&delta.changes)?; - let mut occupied = self.rolls.entry_sync(repo).or_insert_with(|| RepoRoll { - tip: None, - entries: BTreeMap::new(), - }); - let roll = occupied.get_mut(); - roll.entries = decoded + let mut occupied = + self.grant_lists + .entry_sync(repo) + .or_insert_with(|| RepoGrantList { + tip: None, + entries: BTreeMap::new(), + }); + let list = occupied.get_mut(); + list.entries = decoded .into_iter() - .fold(std::mem::take(&mut roll.entries), |entries, change| { + .fold(std::mem::take(&mut list.entries), |entries, change| { apply(interner, entries, change) }); - roll.tip = Some(delta.tip); + list.tip = Some(delta.tip); } } Ok(()) @@ -406,16 +412,16 @@ impl CollaboratorsProjection { } fn purge_repo(&self, repo: RepoKey) { - let _ = self.rolls.remove_sync(&repo); + let _ = self.grant_lists.remove_sync(&repo); } - fn install(&self, repo: RepoKey, roll: RepoRoll) { - match self.rolls.entry_sync(repo) { + fn install(&self, repo: RepoKey, list: RepoGrantList) { + match self.grant_lists.entry_sync(repo) { scc::hash_map::Entry::Occupied(mut occupied) => { - let _ = occupied.insert(roll); + let _ = occupied.insert(list); } scc::hash_map::Entry::Vacant(vacant) => { - vacant.insert_entry(roll); + vacant.insert_entry(list); } } } diff --git a/knot2/crates/knot-index/src/social.rs b/knot2/crates/knot-index/src/social.rs index 383abb975..eeacf55d1 100644 --- a/knot2/crates/knot-index/src/social.rs +++ b/knot2/crates/knot-index/src/social.rs @@ -50,7 +50,7 @@ impl Slot { RecordAddress::new( interner.resolve_collection(self.collection), RecordRkey::new(interner.key_str(self.rkey)) - .expect("An interned record key spells a valid rkey"), + .expect("An interned record key is a valid rkey"), ) } } @@ -432,7 +432,7 @@ impl SocialProjection { TouchStamp::new(self.epoch.load(AtomicOrdering::Relaxed)) } - pub(crate) fn is_folded(&self, interner: &Interner, repo: &RepoDid) -> bool { + pub(crate) fn is_materialized(&self, interner: &Interner, repo: &RepoDid) -> bool { interner .repo(repo) .is_some_and(|repo| self.repos.contains_sync(&repo)) @@ -911,8 +911,8 @@ fn rebuild_whole( knot_cobs::decode_social(change.payload()) .map(|decoded| { let before = state.object().map(|object| object.version()); - let folded = state.fold(&decoded); - let accepted = folded + let applied = state.apply(&decoded); + let accepted = applied .object() .map(|object| object.version()) .is_some_and(|version| Some(version) != before); @@ -921,7 +921,7 @@ fn rebuild_whole( } else { served }; - (folded, served) + (applied, served) }) .map_err(|error| IndexError::Decode { change: change.id, diff --git a/knot2/crates/knot-index/tests/lifecycle.rs b/knot2/crates/knot-index/tests/lifecycle.rs index d56ba27fe..e91cc7575 100644 --- a/knot2/crates/knot-index/tests/lifecycle.rs +++ b/knot2/crates/knot-index/tests/lifecycle.rs @@ -11,7 +11,7 @@ mod common; use common::{World, acc, at, grant, meta_home, own, repo_did, rkey}; #[test] -fn rebuild_folds_members_and_registry_and_collaborators_fold_on_access() { +fn rebuild_materializes_members_and_registry_and_collaborators_materialize_on_access() { let world = World::new(); let repo = repo_did("squid"); world.seed_members(); @@ -38,7 +38,7 @@ fn rebuild_folds_members_and_registry_and_collaborators_fold_on_access() { assert_eq!( index.effective_collaborator(&repo, &acc("lyna")), Resolved::Warming, - "roll reads warming until first access, rebuild doesn't fold collaborators" + "rebuild doesn't materialize collaborators, so the grant list reads warming until first access" ); index.ensure_collaborators(&repo).unwrap(); @@ -78,7 +78,7 @@ fn every_accessor_fails_closed_while_warming() { assert_eq!( index.collaborator_entries(&repo_did("squid")), Resolved::Warming, - "Roll that has never been folded fails closed" + "A grant list that hasn't materialized fails closed" ); assert_eq!( index.resolve_repo(&own("nel"), &rkey("anemone")), @@ -146,12 +146,12 @@ fn a_re_added_member_keeps_the_first_provenance() { (acc("nel"), asserted("nel", 1)), (acc("olaren"), asserted("nel", 2)), ]), - "duplicate add never rewrites original provenance, matching canonical roll" + "duplicate add never rewrites original provenance, matching the canonical grant list" ); } #[test] -fn collaborator_entries_match_the_canonical_roll() { +fn collaborator_entries_match_the_canonical_grant_list() { let world = World::new(); let repo = repo_did("squid"); world.seed_registry(&repo); @@ -191,7 +191,7 @@ fn collaborator_entries_match_the_canonical_roll() { assert_eq!( index.collaborator_entries(&repo), Resolved::Ready(expected), - "projected entries disagree with canonical Evaluate fold" + "projected entries disagree with the canonical Evaluate replay" ); } @@ -246,7 +246,7 @@ fn a_legacy_invite_admits_and_its_acceptance_changes_nothing() { assert_eq!( acl(), admitted, - "an invite replayed out of a live chain is a membership, the roll is the truth" + "an invite replayed out of a live chain is a membership, and it doesn't take an acceptance" ); on_knot(MembersChange::Accept(accepted.clone()), 6); @@ -256,12 +256,12 @@ fn a_legacy_invite_admits_and_its_acceptance_changes_nothing() { assert_eq!( acl(), admitted, - "an acceptance left over from the consent model folds to nothing" + "an acceptance from the consent model doesn't change the grant list" ); } #[test] -fn a_folded_repo_serves_while_unaccessed_repos_stay_warming() { +fn a_materialized_repo_serves_while_unaccessed_repos_stay_warming() { let world = World::new(); let present = repo_did("squid"); let absent = repo_did("kelp"); @@ -287,12 +287,12 @@ fn a_folded_repo_serves_while_unaccessed_repos_stay_warming() { .collaborator_entries(&present) .map(|entries| entries.len()), Resolved::Ready(1), - "folded repo serves its roll" + "an accessed repo serves its grant list" ); assert_eq!( index.collaborator_entries(&absent), Resolved::Warming, - "registered repo that was never accessed stays fail-closed until folded" + "registered repo that was never accessed stays fail-closed until materialized" ); assert_eq!( index.effective_collaborator(&repo_did("conch"), &acc("lyna")), @@ -335,7 +335,7 @@ fn an_ambiguous_meta_cob_fails_refresh_and_rebuild() { } #[test] -fn a_repo_missing_on_disk_does_not_fail_the_boot_and_isolates_its_fold() { +fn a_repo_missing_on_disk_does_not_fail_the_boot_and_isolates_its_materialization() { let world = World::new(); let present = repo_did("squid"); let absent = repo_did("kelp"); @@ -347,7 +347,7 @@ fn a_repo_missing_on_disk_does_not_fail_the_boot_and_isolates_its_fold() { let index = world.index(); index .rebuild() - .expect("boot folds members and registry only, so missing repo dir never fails it"); + .expect("boot materializes members and registry only, so a missing repo dir never fails it"); index.ensure_collaborators(&present).unwrap(); assert_eq!( @@ -356,12 +356,12 @@ fn a_repo_missing_on_disk_does_not_fail_the_boot_and_isolates_its_fold() { ); assert!( index.ensure_collaborators(&absent).is_err(), - "folding repo with no dir on disk fails for that repo alone" + "materializing a repo with no dir on disk fails for that repo alone" ); assert_eq!( index.effective_collaborator(&absent, &acc("lyna")), Resolved::Warming, - "repo the index couldn't fold stays fail-closed" + "repo the index couldn't materialize stays fail-closed" ); } diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index 81512cca6..71d3ff726 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -101,7 +101,7 @@ fn a_concurrent_reader_never_sees_a_net_absent_subject() { } #[test] -fn a_concurrent_reader_never_sees_a_collaborator_roll_emptied_mid_refresh() { +fn a_concurrent_reader_never_sees_a_collaborator_grant_list_emptied_mid_refresh() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); @@ -155,8 +155,8 @@ fn a_concurrent_reader_never_sees_a_collaborator_roll_emptied_mid_refresh() { assert!( !leaked.load(Ordering::Acquire), - "in-place mem::take runs under per-repo lock, so anchor present in both \ - pre- and post-refresh roll is never observed absent or warming mid-refresh" + "a concurrent reader never sees anchor absent or warming mid-refresh, since the in-place \ + `mem::take` runs under the per-repo lock" ); assert_eq!( index.effective_collaborator(&repo, &acc("anchor")), @@ -169,7 +169,7 @@ fn cob_ref(type_name: &str, object: CobId) -> RefName { } #[test] -fn a_diverged_collaborators_tip_purges_the_roll_instead_of_serving_it_stale() { +fn a_diverged_collaborators_tip_purges_the_grant_list_instead_of_serving_it_stale() { let world = World::new(); let repo = repo_did("squid"); let git = world.layout.create(&repo).unwrap(); @@ -209,12 +209,12 @@ fn a_diverged_collaborators_tip_purges_the_roll_instead_of_serving_it_stale() { assert!( index.refresh_collaborators(&repo).is_err(), - "Tip that doesn't descend from folded tip anymore is structural error" + "Tip that doesn't descend from the materialized tip anymore is structural error" ); assert_eq!( index.effective_collaborator(&repo, &acc("lyna")), Resolved::Warming, - "diverged COB tip purges roll and fails closed, it doesn't serve \ + "diverged COB tip purges the grant list and fails closed without serving \ pre-divergence collaborators" ); } @@ -248,7 +248,7 @@ fn a_diverged_members_tip_fails_closed_to_warming() { assert!( index.refresh_members().is_err(), - "Tip that doesn't descend from folded tip anymore is structural error" + "Tip that doesn't descend from the materialized tip anymore is structural error" ); assert_eq!(index.coverage().members, Coverage::Warming); assert_eq!( @@ -691,7 +691,7 @@ fn full_budget_reports_unheld_readings_and_then_stops_growing_entirely() { #[test] fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floor() { - let (_world, index) = folded(); + let (_world, index) = materialized(); index .keys() .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0)); @@ -704,7 +704,7 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo index.keys().note_miss(); let Resolved::Ready(work) = index.keys().work(at(100), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert_eq!( work.suspected.len(), @@ -739,7 +739,7 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo "an account inside the first half of its lease is left alone" ); let Resolved::Ready(work) = index.keys().work(at(1_800), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert_eq!( work.due.len(), @@ -756,34 +756,34 @@ fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floo } #[test] -fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() { +fn an_unmaterialized_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() { let world = World::new(); - let folded = repo_did("squid"); + let created = repo_did("squid"); let never_created = repo_did("limpet"); - world.layout.create(&folded).unwrap(); - let registry = world.seed_registry(&folded); + world.layout.create(&created).unwrap(); + let registry = world.seed_registry(&created); world.register_owned(&never_created, "limpet", "cuttle", registry); let index = world.index(); index.refresh_registry().unwrap(); - index.refresh_collaborators(&folded).unwrap(); + index.refresh_collaborators(&created).unwrap(); let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else { - panic!("One repo the knot hasn't folded mustn't stop it filling the keys it can enumerate"); + panic!("One repo the knot hasn't materialized mustn't stop it filling the keys it can enumerate"); }; assert_eq!( work.hosted, HostedCoverage::Partial { unread: 1 }, - "the pass must know it read less than the whole roll, or it evicts the accounts it \ + "the pass must report it read less than the whole grant list, or it evicts the accounts it \ couldn't enumerate" ); assert!( !work.due.is_empty(), - "the owner of the repo that did fold still needs its keys read" + "the owner of the repo that did materialize still needs its keys read" ); assert!( !work.complete, - "a partial roll mustn't let the fill call the set complete, or the knot rejects a \ - pusher whose repo it never managed to read" + "a partial grant list mustn't read as complete, or the knot rejects a \ + pusher whose repo it couldn't read" ); assert_eq!( @@ -792,7 +792,7 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() "the repo registered without ever being created is what the knot can't open" ); let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert_eq!( work.hosted, @@ -818,18 +818,18 @@ fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() #[test] fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() { - let (world, index) = folded(); + let (world, index) = materialized(); index .keys() .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0)); let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert!(work.complete, "The one pusher's keys are on file"); index.keys().mark_ready(work.generation); assert_eq!(index.keys().coverage(), Coverage::Ready); - let roll = world.seed_members(); + let members = world.seed_members(); index.refresh_members().unwrap(); assert_eq!( index.keys().coverage(), @@ -839,9 +839,9 @@ fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() { ); let Resolved::Ready(rereading) = index.keys().work(at(0), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; - world.add_member(roll, "teq", 5); + world.add_member(members, "teq", 5); index.refresh_members().unwrap(); index.keys().mark_ready(rereading.generation); assert_eq!( @@ -855,11 +855,11 @@ fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() { #[test] fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_coverage() { let world = World::new(); - let folded = repo_did("squid"); + let hosted = repo_did("squid"); let second = repo_did("limpet"); - world.layout.create(&folded).unwrap(); + world.layout.create(&hosted).unwrap(); world.layout.create(&second).unwrap(); - let registry = world.seed_registry(&folded); + let registry = world.seed_registry(&hosted); world.register_owned(&second, "limpet", "cuttle", registry); let index = world.index_within(KeyBudget::from_bytes(560)); index.refresh_registry().unwrap(); @@ -880,7 +880,7 @@ fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_co its life accepting every key offered to it" ); let Resolved::Ready(later) = index.keys().work(at(3_601), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert!( later.due.as_slice().contains(&acc("cuttle")), @@ -921,11 +921,11 @@ fn a_key_read_outside_its_lease_stops_answering_for_its_publisher() { } #[test] -fn a_warming_member_roll_still_yields_the_accounts_that_may_push() { - let (_world, index) = folded(); +fn a_warming_member_grant_list_still_yields_the_accounts_that_may_push() { + let (_world, index) = materialized(); let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else { - panic!("Registry is folded, so the knot knows who may push"); + panic!("The registry is materialized, so the knot knows who may push"); }; assert_eq!(work.pushers.len(), 1, "The one hosted repo has one owner"); assert_eq!( @@ -935,7 +935,7 @@ fn a_warming_member_roll_still_yields_the_accounts_that_may_push() { ); assert!( work.members.is_warming(), - "an unfolded member roll mustn't stop the knot reading the keys it checks pushes against" + "the knot still reads the push keys while the member grant list is warming" ); } @@ -951,7 +951,7 @@ fn the_sweep_retains_keys_a_push_recorded_for_a_collaborator() { let kept = || match index.keys().work(at(0), sweep()).map(|work| work.members) { Resolved::Ready(Resolved::Ready(members)) => members.kept, - _ => panic!("Registry or member roll is still warming"), + _ => panic!("The registry or the member grant list is still warming"), }; index .keys() @@ -972,7 +972,7 @@ fn the_sweep_retains_keys_a_push_recorded_for_a_collaborator() { #[test] fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() { - let (_world, index) = folded(); + let (_world, index) = materialized(); let key = OfferedKey::from_bytes(vec![7]); index.keys().record(&acc("nel"), vec![key.clone()], hour(0)); @@ -1035,7 +1035,7 @@ fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() { #[test] fn an_account_the_knot_never_reads_stops_delaying_the_whole_set() { - let (_world, index) = folded(); + let (_world, index) = materialized(); let offered = OfferedKey::from_bytes(vec![7]); assert_eq!( @@ -1118,7 +1118,7 @@ fn concurrent_reads_of_one_account_never_leave_a_key_answering_for_a_set_it_left }); } -fn folded() -> (World, knot_index::Index) { +fn materialized() -> (World, knot_index::Index) { let world = World::new(); let repo = repo_did("squid"); world.layout.create(&repo).unwrap(); @@ -1666,7 +1666,7 @@ fn kelp_ticket() -> (World, RepoDid, CobId) { (world, repo, object) } -fn folded_kelp() -> (World, RepoDid, CobId, knot_index::Index) { +fn materialized_kelp() -> (World, RepoDid, CobId, knot_index::Index) { let (world, repo, object) = kelp_ticket(); let index = world.index(); index.refresh_social(&repo).unwrap(); @@ -1720,17 +1720,17 @@ fn assert_projections_agree( assert_eq!( incremental.social_object_at(repo, at), cold.social_object_at(repo, at), - "incremental fold, and cold fold, disagree about which object answers at {at}" + "incremental replay, and cold replay, disagree about which object serves at {at}" ); assert_eq!( incremental.social_children_of(repo, at), cold.social_children_of(repo, at), - "Incremental fold and cold fold disagree about children of {at}" + "Incremental replay and cold replay disagree about children of {at}" ); assert_eq!( incremental.social_labels_of(repo, at), cold.social_labels_of(repo, at), - "Incremental fold and cold fold disagree about the standing labels on {at}" + "Incremental replay and cold replay disagree about the current labels on {at}" ); [ ticket_collection(), @@ -1795,7 +1795,7 @@ fn social_projection_answers_by_address_by_kind_and_by_parent() { assert_eq!( index.social_latest_child(&repo, &ticket_at("3lubrptx57d22"), &comment_collection()), Resolved::Ready(Some(comment_at("3lubrptx57d44"))), - "Latest comment is the one fold's order puts last" + "Latest comment is the one replay's order puts last" ); assert_eq!( index.social_latest_child(&repo, &ticket_at("3lubrptx57d22"), &ticket_collection()), @@ -1966,7 +1966,7 @@ fn untouched_repo_stays_warming_until_write_reaches_answer() { assert_eq!( index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Warming, - "unfolded repository fails closed, turning away write that reads it" + "unmaterialized repository fails closed, turning away write that reads it" ); assert_eq!( index.next_subject_number(&repo, &ticket_collection()), @@ -2156,7 +2156,7 @@ fn an_unlabeled_ticket_serves_no_labels() { } #[test] -fn incremental_label_refresh_matches_a_cold_fold() { +fn incremental_label_refresh_matches_a_cold_replay() { let world = World::new(); let repo = repo_did("squid"); let labels = label_set(&["wontfix", "reviewer"]); @@ -2170,8 +2170,8 @@ fn incremental_label_refresh_matches_a_cold_fold() { assert_eq!( cold.social_labels_of(&repo, &subject), warmed.social_labels_of(&repo, &subject), - "a ticket folded incrementally onto a warm projection serves the same tangled set \ - as a cold fold from the git history" + "a ticket replayed incrementally onto a warm projection serves the same tangled set \ + as a cold replay from the git history" ); let labels = Some(label_set(&["reviewer"])); @@ -2187,8 +2187,8 @@ fn incremental_label_refresh_matches_a_cold_fold() { warmed .refresh_social_object(&repo, &ticket_type_name(), object) .unwrap(); - let refolded = world.index(); - refolded.refresh_social(&repo).unwrap(); + let replayed = world.index(); + replayed.refresh_social(&repo).unwrap(); let edited = Resolved::Ready(label_set(&["reviewer"])); assert_eq!( warmed.social_labels_of(&repo, &subject), @@ -2196,9 +2196,9 @@ fn incremental_label_refresh_matches_a_cold_fold() { "an edit's tangledLabels replaces the opened set on a warm projection" ); assert_eq!( - refolded.social_labels_of(&repo, &subject), + replayed.social_labels_of(&repo, &subject), edited, - "and a cold fold off the git history serves the edited set too" + "and a cold replay off the git history serves the edited set too" ); } @@ -2225,7 +2225,15 @@ fn edit_omitting_tangled_labels_clears_existing_set() { "The indexed ticket serves its labels." ); - world.edit_ticket(&repo, object, &acc("nel"), note_record("kelp").1, "kelp forest", None, 20); + world.edit_ticket( + &repo, + object, + &acc("nel"), + note_record("kelp").1, + "kelp forest", + None, + 20, + ); let edited = world.index(); edited.refresh_social(&repo).unwrap(); @@ -2366,7 +2374,7 @@ fn contested_claims_stop_counting_toward_serving_bytes() { #[test] fn unreadable_social_change_answers_warming_until_repo_or_object_refresh() { - let (world, repo, object, index) = folded_kelp(); + let (world, repo, object, index) = materialized_kelp(); assert_eq!( index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Ready(Some(object)) @@ -2422,7 +2430,7 @@ fn unprojected_cob_skips_without_failing_repository() { assert_eq!( index.social_of_kind(&repo, &ticket_collection()), Resolved::Ready(vec![object]), - "without becoming social objects, a roll and a COB of a type this crate doesn't project \ + "without becoming social objects, a grant list and a COB of a type this crate doesn't project \ ought to share the repository, and an unreadable one of them shouldn't take the \ repository down" ); @@ -2510,7 +2518,7 @@ fn eviction_past_residency_forgets_coldest_and_skips_guarded_repo() { #[test] fn two_creates_racing_on_one_repo_both_take_the_same_number() { - let (world, repo, _first_object, index) = folded_kelp(); + let (world, repo, _first_object, index) = materialized_kelp(); let read = std::sync::Barrier::new(2); let write = std::sync::Barrier::new(2); @@ -2524,7 +2532,7 @@ fn two_creates_racing_on_one_repo_both_take_the_same_number() { let Resolved::Ready(Some(number)) = index.next_subject_number(repo, &ticket_collection()) else { - panic!("Projection is folded before either create reads it"); + panic!("Projection is materialized before either create reads it"); }; write.wait(); let object = world.ticket(repo, key, &acc("nel"), number.get(), "raced", 20); @@ -2566,7 +2574,7 @@ fn two_creates_racing_on_one_repo_both_take_the_same_number() { #[test] fn two_objects_claiming_one_address_leave_one_reachable() { - let (world, repo, first_object, index) = folded_kelp(); + let (world, repo, first_object, index) = materialized_kelp(); let second_object = world.ticket(&repo, "3lubrptx57d22", &acc("olaren"), 2, "squatting", 20); index.refresh_social(&repo).unwrap(); @@ -2614,7 +2622,7 @@ fn two_objects_claiming_one_address_leave_one_reachable() { #[test] fn refreshing_one_object_leaves_other_answers_where_cold_rebuild_puts_them() { - let (world, repo, incumbent, index) = folded_kelp(); + let (world, repo, incumbent, index) = materialized_kelp(); let untouched = world.ticket(&repo, "3lubrptx57d33", &acc("nel"), 2, "shell", 20); index.refresh_social(&repo).unwrap(); let before = index.social_of_kind(&repo, &ticket_collection()); @@ -2627,7 +2635,7 @@ fn refreshing_one_object_leaves_other_answers_where_cold_rebuild_puts_them() { assert_eq!( index.social_object_at(&repo, &ticket_at("3lubrptx57d22")), Resolved::Ready(Some(incumbent)), - "folding latecomer alone doesn't hand it incumbent's address" + "replaying the latecomer alone doesn't hand it incumbent's address" ); assert_eq!( index.social_of_kind(&repo, &ticket_collection()), @@ -2653,7 +2661,7 @@ fn refreshing_one_object_leaves_other_answers_where_cold_rebuild_puts_them() { #[test] fn a_change_that_opens_no_object_still_costs_the_repository_its_bytes() { - let (world, repo, object, index) = folded_kelp(); + let (world, repo, object, index) = materialized_kelp(); let Resolved::Ready(opened) = index.social_structure(&repo) else { panic!("Projection must be built"); }; diff --git a/knot2/crates/knot-index/tests/properties.rs b/knot2/crates/knot-index/tests/properties.rs index 667489deb..3df77a5b9 100644 --- a/knot2/crates/knot-index/tests/properties.rs +++ b/knot2/crates/knot-index/tests/properties.rs @@ -122,9 +122,9 @@ proptest! { full.rebuild().unwrap(); let canonical = store.get::(object).unwrap(); - let roll = canonical.state(); + let list = canonical.state(); let expected: Vec> = (0u8..4) - .map(|subject| Resolved::Ready(roll.contains(&acc(subject)))) + .map(|subject| Resolved::Ready(list.contains(&acc(subject)))) .collect(); prop_assert_eq!( (0u8..4).map(|s| incremental.effective_member(&acc(s))).collect::>(), @@ -168,9 +168,9 @@ proptest! { full.refresh_collaborators(&repo).unwrap(); let canonical = store.get::(object).unwrap(); - let roll = canonical.state(); + let list = canonical.state(); let expected: Vec> = (0u8..4) - .map(|subject| Resolved::Ready(roll.contains(&acc(subject)))) + .map(|subject| Resolved::Ready(list.contains(&acc(subject)))) .collect(); prop_assert_eq!( (0u8..4).map(|s| incremental.effective_collaborator(&repo, &acc(s))).collect::>(), diff --git a/knot2/crates/knot-keyfill/tests/fill.rs b/knot2/crates/knot-keyfill/tests/fill.rs index b00e57075..e9279683a 100644 --- a/knot2/crates/knot-keyfill/tests/fill.rs +++ b/knot2/crates/knot-keyfill/tests/fill.rs @@ -110,13 +110,13 @@ fn hosted_index(scratch: &TempDir, repos: &[Hosted], members: &[AccountDid]) -> }) }; if let Some((first, rest)) = members.split_first() { - let roll = store + let list = store .create(&home, &granted(first), &signer, UnixSeconds::new(1)) .unwrap() .object; rest.iter().for_each(|subject| { store - .update(&home, roll, &granted(subject), &signer, UnixSeconds::new(2)) + .update(&home, list, &granted(subject), &signer, UnixSeconds::new(2)) .unwrap(); }); } diff --git a/knot2/crates/knot-maintenance/src/lib.rs b/knot2/crates/knot-maintenance/src/lib.rs index 7938ad964..9d979d725 100644 --- a/knot2/crates/knot-maintenance/src/lib.rs +++ b/knot2/crates/knot-maintenance/src/lib.rs @@ -17,7 +17,9 @@ mod scheduler; mod test_support; pub use midx::MidxStatus; -pub use scheduler::{AttachmentRoots, MaintenanceHandle, PushBytes, RepoSource, Scheduler}; +pub use scheduler::{ + AttachmentRoots, GrantScope, GrantSync, MaintenanceHandle, PushBytes, RepoSource, Scheduler, +}; pub const MIN_REFLOG_RETENTION_SECS: i64 = 30 * 24 * 60 * 60; diff --git a/knot2/crates/knot-maintenance/src/scheduler.rs b/knot2/crates/knot-maintenance/src/scheduler.rs index 3a7fb3de3..5d2301fc4 100644 --- a/knot2/crates/knot-maintenance/src/scheduler.rs +++ b/knot2/crates/knot-maintenance/src/scheduler.rs @@ -30,6 +30,14 @@ pub trait AttachmentRoots: Send + Sync { repo: &RepoDid, ) -> Result, knot_lfs::RootsDenied>; } +pub enum GrantScope { + Knot, + Repo(RepoDid), +} + +pub trait GrantSync: Send + Sync + 'static { + fn sweep(&self, scope: GrantScope); +} const MIN_ATTACHMENT_GRACE: std::time::Duration = std::time::Duration::from_secs(60); @@ -83,6 +91,7 @@ pub struct Scheduler { interval: Duration, triggers: mpsc::Receiver, lfs: Option, + grants: Option>, } impl Scheduler { @@ -107,6 +116,7 @@ impl Scheduler { interval, triggers, lfs: None, + grants: None, }; (scheduler, handle) } @@ -126,6 +136,11 @@ impl Scheduler { self } + pub fn with_grant_sync(mut self, grants: Arc) -> Self { + self.grants = Some(grants); + self + } + pub fn with_attachment_roots(mut self, roots: Arc) -> Self { match &mut self.lfs { Some(lfs) => lfs.attachments = Some(roots), @@ -292,14 +307,25 @@ impl Scheduler { } async fn sweep_all(&self, shutdown: &watch::Receiver) { + self.sync_grants(GrantScope::Knot).await; for repo in self.source.repos() { if *shutdown.borrow() { return; } + self.sync_grants(GrantScope::Repo(repo.clone())).await; self.maintain_one(repo).await; } } + async fn sync_grants(&self, scope: GrantScope) { + let Some(grants) = self.grants.clone() else { + return; + }; + if let Err(join) = tokio::task::spawn_blocking(move || grants.sweep(scope)).await { + tracing::error!(%join, "a grant sync task panicked"); + } + } + async fn lfs_sweep_all(&self, shutdown: &watch::Receiver) { if self.lfs.is_none() { return; diff --git a/knot2/crates/knot-migrate/src/emit.rs b/knot2/crates/knot-migrate/src/emit.rs index b04f86307..b24e2e027 100644 --- a/knot2/crates/knot-migrate/src/emit.rs +++ b/knot2/crates/knot-migrate/src/emit.rs @@ -152,7 +152,7 @@ fn write_grant_set( make: impl Fn(Grant) -> E::Change, ) -> Result where - E: Checkpoint + Evaluate, + E: Checkpoint + Evaluate, E::State: Serialize + DeserializeOwned, E::Change: KnotAuthored + Clone, { @@ -164,7 +164,7 @@ where signer, |grant| make(to_grant(grant)), |grant| grant.created_at, - |roll, grant| roll.contains(&grant.subject), + |list, grant| list.contains(&grant.subject), |_| Ok(()), ) } @@ -297,8 +297,8 @@ where .map_err(fail) })?; - let (folded, _) = store.materialize::(object).map_err(fail)?; - let absent = items.iter().filter(|item| !present(&folded, item)).count(); + let (list, _) = store.materialize::(object).map_err(fail)?; + let absent = items.iter().filter(|item| !present(&list, item)).count(); match absent { 0 => Ok(GrantSetOutcome { appended: created + missing.len() as u64, diff --git a/knot2/crates/knot-record/src/chain.rs b/knot2/crates/knot-record/src/chain.rs index ceffd8db1..d68613056 100644 --- a/knot2/crates/knot-record/src/chain.rs +++ b/knot2/crates/knot-record/src/chain.rs @@ -145,34 +145,32 @@ pub struct CommitChunk { pub blocks: BTreeMap, } -pub trait Records { - fn placed(&self) -> &BTreeMap; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Due { + Keep(RecordCid), + Write(RecordBody), +} - fn body(&self, key: &MstKey) -> Result; +pub trait Records { + fn due(&self) -> &BTreeMap; } pub struct InTree { - placed: BTreeMap, - bodies: BTreeMap, + due: BTreeMap, } pub fn in_tree(bodies: BTreeMap) -> impl Records { - let placed = bodies - .iter() - .map(|(key, body)| (key.clone(), body.cid())) - .collect(); - InTree { placed, bodies } + InTree { + due: bodies + .into_iter() + .map(|(key, body)| (key, Due::Write(body))) + .collect(), + } } impl Records for InTree { - fn placed(&self) -> &BTreeMap { - &self.placed - } - - fn body(&self, key: &MstKey) -> Result { - self.bodies.get(key).cloned().ok_or_else(|| { - ChainError::Malformed(format!("the record key {key} came without its bytes")) - }) + fn due(&self) -> &BTreeMap { + &self.due } } @@ -491,7 +489,7 @@ pub async fn initialize( pub async fn materialize( repo: &Repo, did: &RepoDid, - records: &BTreeMap, + records: &dyn Records, signer: &dyn Signer, emit: &mut dyn Emit, now: UnixSeconds, @@ -500,12 +498,18 @@ pub async fn materialize( return Err(ChainError::AlreadyMaterialized); } let ops: Vec = records + .due() .iter() - .map(|(key, body)| VerifiedWriteOp::Create { - key: key.as_str().into(), - cid: crate::cid_of(body.as_bytes()), + .map(|(key, due)| match due { + Due::Write(record) => Ok(VerifiedWriteOp::Create { + key: key.as_str().into(), + cid: record.cid().cid(), + }), + Due::Keep(_) => Err(ChainError::Malformed(format!( + "a kept record can't seed an empty chain: {key}" + ))), }) - .collect(); + .collect::, ChainError>>()?; let mut plan = Plan { repo, did: did_of(did)?, @@ -518,7 +522,7 @@ pub async fn materialize( blocks_tree: None, now, }; - plan_chunks(&mut plan, &in_tree(records.clone()), ops).await + plan_chunks(&mut plan, records, ops).await } pub async fn sync( @@ -539,7 +543,7 @@ pub async fn sync( .iter() .any(|prefix| key.as_str().starts_with(prefix)) }; - if let Some(key) = records.placed().keys().find(|key| !synced(key)) { + if let Some(key) = records.due().keys().find(|key| !synced(key)) { return Err(ChainError::Malformed(format!( "the record key {key} isn't in the collections this chain syncs" ))); @@ -550,28 +554,31 @@ pub async fn sync( let before = load_tree(repo, &tip); let leaves = keyed_leaves(&before).await?; let mut ops: Vec = records - .placed() + .due() .iter() - .filter_map(|(key, placed)| { - let cid = placed.cid(); - match leaves.get(key) { - None => Some(VerifiedWriteOp::Create { - key: key.as_str().into(), - cid, - }), - Some(prev) if *prev != cid => Some(VerifiedWriteOp::Update { + .filter_map(|(key, due)| match (due, leaves.get(key)) { + (Due::Write(record), None) => Some(Ok(VerifiedWriteOp::Create { + key: key.as_str().into(), + cid: record.cid().cid(), + })), + (Due::Write(record), Some(prev)) if *prev != record.cid().cid() => { + Some(Ok(VerifiedWriteOp::Update { key: key.as_str().into(), - cid, + cid: record.cid().cid(), prev: *prev, - }), - Some(_) => None, + })) } + (Due::Keep(kept), Some(prev)) if *prev == kept.cid() => None, + (Due::Keep(_), _) => Some(Err(ChainError::Malformed(format!( + "a kept record isn't on the chain: {key}" + )))), + (Due::Write(_), Some(_)) => None, }) - .collect(); + .collect::, ChainError>>()?; ops.extend( leaves .iter() - .filter(|(key, _)| synced(key) && !records.placed().contains_key(*key)) + .filter(|(key, _)| synced(key) && !records.due().contains_key(*key)) .map(|(key, prev)| VerifiedWriteOp::Delete { key: key.as_str().into(), prev: *prev, @@ -595,6 +602,21 @@ pub async fn sync( plan_chunks(&mut plan, records, ops).await } +pub async fn settle( + repo: &Repo, + did: &RepoDid, + collections: &[RecordCollection], + records: &dyn Records, + signer: &dyn Signer, + emit: &mut dyn Emit, + now: UnixSeconds, +) -> Result, ChainError> { + match tip(repo)? { + Some(_) => sync(repo, did, collections, records, signer, emit, now).await, + None => materialize(repo, did, records, signer, emit, now).await, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RecordOp { Create { record: RecordBody }, @@ -876,12 +898,9 @@ async fn draft_chunk( } VerifiedWriteOp::Delete { .. } => continue, }; - if !records.placed().contains_key(key.as_str()) { + let Some(Due::Write(body)) = records.due().get(key.as_str()) else { continue; - } - let key = MstKey::new(key.as_str().to_owned()) - .map_err(|error| ChainError::Malformed(error.to_string()))?; - let body = records.body(&key)?; + }; blocks.insert(*cid, Bytes::copy_from_slice(body.as_bytes())); } Ok(ChunkDraft { @@ -1071,7 +1090,7 @@ mod tests { materialize( repo, &repo_did(), - records, + &in_tree(records.clone()), signer, &mut Ticker::new(), UnixSeconds::new(1), @@ -1141,12 +1160,12 @@ mod tests { .await .unwrap() .is_empty(), - "empty roll doesn't materialize any commit at all" + "an empty grant list doesn't materialize any commit" ); assert!(tip(&repo).unwrap().is_none()); assert!( leaves_of(&repo).await.is_empty(), - "a chain that never materialized must answer an empty leaf list rather than failing the reader" + "a repo without a chain serves an empty leaf list instead of an error" ); let first = enable(&repo, &records(1), &key()).await.unwrap(); assert!(matches!( @@ -1558,7 +1577,7 @@ mod tests { let enabled = materialize( &repo, &repo_did(), - &records(2), + &in_tree(records(2)), &key(), &mut emit, UnixSeconds::new(1), @@ -1620,7 +1639,7 @@ mod tests { materialize( &repo, &repo_did(), - &records(2), + &in_tree(records(2)), &key(), &mut emit, UnixSeconds::new(1), @@ -1672,7 +1691,7 @@ mod tests { materialize( &repo, &repo_did(), - &records(2), + &in_tree(records(2)), &key(), &mut emit, UnixSeconds::new(1), @@ -1717,7 +1736,7 @@ mod tests { materialize( &right, &repo_did(), - &records(2), + &in_tree(records(2)), &key(), &mut Collecting::default(), UnixSeconds::new(3), @@ -2115,7 +2134,7 @@ mod tests { materialize( repo, &repo_did(), - base, + &in_tree(base.clone()), &key(), &mut Stamped(ENABLED_AT), UnixSeconds::new(1), diff --git a/knot2/crates/knot-record/src/grant.rs b/knot2/crates/knot-record/src/grant.rs new file mode 100644 index 000000000..deff7df9e --- /dev/null +++ b/knot2/crates/knot-record/src/grant.rs @@ -0,0 +1,157 @@ +use std::collections::BTreeMap; + +use knot_types::{AccountDid, DidRkey, MstKey, UnixSeconds}; +use serde::Serialize; + +use crate::{CreatedAt, RecordBody, encoded}; + +pub const MEMBER_COLLECTION: &str = "org.tangled.knot.member"; + +pub const COLLABORATOR_COLLECTION: &str = "org.tangled.graph.collaborator"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GrantCollection { + Member, + Collaborator, +} + +impl GrantCollection { + pub fn nsid(&self) -> &'static str { + match self { + Self::Member => MEMBER_COLLECTION, + Self::Collaborator => COLLABORATOR_COLLECTION, + } + } + + pub fn records(&self) -> knot_types::RecordCollection { + knot_types::RecordCollection::new(self.nsid()).expect("A grant collection is a valid nsid") + } + + pub fn surface(&self) -> &'static str { + match self { + Self::Member => "members", + Self::Collaborator => "collaborators", + } + } +} + +#[derive(Serialize)] +struct GrantEncoding<'a> { + #[serde(rename = "$type")] + record_type: &'a str, + #[serde(rename = "createdAt")] + created_at: &'a CreatedAt, +} + +pub fn bodies<'a>( + entries: impl IntoIterator, + collection: GrantCollection, +) -> BTreeMap { + entries + .into_iter() + .filter_map(|(subject, offered_at)| { + let key = DidRkey::new(subject.as_str()) + .map_err(|error| error.to_string()) + .and_then(|rkey| { + MstKey::new(format!("{}/{}", collection.nsid(), rkey.as_str())) + .map_err(|error| error.to_string()) + }); + let Ok(key) = key else { + tracing::warn!( + subject = subject.as_str(), + "a subject that isn't valid as a record key is skipped, and its access \ + stays on the grant list" + ); + return None; + }; + let stamped = CreatedAt::at(offered_at) + .map_err(|error| error.to_string()) + .and_then(|created_at| { + encoded(&GrantEncoding { + record_type: collection.nsid(), + created_at: &created_at, + }) + .map_err(|error| error.to_string()) + }); + stamped + .map(|body| (key, body)) + .inspect_err(|error| { + tracing::warn!( + subject = subject.as_str(), + %error, + "an offer that won't render is skipped, and its access stays on the grant list" + ) + }) + .ok() + }) + .collect() +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn subject(did: &str) -> AccountDid { + AccountDid::new(did).unwrap() + } + + fn stamped(did: &AccountDid, secs: i64) -> (&AccountDid, UnixSeconds) { + (did, UnixSeconds::new(secs)) + } + + #[test] + fn a_grant_list_entry_becomes_one_record_keyed_by_its_subject() { + let whelk = subject("did:plc:whelk"); + let rendered = bodies( + [stamped(&whelk, 1_700_000_000)], + GrantCollection::Collaborator, + ); + let (key, body) = rendered.iter().next().unwrap(); + assert_eq!(key.as_str(), "org.tangled.graph.collaborator/did:plc:whelk"); + let decoded: BTreeMap = + serde_ipld_dagcbor::from_slice(body.as_bytes()).unwrap(); + assert_eq!( + decoded, + BTreeMap::from([ + ("$type".into(), COLLABORATOR_COLLECTION.into()), + ("createdAt".into(), "2023-11-14T22:13:20Z".into()), + ]) + ); + assert_eq!( + bodies([stamped(&whelk, 1_700_000_000)], GrantCollection::Member), + bodies([stamped(&whelk, 1_700_000_000)], GrantCollection::Member), + "the same grant list entry renders the same bytes, so a re-sync is a no-op" + ); + assert_ne!( + bodies([stamped(&whelk, 1_800_000_000)], GrantCollection::Member), + bodies([stamped(&whelk, 1_700_000_000)], GrantCollection::Member), + "a re-offer over a new stamp renders new bytes" + ); + } + + #[test] + fn an_entry_that_cant_become_a_record_is_skipped_alone() { + let whelk = subject("did:plc:whelk"); + let encoded = bodies( + [ + stamped(&subject(&format!("did:web:{}.oyster.cafe", "a".repeat(300))), 1), + stamped(&subject("did:web:knot.nel.pet:8080"), 2), + stamped(&whelk, i64::MIN), + stamped(&whelk, 1_700_000_000), + ], + GrantCollection::Member, + ); + assert_eq!( + encoded.len(), + 1, + "only the entry that can't become a record is skipped, and the rest of the grant \ + list still gets its records" + ); + assert!( + encoded + .keys() + .all(|key| key.as_str().ends_with(whelk.as_str())) + ); + } +} diff --git a/knot2/crates/knot-record/src/lib.rs b/knot2/crates/knot-record/src/lib.rs index 15f59bfeb..6aaded3cf 100644 --- a/knot2/crates/knot-record/src/lib.rs +++ b/knot2/crates/knot-record/src/lib.rs @@ -12,6 +12,7 @@ pub mod chain; pub mod comment; pub mod git_ref; pub mod git_refs; +pub mod grant; pub mod label; pub mod manifest; pub mod mst; diff --git a/knot2/crates/knot-record/src/ticket.rs b/knot2/crates/knot-record/src/ticket.rs index c6b14f41c..5a2f52c68 100644 --- a/knot2/crates/knot-record/src/ticket.rs +++ b/knot2/crates/knot-record/src/ticket.rs @@ -2,7 +2,9 @@ use std::fmt; use std::str::FromStr; use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; -use knot_types::{AccountDid, AtUri, BranchName, RecordBody, RecordBodyError, RefName, UnixSeconds}; +use knot_types::{ + AccountDid, AtUri, BranchName, RecordBody, RecordBodyError, RefName, UnixSeconds, +}; use serde::{Deserialize, Serialize, Serializer}; use crate::comment::{MarkdownEncoding, markdown_encoding}; diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 143a708f7..fe1f33e71 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -69,6 +69,19 @@ impl knot_pack::HandleResolver for AtprotoHandleReso } } +struct GrantSweeper(Arc>); + +impl knot_maintenance::GrantSync for GrantSweeper { + fn sweep(&self, scope: knot_maintenance::GrantScope) { + match scope { + knot_maintenance::GrantScope::Knot => knot_xrpc::grants::sweep_members(&self.0), + knot_maintenance::GrantScope::Repo(repo) => { + knot_xrpc::grants::sweep_collaborators(&self.0, &repo) + } + } + } +} + fn init_tracing() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -234,13 +247,15 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { .meta_path(&knot_did) .context("resolve meta-repo path")?; - knot_types::DidRkey::new(knot_did.as_str()).with_context(|| { - format!( - "{} can't be the record key of an acceptance record, so no member could ever accept \ - this knot: a did:web with a port or a path is a development form", - knot_did.as_str() - ) - })?; + let knot_repo = knot_types::DidRkey::new(knot_did.as_str()) + .with_context(|| { + format!( + "{} is a did:web with a port or a path, a development form that isn't valid \ + as a record key, so the knot account can't exist", + knot_did.as_str() + ) + }) + .map(|rkey| knot_types::RepoDid::from(&rkey))?; let key_pace = keyfill_pace(&config.keyfill); let budget = knot_index::KeyBudget::from_mib(config.keyfill.key_budget_mib as usize); @@ -550,7 +565,7 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { knot_config::init(config); let blob_views = Arc::new(knot_xrpc::BlobViews::default()); - let (maintenance_handle, maintenance_shutdown, maintenance_task) = if maintenance_enabled { + let (maintenance_scheduler, maintenance_handle) = if maintenance_enabled { let (scheduler, handle) = knot_maintenance::Scheduler::new( layout.clone(), Arc::new(IndexRepos(Arc::clone(&index))), @@ -568,15 +583,9 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { ))), None => scheduler, }; - let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - let task = tokio::spawn(scheduler.run(shutdown_rx)); - tracing::info!( - interval_secs = maintenance_interval.as_secs(), - "maintenance scheduler running" - ); - (handle, Some(shutdown_tx), Some(task)) + (Some(scheduler), handle) } else { - (knot_maintenance::MaintenanceHandle::disabled(), None, None) + (None, knot_maintenance::MaintenanceHandle::disabled()) }; let slots = knot_resource::Slots::for_machine(); @@ -612,6 +621,7 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { admission, contribution_policy, knot_did, + knot_repo, knot_hostname: hostname, meta_path, knot_service_url, @@ -649,6 +659,19 @@ async fn run(config: knot_config::Validated) -> anyhow::Result<()> { Some(handle) => ssh_base.with_lfs(handle.clone(), lfs_max_ssh_transfers), None => ssh_base, }); + let (maintenance_shutdown, maintenance_task) = maintenance_scheduler + .map(|scheduler| { + let scheduler = + scheduler.with_grant_sync(Arc::new(GrantSweeper(Arc::clone(&xrpc_state)))); + let (shutdown, rx) = tokio::sync::watch::channel(false); + let task = tokio::spawn(scheduler.run(rx)); + tracing::info!( + interval_secs = maintenance_interval.as_secs(), + "maintenance scheduler running" + ); + (shutdown, task) + }) + .unzip(); { let state = Arc::clone(&xrpc_state); tokio::task::spawn_blocking(move || knot_xrpc::materialize::enable_surfaces(&state)); diff --git a/knot2/crates/knot-sim/src/harness.rs b/knot2/crates/knot-sim/src/harness.rs index fea8fdd5c..71999474b 100644 --- a/knot2/crates/knot-sim/src/harness.rs +++ b/knot2/crates/knot-sim/src/harness.rs @@ -50,7 +50,7 @@ const NO_REFLOG_EXPIRY_FLOOR_SECS: i64 = i64::MAX / 4; const NO_PRUNE_EXPIRY_GRACE_SECS: u64 = (i64::MAX / 4) as u64; const MAINTAIN_ATTEMPTS: usize = 4; -pub(crate) const SUBJECT_DIDS: [&str; 8] = [ +const SUBJECT_DIDS: [&str; 8] = [ "did:plc:limpet", "did:plc:whelk", "did:plc:mussel", @@ -61,6 +61,14 @@ pub(crate) const SUBJECT_DIDS: [&str; 8] = [ "did:plc:nautilus", ]; +fn knot_host() -> KnotHostname { + KnotHostname::new(KNOT_HOST).unwrap() +} + +pub(crate) fn subject_dids() -> [AccountDid; 8] { + SUBJECT_DIDS.map(|did| AccountDid::new(did).expect("subject did")) +} + pub(crate) type Responder = Box Result + Send + Sync>; @@ -162,7 +170,7 @@ pub(crate) struct Harness { impl Harness { pub(crate) fn build(seed: u64, stranger_pool: usize) -> Self { let dir = tempfile::tempdir().expect("sim tempdir"); - let knot = KnotId::new(format!("did:web:{KNOT_HOST}")).expect("knot did"); + let knot = knot_host().knot_did(); let layout = Layout::new(dir.path().join("repos")) .reserving_meta(&knot) .expect("reserve meta"); @@ -180,7 +188,7 @@ impl Harness { ); let knot_pubkey = secrets.ensure(&knot).expect("seal knot key"); let knot_service_url = - KnotServiceUrl::new(format!("https://{KNOT_HOST}")).expect("knot service url"); + KnotServiceUrl::new(format!("https://{}", knot_host())).expect("knot service url"); let admin = actor(ADMIN_HOST, 1); let strangers: Vec = (0..stranger_pool) @@ -189,10 +197,7 @@ impl Harness { actor(&host, STRANGER_SEED_BASE + index as u64) }) .collect(); - let subjects: Vec = SUBJECT_DIDS - .iter() - .map(|did| AccountDid::new(*did).expect("subject did")) - .collect(); + let subjects: Vec = subject_dids().into(); let seed_repo = RepoDid::new("did:plc:squid").expect("seed repo did"); seed_on_disk(&layout, &seed_repo); @@ -224,7 +229,7 @@ impl Harness { admins: BTreeSet::from([admin.did.clone()]), admission: AdmissionPolicy::Closed, knot: knot.clone(), - knot_hostname: KnotHostname::new(KNOT_HOST).unwrap(), + knot_hostname: knot_host(), meta_path, knot_service_url, service_owner: admin.did.clone(), @@ -412,10 +417,7 @@ impl Harness { tokio::task::spawn_blocking(move || knot_xrpc::materialize::enable_surfaces(&boot)) .await .expect("enable_surfaces must finish"); - assert_eq!( - failed, 0, - "enable_surfaces must report zero failures" - ); + assert_eq!(failed, 0, "enable_surfaces must report zero failures"); } pub(crate) fn router(&self) -> Router { @@ -804,6 +806,7 @@ fn assemble_router( admins, admission, contribution_policy: knot_types::ContributionPolicy::Anyone, + knot_repo: knot_xrpc::knot_repo(&knot).unwrap(), knot_did: knot, knot_hostname, meta_path, diff --git a/knot2/crates/knot-sim/src/realdata.rs b/knot2/crates/knot-sim/src/realdata.rs index 89e6472a7..6f560f71b 100644 --- a/knot2/crates/knot-sim/src/realdata.rs +++ b/knot2/crates/knot-sim/src/realdata.rs @@ -7,7 +7,9 @@ use futures::stream::StreamExt; use serde_json::json; use knot_runtime::{K256Signer, SeededEntropy}; -use knot_types::{AccountDid, HttpStatus, KnotHostname, OwnerDid, RecordRkey, RepoDid}; +use knot_types::{ + AccountDid, HttpStatus, KnotHostname, Nsid, OwnerDid, RecordCollection, RecordRkey, RepoDid, +}; use crate::harness::{Harness, SocialProgress, SocialStage}; use crate::trace::{OperationIndex, Outcome, RoundNumber, Snapshot, Step, Trace, fnv1a}; @@ -27,6 +29,18 @@ const PATCH_COLLECTION: &str = "org.tangled.review.patch"; const CREATE_RECORD_NSID: &str = "com.atproto.repo.createRecord"; const PULL_MERGE_NSID: &str = "sh.tangled.repo.pull.merge"; +fn nsid(name: &str) -> Nsid { + Nsid::new_owned(name).expect("the xrpc method is an NSID") +} + +fn ticket_collection() -> RecordCollection { + RecordCollection::new(TICKET_COLLECTION).unwrap() +} + +fn patch_collection() -> RecordCollection { + RecordCollection::new(PATCH_COLLECTION).unwrap() +} + #[derive(Clone, Copy)] pub(crate) struct SampledRepos(usize); @@ -185,7 +199,9 @@ async fn drive(harness: Arc, actors: RealActors, rounds: u32) -> Trace .lock() .expect("social lock") .get_mut(&outcome.repo) - .map(|progress| progress.record(outcome.stage, outcome.rkey.clone())) + .map(|progress| { + progress.record(outcome.stage, outcome.rkey.clone()) + }) .unwrap_or(false); if complete { harness.seed_social(&outcome.repo); @@ -232,12 +248,12 @@ fn arm_drops(harness: &Harness, execs: &[Exec]) -> Vec { hosts } -fn member_verb(draw: u64) -> (&'static str, &'static str) { +fn member_verb(draw: u64) -> (Nsid, &'static str) { match draw { - 0 => ("sh.tangled.knot.addMember", "addMember"), - 1 => ("sh.tangled.knot.removeMember", "removeMember"), - 2 => ("sh.tangled.knot.ban", "ban"), - _ => ("sh.tangled.knot.unban", "unban"), + 0 => (nsid("sh.tangled.knot.addMember"), "addMember"), + 1 => (nsid("sh.tangled.knot.removeMember"), "removeMember"), + 2 => (nsid("sh.tangled.knot.ban"), "ban"), + _ => (nsid("sh.tangled.knot.unban"), "unban"), } } @@ -255,13 +271,13 @@ fn mutate( .iter() .filter(|_| rng.chance(2, 3)) .for_each(|subject| { - let (nsid, short) = member_verb(rng.below(4)); + let (method, short) = member_verb(rng.below(4)); let skew = rng.chance(1, 5); let request = signed_post( harness, &harness.admin.signer, &harness.admin.did, - nsid, + &method, &harness.knot_aud, json!({ "subject": subject }), skew, @@ -293,7 +309,7 @@ fn mutate( harness, &owner_signer(actors.seed, owner), &AccountDid::from(owner.clone()), - "sh.tangled.repo.addCollaborator", + &nsid("sh.tangled.repo.addCollaborator"), &harness.knot_aud, json!({ "repo": repo, "subject": subject }), skew, @@ -327,7 +343,7 @@ fn mutate( harness, &harness.admin.signer, &harness.admin.did, - "org.tangled.repo.createRepo", + &nsid("org.tangled.repo.createRepo"), &harness.knot_aud, json!({ "name": name }), skew, @@ -355,7 +371,7 @@ fn mutate( harness, &stranger.signer, &stranger.did, - "sh.tangled.knot.addMember", + &nsid("sh.tangled.knot.addMember"), &harness.knot_aud, json!({ "subject": subjects[0] }), false, @@ -384,84 +400,87 @@ fn mutate( repos }; - social_repos - .into_iter() - .for_each(|(repo, progress)| { - let SocialProgress { - stage, - base, - head, - ticket, - pull, - } = progress; - let (signer, did) = actors - .owner_repos - .iter() - .find(|(candidate, _)| candidate == &repo) - .map(|(_, owner)| (owner_signer(actors.seed, owner), AccountDid::from(owner.clone()))) - .unwrap_or_else(|| (admin_signer(actors.seed), harness.admin.did.clone())); - let (op, nsid, body) = match stage { - SocialStage::Ticket => ( - "createTicket", - CREATE_RECORD_NSID, - json!({ - "repo": repo, - "collection": TICKET_COLLECTION, - "record": { - "$type": TICKET_COLLECTION, - "repo": repo, - "title": "sim ticket", - "body": "a ticket from the sim", - "state": "open", - "createdAt": "2026-09-11T00:00:00Z" - } - }), - ), - SocialStage::Patch => ( - "createPatch", - CREATE_RECORD_NSID, - json!({ - "repo": repo, - "collection": PATCH_COLLECTION, - "record": { - "$type": PATCH_COLLECTION, - "ticket": ticket.expect("ticket rkey"), - "base": { "oid": base.to_hex() }, - "head": { "oid": head.to_hex() }, - "createdAt": "2026-09-11T00:00:00Z" - } - }), - ), - SocialStage::Merge => ( - "mergePull", - PULL_MERGE_NSID, - json!({ + social_repos.into_iter().for_each(|(repo, progress)| { + let SocialProgress { + stage, + base, + head, + ticket, + pull, + } = progress; + let (signer, did) = actors + .owner_repos + .iter() + .find(|(candidate, _)| candidate == &repo) + .map(|(_, owner)| { + ( + owner_signer(actors.seed, owner), + AccountDid::from(owner.clone()), + ) + }) + .unwrap_or_else(|| (admin_signer(actors.seed), harness.admin.did.clone())); + let (op, method, body) = match stage { + SocialStage::Ticket => ( + "createTicket", + nsid(CREATE_RECORD_NSID), + json!({ + "repo": repo, + "collection": &ticket_collection(), + "record": { + "$type": &ticket_collection(), "repo": repo, - "rkey": pull.expect("pull rkey") - }), - ), - }; - let request = signed_post( - harness, - &signer, - &did, - nsid, - &repo, - body, - false, - round, - execs.len(), - ); - execs.push(Exec::Http { - op, - actor: format!("owner:{op}"), - fault: "none", - request, - killed: false, - capture_create: false, - social: Some((repo, stage)), - }); + "title": "sim ticket", + "body": "a ticket from the sim", + "state": "open", + "createdAt": "2026-09-11T00:00:00Z" + } + }), + ), + SocialStage::Patch => ( + "createPatch", + nsid(CREATE_RECORD_NSID), + json!({ + "repo": repo, + "collection": &patch_collection(), + "record": { + "$type": &patch_collection(), + "ticket": ticket.expect("ticket rkey"), + "base": { "oid": base.to_hex() }, + "head": { "oid": head.to_hex() }, + "createdAt": "2026-09-11T00:00:00Z" + } + }), + ), + SocialStage::Merge => ( + "mergePull", + nsid(PULL_MERGE_NSID), + json!({ + "repo": repo, + "rkey": pull.expect("pull rkey") + }), + ), + }; + let request = signed_post( + harness, + &signer, + &did, + &method, + &repo, + body, + false, + round, + execs.len(), + ); + execs.push(Exec::Http { + op, + actor: format!("owner:{op}"), + fault: "none", + request, + killed: false, + capture_create: false, + social: Some((repo, stage)), }); + }); execs } diff --git a/knot2/crates/knot-sim/src/workload.rs b/knot2/crates/knot-sim/src/workload.rs index 74062afd3..e97755585 100644 --- a/knot2/crates/knot-sim/src/workload.rs +++ b/knot2/crates/knot-sim/src/workload.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use tower::ServiceExt; use knot_runtime::{Entropy, K256Signer, SeededEntropy, Signer}; -use knot_types::{AccountDid, HttpStatus, KnotHostname, RepoDid, UnixSeconds}; +use knot_types::{AccountDid, HttpStatus, KnotHostname, Nsid, RepoDid, UnixSeconds}; -use crate::harness::{Harness, SUBJECT_DIDS}; +use crate::harness::{Harness, subject_dids}; use crate::trace::{OperationIndex, Outcome, Projection, RoundNumber, Step, Trace, fnv1a}; const SKEW_BACKDATE_SECS: i64 = 600; @@ -184,9 +184,9 @@ struct Model { } pub(crate) fn predict(seed: u64, rounds: u32) -> Projection { - let subject_did = - |subject: SubjectIndex| AccountDid::new(SUBJECT_DIDS[subject.0]).expect("subject did"); - let plan = plan(seed, rounds, SUBJECT_DIDS.len()); + let subjects = subject_dids(); + let subject_did = |subject: SubjectIndex| subjects[subject.0].clone(); + let plan = plan(seed, rounds, subjects.len()); let ops = || plan.iter().flat_map(|round| round.phase.ops()); let repos = 1 + ops().filter(|planned| planned.creates_a_repo()).count(); let model = ops().fold(Model::default(), |mut model, planned| { @@ -632,7 +632,7 @@ fn admin_request( AdminOp::AddMember(subject) => admin_post( harness, "addMember", - "sh.tangled.knot.addMember", + &nsid("sh.tangled.knot.addMember"), json!({ "subject": subjects[subject.0] }), skew, round, @@ -641,7 +641,7 @@ fn admin_request( AdminOp::RemoveMember(subject) => admin_post( harness, "removeMember", - "sh.tangled.knot.removeMember", + &nsid("sh.tangled.knot.removeMember"), json!({ "subject": subjects[subject.0] }), skew, round, @@ -650,7 +650,7 @@ fn admin_request( AdminOp::Ban(subject) => admin_post( harness, "ban", - "sh.tangled.knot.ban", + &nsid("sh.tangled.knot.ban"), json!({ "subject": subjects[subject.0] }), skew, round, @@ -659,7 +659,7 @@ fn admin_request( AdminOp::Unban(subject) => admin_post( harness, "unban", - "sh.tangled.knot.unban", + &nsid("sh.tangled.knot.unban"), json!({ "subject": subjects[subject.0] }), skew, round, @@ -670,7 +670,7 @@ fn admin_request( admin_post( harness, "create", - "org.tangled.repo.createRepo", + &nsid("org.tangled.repo.createRepo"), json!({ "name": name }), skew, round, @@ -680,7 +680,7 @@ fn admin_request( AdminOp::AddCollaborator(repo, subject) => admin_post( harness, "addCollaborator", - "sh.tangled.repo.addCollaborator", + &nsid("sh.tangled.repo.addCollaborator"), json!({ "repo": repo_at(repos, repo), "subject": subjects[subject.0], @@ -703,7 +703,7 @@ fn probe_request( harness, &actor.signer, &actor.did, - "sh.tangled.knot.addMember", + &nsid("sh.tangled.knot.addMember"), &harness.knot_aud, json!({ "subject": harness.subjects[0] }), false, @@ -725,11 +725,15 @@ fn repo_get(method: &str, param: &str, repo: &RepoDid) -> Request { )) } +fn nsid(name: &str) -> Nsid { + Nsid::new_owned(name).expect("the xrpc method is an NSID") +} + pub(crate) fn signed_post( harness: &Harness, signer: &K256Signer, did: &AccountDid, - nsid: &'static str, + method: &Nsid, aud: &impl AsRef, body: Value, skew: bool, @@ -740,14 +744,14 @@ pub(crate) fn signed_post( signer, did, aud, - nsid, + method, jwt_window(harness, skew), round, OperationIndex::new(slot as u32), ); Request { method: Method::POST, - uri: format!("/xrpc/{nsid}"), + uri: format!("/xrpc/{method}"), token: Some(token), body: encode_body(body), actor: String::new(), @@ -770,7 +774,7 @@ pub(crate) fn maintain_outcome(harness: &Harness, repo: &RepoDid) -> Outcome { fn admin_post( harness: &Harness, method_short: &str, - nsid: &'static str, + method: &Nsid, body: Value, skew: bool, round: RoundNumber, @@ -780,7 +784,7 @@ fn admin_post( harness, &harness.admin.signer, &harness.admin.did, - nsid, + method, &harness.knot_aud, body, skew, @@ -807,7 +811,7 @@ pub(crate) fn mint( signer: &K256Signer, issuer: &AccountDid, aud: &impl AsRef, - nsid: &str, + nsid: &Nsid, window: (UnixSeconds, UnixSeconds), round: RoundNumber, index: OperationIndex, @@ -856,7 +860,10 @@ pub(crate) async fn http_call( [127, 0, 0, 1], 4242, )))); - let response = router.oneshot(request).await.expect("router must return a response"); + let response = router + .oneshot(request) + .await + .expect("router must return a response"); let status = HttpStatus::from(response.status()); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await diff --git a/knot2/crates/knot-sim/tests/h3.rs b/knot2/crates/knot-sim/tests/h3.rs index 7b9dad22d..fb11b099e 100644 --- a/knot2/crates/knot-sim/tests/h3.rs +++ b/knot2/crates/knot-sim/tests/h3.rs @@ -132,7 +132,7 @@ fn serve_dids() -> Arc { }) } -async fn clone_over_h3(edge: &Edge, did: &str, tip: &str) -> Vec { +async fn clone_over_h3(edge: &Edge, did: &RepoDid, tip: &str) -> Vec { let connection = edge .client .connect(edge.addr, "localhost") @@ -185,7 +185,7 @@ async fn clone_over_h3(edge: &Edge, did: &str, tip: &str) -> Vec { async fn cloned_set( layout: &Layout, - did: &str, + did: &RepoDid, tip: &str, format: ObjectFormat, ) -> BTreeSet { @@ -211,19 +211,18 @@ async fn cloned_set( object_set(clonedir.path()) } -async fn the_live_h3_transport_is_logically_reproducible(format: ObjectFormat, did_str: &str) { +async fn the_live_h3_transport_is_logically_reproducible(format: ObjectFormat, did: &RepoDid) { let scan = tempfile::tempdir().unwrap(); let scratch = tempfile::tempdir().unwrap(); - let did = RepoDid::new(did_str).unwrap(); let layout = Layout::new(scan.path()).with_object_format(format); - layout.create(&did).unwrap(); - let bare = layout.repo_path(&did).unwrap(); + layout.create(did).unwrap(); + let bare = layout.repo_path(did).unwrap(); let tip = seed_bare(&scratch.path().join("work"), &bare, format); let truth = object_set(&bare); - let first = cloned_set(&layout, did_str, &tip, format).await; - let second = cloned_set(&layout, did_str, &tip, format).await; + let first = cloned_set(&layout, did, &tip, format).await; + let second = cloned_set(&layout, did, &tip, format).await; assert_eq!( first, second, @@ -237,6 +236,14 @@ async fn the_live_h3_transport_is_logically_reproducible(format: ObjectFormat, d #[tokio::test(flavor = "multi_thread")] async fn the_new_h3_transport_replays_to_the_same_object_set_off_the_recorded_trace() { - the_live_h3_transport_is_logically_reproducible(ObjectFormat::SHA1, "did:plc:squid").await; - the_live_h3_transport_is_logically_reproducible(ObjectFormat::SHA256, "did:plc:cuttle").await; + the_live_h3_transport_is_logically_reproducible( + ObjectFormat::SHA1, + &RepoDid::new("did:plc:squid").unwrap(), + ) + .await; + the_live_h3_transport_is_logically_reproducible( + ObjectFormat::SHA256, + &RepoDid::new("did:plc:cuttle").unwrap(), + ) + .await; } diff --git a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs index d569d68bb..171cae4c5 100644 --- a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs +++ b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs @@ -21,8 +21,8 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OwnerDid, RepoDid, - RepoName, RepoRkey, UnixSeconds, + AccountDid, ActorId, AdmissionPolicy, AuthorName, Did, Email, KnotHostname, KnotId, + KnotServiceUrl, Nsid, OwnerDid, RepoDid, RepoName, RepoRkey, ServiceDid, UnixSeconds, }; use tempfile::TempDir; use tokio::net::TcpListener; @@ -37,6 +37,18 @@ const PDS_HOST: &str = "pds.oyster.cafe"; const KNOT_DID: &str = "did:web:nel.pet"; const PINNED_DATE: &str = "2026-07-07T12:00:00+00:00"; +fn repo_did() -> RepoDid { + RepoDid::new(REPO_DID).unwrap() +} + +fn owner() -> AccountDid { + AccountDid::new(OWNER_DID).unwrap() +} + +fn knot_service_did() -> ServiceDid { + ServiceDid::new(KNOT_DID).unwrap() +} + fn require_git_lfs() -> bool { let available = Command::new("git-lfs") .arg("version") @@ -136,21 +148,25 @@ fn actor_signer() -> K256Signer { K256Signer::from_slice(&[9u8; 32]).unwrap() } -fn did_document(did: &str, pds: &str) -> Vec { - let multikey = knot_types::crypto::multikey(0xe7, actor_signer().public_key().as_bytes()); +fn did_document(did: &Did, pds: &KnotServiceUrl) -> Vec { + let multikey = ActorId::new(knot_types::crypto::multikey( + 0xe7, + actor_signer().public_key().as_bytes(), + )) + .unwrap(); serde_json::to_vec(&serde_json::json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": ["at://nel.pet"], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, - "publicKeyMultibase": multikey + "controller": did.as_str(), + "publicKeyMultibase": multikey.as_str() }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": pds + "serviceEndpoint": pds.as_str() }] })) .unwrap() @@ -159,7 +175,7 @@ fn did_document(did: &str, pds: &str) -> Vec { fn list_records_body(public_line: &str) -> Vec { serde_json::to_vec(&serde_json::json!({ "records": [{ - "uri": format!("at://{OWNER_DID}/org.tangled.key.sshKey/1"), + "uri": format!("at://{}/org.tangled.key.sshKey/1", owner()), "value": { "$type": "org.tangled.key.sshKey", "publicKey": public_line, @@ -173,12 +189,13 @@ fn list_records_body(public_line: &str) -> Vec { fn fake_http( published_line: String, - knot_pds: String, + knot_pds: KnotServiceUrl, ) -> FakeHttp< impl Fn(&knot_runtime::HttpRequest) -> Result + Send + Sync, > { + let foreign_pds = KnotServiceUrl::new(format!("https://{PDS_HOST}")).unwrap(); FakeHttp::new(move |request: &knot_runtime::HttpRequest| { let host = request.url.host_str().unwrap_or_default().to_string(); let path = request.url.path().to_string(); @@ -187,11 +204,12 @@ fn fake_http( } else if host == "plc.directory" && request.method == http::Method::POST { b"{}".to_vec() } else if host == "plc.directory" && path.starts_with("/did:") { - let did = path.trim_start_matches('/'); - match did == REPO_DID { - true => did_document(did, &knot_pds), - false => did_document(did, &format!("https://{PDS_HOST}")), - } + let did = Did::new_owned(path.trim_start_matches('/')).expect("the plc path is a DID"); + let pds = match did.as_str() == repo_did().as_str() { + true => &knot_pds, + false => &foreign_pds, + }; + did_document(&did, pds) } else { return Ok(HttpResponse { status: http::StatusCode::NOT_FOUND, @@ -207,19 +225,28 @@ fn fake_http( }) } -fn service_jwt(nsid: &str, jti: &str) -> String { - service_jwt_as(OWNER_DID, nsid, jti) +fn nsid(name: &str) -> Nsid { + Nsid::new_owned(name).expect("the xrpc method is an NSID") +} + +fn service_jwt(method: &Nsid, jti: &str) -> String { + service_jwt_as( + &Did::new_owned(OWNER_DID).unwrap(), + &knot_service_did(), + method, + jti, + ) } -fn service_jwt_as(iss: &str, nsid: &str, jti: &str) -> String { +fn service_jwt_as(iss: &Did, audience: &ServiceDid, method: &Nsid, jti: &str) -> String { let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); let claims = serde_json::json!({ "iss": iss, - "aud": KNOT_DID, + "aud": audience.as_str(), "exp": 1_001, "iat": 999, "jti": jti, - "lxm": nsid, + "lxm": method, }); let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); let signing_input = format!("{header}.{payload}"); @@ -251,7 +278,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { let meta_path = scan.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(scan.path().join("repos")); - let repo_did = RepoDid::new(REPO_DID).unwrap(); + let repo_did = repo_did(); layout.create(&repo_did).unwrap(); let knot = KnotId::new(KNOT_DID).unwrap(); @@ -290,7 +317,10 @@ async fn spawn(published_line: String, with_h3: bool) -> World { let http_port = http_listener.local_addr().unwrap().port(); let http_base = format!("http://127.0.0.1:{http_port}"); let atproto = Arc::new(Atproto::new( - fake_http(published_line, format!("https://127.0.0.1:{http_port}")), + fake_http( + published_line, + KnotServiceUrl::new(format!("https://127.0.0.1:{http_port}")).unwrap(), + ), ManualClock::new(UnixMicros::new(1_000_000_000)), knot.clone(), knot_atproto::PlcDirectory::new(Url::parse("https://plc.directory/").unwrap()).unwrap(), @@ -316,7 +346,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { knot_actor: knot_types::ActorId::from_secp256k1(actor_signer().public_key().as_bytes()), hostname: KnotHostname::new("nel.pet").unwrap(), appview: knot_types::AppviewEndpoint::new("https://tangled.test").unwrap(), - admins: BTreeSet::from([AccountDid::new(OWNER_DID).unwrap()]), + admins: BTreeSet::from([owner()]), admission: AdmissionPolicy::Closed, max_pack_bytes: knot_xrpc::MaxWireBytes::new(1 << 30), archive_limit: knot_git::ArchiveLimit::default(), @@ -340,7 +370,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { secrets, entropy: Arc::new(OsEntropy), invite_codes: Default::default(), - admins: BTreeSet::from([AccountDid::new(OWNER_DID).unwrap()]), + admins: BTreeSet::from([owner()]), admission: AdmissionPolicy::Closed, contribution_policy: knot_types::ContributionPolicy::Anyone, writes: Arc::new(knot_resource::SubjectLimiter::new( @@ -350,6 +380,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { }, )), social_budget: knot_resource::SocialBudgetBytes::new(1 << 28), + knot_repo: knot_xrpc::knot_repo(&knot).unwrap(), knot_did: knot, knot_hostname: KnotHostname::new("nel.pet").unwrap(), meta_path, @@ -377,7 +408,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { )) })), pack_limits: knot_pack::PackLimits::default(), - service_owner: AccountDid::new(OWNER_DID).unwrap(), + service_owner: owner(), firehose: Arc::new(knot_events::EventLog::new( ManualClock::new(UnixMicros::new(1_000_000_000)), knot_events::ReplayBounds::new( @@ -528,12 +559,12 @@ fn clone_and_pull(base: &Path, url: &str, name: &str, env: &[(String, String)]) } async fn create_fork(world: &World, jti: &str) -> (http::StatusCode, serde_json::Value) { - let token = service_jwt("org.tangled.repo.createRepo", jti); + let token = service_jwt(&nsid("org.tangled.repo.createRepo"), jti); let body = serde_json::json!({ "name": FORK_NAME, "source": { "$type": "org.tangled.repo.defs#sourceTangled", - "did": REPO_DID, + "did": repo_did(), }, }); let request = http::Request::builder() @@ -578,8 +609,9 @@ async fn the_lfs_round_trip_gate_holds_over_both_transports_and_the_fork() { seed_lfs_work(&work, &env, &media); let push_url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - world.ssh_port + "ssh://git@127.0.0.1:{}/{}/{REPO_NAME}", + world.ssh_port, + owner() ); let (ok, out) = { let work = work.clone(); @@ -588,7 +620,7 @@ async fn the_lfs_round_trip_gate_holds_over_both_transports_and_the_fork() { }; assert!(ok, "lfs push over ssh failed:\n{out}"); - let source_repo = RepoDid::new(REPO_DID).unwrap(); + let source_repo = repo_did(); assert_eq!( world .lfs @@ -599,7 +631,7 @@ async fn the_lfs_round_trip_gate_holds_over_both_transports_and_the_fork() { "pushed media must be durable in the store" ); - let clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); + let clone_url = format!("{}/{}/{REPO_NAME}", world.http_base, owner()); let dst = { let base = scratch.path().to_path_buf(); let env = env.clone(); @@ -652,7 +684,7 @@ async fn the_lfs_round_trip_gate_holds_over_both_transports_and_the_fork() { "fork prefix must hold its own copy of the media" ); - let fork_url = format!("{}/{OWNER_DID}/{FORK_NAME}", world.http_base); + let fork_url = format!("{}/{}/{FORK_NAME}", world.http_base, owner()); let fork_dst = { let base = scratch.path().to_path_buf(); let env = env.clone(); @@ -718,8 +750,9 @@ async fn many_objects_ride_default_git_lfs_concurrency_over_both_transports() { seed_many_lfs(&work, &env, count); let push_url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - world.ssh_port + "ssh://git@127.0.0.1:{}/{}/{REPO_NAME}", + world.ssh_port, + owner() ); let (ok, out) = { let work = work.clone(); @@ -732,7 +765,7 @@ async fn many_objects_ride_default_git_lfs_concurrency_over_both_transports() { the per-peer connection limit:\n{out}" ); - let clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); + let clone_url = format!("{}/{}/{REPO_NAME}", world.http_base, owner()); let dst = { let base = scratch.path().to_path_buf(); let env = env.clone(); @@ -889,8 +922,9 @@ async fn the_lfs_stack_is_conformant_with_the_reference_server_and_client() { }); let knot_push_url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - world.ssh_port + "ssh://git@127.0.0.1:{}/{}/{REPO_NAME}", + world.ssh_port, + owner() ); let reference_push_url = format!("ssh://ref@localhost{}", upstream.display()); let pushes = { @@ -915,7 +949,7 @@ async fn the_lfs_stack_is_conformant_with_the_reference_server_and_client() { assert!(ok, "push failed:\n{out}"); }); - let source_repo = RepoDid::new(REPO_DID).unwrap(); + let source_repo = repo_did(); let knot_objects: std::collections::BTreeSet<(String, u64)> = world .lfs .store @@ -937,7 +971,7 @@ async fn the_lfs_stack_is_conformant_with_the_reference_server_and_client() { "both servers hold identical object sets after the same push" ); - let knot_clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); + let knot_clone_url = format!("{}/{}/{REPO_NAME}", world.http_base, owner()); let (knot_dst, reference_dst) = { let base = scratch.path().to_path_buf(); let knot_env = knot_env.clone(); @@ -1037,7 +1071,7 @@ async fn lfs_batch( }); let mut builder = http::Request::builder() .method("POST") - .uri(format!("/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/batch")) + .uri(format!("/{}/{REPO_NAME}/info/lfs/objects/batch", owner())) .header(http::header::CONTENT_TYPE, "application/vnd.git-lfs+json"); if let Some(auth) = auth { builder = builder.header(http::header::AUTHORIZATION, auth); @@ -1065,7 +1099,8 @@ async fn lfs_put( let mut builder = http::Request::builder() .method("PUT") .uri(format!( - "/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", + "/{}/{REPO_NAME}/info/lfs/objects/{}", + owner(), oid.as_str() )) .header(http::header::CONTENT_LENGTH, bytes.len()); @@ -1092,7 +1127,7 @@ async fn lfs_http_push_stores_an_object_with_a_push_token() { let scratch = tempfile::tempdir().unwrap(); let (_key_path, public_line) = keygen(scratch.path()); let world = spawn_world(public_line).await; - let repo = RepoDid::new(REPO_DID).unwrap(); + let repo = repo_did(); let payload: Vec = (0..4096u32) .map(|n| (n.wrapping_mul(17) % 251) as u8) @@ -1102,7 +1137,7 @@ async fn lfs_http_push_stores_an_object_with_a_push_token() { let bearer = format!( "Bearer {}", - service_jwt("sh.tangled.repo.push", "lfs-http-push-1") + service_jwt(&nsid("sh.tangled.repo.push"), "lfs-http-push-1") ); let (status, body) = lfs_batch(&world, Some(&bearer), "upload", &oid, size).await; assert_eq!( @@ -1116,8 +1151,9 @@ async fn lfs_http_push_stores_an_object_with_a_push_token() { assert_eq!( href, format!( - "{}/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", + "{}/{}/{REPO_NAME}/info/lfs/objects/{}", world.http_base, + owner(), oid.as_str() ), "upload href points at the object route on this knot" @@ -1152,7 +1188,8 @@ async fn lfs_http_push_stores_an_object_with_a_push_token() { let get = http::Request::builder() .method("GET") .uri(format!( - "/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", + "/{}/{REPO_NAME}/info/lfs/objects/{}", + owner(), oid.as_str() )) .body(axum::body::Body::empty()) @@ -1176,7 +1213,7 @@ async fn lfs_http_push_stores_an_object_with_a_push_token() { .map(|n| (n.wrapping_mul(29) % 251) as u8) .collect(); let oid2 = LfsOid::from_digest(knot_types::Sha256Digest::hash(&payload2)); - let basic = basic_auth(&service_jwt("sh.tangled.repo.push", "lfs-http-push-2")); + let basic = basic_auth(&service_jwt(&nsid("sh.tangled.repo.push"), "lfs-http-push-2")); let (status, body) = lfs_batch(&world, Some(&basic), "upload", &oid2, payload2.len() as u64).await; assert_eq!( @@ -1218,7 +1255,7 @@ async fn lfs_http_push_rejects_missing_and_mismatched_credentials() { let wrong_method = format!( "Bearer {}", - service_jwt("org.tangled.repo.createRepo", "lfs-http-neg-method") + service_jwt(&nsid("org.tangled.repo.createRepo"), "lfs-http-neg-method") ); let (status, _) = lfs_batch(&world, Some(&wrong_method), "upload", &oid, size).await; assert_eq!( @@ -1229,7 +1266,12 @@ async fn lfs_http_push_rejects_missing_and_mismatched_credentials() { let stranger = format!( "Bearer {}", - service_jwt_as(REPO_DID, "sh.tangled.repo.push", "lfs-http-neg-acl") + service_jwt_as( + &Did::new_owned(REPO_DID).unwrap(), + &knot_service_did(), + &nsid("sh.tangled.repo.push"), + "lfs-http-neg-acl", + ) ); let (status, _) = lfs_batch(&world, Some(&stranger), "upload", &oid, size).await; assert_eq!( @@ -1248,7 +1290,7 @@ async fn lfs_http_push_rejects_missing_and_mismatched_credentials() { world .lfs .store - .probe(Pool::Lfs, &RepoDid::new(REPO_DID).unwrap(), &oid) + .probe(Pool::Lfs, &repo_did(), &oid) .unwrap() .is_none(), "no rejected request may leave an object behind" @@ -1277,7 +1319,7 @@ async fn git_push_over_http_authenticates_and_lands_the_ref() { let (ok, out) = git(&work, &env, &["commit", "-q", "-m", "init over http"]); assert!(ok, "commit failed:\n{out}"); - let url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); + let url = format!("{}/{}/{REPO_NAME}", world.http_base, owner()); let (ok, out) = { let work = work.clone(); @@ -1290,7 +1332,7 @@ async fn git_push_over_http_authenticates_and_lands_the_ref() { "an unauthenticated http push must be refused, git reported success:\n{out}" ); - let token = service_jwt("sh.tangled.repo.push", "git-http-push-1"); + let token = service_jwt(&nsid("sh.tangled.repo.push"), "git-http-push-1"); let header = format!( "http.extraHeader=Authorization: Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("x-tangled-token:{token}")) @@ -1320,7 +1362,7 @@ async fn git_push_over_http_authenticates_and_lands_the_ref() { "http.extraHeader=Authorization: Basic {}", base64::engine::general_purpose::STANDARD.encode(format!( "x-tangled-token:{}", - service_jwt("org.tangled.repo.createRepo", "git-http-push-neg") + service_jwt(&nsid("org.tangled.repo.createRepo"), "git-http-push-neg") )) ); std::fs::write(work.join("README.md"), "second write\n").unwrap(); @@ -1404,10 +1446,10 @@ async fn git_push_over_h3_authenticates_and_lands_the_ref() { let pack = build_pack(&work, &env, &tip); let advert_uri = - format!("https://localhost/{OWNER_DID}/{REPO_NAME}/info/refs?service=git-receive-pack"); - let receive_uri = format!("https://localhost/{OWNER_DID}/{REPO_NAME}/git-receive-pack"); + format!("https://localhost/{}/{REPO_NAME}/info/refs?service=git-receive-pack", owner()); + let receive_uri = format!("https://localhost/{}/{REPO_NAME}/git-receive-pack", owner()); let warmup = - format!("https://localhost/{OWNER_DID}/{REPO_NAME}/info/refs?service=git-upload-pack"); + format!("https://localhost/{}/{REPO_NAME}/info/refs?service=git-upload-pack", owner()); const RECEIVE_CT: &str = "application/x-git-receive-pack-request"; let (status, _) = @@ -1418,7 +1460,7 @@ async fn git_push_over_h3_authenticates_and_lands_the_ref() { "an unauthenticated receive advertisement must be challenged over h3" ); - let token = basic_auth(&service_jwt("sh.tangled.repo.push", "git-h3-adv-1")); + let token = basic_auth(&service_jwt(&nsid("sh.tangled.repo.push"), "git-h3-adv-1")); let (status, advert) = common::h3_request( edge, Method::GET, @@ -1454,7 +1496,7 @@ async fn git_push_over_h3_authenticates_and_lands_the_ref() { "an unauthenticated receive-pack post must be challenged over h3" ); - let wrong = basic_auth(&service_jwt("org.tangled.repo.createRepo", "git-h3-neg")); + let wrong = basic_auth(&service_jwt(&nsid("org.tangled.repo.createRepo"), "git-h3-neg")); let (status, _) = common::h3_request( edge, Method::POST, @@ -1473,7 +1515,7 @@ async fn git_push_over_h3_authenticates_and_lands_the_ref() { "a token bound to another method cannot authorize a receive-pack over h3" ); - let good = basic_auth(&service_jwt("sh.tangled.repo.push", "git-h3-push-1")); + let good = basic_auth(&service_jwt(&nsid("sh.tangled.repo.push"), "git-h3-push-1")); let (status, report) = common::h3_request( edge, Method::POST, @@ -1497,7 +1539,7 @@ async fn git_push_over_h3_authenticates_and_lands_the_ref() { "the ref update must be accepted over h3:\n{report}" ); - let repo = world.layout.open(&RepoDid::new(REPO_DID).unwrap()).unwrap(); + let repo = world.layout.open(&repo_did()).unwrap(); let landed = repo.references().unwrap().into_iter().any(|record| { record.name.as_str() == "refs/heads/main" && record.target.to_string() == tip }); diff --git a/knot2/crates/knot-sim/tests/social_chaos.rs b/knot2/crates/knot-sim/tests/social_chaos.rs index 75f58df2f..459343c1f 100644 --- a/knot2/crates/knot-sim/tests/social_chaos.rs +++ b/knot2/crates/knot-sim/tests/social_chaos.rs @@ -156,7 +156,7 @@ fn serving_records(index: &Index, layout: &Layout, repo: &RepoDid, when: &str) - let projection = entry.state.serving().map(|version| version.cid()); let chain_leaf = leaves.get(&entry.address.mst_key()).copied(); let type_name = TypeName::new(entry.address.collection().as_str()).unwrap(); - let folded = knot_cobs::social_state(&store, &type_name, entry.object) + let replayed = knot_cobs::social_state(&store, &type_name, entry.object) .unwrap() .and_then(|(state, _)| { state @@ -172,8 +172,8 @@ fn serving_records(index: &Index, layout: &Layout, repo: &RepoDid, when: &str) - ); assert_eq!( projection, - folded, - "{when}: projection and a fresh fold differ for {}", + replayed, + "{when}: projection and a fresh replay differ for {}", entry.address.at_uri(repo) ); projection.is_some() as usize diff --git a/knot2/crates/knot-sim/tests/ssh.rs b/knot2/crates/knot-sim/tests/ssh.rs index 459d892e2..8b506899a 100644 --- a/knot2/crates/knot-sim/tests/ssh.rs +++ b/knot2/crates/knot-sim/tests/ssh.rs @@ -9,7 +9,7 @@ use knot_git::{Layout, Repo}; use knot_runtime::{ FakeHttp, HttpResponse, K256Signer, ManualClock, SeededEntropy, Signer, UnixMicros, }; -use knot_types::{KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; +use knot_types::{Did, KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds}; use tempfile::TempDir; use tokio::net::TcpListener; use url::Url; @@ -21,6 +21,18 @@ const PDS_HOST: &str = "pds.oyster.cafe"; const KNOT_DID: &str = "did:web:nel.pet"; const PINNED_DATE: &str = "2026-06-20T12:00:00+00:00"; +fn repo_did() -> RepoDid { + RepoDid::new(REPO_DID).unwrap() +} + +fn owner_did() -> OwnerDid { + OwnerDid::new(OWNER_DID).unwrap() +} + +fn knot_id() -> KnotId { + KnotId::new(KNOT_DID).unwrap() +} + fn git(cwd: &Path, env: &[(&str, &str)], args: &[&str]) -> (bool, String) { let mut command = knot_fixtures::command_at(cwd, PINNED_DATE); command.args(args); @@ -61,15 +73,15 @@ fn keygen(dir: &Path) -> (String, String) { (path.to_str().unwrap().to_string(), public_line) } -fn did_document(signer: &K256Signer, did: &str) -> Vec { +fn did_document(signer: &K256Signer, did: &Did) -> Vec { let multikey = knot_types::crypto::multikey(0xe7, signer.public_key().as_bytes()); serde_json::to_vec(&serde_json::json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": ["at://nel.pet"], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, + "controller": did.as_str(), "publicKeyMultibase": multikey }], "service": [{ @@ -84,7 +96,7 @@ fn did_document(signer: &K256Signer, did: &str) -> Vec { fn list_records_body(public_line: &str) -> Vec { serde_json::to_vec(&serde_json::json!({ "records": [{ - "uri": format!("at://{OWNER_DID}/org.tangled.key.sshKey/1"), + "uri": format!("at://{}/org.tangled.key.sshKey/1", owner_did()), "value": { "$type": "org.tangled.key.sshKey", "publicKey": public_line, @@ -98,15 +110,17 @@ fn list_records_body(public_line: &str) -> Vec { fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport { let signer = K256Signer::generate(&SeededEntropy::new(1)); + let owner = Did::new_owned(OWNER_DID).unwrap(); + let repo = Did::new_owned(REPO_DID).unwrap(); FakeHttp::new(move |request| { let host = request.url.host_str().unwrap_or_default().to_string(); let path = request.url.path().to_string(); let body = if host == PDS_HOST { list_records_body(&published_line) - } else if path.ends_with(OWNER_DID) { - did_document(&signer, OWNER_DID) - } else if path.ends_with(REPO_DID) { - did_document(&signer, REPO_DID) + } else if path.ends_with(owner.as_str()) { + did_document(&signer, &owner) + } else if path.ends_with(repo.as_str()) { + did_document(&signer, &repo) } else { return Ok(HttpResponse { status: http::StatusCode::NOT_FOUND, @@ -142,16 +156,16 @@ async fn spawn(published_line: String) -> Server { let meta_path = scan.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(scan.path().join("repos")); - let repo_did = RepoDid::new(REPO_DID).unwrap(); + let repo_did = repo_did(); layout.create(&repo_did).unwrap(); let signer = K256Signer::generate(&SeededEntropy::new(2)); let meta = Repo::open(&meta_path).unwrap(); CobStore::new(&meta) .create( - &CobHome::from(&KnotId::new(KNOT_DID).unwrap()), + &CobHome::from(&knot_id()), &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER_DID).unwrap(), + owner: owner_did(), rkey: RepoRkey::new(REPO_NAME).unwrap(), name: RepoName::new(REPO_NAME).unwrap(), repo: repo_did.clone(), @@ -169,7 +183,7 @@ async fn spawn(published_line: String) -> Server { let atproto = Arc::new(Atproto::new( fake_http(published_line), ManualClock::new(UnixMicros::new(1_000_000_000)), - KnotId::new(KNOT_DID).unwrap(), + knot_id(), knot_atproto::PlcDirectory::new(Url::parse("https://plc.directory/").unwrap()).unwrap(), )); let key_dir = scan.path().join("hostkey"); @@ -220,8 +234,9 @@ async fn push_once(scratch: &Path) -> (String, Option) { let (key_path, public_line) = keygen(scratch); let server = spawn(public_line).await; let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port + "ssh://git@127.0.0.1:{}/{}/{REPO_NAME}", + server.port, + owner_did() ); let ssh = ssh_command(&key_path); let work = scratch.join("work"); diff --git a/knot2/crates/knot-ssh/examples/ephemeral_knot.rs b/knot2/crates/knot-ssh/examples/ephemeral_knot.rs index be4769ac7..c9b671aac 100644 --- a/knot2/crates/knot-ssh/examples/ephemeral_knot.rs +++ b/knot2/crates/knot-ssh/examples/ephemeral_knot.rs @@ -11,7 +11,8 @@ use knot_runtime::{ FakeHttp, HttpResponse, K256Signer, ManualClock, SeededEntropy, Signer, UnixMicros, }; use knot_types::{ - AdmissionPolicy, KnotHostname, KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds, + ActorId, AdmissionPolicy, Did, KnotHostname, KnotId, KnotServiceUrl, OwnerDid, RepoDid, + RepoName, RepoRkey, UnixSeconds, }; use tokio::net::TcpListener; use url::Url; @@ -64,21 +65,25 @@ fn vm_hwm_mib() -> u64 { .unwrap_or(0) } -fn did_document(signer: &K256Signer, did: &str, pds: &str) -> Vec { - let multikey = knot_types::crypto::multikey(0xe7, signer.public_key().as_bytes()); +fn did_document(signer: &K256Signer, did: &Did, pds: &KnotServiceUrl) -> Vec { + let multikey = ActorId::new(knot_types::crypto::multikey( + 0xe7, + signer.public_key().as_bytes(), + )) + .unwrap(); serde_json::to_vec(&serde_json::json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": ["at://nel.pet"], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, - "publicKeyMultibase": multikey + "controller": did.as_str(), + "publicKeyMultibase": multikey.as_str() }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": pds + "serviceEndpoint": pds.as_str() }] })) .unwrap() @@ -116,16 +121,16 @@ fn not_found() -> HttpResponse { fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport { let signer = K256Signer::generate(&SeededEntropy::new(1)); - let pds = format!("https://{PDS_HOST}"); + let pds = KnotServiceUrl::new(format!("https://{PDS_HOST}")).unwrap(); FakeHttp::new(move |request| { let host = request.url.host_str().unwrap_or_default().to_string(); let path = request.url.path().to_string(); let body = if host == PDS_HOST { list_records_body(&published_line) } else if path.ends_with(REPO_DID) { - did_document(&signer, REPO_DID, &pds) + did_document(&signer, &Did::new_owned(REPO_DID).unwrap(), &pds) } else if path.ends_with(OWNER_DID) { - did_document(&signer, OWNER_DID, &pds) + did_document(&signer, &Did::new_owned(OWNER_DID).unwrap(), &pds) } else { return Ok(not_found()); }; diff --git a/knot2/crates/knot-ssh/tests/ssh_push.rs b/knot2/crates/knot-ssh/tests/ssh_push.rs index 723614657..0ec4c59a8 100644 --- a/knot2/crates/knot-ssh/tests/ssh_push.rs +++ b/knot2/crates/knot-ssh/tests/ssh_push.rs @@ -16,7 +16,8 @@ use knot_runtime::{ FakeDns, FakeHttp, HttpResponse, K256Signer, ManualClock, SeededEntropy, Signer, UnixMicros, }; use knot_types::{ - AccountDid, KnotId, Oid, OwnerDid, RefName, RepoDid, RepoName, RepoRkey, UnixSeconds, + AccountDid, Did, KnotId, KnotServiceUrl, Oid, OwnerDid, RefName, RepoDid, RepoName, RepoRkey, + UnixSeconds, }; use tempfile::TempDir; use tokio::net::TcpListener; @@ -30,6 +31,38 @@ const TID_RKEY: &str = "3mizfnpxii522"; const TID_REPO_NAME: &str = "periwinkle.cloud"; const PDS_HOST: &str = "pds.oyster.cafe"; +fn repo_did() -> RepoDid { + RepoDid::new(REPO_DID).unwrap() +} + +fn repo_name() -> RepoName { + RepoName::new(REPO_NAME).unwrap() +} + +fn owner() -> AccountDid { + AccountDid::new(OWNER_DID).unwrap() +} + +fn owner_did() -> OwnerDid { + OwnerDid::new(OWNER_DID).unwrap() +} + +fn tid_repo_did() -> RepoDid { + RepoDid::new(TID_REPO_DID).unwrap() +} + +fn tid_rkey() -> RepoRkey { + RepoRkey::new(TID_RKEY).unwrap() +} + +fn tid_repo_name() -> RepoName { + RepoName::new(TID_REPO_NAME).unwrap() +} + +fn pds_url() -> KnotServiceUrl { + KnotServiceUrl::new(format!("https://{PDS_HOST}")).unwrap() +} + fn git(cwd: &Path, env: &[(&str, &str)], args: &[&str]) -> (bool, String) { let mut command = knot_fixtures::command(cwd); command.args(args); @@ -72,21 +105,21 @@ fn keygen(dir: &Path, name: &str) -> (String, String) { (path.to_str().unwrap().to_string(), public_line) } -fn did_document(signer: &K256Signer, did: &str, pds: &str) -> Vec { +fn did_document(signer: &K256Signer, did: &Did, pds: &KnotServiceUrl) -> Vec { let multikey = knot_types::crypto::multikey(0xe7, signer.public_key().as_bytes()); serde_json::to_vec(&serde_json::json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": ["at://nel.pet"], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, + "controller": did.as_str(), "publicKeyMultibase": multikey }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": pds + "serviceEndpoint": pds.as_str() }] })) .unwrap() @@ -120,7 +153,7 @@ fn ok_body(body: Vec) -> HttpResponse { fn fake_dns() -> impl knot_runtime::DnsTxtResolver { FakeDns::new(|name: &str| { Ok(match name { - "_atproto.nel.pet" => vec![format!("did={OWNER_DID}")], + "_atproto.nel.pet" => vec![format!("did={}", owner())], _ => Vec::new(), }) }) @@ -148,18 +181,21 @@ fn server_error() -> HttpResponse { fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport { let signer = K256Signer::generate(&SeededEntropy::new(1)); - let pds = format!("https://{PDS_HOST}"); + let pds = pds_url(); + let repo = Did::new_owned(REPO_DID).unwrap(); + let tid_repo = Did::new_owned(TID_REPO_DID).unwrap(); + let owner = Did::new_owned(OWNER_DID).unwrap(); FakeHttp::new(move |request| { let host = request.url.host_str().unwrap_or_default().to_string(); let path = request.url.path().to_string(); - let body = if host == PDS_HOST { + let body = if host == pds.authority() { list_records_body(&[&published_line]) - } else if path.ends_with(REPO_DID) { - did_document(&signer, REPO_DID, &pds) - } else if path.ends_with(TID_REPO_DID) { - did_document(&signer, TID_REPO_DID, &pds) - } else if path.ends_with(OWNER_DID) { - did_document(&signer, OWNER_DID, &pds) + } else if path.ends_with(repo.as_str()) { + did_document(&signer, &repo, &pds) + } else if path.ends_with(tid_repo.as_str()) { + did_document(&signer, &tid_repo, &pds) + } else if path.ends_with(owner.as_str()) { + did_document(&signer, &owner, &pds) } else { return Ok(not_found()); }; @@ -169,38 +205,38 @@ fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport { #[derive(Default, Clone)] struct Accounts { - identities: Arc>>>, - unreachable: Arc>>, + identities: Arc>>>, + unreachable: Arc>>, listings: Arc, } impl Accounts { - fn publishing(identities: HashMap>) -> Self { + fn publishing(identities: HashMap>) -> Self { Self { identities: Arc::new(Mutex::new(identities)), ..Self::default() } } - fn unreachable(self, dids: HashSet) -> Self { + fn unreachable(self, dids: HashSet) -> Self { *self.unreachable.lock().unwrap() = dids; self } - fn restore(&self, did: &str) { + fn restore(&self, did: &AccountDid) { self.unreachable.lock().unwrap().remove(did); } - fn publish(&self, did: &str, line: String) { + fn publish(&self, did: &AccountDid, line: String) { self.identities .lock() .unwrap() - .entry(did.to_string()) + .entry(did.clone()) .or_default() .push(line); } - fn published_by(&self, did: &str) -> Vec { + fn published_by(&self, did: &AccountDid) -> Vec { self.identities .lock() .unwrap() @@ -216,24 +252,32 @@ impl Accounts { fn multi_http(accounts: Accounts) -> impl knot_runtime::HttpTransport { let signer = K256Signer::generate(&SeededEntropy::new(77)); + let pds_test = KnotServiceUrl::new("https://pds.test").unwrap(); FakeHttp::new(move |request| { let host = request.url.host_str().unwrap_or_default().to_string(); if host == "plc.directory" { - let did = request.url.path().trim_start_matches('/').to_string(); - return Ok(ok_body(did_document(&signer, &did, "https://pds.test"))); + let did = Did::new_owned(request.url.path().trim_start_matches('/')) + .map(|did| did_document(&signer, &did, &pds_test)) + .unwrap_or_default(); + return Ok(ok_body(did)); } if host == "pds.test" { let repo = request .url .query_pairs() .find(|(key, _)| key == "repo") - .map(|(_, value)| value.into_owned()) - .unwrap_or_default(); + .and_then(|(_, value)| AccountDid::new(value).ok()); accounts.listings.fetch_add(1, Ordering::SeqCst); - if accounts.unreachable.lock().unwrap().contains(&repo) { + if repo + .as_ref() + .is_some_and(|did| accounts.unreachable.lock().unwrap().contains(did)) + { return Ok(server_error()); } - let lines = accounts.published_by(&repo); + let lines = repo + .as_ref() + .map(|did| accounts.published_by(did)) + .unwrap_or_default(); let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); return Ok(ok_body(list_records_body(&refs))); } @@ -298,7 +342,7 @@ async fn spawn_server_core( let meta_path = scan.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(scan.path().join("repos")); - let repo_did = RepoDid::new(REPO_DID).unwrap(); + let repo_did = repo_did(); layout.create(&repo_did).unwrap(); let signer = K256Signer::generate(&SeededEntropy::new(2)); @@ -309,9 +353,9 @@ async fn spawn_server_core( .create( &home, &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER_DID).unwrap(), + owner: owner_did(), rkey: RepoRkey::new(REPO_NAME).unwrap(), - name: RepoName::new(REPO_NAME).unwrap(), + name: repo_name(), repo: repo_did.clone(), created_at: UnixSeconds::new(1), policy: knot_types::ContributionPolicy::default(), @@ -322,16 +366,16 @@ async fn spawn_server_core( .unwrap() .object; - let tid_repo_did = RepoDid::new(TID_REPO_DID).unwrap(); + let tid_repo_did = tid_repo_did(); layout.create(&tid_repo_did).unwrap(); store .update( &home, registry, &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER_DID).unwrap(), - rkey: RepoRkey::new(TID_RKEY).unwrap(), - name: RepoName::new(TID_REPO_NAME).unwrap(), + owner: owner_did(), + rkey: tid_rkey(), + name: tid_repo_name(), repo: tid_repo_did, created_at: UnixSeconds::new(2), policy: knot_types::ContributionPolicy::default(), @@ -390,6 +434,7 @@ async fn spawn_server_core( }, )), social_budget: knot_resource::SocialBudgetBytes::new(1 << 28), + knot_repo: knot_xrpc::knot_repo(&knot).unwrap(), knot_did: knot, knot_hostname: knot_types::KnotHostname::new("knot.test").unwrap(), meta_path: scan.path().join("meta"), @@ -415,7 +460,7 @@ async fn spawn_server_core( |_request: &knot_runtime::HttpRequest| Ok(not_found()), )), pack_limits: knot_pack::PackLimits::default(), - service_owner: AccountDid::new(OWNER_DID).unwrap(), + service_owner: owner(), subscriber_gate: Arc::new(knot_events::SubscriberGate::new( knot_events::GlobalSubscriberLimit::new(16), knot_events::PerPeerSubscriberLimit::new(4), @@ -540,15 +585,20 @@ fn seed_commits(work: &Path, count: usize) { }); } -fn seed_cob(work: &Path, signer_seed: u64, subject: &str, home: &CobHome) -> (Oid, String, String) { +fn seed_cob( + work: &Path, + signer_seed: u64, + subject: &AccountDid, + home: &CobHome, +) -> (Oid, String, String) { let repo = Repo::open(work).unwrap(); let signer = K256Signer::generate(&SeededEntropy::new(signer_seed)); let created = CobStore::new(&repo) .create( home, &MembersChange::Add(Grant { - subject: AccountDid::new(subject).unwrap(), - added_by: AccountDid::new(OWNER_DID).unwrap(), + subject: subject.clone(), + added_by: owner(), created_at: UnixSeconds::new(1), }), &signer, @@ -608,10 +658,7 @@ async fn fixture_with_archive_limit(archive_limit: ArchiveLimit) -> Fixture { false, ) .await; - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); Fixture { scratch, @@ -643,10 +690,7 @@ async fn incremental_fetch_exit( let scratch = tempfile::tempdir().unwrap(); let (key_path, public_line) = keygen(scratch.path(), "client"); let (server, _index) = spawn_server(public_line, MaxWireBytes::new(1 << 30)).await; - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); seed_commits(&work, seed_count); @@ -704,12 +748,12 @@ async fn a_display_name_addresses_a_repo_whose_record_key_is_a_tid() { let head = seed_work(&fx.work); let head_oid = Oid::from_hex(&head).unwrap(); let port = fx.server.port; - let target = RepoDid::new(TID_REPO_DID).unwrap(); + let target = tid_repo_did(); let variants = [ - format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}"), - format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}.git"), - format!("ssh://git@127.0.0.1:{port}/nel.pet/{TID_REPO_NAME}"), - format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_RKEY}"), + format!("ssh://git@127.0.0.1:{port}/{}/{}", owner(), tid_repo_name()), + format!("ssh://git@127.0.0.1:{port}/{}/{}.git", owner(), tid_repo_name()), + format!("ssh://git@127.0.0.1:{port}/nel.pet/{}", tid_repo_name()), + format!("ssh://git@127.0.0.1:{port}/{}/{}", owner(), tid_rkey()), ]; let fx = &fx; let target = ⌖ @@ -731,7 +775,7 @@ async fn a_display_name_addresses_a_repo_whose_record_key_is_a_tid() { let (ok, out) = push( &fx.work, - &format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/whelk"), + &format!("ssh://git@127.0.0.1:{port}/{}/whelk", owner()), &fx.key_path, &["main"], ) @@ -749,10 +793,10 @@ async fn addressing_variants_land() { let head_oid = Oid::from_hex(&head).unwrap(); let port = fx.server.port; let variants = [ - format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{REPO_NAME}"), - format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{REPO_NAME}.git"), - format!("ssh://git@127.0.0.1:{port}/nel.pet/{REPO_NAME}"), - format!("ssh://git@127.0.0.1:{port}/{REPO_DID}"), + format!("ssh://git@127.0.0.1:{port}/{}/{}", owner(), repo_name()), + format!("ssh://git@127.0.0.1:{port}/{}/{}.git", owner(), repo_name()), + format!("ssh://git@127.0.0.1:{port}/nel.pet/{}", repo_name()), + format!("ssh://git@127.0.0.1:{port}/{}", repo_did()), ]; let fx = &fx; futures::stream::iter(variants) @@ -773,10 +817,7 @@ async fn a_push_while_the_index_is_warming_is_refused() { let scratch = tempfile::tempdir().unwrap(); let (key_path, public_line) = keygen(scratch.path(), "client"); let (server, _index) = spawn_server_with(public_line, MaxWireBytes::new(1 << 30), false).await; - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); seed_work(&work); @@ -798,7 +839,7 @@ async fn unresolvable_targets_refused() { seed_work(&fx.work); let port = fx.server.port; - let bad_name = format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/conch"); + let bad_name = format!("ssh://git@127.0.0.1:{port}/{}/conch", owner()); let (ok, out) = push(&fx.work, &bad_name, &fx.key_path, &["main"]).await; assert!( !ok, @@ -887,10 +928,7 @@ async fn an_oversized_push_is_refused_at_the_ssh_boundary() { let scratch = tempfile::tempdir().unwrap(); let (key_path, public_line) = keygen(scratch.path(), "client"); let (server, _index) = spawn_server(public_line, MaxWireBytes::new(64)).await; - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); seed_work(&work); @@ -930,10 +968,7 @@ async fn a_denied_push_over_ssh_leaves_no_objects_in_the_live_odb() { let (_registered_path, registered_line) = keygen(scratch.path(), "registered"); let (attacker_path, _attacker_line) = keygen(scratch.path(), "attacker"); let (server, _index) = spawn_server(registered_line, MaxWireBytes::new(1 << 30)).await; - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); let head = seed_work(&work); @@ -1002,20 +1037,22 @@ async fn ref_namespace_policy() { async fn cob_ref_guard_lifecycle() { let fx = fixture().await; seed_work(&fx.work); - let home = CobHome::from(&RepoDid::new(REPO_DID).unwrap()); + let home = CobHome::from(&repo_did()); let foreign = CobHome::from(&RepoDid::new("did:plc:whelk").unwrap()); let (ok, out) = push(&fx.work, &fx.url, &fx.key_path, &["main"]).await; assert!(ok, "head must land for the advertisement check:\n{out}"); - let (owned_tip, owned_ref, owned_spec) = seed_cob(&fx.work, 1, "did:plc:limpet", &home); + let (owned_tip, owned_ref, owned_spec) = + seed_cob(&fx.work, 1, &AccountDid::new("did:plc:limpet").unwrap(), &home); let (ok, out) = push(&fx.work, &fx.url, &fx.key_path, &[owned_spec.as_str()]).await; assert!( ok, "COB ref signed by the repository key must verify and land over ssh:\n{out}" ); - let (_forged_tip, forged_ref, forged_spec) = seed_cob(&fx.work, 9, "did:plc:whelk", &home); + let (_forged_tip, forged_ref, forged_spec) = + seed_cob(&fx.work, 9, &AccountDid::new("did:plc:whelk").unwrap(), &home); let (ok, out) = push(&fx.work, &fx.url, &fx.key_path, &[forged_spec.as_str()]).await; assert!( !ok, @@ -1023,7 +1060,7 @@ async fn cob_ref_guard_lifecycle() { ); let (_transplant_tip, transplant_ref, transplant_spec) = - seed_cob(&fx.work, 1, "did:plc:mussel", &foreign); + seed_cob(&fx.work, 1, &AccountDid::new("did:plc:mussel").unwrap(), &foreign); let (ok, out) = push(&fx.work, &fx.url, &fx.key_path, &[transplant_spec.as_str()]).await; assert!( !ok, @@ -1064,7 +1101,7 @@ async fn cob_ref_guard_lifecycle() { knot_types::CobId::new(owned_tip), &MembersChange::Add(Grant { subject: AccountDid::new("did:plc:bailey").unwrap(), - added_by: AccountDid::new(OWNER_DID).unwrap(), + added_by: owner(), created_at: UnixSeconds::new(2), }), &K256Signer::generate(&SeededEntropy::new(1)), @@ -1124,7 +1161,7 @@ async fn a_filled_key_set_refuses_an_unregistered_key_and_an_acl_write_reopens_t ); fx.index.keys().record( - &AccountDid::new(OWNER_DID).unwrap(), + &owner(), vec![knot_types::OfferedKey::from_bytes(registered_blob(&fx))], forever(), ); @@ -1317,7 +1354,7 @@ async fn a_handle_in_the_url_identifies_a_visitor_and_lets_a_multi_key_client_fi "ssh -i {unregistered_path} -i {} {UNSHARED_SSH}", fx.key_path ); - let identified = format!("ssh://nel.pet@127.0.0.1:{}/{REPO_DID}", fx.server.port); + let identified = format!("ssh://nel.pet@127.0.0.1:{}/{}", fx.server.port, repo_did()); let (ok, out) = { let (work, url) = (fx.work.clone(), identified.clone()); tokio::task::spawn_blocking(move || { @@ -1366,7 +1403,7 @@ fn registered_index( let meta_path = scratch.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(scratch.path().join("repos")); - let repo_did = RepoDid::new(REPO_DID).unwrap(); + let repo_did = repo_did(); layout.create(&repo_did).unwrap(); let signer = K256Signer::generate(&SeededEntropy::new(2)); @@ -1375,9 +1412,9 @@ fn registered_index( .create( &CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()), &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER_DID).unwrap(), + owner: owner_did(), rkey: RepoRkey::new(REPO_NAME).unwrap(), - name: RepoName::new(REPO_NAME).unwrap(), + name: repo_name(), repo: repo_did.clone(), created_at: UnixSeconds::new(1), policy: knot_types::ContributionPolicy::default(), @@ -1400,8 +1437,8 @@ async fn an_unreachable_pds_reads_as_transient_and_its_keys_get_in_once_it_recov let (fresh_key, fresh_line) = keygen(scratch.path(), "fresh"); let (layout, repo_did, index) = registered_index(&scratch, knot_index::KeyBudget::DEFAULT); - let accounts = Accounts::publishing(HashMap::from([(OWNER_DID.to_string(), vec![stale_line])])) - .unreachable(HashSet::from([OWNER_DID.to_string()])); + let accounts = Accounts::publishing(HashMap::from([(owner(), vec![stale_line])])) + .unreachable(HashSet::from([owner()])); let port = launch( &scratch.path().join("hostkey"), layout.clone(), @@ -1409,7 +1446,7 @@ async fn an_unreachable_pds_reads_as_transient_and_its_keys_get_in_once_it_recov accounts.clone(), ) .await; - let url = format!("ssh://git@127.0.0.1:{port}/{REPO_DID}"); + let url = format!("ssh://git@127.0.0.1:{port}/{}", repo_did); let work = scratch.path().join("work"); let head = seed_work(&work); @@ -1432,14 +1469,14 @@ async fn an_unreachable_pds_reads_as_transient_and_its_keys_get_in_once_it_recov "the refused push leaves the repo empty" ); - accounts.restore(OWNER_DID); + accounts.restore(&owner()); let (ok, out) = push(&work, &url, &stale_key, &["main"]).await; assert!( ok, "the key the owner publishes must push once its PDS answers again:\n{out}" ); - accounts.publish(OWNER_DID, fresh_line); + accounts.publish(&owner(), fresh_line); let (ok, out) = push(&work, &url, &fresh_key, &["main", "--force"]).await; assert!( ok, @@ -1465,7 +1502,7 @@ async fn an_account_the_budget_couldnt_fit_still_clears_the_handshake_and_pushes .unwrap(); assert_eq!( index.keys().record( - &AccountDid::new(OWNER_DID).unwrap(), + &owner(), vec![knot_types::OfferedKey::from_bytes(blob)], forever(), ), @@ -1478,10 +1515,10 @@ async fn an_account_the_budget_couldnt_fit_still_clears_the_handshake_and_pushes &scratch.path().join("hostkey"), layout.clone(), Arc::clone(&index), - Accounts::publishing(HashMap::from([(OWNER_DID.to_string(), vec![owner_line])])), + Accounts::publishing(HashMap::from([(owner(), vec![owner_line])])), ) .await; - let url = format!("ssh://git@127.0.0.1:{port}/{REPO_DID}"); + let url = format!("ssh://git@127.0.0.1:{port}/{}", repo_did); let work = scratch.path().join("work"); let head = seed_work(&work); @@ -1499,11 +1536,6 @@ async fn an_account_the_budget_couldnt_fit_still_clears_the_handshake_and_pushes #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collaborate_on() { - const REPO_A: &str = "did:plc:squid"; - const REPO_B: &str = "did:plc:clam"; - const OWNER: &str = "did:plc:nel"; - const COLLAB: &str = "did:plc:olaren"; - let scratch = tempfile::tempdir().unwrap(); let (owner_key, owner_line) = keygen(scratch.path(), "owner"); let (collab_key, collab_line) = keygen(scratch.path(), "collab"); @@ -1511,8 +1543,8 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab let meta_path = scratch.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(scratch.path().join("repos")); - let repo_a = RepoDid::new(REPO_A).unwrap(); - let repo_b = RepoDid::new(REPO_B).unwrap(); + let repo_a = repo_did(); + let repo_b = RepoDid::new("did:plc:clam").unwrap(); let git_a = layout.create(&repo_a).unwrap(); layout.create(&repo_b).unwrap(); @@ -1524,7 +1556,7 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab .create( &knot_home, &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER).unwrap(), + owner: owner_did(), rkey: RepoRkey::new("anemone").unwrap(), name: RepoName::new("anemone").unwrap(), repo: repo_a.clone(), @@ -1540,7 +1572,7 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab &knot_home, reg.object, &RegistryChange::Register(Registration { - owner: OwnerDid::new(OWNER).unwrap(), + owner: owner_did(), rkey: RepoRkey::new("barnacle").unwrap(), name: RepoName::new("barnacle").unwrap(), repo: repo_b.clone(), @@ -1555,8 +1587,8 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab .create( &knot_home, &MembersChange::Add(Grant { - subject: AccountDid::new(COLLAB).unwrap(), - added_by: AccountDid::new(OWNER).unwrap(), + subject: new_collab(), + added_by: owner(), created_at: UnixSeconds::new(1), }), &signer, @@ -1567,8 +1599,8 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab .create( &CobHome::from(&repo_a), &CollaboratorsChange::Add(Grant { - subject: AccountDid::new(COLLAB).unwrap(), - added_by: AccountDid::new(OWNER).unwrap(), + subject: new_collab(), + added_by: owner(), created_at: UnixSeconds::new(1), }), &signer, @@ -1581,8 +1613,8 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab index.warm_collaborators(); let identities = HashMap::from([ - (OWNER.to_string(), vec![owner_line]), - (COLLAB.to_string(), vec![collab_line]), + (owner(), vec![owner_line]), + (new_collab(), vec![collab_line]), ]); let accounts = Accounts::publishing(identities); let port = launch( @@ -1595,7 +1627,7 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab let work_a = scratch.path().join("work_a"); let head_a = seed_work(&work_a); - let url_a = format!("ssh://git@127.0.0.1:{port}/{REPO_A}"); + let url_a = format!("ssh://git@127.0.0.1:{port}/{}", repo_a); let (ok, out) = push(&work_a, &url_a, &collab_key, &["main"]).await; assert!( ok, @@ -1609,7 +1641,7 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab let work_b = scratch.path().join("work_b"); seed_work(&work_b); - let url_b = format!("ssh://git@127.0.0.1:{port}/{REPO_B}"); + let url_b = format!("ssh://git@127.0.0.1:{port}/{}", repo_b); let (denied, out) = push(&work_b, &url_b, &collab_key, &["main"]).await; assert!( !denied, @@ -1645,6 +1677,14 @@ async fn a_collaborator_pushes_its_repo_but_is_denied_on_a_repo_it_doesnt_collab const NEW_COLLAB: &str = "did:plc:olaren"; const COLLAB_BYSTANDER: &str = "did:plc:bailey"; +fn new_collab() -> AccountDid { + AccountDid::new(NEW_COLLAB).unwrap() +} + +fn collab_bystander() -> AccountDid { + AccountDid::new(COLLAB_BYSTANDER).unwrap() +} + struct Invitation { scratch: TempDir, layout: Layout, @@ -1657,7 +1697,7 @@ struct Invitation { impl Invitation { fn tip(&self) -> Option { - main_tip(&self.layout, &RepoDid::new(REPO_DID).unwrap()) + main_tip(&self.layout, &repo_did()) } async fn push(&self, key: &str) -> (bool, String) { let work = self.scratch.path().join("work"); @@ -1675,8 +1715,8 @@ async fn a_new_collaborator(pds_down: bool) -> Invitation { let head = Oid::from_hex(&seed_work(&dir.join("work"))).unwrap(); let now = UnixSeconds::new(1); let invited = CollaboratorsChange::Add(Grant { - subject: AccountDid::new(NEW_COLLAB).unwrap(), - added_by: AccountDid::new(OWNER_DID).unwrap(), + subject: new_collab(), + added_by: owner(), created_at: now, }); let signer = K256Signer::generate(&SeededEntropy::new(2)); @@ -1689,19 +1729,18 @@ async fn a_new_collaborator(pds_down: bool) -> Invitation { let key = russh::keys::ssh_key::PublicKey::from_openssh(line).unwrap(); vec![knot_types::OfferedKey::from_bytes(key.to_bytes().unwrap())] }; - let owner = AccountDid::new(OWNER_DID).unwrap(); - let bystander = AccountDid::new(COLLAB_BYSTANDER).unwrap(); + let bystander = collab_bystander(); let keys = index.keys(); - keys.record(&owner, held(&owner_line), forever()); + keys.record(&owner(), held(&owner_line), forever()); keys.record(&bystander, held(&bystander_line), forever()); let records = Accounts::publishing(HashMap::from([ - (OWNER_DID.to_string(), vec![owner_line]), - (NEW_COLLAB.to_string(), vec![collab_line]), + (owner(), vec![owner_line]), + (new_collab(), vec![collab_line]), ])); let accounts = match pds_down { false => records, - true => records.unreachable(HashSet::from([NEW_COLLAB.to_string()])), + true => records.unreachable(HashSet::from([new_collab()])), }; let port = launch(dir, layout.clone(), Arc::clone(&index), accounts.clone()).await; Invitation { @@ -1721,8 +1760,8 @@ async fn a_new_collaborator_pushes_once_the_knot_reads_their_keys() { let (ok, out) = invitation.push(&invitation.collab_key).await; assert!( ok, - "the roll seats this account and the push is what makes the knot read their keys, so \ - no second call from the client is needed:\n{out}" + "the grant list admits this account and the push is what makes the knot read their keys, \ + so the client doesn't need a second call:\n{out}" ); assert_eq!(invitation.tip(), Some(invitation.head)); } @@ -1736,7 +1775,7 @@ async fn wide_door_admits_whichever_key_a_new_collaborator_offers_first() { assert!( !ok, "the ssh username is git and the repository arrives in the exec command, so at key \ - identification the knot can't know which roll to weigh a key against:\n{out}" + identification the knot can't know which grant list to weigh a key against:\n{out}" ); assert!( out.contains("IdentitiesOnly"), @@ -1755,14 +1794,14 @@ async fn a_new_collaborators_first_push_survives_a_strangers_failed_one() { let (ok, out) = invitation.push(&invitation.bystander_key).await; assert!( !ok, - "bystander's push is refused, and roll doesn't list their key anywhere" + "bystander's push is refused, and the grant list doesn't list their key anywhere" ); assert!( out.contains("couldn't read the account records"), "key is on file: session doesn't cost a miss reservation, and fan-out runs, which is what spends the collaborator's pacer while their pds is down:\n{out}" ); - invitation.accounts.restore(NEW_COLLAB); + invitation.accounts.restore(&new_collab()); let (ok, out) = invitation.push(&invitation.collab_key).await; assert!( ok, @@ -1974,7 +2013,7 @@ fn trickled_lfs_upload( "-p", &port.to_string(), "git@127.0.0.1", - &format!("git-lfs-transfer '{OWNER_DID}/{REPO_NAME}' upload"), + &format!("git-lfs-transfer '{}/{}' upload", owner(), repo_name()), ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -2088,7 +2127,7 @@ async fn shutdown_drains_an_in_flight_lfs_transfer_before_exit() { .expect("the drained listener must exit promptly once transfers finish") .unwrap(); - let repo_did = RepoDid::new(REPO_DID).unwrap(); + let repo_did = repo_did(); assert_eq!( handle .store @@ -2151,10 +2190,7 @@ async fn an_authorized_ssh_push_projects_the_ref_records_the_http_push_projects( .await; let firehose = firehose.expect("the projection fixture serves a firehose"); - let url = format!( - "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", - server.port - ); + let url = format!("ssh://git@127.0.0.1:{}/{}/{}", server.port, owner(), repo_name()); let work = scratch.path().join("work"); let head = seed_work(&work); diff --git a/knot2/crates/knot-types/src/ids.rs b/knot2/crates/knot-types/src/ids.rs index 5721efbc2..bbc9ca557 100644 --- a/knot2/crates/knot-types/src/ids.rs +++ b/knot2/crates/knot-types/src/ids.rs @@ -280,9 +280,9 @@ fn parse_record_collection(value: &str) -> Option> { // I try to capture why this isn't just me dicking around, in the test // a_collection_that_would_overrun_an_mst_key_isnt_a_record_collection. - // Our rkeys are always gonna be 13 bytes, and an MST is not gonna like a key over 256 bytes, - // so we can handle those two things nicely here instead of downstream having to deal with - // len error later. + // A tid rkey is always 13 bytes, a did rkey is longer, and an MST is not gonna like a key + // over 256 bytes, so tid-keyed collections get their whole bound here and `MstKey::new` + // stops a did rkey that overruns. let room = crate::record::MST_KEY_MAX_BYTES - crate::record::RECORD_RKEY_BYTES - 1; (nsid.as_str().len() <= room).then_some(nsid) } @@ -445,7 +445,7 @@ impl RecordRkey { pub fn as_label_key(&self) -> Option<&LabelKey> { match self { Self::Composed(key) => Some(key), - Self::Minted(_) => None, + Self::Minted(_) | Self::Subject(_) => None, } } } @@ -454,6 +454,7 @@ impl RecordRkey { pub enum RecordRkey { Minted(Tid), Composed(LabelKey), + Subject(DidRkey), } impl RecordRkey { @@ -461,7 +462,9 @@ impl RecordRkey { let value = value.into(); match parse_tid(&value) { Some(tid) => Ok(Self::Minted(tid)), - None => LabelKey::new(value).map(Self::Composed), + None => DidRkey::new(value.clone()) + .map(Self::Subject) + .or_else(|_| LabelKey::new(value).map(Self::Composed)), } } @@ -469,6 +472,7 @@ impl RecordRkey { match self { Self::Minted(tid) => tid.as_str(), Self::Composed(key) => key.as_str(), + Self::Subject(key) => key.as_str(), } } } diff --git a/knot2/crates/knot-types/src/policy.rs b/knot2/crates/knot-types/src/policy.rs index 83a1972ed..f01eab2da 100644 --- a/knot2/crates/knot-types/src/policy.rs +++ b/knot2/crates/knot-types/src/policy.rs @@ -162,7 +162,7 @@ impl RepoRole { match self { Self::Owner => "owner", Self::Collaborator => "collaborator", - Self::KnotAdmin(Membership::Member) => "knot admin on member roll", + Self::KnotAdmin(Membership::Member) => "knot admin on the member grant list", Self::KnotAdmin(Membership::Outsider) => "knot admin", Self::Member => "member", Self::Stranger => "stranger", diff --git a/knot2/crates/knot-types/src/record.rs b/knot2/crates/knot-types/src/record.rs index f958de292..4f7efb14e 100644 --- a/knot2/crates/knot-types/src/record.rs +++ b/knot2/crates/knot-types/src/record.rs @@ -24,7 +24,7 @@ pub enum AddressParseError { Authority, #[error("Collection is missing from the URI or isn't a record collection")] Collection, - #[error("The URI's record key is missing, or rkey isn't a TID or label key")] + #[error("The URI's record key is missing, or rkey isn't a TID, label key, or DID")] RecordKey, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -423,15 +423,21 @@ mod tests { ); assert!( RecordRkey::new("self").is_ok_and(|rkey| rkey.as_label_key().is_some()), - "`self` is a slug here, and a DID can't spell a slug: the colon isn't in the slug \ + "`self` is a slug here, and a DID isn't a slug: the colon isn't in the slug \ charset" ); - assert!(RecordRkey::new("did:plc:squid").is_err()); + assert!( + RecordRkey::new("did:plc:squid").is_ok_and(|rkey| rkey.as_label_key().is_none()), + "a grant record is keyed by the subject's DID" + ); + assert!( + RecordRkey::new("did:web:knot.nel.pet:8080").is_err(), + "a ported did:web is a development form that isn't valid as a record key" + ); assert!(RecordRkey::new("").is_err()); assert!( crate::LabelKey::new("3lubrptx57d22").is_err(), - "a key that spells a TID parses as a TID, so a TID-shaped slug never reaches the \ - composed arm" + "a TID-shaped key parses as a TID, so it never reaches the composed arm" ); let composed = RecordRkey::composed(crate::LabelKey::new("wont-fix.2").unwrap()); assert!( diff --git a/knot2/crates/knot-xrpc/src/atproto.rs b/knot2/crates/knot-xrpc/src/atproto.rs index ed28e9f42..d362c446b 100644 --- a/knot2/crates/knot-xrpc/src/atproto.rs +++ b/knot2/crates/knot-xrpc/src/atproto.rs @@ -157,12 +157,13 @@ pub(crate) fn resolve( state: &XrpcState, subject: &RepoSubject, ) -> Result { - if subject.as_str() == state.knot_did.as_str() - || subject.as_str() == state.knot_hostname.as_str() - { + let self_hosted = match subject { + RepoSubject::Did(did) => *did == state.knot_repo, + RepoSubject::Host(host) => *host == state.knot_hostname, + }; + if self_hosted { return Ok(AtprotoRepo { - did: RepoDid::new(state.knot_did.as_str()) - .expect("the knot's own DID opens its meta repository"), + did: state.knot_repo.clone(), repo: Repo::open(&state.meta_path)?, }); } @@ -517,12 +518,9 @@ pub(crate) async fn sync_list_repos( "registry projection is still warming, please retry shortly", )); } - let dids: Vec = std::iter::once(RepoSubject::Did( - RepoDid::new(state.knot_did.as_str()) - .expect("the knot's own DID opens its meta repository"), - )) - .chain(state.index.hosted_repos().into_iter().map(RepoSubject::Did)) - .collect(); + let dids: Vec = std::iter::once(RepoSubject::Did(state.knot_repo.clone())) + .chain(state.index.hosted_repos().into_iter().map(RepoSubject::Did)) + .collect(); let cursor = next_offset(params.cursor.get(), params.limit.get(), dids.len()); let page: Vec = dids .into_iter() @@ -806,7 +804,7 @@ pub(crate) async fn repo_describe_repo( axum::extract::Query(params): axum::extract::Query, ) -> Result { let found = resolve(&state, ¶ms.repo)?; - let knot = found.did.as_str() == state.knot_did.as_str(); + let knot = found.did == state.knot_repo; let handle = if knot { state.knot_hostname.as_str().to_owned() } else { diff --git a/knot2/crates/knot-xrpc/src/blocklist.rs b/knot2/crates/knot-xrpc/src/blocklist.rs index b75274673..8c49a2c78 100644 --- a/knot2/crates/knot-xrpc/src/blocklist.rs +++ b/knot2/crates/knot-xrpc/src/blocklist.rs @@ -13,7 +13,7 @@ use knot_git::Repo; use knot_runtime::{Clock, HttpTransport}; use knot_types::{AccountDid, UnixSeconds}; -use crate::cob::{Authorized, roll_apply}; +use crate::cob::{Authorized, grant_list_apply}; use crate::error::XrpcError; use crate::{XrpcState, decode, ok_empty, run_blocking}; @@ -100,7 +100,7 @@ async fn commit_blocklist( run_blocking(move || { let _guard = cob_locks.meta(); let meta = Repo::open(&meta_path)?; - roll_apply::( + grant_list_apply::( &CobStore::new(&meta), &home, Authorized::by_decree(change), diff --git a/knot2/crates/knot-xrpc/src/cob.rs b/knot2/crates/knot-xrpc/src/cob.rs index c0d521523..24bf8d98c 100644 --- a/knot2/crates/knot-xrpc/src/cob.rs +++ b/knot2/crates/knot-xrpc/src/cob.rs @@ -1,5 +1,5 @@ use knot_cob::{Checkpoint, CobHome, CobStore, Evaluate, KnotAuthored, Materialized, Record}; -use knot_cobs::{GrantChange, Roll}; +use knot_cobs::{GrantChange, GrantList}; use knot_runtime::Signer; use knot_types::{AccountDid, UnixSeconds}; @@ -17,7 +17,7 @@ impl Authorized { } } -pub(crate) fn roll_apply( +pub(crate) fn grant_list_apply( store: &CobStore, home: &CobHome, Authorized(change): Authorized, @@ -25,11 +25,11 @@ pub(crate) fn roll_apply( now: UnixSeconds, ) -> Result<(), XrpcError> where - E: Checkpoint + Evaluate, + E: Checkpoint + Evaluate, E::Change: KnotAuthored + Clone + GrantChange, { match store.list::().map_err(XrpcError::from)?.as_slice() { - [] => match Roll::empty().would_change(&change) { + [] => match GrantList::empty().would_change(&change) { true => store .create_materialized( home, @@ -45,8 +45,8 @@ where false => Ok(()), }, [object] => store - .update_materialized_checkpointed::(home, *object, signer, now, |roll| { - Ok(roll.would_change(&change).then(|| Materialized { + .update_materialized_checkpointed::(home, *object, signer, now, |list| { + Ok(list.would_change(&change).then(|| Materialized { record: Record::Bare, change: change.clone(), })) diff --git a/knot2/crates/knot-xrpc/src/collaborators.rs b/knot2/crates/knot-xrpc/src/collaborators.rs index 738316a0a..1751b827c 100644 --- a/knot2/crates/knot-xrpc/src/collaborators.rs +++ b/knot2/crates/knot-xrpc/src/collaborators.rs @@ -13,7 +13,7 @@ use knot_index::Resolved; use knot_runtime::{Clock, HttpTransport}; use knot_types::{AccountDid, RepoDid, UnixSeconds}; -use crate::cob::{Authorized, roll_apply}; +use crate::cob::{Authorized, grant_list_apply}; use crate::error::XrpcError; use crate::{XrpcState, decode, ok_empty, run_blocking}; @@ -90,6 +90,7 @@ async fn commit_collaborators( let layout = state.layout.clone(); let index = Arc::clone(&state.index); let cob_locks = Arc::clone(&state.cob_locks); + let surfaces = Arc::clone(state); run_blocking(move || { let _guard = cob_locks.repo(&repo); owner_unmoved(&index, &repo, &owner)?; @@ -97,7 +98,7 @@ async fn commit_collaborators( return Ok(()); } let git = layout.open(&repo)?; - roll_apply::( + grant_list_apply::( &CobStore::new(&git), &CobHome::from(&repo), change, @@ -105,6 +106,10 @@ async fn commit_collaborators( now, )?; index.refresh_collaborators(&repo)?; + crate::grants::noted( + crate::grants::sync_collaborators(&surfaces, &git, &repo), + knot_record::grant::GrantCollection::Collaborator, + ); Ok(()) }) .await?; diff --git a/knot2/crates/knot-xrpc/src/firehose.rs b/knot2/crates/knot-xrpc/src/firehose.rs index 21c50e803..d7e485140 100644 --- a/knot2/crates/knot-xrpc/src/firehose.rs +++ b/knot2/crates/knot-xrpc/src/firehose.rs @@ -1281,7 +1281,7 @@ mod tests { let chunks = futures::executor::block_on(chain::materialize( &repo, &did, - &records(1), + &chain::in_tree(records(1)), &signer, &mut emit, UnixSeconds::new(1), @@ -1366,7 +1366,7 @@ mod tests { let chunks = chain::materialize( &repo, &did, - &records(1), + &chain::in_tree(records(1)), &signer, &mut emit, UnixSeconds::new(1), @@ -1439,7 +1439,7 @@ mod tests { futures::executor::block_on(chain::materialize( &repo, did, - &records(1), + &chain::in_tree(records(1)), &signer, &mut emit, UnixSeconds::new(1), @@ -1484,7 +1484,7 @@ mod tests { futures::executor::block_on(chain::materialize( &Repo::open(&path).unwrap(), &did(), - &records(1), + &chain::in_tree(records(1)), &signer, &mut Ticker::new(), UnixSeconds::new(1), diff --git a/knot2/crates/knot-xrpc/src/grants.rs b/knot2/crates/knot-xrpc/src/grants.rs new file mode 100644 index 000000000..b657fe1db --- /dev/null +++ b/knot2/crates/knot-xrpc/src/grants.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use knot_git::Repo; +use knot_index::{Entry, Resolved}; +use knot_record::chain; +use knot_record::grant::{self, GrantCollection}; +use knot_runtime::{Clock, HttpTransport}; +use knot_types::{AccountDid, RepoDid}; + +use crate::XrpcState; + +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error(transparent)] + Chain(#[from] chain::ChainError), + #[error(transparent)] + Frames(#[from] crate::firehose::UnstampedFrames), + #[error("the signing key wouldn't load for a grant sync: {0}")] + Key(#[from] knot_secrets::SecretsError), +} + +fn sync_grants_for( + state: &Arc>, + git: &Repo, + did: &RepoDid, + entries: Resolved>, + collection: GrantCollection, +) -> Result { + let entries = match entries { + Resolved::Ready(entries) => entries, + Resolved::Warming => { + tracing::warn!( + surface = collection.surface(), + "the projection is warming, so its records stay as they are rather than \ + syncing an empty grant list" + ); + return Ok(0); + } + }; + let now = state.now(); + let signer = state.secrets.signer(&state.knot_did)?; + let records = chain::in_tree(grant::bodies( + entries + .iter() + .map(|(subject, entry)| (subject, entry.created_at)), + collection, + )); + let mut emit = crate::firehose::FirehoseEmit::new(&state.firehose, did, now); + let chunks = futures::executor::block_on(chain::settle( + git, + did, + std::slice::from_ref(&collection.records()), + &records, + &signer, + &mut emit, + now, + ))?; + emit.finish()?; + Ok(chunks.len()) +} + +pub(crate) fn sync_members( + state: &Arc>, + meta: &Repo, +) -> Result { + sync_grants_for( + state, + meta, + &state.knot_repo, + state.index.member_entries(), + GrantCollection::Member, + ) +} + +pub(crate) fn sync_collaborators( + state: &Arc>, + git: &Repo, + repo: &RepoDid, +) -> Result { + sync_grants_for( + state, + git, + repo, + state.index.collaborator_entries(repo), + GrantCollection::Collaborator, + ) +} + +pub fn sweep_members(state: &Arc>) { + let _guard = state.cob_locks.meta(); + match Repo::open(&state.meta_path) { + Ok(meta) => noted(sync_members(state, &meta), GrantCollection::Member), + Err(error) => tracing::warn!(%error, "the grant sweep couldn't open the members chain"), + } +} + +pub fn sweep_collaborators( + state: &Arc>, + repo: &RepoDid, +) { + let _guard = state.cob_locks.repo(repo); + match state.layout.open(repo) { + Ok(git) => noted( + sync_collaborators(state, &git, repo), + GrantCollection::Collaborator, + ), + Err(error) => tracing::warn!( + repo = repo.as_str(), + %error, + "the grant sweep couldn't open a collaborator chain" + ), + } +} + +pub(crate) fn noted(outcome: Result, collection: GrantCollection) { + match outcome { + Ok(0) => {} + Ok(commits) => tracing::info!( + commits, + surface = collection.surface(), + "grant records synced" + ), + Err(error) => tracing::warn!( + %error, + surface = collection.surface(), + "grant records wouldn't sync, the grant list stays put, and a later grant write, \ + sweep, or restart retries" + ), + } +} diff --git a/knot2/crates/knot-xrpc/src/invite_codes.rs b/knot2/crates/knot-xrpc/src/invite_codes.rs index a514332ec..4c5acdb33 100644 --- a/knot2/crates/knot-xrpc/src/invite_codes.rs +++ b/knot2/crates/knot-xrpc/src/invite_codes.rs @@ -15,7 +15,7 @@ use knot_types::{KnotHostname, UnixSeconds}; use crate::cob::Authorized; use crate::error::XrpcError; use crate::members::commit_members; -use crate::{XrpcState, decode, folded_member, ok_empty}; +use crate::{XrpcState, current_member, decode, ok_empty}; pub(crate) const REDEEM_ROUTE: &str = "/xrpc/org.tangled.temp.server.redeemInviteCode"; @@ -84,7 +84,7 @@ pub(crate) async fn redeem_invite_code( )); } - match folded_member(&state, &actor).await? { + match current_member(&state, &actor).await? { Resolved::Warming => { return Err(XrpcError::warming( "member projection is still warming, please retry shortly", diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 6472af094..e4c4256da 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -9,6 +9,7 @@ mod error; mod firehose; mod forks; mod git; +pub mod grants; pub mod imports; pub mod invite_codes; pub mod legacy_admin; @@ -190,6 +191,7 @@ pub struct XrpcState { pub admission: AdmissionPolicy, pub contribution_policy: ContributionPolicy, pub knot_did: KnotId, + pub knot_repo: RepoDid, pub knot_hostname: KnotHostname, pub ci_logs: Option, pub meta_path: PathBuf, @@ -218,6 +220,10 @@ pub struct XrpcState { pub rkeys: RecordKeys, } +pub fn knot_repo(knot_did: &KnotId) -> Result { + knot_types::DidRkey::new(knot_did.as_str()).map(|rkey| RepoDid::from(&rkey)) +} + impl XrpcState { pub fn now(&self) -> UnixSeconds { UnixSeconds::new((self.atproto.now().get() / 1_000_000) as i64) @@ -664,7 +670,7 @@ pub(crate) async fn project_collaborators( run_blocking(move || index.ensure_collaborators(&target).map_err(XrpcError::from)).await } -pub(crate) async fn folded_member( +pub(crate) async fn current_member( state: &XrpcState, actor: &AccountDid, ) -> Result, XrpcError> { diff --git a/knot2/crates/knot-xrpc/src/materialize.rs b/knot2/crates/knot-xrpc/src/materialize.rs index f8e7f0569..6a144d87b 100644 --- a/knot2/crates/knot-xrpc/src/materialize.rs +++ b/knot2/crates/knot-xrpc/src/materialize.rs @@ -35,14 +35,19 @@ pub fn enable_surfaces(state: &Arc>) return 1; } }; - let knot_did = - RepoDid::new(state.knot_did.as_str()).expect("Knot's did heads the members chain"); + let knot_did = state.knot_repo.clone(); let mut commits = 0; let mut failed = 0; { let _guard = state.cob_locks.meta(); match Repo::open(&state.meta_path) { - Ok(meta) => announce_unframed(state, &meta, &knot_did, now), + Ok(meta) => { + crate::grants::noted( + crate::grants::sync_members(state, &meta), + knot_record::grant::GrantCollection::Member, + ); + announce_unframed(state, &meta, &knot_did, now); + } Err(error) => { tracing::error!(%error, "the members chain failed to enable"); failed += 1; @@ -57,6 +62,10 @@ pub fn enable_surfaces(state: &Arc>) let (round, failure) = reconcile_social(state, &git, &did, &signer, now); commits += round; failed += failure; + crate::grants::noted( + crate::grants::sync_collaborators(state, &git, &did), + knot_record::grant::GrantCollection::Collaborator, + ); announce_unframed(state, &git, &did, now); } commits += crate::refrecords::sync_refs( diff --git a/knot2/crates/knot-xrpc/src/members.rs b/knot2/crates/knot-xrpc/src/members.rs index 4eeb9d332..593154b6b 100644 --- a/knot2/crates/knot-xrpc/src/members.rs +++ b/knot2/crates/knot-xrpc/src/members.rs @@ -13,7 +13,7 @@ use knot_git::Repo; use knot_runtime::{Clock, HttpTransport}; use knot_types::{AccountDid, UnixSeconds}; -use crate::cob::{Authorized, roll_apply}; +use crate::cob::{Authorized, grant_list_apply}; use crate::error::XrpcError; use crate::{XrpcState, decode, ok_empty, run_blocking}; @@ -71,11 +71,16 @@ pub(crate) async fn commit_members( let index = Arc::clone(&state.index); let cob_locks = Arc::clone(&state.cob_locks); let home = CobHome::from(&state.knot_did); + let surfaces = Arc::clone(state); run_blocking(move || { let _guard = cob_locks.meta(); let meta = Repo::open(&meta_path)?; - roll_apply::(&CobStore::new(&meta), &home, change, &signer, now)?; + grant_list_apply::(&CobStore::new(&meta), &home, change, &signer, now)?; index.refresh_members()?; + crate::grants::noted( + crate::grants::sync_members(&surfaces, &meta), + knot_record::grant::GrantCollection::Member, + ); Ok(()) }) .await?; diff --git a/knot2/crates/knot-xrpc/src/refrecords.rs b/knot2/crates/knot-xrpc/src/refrecords.rs index 9916832dd..c8f51d73e 100644 --- a/knot2/crates/knot-xrpc/src/refrecords.rs +++ b/knot2/crates/knot-xrpc/src/refrecords.rs @@ -55,7 +55,7 @@ pub fn sync_refs( match (chain::tip(git), records.is_empty() && refs_self.is_empty()) { (Ok(None), true) => Ok(Vec::new()), (Ok(None), false) => { - let seeded = records.into_iter().chain(refs_self).collect(); + let seeded = chain::in_tree(records.into_iter().chain(refs_self).collect()); chain::materialize(git, did, &seeded, &signer, &mut emit, now).await } (Ok(Some(_)), _) => { diff --git a/knot2/crates/knot-xrpc/src/repos.rs b/knot2/crates/knot-xrpc/src/repos.rs index 60b3a1af9..5f84ea29b 100644 --- a/knot2/crates/knot-xrpc/src/repos.rs +++ b/knot2/crates/knot-xrpc/src/repos.rs @@ -94,7 +94,7 @@ pub(crate) async fn reserve_key( ) .await?; let ReserveInput { repo_did } = decode(&body)?; - if repo_did.as_str() == state.knot_did.as_str() { + if repo_did == state.knot_repo { return Err(XrpcError::invalid_request( "repoDid mustn't be knot's own identity", )); diff --git a/knot2/crates/knot-xrpc/src/social.rs b/knot2/crates/knot-xrpc/src/social.rs index 465aeee04..c6f76f0b0 100644 --- a/knot2/crates/knot-xrpc/src/social.rs +++ b/knot2/crates/knot-xrpc/src/social.rs @@ -588,7 +588,7 @@ async fn permission( let access = RepoAccess::of(&collaboration, &membership, &policy) .ok_or_else(|| XrpcError::internal("Consents answered with mismatched subjects"))?; let acl = KnotAcl::new(&state.admins, state.admission, &state.index); - ready(knot_acl::permission_of(&acl, &access), "roll") + ready(knot_acl::permission_of(&acl, &access), "grant list") } async fn warm_social( @@ -874,31 +874,19 @@ fn tree_body( #[derive(Default)] struct Kept { - placed: BTreeMap, - bodies: BTreeMap, + due: BTreeMap, } impl Kept { - fn taking(mut self, key: MstKey, placed: RecordCid, body: Option) -> Self { - if let Some(body) = body { - self.bodies.insert(key.clone(), body); - } - self.placed.insert(key, placed); + fn taking(mut self, key: MstKey, due: chain::Due) -> Self { + self.due.insert(key, due); self } } impl chain::Records for Kept { - fn placed(&self) -> &BTreeMap { - &self.placed - } - - fn body(&self, key: &MstKey) -> Result { - self.bodies.get(key).cloned().ok_or_else(|| { - chain::ChainError::Malformed(format!( - "the record at {key} is missing the bytes the projection had for it" - )) - }) + fn due(&self) -> &BTreeMap { + &self.due } } pub(crate) fn reconcile( @@ -927,9 +915,9 @@ pub(crate) fn reconcile( } else { None }; - let (cid, body) = match (stale, body) { - (false, _) => (Some(*version), None), - (true, Some(body)) => (Some(*version), Some(body)), + let due = match (stale, body) { + (false, _) => Some(chain::Due::Keep(*version)), + (true, Some(body)) => Some(chain::Due::Write(body)), (true, None) => { tracing::error!( repo = %repo, @@ -937,26 +925,19 @@ pub(crate) fn reconcile( %version, "A live social record is missing its body in the change tree; the chain keeps its served leaf and leaves the body out" ); - (leaf.and_then(RecordCid::new), None) + leaf.and_then(RecordCid::new).map(chain::Due::Keep) } }; - Ok::(match cid { - Some(cid) => kept.taking(key, cid, body), + Ok::(match due { + Some(due) => kept.taking(key, due), None => kept, }) })?; - if chain::tip(git)?.is_none() { - if !serving.is_empty() { - tracing::error!( - repo = %repo, - records = serving.len(), - "Social records exist on a repository whose chain was never enabled, so they stay off the atproto surface" - ); - } + if chain::tip(git)?.is_none() && chain::Records::due(&records).is_empty() { return Ok(Reconciled { commits: 0 }); } let mut emit = crate::firehose::FirehoseEmit::new(&state.firehose, repo, now); - let chunks = futures::executor::block_on(chain::sync( + let chunks = futures::executor::block_on(chain::settle( git, repo, &knot_cobs::social_collections(), @@ -1518,14 +1499,14 @@ fn serving_object( address: &RecordAddress, object: CobId, ) -> Result, XrpcError> { - Ok(folded_at(ledger.git, address, object)? + Ok(state_at(ledger.git, address, object)? .0 .object() .filter(|live| !live.is_erased()) .cloned()) } -fn folded_at( +fn state_at( git: &Repo, address: &RecordAddress, object: CobId, @@ -2616,8 +2597,8 @@ fn reconcile_repo_pulls( .filter_map(|(object, address)| { warned( repo, - "A patch wouldn't fold at boot, so its keep refs and branch moves wait", - folded_at(&git, &address, *object).map(|(folded, _)| folded.object().cloned()), + "A patch wouldn't materialize at boot, so its keep refs and branch moves wait", + state_at(&git, &address, *object).map(|(state, _)| state.object().cloned()), ) .map(|_| address) }) diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index 3b12aa748..b12042633 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -26,8 +26,9 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, AdmissionPolicy, AtUri, AuthorName, ContributionPolicy, Email, KnotHostname, - KnotId, OriginUrl, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds, + AccountDid, ActorId, AdmissionPolicy, AtUri, AuthorName, ContributionPolicy, Did, Email, + KnotHostname, KnotId, KnotServiceUrl, Nsid, OriginUrl, OwnerDid, RecordCollection, + RecordRkey, RepoDid, RepoName, RepoRkey, ServiceDid, UnixSeconds, }; use crate::XrpcState; @@ -67,6 +68,10 @@ fn knot_did() -> KnotId { KnotId::new(format!("did:web:{KNOT_HOST}")).unwrap() } +fn knot_service_did() -> ServiceDid { + ServiceDid::new(format!("did:web:{KNOT_HOST}")).unwrap() +} + fn account(host: &str) -> AccountDid { AccountDid::new(format!("did:web:{host}")).unwrap() } @@ -87,62 +92,66 @@ fn signer(seed: u64) -> K256Signer { K256Signer::generate(&SeededEntropy::new(seed)) } -fn did_web_doc(did: &str, sec1: &[u8], pds: &str) -> Bytes { +fn did_web_doc(did: &Did, sec1: &[u8], pds: &KnotServiceUrl) -> Bytes { let multikey = knot_types::crypto::multikey(0xe7, sec1); Bytes::from( serde_json::to_vec(&json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": [], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, + "controller": did.as_str(), "publicKeyMultibase": multikey }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": pds + "serviceEndpoint": pds.as_str() }] })) .unwrap(), ) } -fn repo_account_doc(did: &str, multikey: &str, pds: &str) -> Bytes { +fn repo_account_doc(did: &Did, multikey: &ActorId, pds: &KnotServiceUrl) -> Bytes { Bytes::from( serde_json::to_vec(&json!({ - "id": did, + "id": did.as_str(), "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, - "publicKeyMultibase": multikey + "controller": did.as_str(), + "publicKeyMultibase": multikey.as_str() }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": pds + "serviceEndpoint": pds.as_str() }] })) .unwrap(), ) } -fn mint(actor: &Actor, nsid: &str) -> String { - mint_for(actor, nsid, &format!("did:web:{KNOT_HOST}")) +fn nsid(name: &str) -> Nsid { + Nsid::new_owned(name).expect("the xrpc method is an NSID") +} + +fn mint(actor: &Actor, nsid: &Nsid) -> String { + mint_for(actor, nsid, &knot_service_did()) } -fn mint_for(actor: &Actor, nsid: &str, audience: &str) -> String { +fn mint_for(actor: &Actor, nsid: &Nsid, audience: &ServiceDid) -> String { let jti = JTI.fetch_add(1, Ordering::Relaxed); let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); let payload = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&json!({ "iss": actor.did.as_str(), - "aud": audience, + "aud": audience.as_str(), "exp": 1_100, "iat": 999, "jti": format!("nonce-{jti}"), - "lxm": nsid, + "lxm": nsid.as_str(), })) .unwrap(), ); @@ -199,7 +208,7 @@ async fn call( world: &World, handler: F, actor: &Actor, - nsid: &str, + nsid: &Nsid, value: serde_json::Value, ) -> Response where @@ -211,7 +220,7 @@ where handler( world.state(), bearer(&token), - crate::Method::from_nsid(nsid), + crate::Method::from_nsid(nsid.as_str()), body(value), ) .await, @@ -221,7 +230,7 @@ where async fn as_member( world: &World, handler: F, - nsid: &str, + nsid: &Nsid, value: serde_json::Value, ) -> Response where @@ -234,7 +243,7 @@ where async fn as_admin( world: &World, handler: F, - nsid: &str, + nsid: &Nsid, value: serde_json::Value, ) -> Response where @@ -247,7 +256,7 @@ where async fn as_stranger( world: &World, handler: F, - nsid: &str, + nsid: &Nsid, value: serde_json::Value, ) -> Response where @@ -261,11 +270,11 @@ fn member_owner() -> OwnerDid { OwnerDid::new(format!("did:web:{MEMBER_HOST}")).unwrap() } -fn resolve(world: &World, rkey: &str) -> Resolved> { +fn resolve(world: &World, name: &RepoName) -> Resolved> { world .state .index - .resolve_repo(&member_owner(), &RepoRkey::new(rkey).unwrap()) + .resolve_repo(&member_owner(), &RepoRkey::new(name.as_str()).unwrap()) } fn bootstrap( @@ -346,6 +355,7 @@ fn state_from( admins: BTreeSet::from([account(ADMIN_HOST)]), admission, contribution_policy: knot_types::ContributionPolicy::Anyone, + knot_repo: crate::knot_repo(&knot).unwrap(), knot_did: knot, knot_hostname: KnotHostname::new(KNOT_HOST).unwrap(), meta_path, @@ -393,8 +403,8 @@ fn world_responder( pds_records: Arc>>, reads: Arc, ) -> Responder { - let doc_url = format!("https://{KNOT_HOST}"); - let plc_key = knot_types::crypto::multikey(0xe7, &pubkeys[MEMBER_HOST]); + let doc_url = KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(); + let plc_key = ActorId::new(knot_types::crypto::multikey(0xe7, &pubkeys[MEMBER_HOST])).unwrap(); Box::new(move |request: &HttpRequest| { if request.method == http::Method::POST { return Ok(HttpResponse { @@ -425,7 +435,14 @@ fn world_responder( if host == "plc.directory" { let did = request.url.path().trim_start_matches('/'); return Ok(match plc_docs.lock().get(did) { - Some(pds) => reply(StatusCode::OK, repo_account_doc(did, &plc_key, pds)), + Some(pds) => reply( + StatusCode::OK, + repo_account_doc( + &Did::new_owned(did).unwrap(), + &plc_key, + &KnotServiceUrl::new(pds.as_str()).unwrap(), + ), + ), None => reply(StatusCode::NOT_FOUND, Bytes::new()), }); } @@ -434,9 +451,9 @@ fn world_responder( status: StatusCode::OK, headers: http::HeaderMap::new(), body: repo_account_doc( - &format!("did:web:{host}"), - &multikey, - &format!("https://{KNOT_HOST}"), + &Did::new_owned(format!("did:web:{host}")).unwrap(), + &ActorId::new(multikey).unwrap(), + &doc_url, ), }); } @@ -444,7 +461,7 @@ fn world_responder( Some(sec1) => Ok(HttpResponse { status: StatusCode::OK, headers: http::HeaderMap::new(), - body: did_web_doc(&format!("did:web:{host}"), sec1, &doc_url), + body: did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), sec1, &doc_url), }), None => Ok(HttpResponse { status: StatusCode::NOT_FOUND, @@ -651,12 +668,12 @@ impl World { State(Arc::clone(&self.state)) } - fn publish_pds_record(&self, rkey: &str) { + fn publish_pds_record(&self, rkey: &RecordRkey) { self.respond_pds_record(rkey, StatusCode::OK); } - fn respond_pds_record(&self, rkey: &str, status: StatusCode) { - self.pds_records.lock().insert(rkey.to_string(), status); + fn respond_pds_record(&self, rkey: &RecordRkey, status: StatusCode) { + self.pds_records.lock().insert(rkey.as_str().to_string(), status); } fn publish_repo_doc(&self, host: &str, multikey: &str) { self.repo_docs @@ -664,10 +681,10 @@ impl World { .insert(host.to_string(), multikey.to_string()); } - fn publish_plc_doc(&self, did: &str, pds: &str) { + fn publish_plc_doc(&self, did: &Did, pds: &KnotServiceUrl) { self.plc_docs .lock() - .insert(did.to_string(), pds.to_string()); + .insert(did.as_str().to_string(), pds.as_str().to_string()); } fn advance(&self, secs: u64) { @@ -688,7 +705,7 @@ impl World { fn append(&self, repo: Option<&RepoDid>, change: &E::Change) where - E: knot_cob::Evaluate, + E: knot_cob::Evaluate, E::Change: knot_cob::KnotAuthored, { let git = match repo { @@ -706,7 +723,7 @@ impl World { [] => _ = store.create(&home, change, &signer, at).unwrap(), [object] => _ = store.update(&home, *object, change, &signer, at).unwrap(), many => panic!( - "{} roll objects under namespace that allows one", + "{} grant list objects under namespace that allows one", many.len() ), } @@ -737,7 +754,7 @@ impl World { } } - fn on_roll(&self, repo: Option<&RepoDid>, subject: &AccountDid) -> Resolved { + fn on_grant_list(&self, repo: Option<&RepoDid>, subject: &AccountDid) -> Resolved { self.refresh(repo); match repo { None => self.state.index.effective_member(subject), @@ -788,7 +805,7 @@ fn doc_responder( sec1: Vec, post_status: impl Fn() -> StatusCode + Send + Sync + 'static, ) -> Responder { - let pds = format!("https://{KNOT_HOST}"); + let pds = KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(); Box::new(move |request: &HttpRequest| { let post = request.method == http::Method::POST; let host = request.url.host_str().unwrap_or_default(); @@ -798,7 +815,7 @@ fn doc_responder( body: if post { Bytes::new() } else { - did_web_doc(&format!("did:web:{host}"), &sec1, &pds) + did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), &sec1, &pds) }, }) }) @@ -822,13 +839,13 @@ fn reply(status: StatusCode, body: Bytes) -> HttpResponse { } fn plc_stage(fake: Arc>, sec1: Vec) -> Responder { - let pds = format!("https://{KNOT_HOST}"); + let pds = KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(); Box::new(move |request: &HttpRequest| { let host = request.url.host_str().unwrap_or_default(); if host != "plc.directory" { return Ok(reply( StatusCode::OK, - did_web_doc(&format!("did:web:{host}"), &sec1, &pds), + did_web_doc(&Did::new_owned(format!("did:web:{host}")).unwrap(), &sec1, &pds), )); } let mut fake = fake.lock(); @@ -901,7 +918,7 @@ async fn minted_repo(fake: PlcFake) -> SweptRepo { let create_status = into_response( crate::repos::create_repo( State(Arc::clone(&state)), - bearer(&mint(&admin, CREATE)), + bearer(&mint(&admin, &nsid(CREATE))), crate::Method::from_nsid(CREATE), body(json!({ "name": "anemone" })), ) @@ -937,8 +954,8 @@ fn subject_body(repo: Option<&RepoDid>, subject: &AccountDid) -> serde_json::Val async fn offer(world: &World, repo: Option<&RepoDid>, subject: &AccountDid) -> StatusCode { let body = subject_body(repo, subject); match repo { - None => as_admin(world, add_member, ADD_MEMBER, body).await, - Some(_) => as_member(world, add_collaborator, ADD_COLLAB, body).await, + None => as_admin(world, add_member, &nsid(ADD_MEMBER), body).await, + Some(_) => as_member(world, add_collaborator, &nsid(ADD_COLLAB), body).await, } .status() } @@ -948,58 +965,63 @@ async fn create_repo_helper(world: &World, name: &str) -> RepoDid { as_member( world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": name }) ) .await .status(), StatusCode::OK ); - match resolve(world, name) { + match resolve(world, &RepoName::new(name).unwrap()) { Resolved::Ready(Some(did)) => did, other => panic!("Repo {name} wasn't registered: {other:?}"), } } async fn create_status(world: &World, actor: &Actor, value: serde_json::Value) -> StatusCode { - call(world, crate::repos::create_repo, actor, CREATE, value) + call(world, crate::repos::create_repo, actor, &nsid(CREATE), value) .await .status() } -async fn reserve_status(world: &World, actor: &Actor, did: &str) -> StatusCode { +async fn reserve_status(world: &World, actor: &Actor, did: &RepoDid) -> StatusCode { call( world, crate::repos::reserve_key, actor, - RESERVE, + &nsid(RESERVE), json!({ "repoDid": did }), ) .await .status() } -async fn reserve_repo_key(world: &World, did_web: &str) -> String { +async fn reserve_repo_key(world: &World, did: &RepoDid) -> String { let response = as_member( world, crate::repos::reserve_key, - RESERVE, - json!({ "repoDid": did_web }), + &nsid(RESERVE), + json!({ "repoDid": did }), ) .await; assert_eq!(response.status(), StatusCode::OK); let key = json_of(response).await["key"].as_str().unwrap().to_string(); - let host = did_web.strip_prefix("did:web:").unwrap(); + let host = did.as_str().strip_prefix("did:web:").unwrap(); world.publish_repo_doc(host, &key); key } -async fn rename_repo_as(world: &World, actor: &Actor, repo: &RepoDid, rkey: &str) -> StatusCode { +async fn rename_repo_as( + world: &World, + actor: &Actor, + repo: &RepoDid, + rkey: &RepoRkey, +) -> StatusCode { call( world, crate::repos::rename_repo, actor, - RENAME, + &nsid(RENAME), json!({ "repo": repo, "rkey": rkey, "name": rkey }), ) .await @@ -1016,7 +1038,7 @@ async fn set_policy_response( world, crate::repos::set_contribution_policy, actor, - SET_POLICY, + &nsid(SET_POLICY), json!({ "repo": repo, "contributionPolicy": policy }), ) .await @@ -1138,7 +1160,7 @@ async fn on_repo( world: &World, handler: F, actor: &Actor, - nsid: &str, + nsid: &Nsid, repo: &RepoDid, value: serde_json::Value, ) -> (StatusCode, serde_json::Value) @@ -1146,12 +1168,12 @@ where F: FnOnce(State, HeaderMap, crate::Method, Bytes) -> Fut, Fut: Future>, { - let token = mint_for(actor, nsid, repo.as_str()); + let token = mint_for(actor, nsid, &ServiceDid::new(repo.as_str()).unwrap()); let response = into_response( handler( world.state(), bearer(&token), - crate::Method::from_nsid(nsid), + crate::Method::from_nsid(nsid.as_str()), body(value), ) .await, @@ -1189,8 +1211,8 @@ async fn create_as( world: &World, actor: &Actor, repo: &RepoDid, - collection: &str, - rkey: Option, + collection: &RecordCollection, + rkey: Option, record: serde_json::Value, ) -> (StatusCode, serde_json::Value) { let mut value = json!({ "repo": repo, "collection": collection, "record": record }); @@ -1201,7 +1223,7 @@ async fn create_as( world, crate::social::create_record, actor, - CREATE_RECORD, + &nsid(CREATE_RECORD), repo, value, ) @@ -1212,8 +1234,8 @@ async fn put_as( world: &World, actor: &Actor, repo: &RepoDid, - collection: &str, - rkey: String, + collection: &RecordCollection, + rkey: RecordRkey, swap: serde_json::Value, record: serde_json::Value, ) -> (StatusCode, serde_json::Value) { @@ -1225,7 +1247,7 @@ async fn put_as( world, crate::social::put_record, actor, - PUT_RECORD, + &nsid(PUT_RECORD), repo, value, ) @@ -1236,8 +1258,8 @@ async fn delete_as( world: &World, actor: &Actor, repo: &RepoDid, - collection: &str, - rkey: String, + collection: &RecordCollection, + rkey: RecordRkey, swap: serde_json::Value, ) -> (StatusCode, serde_json::Value) { let value = merged( @@ -1248,7 +1270,7 @@ async fn delete_as( world, crate::social::delete_record, actor, - DELETE_RECORD, + &nsid(DELETE_RECORD), repo, value, ) @@ -1275,7 +1297,7 @@ impl<'a> Ticket<'a> { self.world, actor, self.repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), None, ticket_record(self.repo, title, text), ) @@ -1292,7 +1314,15 @@ impl<'a> Ticket<'a> { actor: &Actor, record: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - create_as(self.world, actor, self.repo, TICKET, None, record).await + create_as( + self.world, + actor, + self.repo, + &RecordCollection::new(TICKET).unwrap(), + None, + record, + ) + .await } async fn refused_create(&self, record: serde_json::Value) -> serde_json::Value { @@ -1313,7 +1343,7 @@ impl<'a> Ticket<'a> { self.world, actor, self.repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), rkey_of(uri), json!({ "swapRecord": from }), record, @@ -1359,7 +1389,7 @@ impl<'a> Ticket<'a> { self.world, actor, self.repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), rkey_of(uri), json!({ "swapRecord": from }), ) @@ -1416,11 +1446,8 @@ impl TicketDraft { } } -fn rkey_of(uri: &AtUri) -> String { - uri.rkey() - .expect("An AT-URI ends in a record key") - .as_ref() - .to_owned() +fn rkey_of(uri: &AtUri) -> RecordRkey { + RecordRkey::new(uri.rkey().expect("An AT-URI ends in a record key").as_ref()).unwrap() } fn record_of( @@ -1436,7 +1463,7 @@ fn record_of( .as_str(), ) .unwrap(), - rkey: knot_types::UriRkey::new(rkey_of(uri)).unwrap(), + rkey: knot_types::UriRkey::new(rkey_of(uri).as_str()).unwrap(), cid, } } @@ -1458,7 +1485,7 @@ async fn record_at( (status, json_of(response).await) } -async fn records_in(world: &World, repo: &RepoDid, collection: &str) -> Vec { +async fn records_in(world: &World, repo: &RepoDid, collection: &RecordCollection) -> Vec { let params: crate::atproto::ListRecordsParams = serde_json::from_value(json!({ "repo": repo, "collection": collection })).unwrap(); let listed = json_of( @@ -1606,7 +1633,7 @@ async fn stranger_opens_tickets_until_policy_closes_and_reads_collaborators_reme "the editor ought to be the authenticated writer, never the repo DID" ); assert_eq!( - records_in(&world, &repo, TICKET).await, + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await, vec![uri.as_str().to_owned()] ); @@ -1643,7 +1670,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), Some(rkey_of(&uri)), ticket_record(&repo, "again", ""), ) @@ -1652,33 +1679,41 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { ); message_contains(&minted, &["mints the record key"]); - futures::stream::iter([Some("3lubrptx57d22".to_owned()), None]) - .for_each(|rkey| { - let (world, repo) = (&world, &repo); - async move { - let rejected = failed( - create_as( - world, - &world.stranger, - repo, - "com.example.gripe", - rkey, - retype(ticket_record(repo, "no", ""), "com.example.gripe"), - ) - .await, - StatusCode::BAD_REQUEST, - ); - message_contains(&rejected, &["isn't a collection this knot takes"]); - } - }) - .await; + futures::stream::iter([ + ("cafe.oyster.gripe", Some("3lubrptx57d22".to_owned())), + ("cafe.oyster.gripe", None), + ("org.tangled.knot.member", Some("did:plc:whelk".to_owned())), + ( + "org.tangled.graph.collaborator", + Some("did:plc:whelk".to_owned()), + ), + ]) + .for_each(|(collection, rkey)| { + let (world, repo) = (&world, &repo); + async move { + let rejected = failed( + create_as( + world, + &world.stranger, + repo, + &RecordCollection::new(collection).unwrap(), + rkey.map(|rkey| RecordRkey::new(rkey).unwrap()), + retype(ticket_record(repo, "no", ""), collection), + ) + .await, + StatusCode::BAD_REQUEST, + ); + message_contains(&rejected, &["isn't a collection this knot takes"]); + } + }) + .await; let wrong_type = failed( create_as( &world, &world.stranger, &repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), None, retype(ticket_record(&repo, "kelp", ""), "org.tangled.review.patch"), ) @@ -1692,7 +1727,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), rkey_of(&uri), json!({ "swapCommit": cid }), ) @@ -1706,7 +1741,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.stranger, &repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), rkey_of(&uri), json!({}), ) @@ -1720,7 +1755,7 @@ async fn stock_routes_dispatch_on_collection_and_reject_foreign_collections() { &world, &world.member, &repo, - "org.tangled.feed.reaction", + &RecordCollection::new("org.tangled.feed.reaction").unwrap(), rkey_of(&uri), json!({ "swapRecord": cid }), json!({ @@ -1775,7 +1810,7 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { .await, StatusCode::FORBIDDEN, ); - assert_eq!(records_in(&world, &repo, TICKET).await.len(), 1); + assert_eq!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.len(), 1); let same = ok(ticket.edit(&world.stranger, &uri, &v2, "kelp, again").await); assert_eq!( @@ -1817,7 +1852,7 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { &world, &world.admin, &repo, - TICKET, + &RecordCollection::new(TICKET).unwrap(), rkey_of(&uri), json!({ "swapRecord": "bafyreidoesnotparse" }), ) @@ -1839,7 +1874,7 @@ async fn rewrites_chain_editors_and_erasure_tombstones_record_and_bytes() { let (status, gone) = record_at(&world, &repo, &uri, None).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(gone["error"], "RecordNotFound"); - assert!(records_in(&world, &repo, TICKET).await.is_empty()); + assert!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty()); assert!( block_served(&world, &repo, &v1).await, "The block tree still keeps the prose git can't delete" @@ -1916,7 +1951,7 @@ async fn sync_record_blocks( .as_str(), ) .unwrap(), - rkey: knot_types::UriRkey::new(rkey_of(uri)).unwrap(), + rkey: knot_types::UriRkey::new(rkey_of(uri).as_str()).unwrap(), }), ) .await @@ -1967,7 +2002,7 @@ fn cob_version_bytes( .as_str(), ) .unwrap(), - knot_types::RecordRkey::new(rkey_of(uri)).unwrap(), + rkey_of(uri), ); let object = match world.state.index.social_object_at(repo, &address) { knot_index::Resolved::Ready(Some(object)) => object, @@ -2018,7 +2053,7 @@ async fn deleted_prose_stays_off_every_presenting_route_and_reachable_from_histo "The deleted version's own CID mustn't resurrect it." ); assert!( - records_in(&world, &repo, TICKET).await.is_empty(), + records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty(), "listRecords must drop the row." ); let proof = sync_record_blocks(&world, &repo, &uri).await; @@ -2434,7 +2469,7 @@ async fn blocklist_lifecycle() { as_admin( &world, crate::blocklist::ban, - BAN, + &nsid(BAN), json!({ "subject": subject }) ) .await @@ -2455,7 +2490,7 @@ async fn blocklist_lifecycle() { as_admin( &world, crate::blocklist::unban, - UNBAN, + &nsid(UNBAN), json!({ "subject": subject }) ) .await @@ -2476,7 +2511,7 @@ async fn blocklist_lifecycle() { as_admin( &world, crate::blocklist::ban, - BAN, + &nsid(BAN), json!({ "subject": format!("did:web:{ADMIN_HOST}") }) ) .await @@ -2488,7 +2523,7 @@ async fn blocklist_lifecycle() { as_stranger( &world, crate::blocklist::ban, - BAN, + &nsid(BAN), json!({ "subject": format!("did:web:{MEMBER_HOST}") }) ) .await @@ -2520,7 +2555,7 @@ async fn member_lifecycle() { as_admin( &world, crate::members::remove_member, - REMOVE_MEMBER, + &nsid(REMOVE_MEMBER), subject.clone() ) .await @@ -2536,7 +2571,7 @@ async fn member_lifecycle() { as_admin( &world, crate::members::remove_member, - REMOVE_MEMBER, + &nsid(REMOVE_MEMBER), subject ) .await @@ -2558,9 +2593,9 @@ async fn redeem(world: &World, actor: &Actor, code: &str) -> StatusCode { actor, // derived, never spelled twice: these tests call the handler directly, so // a token minted for the wrong nsid would still agree with itself - crate::invite_codes::REDEEM_ROUTE + &nsid(crate::invite_codes::REDEEM_ROUTE .strip_prefix("/xrpc/") - .expect("xrpc route paths are prefixed"), + .expect("xrpc route paths are prefixed")), json!({ "code": code }), ) .await @@ -2619,7 +2654,7 @@ async fn a_link_redeemed_by_a_member_the_admin_had_already_added_is_a_no_op() { assert_eq!( redeem(&world, &world.member, &code).await, StatusCode::OK, - "a redemption by an account already on the roll must answer ok, not conflict" + "a redemption by an account already on the grant list must answer ok, not conflict" ); assert_eq!( world.state.index.effective_member(&member), @@ -2658,7 +2693,7 @@ async fn add_member_auth_outcomes() { let world = World::new(); let lowercase = { - let token = mint(&world.admin, ADD_MEMBER); + let token = mint(&world.admin, &nsid(ADD_MEMBER)); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, @@ -2668,7 +2703,7 @@ async fn add_member_auth_outcomes() { }; let cases = vec![ Case { - headers: bearer(&mint(&world.member, ADD_MEMBER)), + headers: bearer(&mint(&world.member, &nsid(ADD_MEMBER))), body: json!({ "subject": "did:web:olaren.dev" }), status: StatusCode::FORBIDDEN, why: "a non-admin cannot add a member", @@ -2680,7 +2715,7 @@ async fn add_member_auth_outcomes() { why: "a request without a token is unauthorized", }, Case { - headers: bearer(&mint(&world.admin, ADD_MEMBER)), + headers: bearer(&mint(&world.admin, &nsid(ADD_MEMBER))), body: json!({ "subject": "not-a-did" }), status: StatusCode::BAD_REQUEST, why: "an invalid DID is rejected at decode by the newtype Deserialize, never reaching a handler", @@ -2722,7 +2757,7 @@ async fn a_transient_upstream_identity_failure_is_a_503_named_upstream_unavailab }); let (_dir, state) = build_state(responder, true); let admin = actor(1, ADMIN_HOST); - let token = mint(&admin, ADD_MEMBER); + let token = mint(&admin, &nsid(ADD_MEMBER)); let response = into_response( crate::members::add_member( State(state), @@ -2751,17 +2786,18 @@ async fn concurrent_first_member_adds_converge_to_a_single_cob_object() { use knot_git::Repo; let world = World::new(); + let method = nsid(ADD_MEMBER); let (left, right) = tokio::join!( as_admin( &world, crate::members::add_member, - ADD_MEMBER, + &method, json!({ "subject": "did:web:witchcraft.systems" }) ), as_admin( &world, crate::members::add_member, - ADD_MEMBER, + &method, json!({ "subject": "did:web:isabelroses.com" }) ), ); @@ -2777,9 +2813,9 @@ async fn concurrent_first_member_adds_converge_to_a_single_cob_object() { let hosts = ["witchcraft.systems", "isabelroses.com"]; hosts.iter().for_each(|host| { assert_eq!( - world.on_roll(None, &account(host)), + world.on_grant_list(None, &account(host)), Resolved::Ready(true), - "both concurrent adds reached the one roll, and {host} is on it" + "both concurrent adds wrote to the same grant list, and {host} is on it" ); }); } @@ -2813,7 +2849,7 @@ async fn re_adding_a_member_under_warming_appends_no_redundant_change() { ) .unwrap(); - let token = mint(&admin, ADD_MEMBER); + let token = mint(&admin, &nsid(ADD_MEMBER)); assert_eq!( into_response( crate::members::add_member( @@ -2864,7 +2900,7 @@ async fn create_mints_a_did_plc_repo_and_refuses_a_duplicate_name() { "a second repo of the same name is refused, never silently overwritten" ); assert_eq!( - resolve(&world, "anemone"), + resolve(&world, &RepoName::new("anemone").unwrap()), Resolved::Ready(Some(repo_did.clone())), "the original repo still owns the name" ); @@ -2917,20 +2953,25 @@ async fn create_and_reserve_reject_bad_identities() { "a did:web create without a prior reserveKey is refused" ); assert_eq!( - resolve(&world, "uni"), + resolve(&world, &RepoName::new("uni").unwrap()), Resolved::Ready(None), "nothing was registered for the unproven did:web" ); assert_eq!( - reserve_status(&world, &world.member, &format!("did:web:{KNOT_HOST}")).await, + reserve_status( + &world, + &world.member, + &RepoDid::new(format!("did:web:{KNOT_HOST}")).unwrap(), + ) + .await, StatusCode::BAD_REQUEST, "the knot's own DID cannot have a repo key reserved against it" ); - let unpublished = "did:web:conch.olaren.dev"; + let unpublished = RepoDid::new("did:web:conch.olaren.dev").unwrap(); assert_eq!( - reserve_status(&world, &world.member, unpublished).await, + reserve_status(&world, &world.member, &unpublished).await, StatusCode::OK ); let impostor = knot_types::crypto::multikey(0xe7, signer(99).public_key().as_bytes()); @@ -2939,7 +2980,7 @@ async fn create_and_reserve_reject_bad_identities() { create_status( &world, &world.member, - json!({ "name": "conch", "did": unpublished }) + json!({ "name": "conch", "did": &unpublished }) ) .await, StatusCode::BAD_REQUEST, @@ -2948,15 +2989,15 @@ async fn create_and_reserve_reject_bad_identities() { assert!( world .layout - .open(&RepoDid::new(unpublished).unwrap()) + .open(&unpublished) .is_err(), "the repo was never created on disk" ); - let victim = "did:web:victim.olaren.dev"; - let reserved = reserve_repo_key(&world, victim).await; + let victim = RepoDid::new("did:web:victim.olaren.dev").unwrap(); + let reserved = reserve_repo_key(&world, &victim).await; assert_eq!( - reserve_repo_key(&world, victim).await, + reserve_repo_key(&world, &victim).await, reserved, "re-reserving returns the same key so an already-published document stays valid" ); @@ -2964,14 +3005,14 @@ async fn create_and_reserve_reject_bad_identities() { create_status( &world, &world.admin, - json!({ "name": "victim", "did": victim }) + json!({ "name": "victim", "did": &victim }) ) .await, StatusCode::BAD_REQUEST, "only the account that reserved the did:web may create it" ); assert!( - world.layout.open(&RepoDid::new(victim).unwrap()).is_err(), + world.layout.open(&victim).is_err(), "the hijack attempt created no repo on disk" ); @@ -2997,12 +3038,12 @@ async fn a_byo_did_web_repo_is_accepted_and_its_key_is_returned() { let world = World::new(); world.grandfather(MEMBER_HOST); let did = "did:web:nautilus.olaren.dev"; - let reserved_key = reserve_repo_key(&world, did).await; + let reserved_key = reserve_repo_key(&world, &RepoDid::new(did).unwrap()).await; let response = as_member( &world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": "nautilus", "did": did }), ) .await; @@ -3035,7 +3076,7 @@ async fn a_byo_did_web_repo_is_accepted_and_its_key_is_returned() { as_member( &world, crate::collaborators::add_collaborator, - ADD_COLLAB, + &nsid(ADD_COLLAB), json!({ "repo": did, "subject": "did:web:witchcraft.systems" }) ) .await @@ -3054,7 +3095,7 @@ async fn a_byo_did_web_repo_is_accepted_and_its_key_is_returned() { let squid = "did:web:squid.olaren.dev"; let victim = RepoDid::new(squid).unwrap(); world.layout.create(&victim).unwrap(); - reserve_repo_key(&world, squid).await; + reserve_repo_key(&world, &victim).await; assert_eq!( create_status( &world, @@ -3130,7 +3171,7 @@ async fn deleting_a_repo_records_the_deactivation_for_get_repo_status() { as_member( &world, crate::repos::delete_repo, - DELETE, + &nsid(DELETE), json!({ "repo": repo.as_str() }), ) .await; @@ -3152,7 +3193,7 @@ async fn resolve_by_name_matches_the_rkey_case_sensitively() { into_response( crate::repos::create_repo( State(Arc::clone(&state)), - bearer(&mint(&admin, CREATE)), + bearer(&mint(&admin, &nsid(CREATE))), crate::Method::from_nsid(CREATE), body(json!({ "name": "anemone" })), ) @@ -3178,17 +3219,32 @@ async fn reserve_key_refuses_once_the_pending_limit_is_reached() { world.grandfather(MEMBER_HOST); assert_eq!( - reserve_status(&world, &world.member, "did:web:p0.olaren.dev").await, + reserve_status( + &world, + &world.member, + &RepoDid::new("did:web:p0.olaren.dev").unwrap(), + ) + .await, StatusCode::OK, "first reservation is within the limit" ); assert_eq!( - reserve_status(&world, &world.member, "did:web:p1.olaren.dev").await, + reserve_status( + &world, + &world.member, + &RepoDid::new("did:web:p1.olaren.dev").unwrap(), + ) + .await, StatusCode::OK, "second reservation reaches the limit" ); assert_eq!( - reserve_status(&world, &world.member, "did:web:p2.olaren.dev").await, + reserve_status( + &world, + &world.member, + &RepoDid::new("did:web:p2.olaren.dev").unwrap(), + ) + .await, StatusCode::TOO_MANY_REQUESTS, "member cannot grow the sealed store without bound past the pending-reservation limit" ); @@ -3200,21 +3256,34 @@ async fn one_account_cannot_exhaust_the_global_reservation_budget() { world.grandfather(MEMBER_HOST); let world_ref = &world; - futures::stream::iter(["did:web:m0.olaren.dev", "did:web:m1.olaren.dev"]) - .for_each(|did| async move { + futures::stream::iter([ + RepoDid::new("did:web:m0.olaren.dev").unwrap(), + RepoDid::new("did:web:m1.olaren.dev").unwrap(), + ]) + .for_each(|did| async move { assert_eq!( - reserve_status(world_ref, &world_ref.member, did).await, + reserve_status(world_ref, &world_ref.member, &did).await, StatusCode::OK ); }) .await; assert_eq!( - reserve_status(&world, &world.member, "did:web:m2.olaren.dev").await, + reserve_status( + &world, + &world.member, + &RepoDid::new("did:web:m2.olaren.dev").unwrap(), + ) + .await, StatusCode::TOO_MANY_REQUESTS, "member is held to its per-actor reservation budget" ); assert_eq!( - reserve_status(&world, &world.admin, "did:web:admin0.olaren.dev").await, + reserve_status( + &world, + &world.admin, + &RepoDid::new("did:web:admin0.olaren.dev").unwrap(), + ) + .await, StatusCode::OK, "a different account keeps its own budget while global capacity remains" ); @@ -3228,7 +3297,7 @@ async fn concurrent_reserve_key_calls_all_succeed() { let handles: Vec<_> = (0..24) .map(|i| { let state = world.state(); - let token = mint(&world.member, RESERVE); + let token = mint(&world.member, &nsid(RESERVE)); let payload = body(json!({ "repoDid": format!("did:web:r{i}.olaren.dev") })); tokio::spawn(async move { into_response( @@ -3278,7 +3347,7 @@ async fn collaborator_lifecycle() { as_member( &world, crate::collaborators::remove_collaborator, - REMOVE_COLLAB, + &nsid(REMOVE_COLLAB), subject() ) .await @@ -3297,7 +3366,7 @@ async fn collaborator_lifecycle() { as_member( &world, crate::collaborators::remove_collaborator, - REMOVE_COLLAB, + &nsid(REMOVE_COLLAB), subject() ) .await @@ -3316,7 +3385,7 @@ async fn repo_management_is_owner_or_collaborator_gated() { as_admin( &world, crate::collaborators::add_collaborator, - ADD_COLLAB, + &nsid(ADD_COLLAB), json!({ "repo": repo_did, "subject": "did:web:isabelroses.com" }) ) .await @@ -3328,7 +3397,7 @@ async fn repo_management_is_owner_or_collaborator_gated() { as_stranger( &world, crate::branches::set_default_branch, - SET_DEFAULT, + &nsid(SET_DEFAULT), json!({ "repo": repo_did, "defaultBranch": "trunk" }) ) .await @@ -3340,7 +3409,7 @@ async fn repo_management_is_owner_or_collaborator_gated() { as_stranger( &world, crate::branches::delete_branch, - DELETE_BRANCH, + &nsid(DELETE_BRANCH), json!({ "repo": repo_did, "branch": "trunk" }) ) .await @@ -3352,7 +3421,7 @@ async fn repo_management_is_owner_or_collaborator_gated() { as_stranger( &world, crate::repos::delete_repo, - DELETE, + &nsid(DELETE), json!({ "repo": repo_did }) ) .await @@ -3362,7 +3431,13 @@ async fn repo_management_is_owner_or_collaborator_gated() { ); assert_eq!( - rename_repo_as(&world, &world.stranger, &repo_did, "stolen").await, + rename_repo_as( + &world, + &world.stranger, + &repo_did, + &RepoRkey::new("stolen").unwrap(), + ) + .await, StatusCode::FORBIDDEN ); assert_eq!( @@ -3373,7 +3448,13 @@ async fn repo_management_is_owner_or_collaborator_gated() { world.grandfather_collaborator(&repo_did, STRANGER_HOST); assert_eq!( - rename_repo_as(&world, &world.stranger, &repo_did, "periwinkle").await, + rename_repo_as( + &world, + &world.stranger, + &repo_did, + &RepoRkey::new("periwinkle").unwrap(), + ) + .await, StatusCode::OK, "rename is gated by can_push, so a collaborator may rename" ); @@ -3389,7 +3470,7 @@ async fn the_owner_sets_the_default_branch_by_repo_did() { as_member( &world, crate::branches::set_default_branch, - SET_DEFAULT, + &nsid(SET_DEFAULT), json!({ "repo": repo_did, "defaultBranch": "trunk" }) ) .await @@ -3441,7 +3522,7 @@ async fn set_default_branch_rejections() { as_member( &world, crate::branches::set_default_branch, - SET_DEFAULT, + &nsid(SET_DEFAULT), json!({ "repo": "did:web:squid.oyster.cafe", "defaultBranch": "trunk" }) ) .await @@ -3453,7 +3534,7 @@ async fn set_default_branch_rejections() { as_member( &world, crate::branches::set_default_branch, - SET_DEFAULT, + &nsid(SET_DEFAULT), json!({ "repo": repo_did, "defaultBranch": "ghost" }) ) .await @@ -3505,7 +3586,7 @@ async fn delete_branch_removes_a_branch_and_refuses_the_default() { as_member( &world, crate::branches::delete_branch, - DELETE_BRANCH, + &nsid(DELETE_BRANCH), json!({ "repo": repo_did, "branch": "trunk" }) ) .await @@ -3523,7 +3604,7 @@ async fn delete_branch_removes_a_branch_and_refuses_the_default() { as_member( &world, crate::branches::delete_branch, - DELETE_BRANCH, + &nsid(DELETE_BRANCH), json!({ "repo": repo_did, "branch": "main" }) ) .await @@ -3543,7 +3624,7 @@ async fn delete_repo_lifecycle_and_guards() { as_member( &world, crate::repos::delete_repo, - DELETE, + &nsid(DELETE), json!({ "repo": plain }) ) .await @@ -3555,16 +3636,16 @@ async fn delete_repo_lifecycle_and_guards() { "bare repo is removed from disk" ); assert_eq!( - resolve(&world, "whelk"), + resolve(&world, &RepoName::new("whelk").unwrap()), Resolved::Ready(None), "repo is deregistered" ); let guarded = create_repo_helper(&world, "conch").await; - world.publish_pds_record("conch"); + world.publish_pds_record(&RecordRkey::new("conch").unwrap()); let delete_conch = || json!({ "repo": guarded }); assert_eq!( - as_member(&world, crate::repos::delete_repo, DELETE, delete_conch()) + as_member(&world, crate::repos::delete_repo, &nsid(DELETE), delete_conch()) .await .status(), StatusCode::CONFLICT, @@ -3577,14 +3658,14 @@ async fn delete_repo_lifecycle_and_guards() { let force_conch = || json!({ "repo": guarded, "force": true }); assert_eq!( - as_member(&world, crate::repos::delete_repo, DELETE, force_conch()) + as_member(&world, crate::repos::delete_repo, &nsid(DELETE), force_conch()) .await .status(), StatusCode::FORBIDDEN, "force is an admin-only escape hatch instead of the owner's" ); assert_eq!( - as_admin(&world, crate::repos::delete_repo, DELETE, force_conch()) + as_admin(&world, crate::repos::delete_repo, &nsid(DELETE), force_conch()) .await .status(), StatusCode::OK, @@ -3603,16 +3684,16 @@ async fn rename_alias_lifecycle() { let repo_a = create_repo_helper(&world, "alpha").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_a, "alphanew").await, + rename_repo_as(&world, &world.member, &repo_a, &RepoRkey::new("alphanew").unwrap()).await, StatusCode::OK ); assert_eq!( - resolve(&world, "alphanew"), + resolve(&world, &RepoName::new("alphanew").unwrap()), Resolved::Ready(Some(repo_a.clone())), "the new rkey resolves on the very next read" ); assert_eq!( - resolve(&world, "alpha"), + resolve(&world, &RepoName::new("alpha").unwrap()), Resolved::Ready(Some(repo_a.clone())), "the prior rkey keeps resolving as an alias" ); @@ -3625,38 +3706,38 @@ async fn rename_alias_lifecycle() { let repo_a2 = create_repo_helper(&world, "alpha").await; assert_ne!(repo_a2, repo_a, "a brand-new repo DID was minted"); assert_eq!( - resolve(&world, "alpha"), + resolve(&world, &RepoName::new("alpha").unwrap()), Resolved::Ready(Some(repo_a2.clone())), "the reused rkey now resolves to the new repo" ); assert_eq!( - resolve(&world, "alphanew"), + resolve(&world, &RepoName::new("alphanew").unwrap()), Resolved::Ready(Some(repo_a.clone())), "the renamed repo keeps its canonical rkey" ); let repo_b = create_repo_helper(&world, "beta").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_b, "alpha").await, + rename_repo_as(&world, &world.member, &repo_b, &RepoRkey::new("alpha").unwrap()).await, StatusCode::CONFLICT, "a rename cannot take the canonical rkey of another live repo" ); assert_eq!( - resolve(&world, "alpha"), + resolve(&world, &RepoName::new("alpha").unwrap()), Resolved::Ready(Some(repo_a2)), "the contested rkey still belongs to its original repo" ); let repo_g = create_repo_helper(&world, "gamma").await; assert_eq!( - rename_repo_as(&world, &world.member, &repo_g, "gammanew").await, + rename_repo_as(&world, &world.member, &repo_g, &RepoRkey::new("gammanew").unwrap()).await, StatusCode::OK ); assert_eq!( as_member( &world, crate::repos::delete_repo, - DELETE, + &nsid(DELETE), json!({ "repo": repo_g }) ) .await @@ -3668,14 +3749,14 @@ async fn rename_alias_lifecycle() { "a renamed repo is deleted through its new rkey" ); assert_eq!( - resolve(&world, "gamma"), + resolve(&world, &RepoName::new("gamma").unwrap()), Resolved::Ready(None), "deletion drops the retained alias along with the repo" ); let ghost = RepoDid::new("did:web:ghost.nel.pet").unwrap(); assert_eq!( - rename_repo_as(&world, &world.member, &ghost, "kelp").await, + rename_repo_as(&world, &world.member, &ghost, &RepoRkey::new("kelp").unwrap()).await, StatusCode::NOT_FOUND, "renaming a repo this knot doesn't host is 404 instead of 403" ); @@ -3689,7 +3770,7 @@ async fn a_rename_against_a_warming_registry_is_unavailable_not_forbidden() { }); let (_dir, state) = build_state(responder, false); - let token = mint(&admin, RENAME); + let token = mint(&admin, &nsid(RENAME)); assert_eq!( into_response( crate::repos::rename_repo( @@ -3772,14 +3853,14 @@ async fn the_router_binds_each_route_to_its_matched_method_scope() { request }; - let matched = mint(&world.admin, ADD_MEMBER); + let matched = mint(&world.admin, &nsid(ADD_MEMBER)); assert_eq!( app.clone().oneshot(post(matched)).await.unwrap().status(), StatusCode::OK, "a token whose lxm is the route's own method authenticates" ); - let sibling = mint(&world.admin, REMOVE_MEMBER); + let sibling = mint(&world.admin, &nsid(REMOVE_MEMBER)); assert_eq!( app.oneshot(post(sibling)).await.unwrap().status(), StatusCode::UNAUTHORIZED, @@ -3991,7 +4072,7 @@ mod merge_endpoints { as_member( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", @@ -4056,7 +4137,7 @@ mod merge_endpoints { as_member( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", @@ -4117,7 +4198,7 @@ mod merge_endpoints { as_member( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", @@ -4162,7 +4243,7 @@ mod merge_endpoints { as_stranger( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) ) .await @@ -4175,7 +4256,7 @@ mod merge_endpoints { as_member( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) ) .await @@ -4190,12 +4271,12 @@ mod merge_endpoints { ); assert_eq!( - as_member(&world, crate::merge::merge, MERGE, json!({ "repo": repo_did, "branch": "driftwood", "patch": UNIFIED_PATCH, "commitMessage": "tide" })).await.status(), + as_member(&world, crate::merge::merge, &nsid(MERGE), json!({ "repo": repo_did, "branch": "driftwood", "patch": UNIFIED_PATCH, "commitMessage": "tide" })).await.status(), StatusCode::BAD_REQUEST, "merging into a branch the repo lacks is an invalid request" ); - let response = as_member(&world, crate::merge::merge, MERGE, json!({ "repo": repo_did, "branch": "main", "patch": CONFLICTING_PATCH, "commitMessage": "tide" })).await; + let response = as_member(&world, crate::merge::merge, &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", "patch": CONFLICTING_PATCH, "commitMessage": "tide" })).await; assert_eq!(response.status(), StatusCode::CONFLICT); let json = json_of(response).await; assert_eq!(json["error"], "MergeConflict"); @@ -4275,7 +4356,7 @@ mod merge_endpoints { as_member( &world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": "Periwinkle" }) ) .await @@ -4283,10 +4364,10 @@ mod merge_endpoints { StatusCode::OK ); assert!(matches!( - resolve(&world, "Periwinkle"), + resolve(&world, &RepoName::new("Periwinkle").unwrap()), Resolved::Ready(None) )); - let repo_did = match resolve(&world, "periwinkle") { + let repo_did = match resolve(&world, &RepoName::new("periwinkle").unwrap()) { Resolved::Ready(Some(did)) => did, other => panic!("Repo wasn't registered under its lowercased name: {other:?}"), }; @@ -4319,7 +4400,7 @@ mod merge_endpoints { as_member( &world, crate::merge::merge, - MERGE, + &nsid(MERGE), json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH, "commitMessage": "tide" }) ) .await @@ -4412,8 +4493,8 @@ mod fork_endpoints { OwnerDid::new(format!("did:web:{MEMBER_HOST}")).unwrap() } - fn source_url(rkey: &str) -> String { - format!("https://{KNOT_HOST}/did:web:{MEMBER_HOST}/{rkey}") + fn source_url(rkey: &RepoRkey) -> String { + format!("https://{KNOT_HOST}/did:web:{MEMBER_HOST}/{}", rkey.as_str()) } fn described_content(world: &World, did: &RepoDid) -> Option { @@ -4425,7 +4506,7 @@ mod fork_endpoints { as_member( world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": name, "source": source }) ) .await @@ -4442,15 +4523,15 @@ mod fork_endpoints { } } - fn tangled(did: &str) -> serde_json::Value { - json!({ "$type": knot_record::manifest::SOURCE_TANGLED, "did": did }) + fn tangled(did: &Did) -> serde_json::Value { + json!({ "$type": knot_record::manifest::SOURCE_TANGLED, "did": did.as_str() }) } fn external(uri: &str) -> serde_json::Value { json!({ "$type": knot_record::manifest::SOURCE_EXTERNAL, "uri": uri }) } - async fn fork_repo(world: &World, source: &str, pds: &str, name: &str) -> RepoDid { + async fn fork_repo(world: &World, source: &Did, pds: &KnotServiceUrl, name: &str) -> RepoDid { world.publish_plc_doc(source, pds); let did = create_repo_from(world, tangled(source), name).await; crate::imports::run_one(&world.state, &did).await; @@ -4504,7 +4585,7 @@ mod fork_endpoints { as_member( world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": "uni", "source": external(source) }), ) .await @@ -4521,7 +4602,7 @@ mod fork_endpoints { .await; let tip = tips[1]; - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; let job = read_job(&world, &did); assert_eq!( job.state, @@ -4561,7 +4642,7 @@ mod fork_endpoints { source.set_head(&master_ref()).unwrap(); let tip = advance(&source, &master_ref(), "reef.txt", "kelp forest\n", 1_000); - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; rewrite_sidecar(&world, &did, |job| { job["refs"]["head_symref"] = serde_json::Value::Null; }); @@ -4577,7 +4658,7 @@ mod fork_endpoints { let world = import_world(); let (source_did, _) = kelp_source(&world, &[("reef.txt", "kelp forest\n")]).await; let source = world.layout.open(&source_did).unwrap(); - let source_url = source_url("kelp"); + let source_url = source_url(&RepoRkey::new("kelp").unwrap()); let did = import_of(&world, &source_url).await; let old = world .layout @@ -4597,7 +4678,7 @@ mod fork_endpoints { as_member( &world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": "uni", "source": external(&source_url) }) ) .await @@ -4647,7 +4728,7 @@ mod fork_endpoints { ) .await; let new = tips[1]; - let source_url = source_url("kelp"); + let source_url = source_url(&RepoRkey::new("kelp").unwrap()); let did = import_of(&world, &source_url).await; rewrite_sidecar(&world, &did, |job| { @@ -4682,7 +4763,7 @@ mod fork_endpoints { async fn describe_repo_hides_the_import_reason_from_anonymous_callers() { let world = import_world(); let _ = kelp_source(&world, &[]).await; - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; // point the stored source at a host that never answers, so the fetch fails rewrite_sidecar(&world, &did, |job| { job["source"] = json!("https://barnacle.nel.pet/did:plc:nowhere/uni"); @@ -4713,7 +4794,7 @@ mod fork_endpoints { "an anonymous caller gets the stable reason, never the raw import error" ); - let owner_token = mint(&world.member, DESCRIBE_REPO); + let owner_token = mint(&world.member, &nsid(DESCRIBE_REPO)); let owner = described_repo(&world, bearer(&owner_token), &did).await; assert_eq!(owner["content"], json!("failed")); let raw_reason = owner["reason"] @@ -4730,7 +4811,7 @@ mod fork_endpoints { as_member( &world, crate::repos::create_repo, - CREATE, + &nsid(CREATE), json!({ "name": "uni", "source": external("http://kelp.example/uni") }) ) .await @@ -4743,7 +4824,7 @@ mod fork_endpoints { async fn queued_imports_are_recovered_without_scanning_git_internals() { let world = import_world(); let _ = kelp_source(&world, &[]).await; - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; let repo_path = world.state.layout.repo_path(&did).unwrap(); let nested = repo_path.join("objects/aa"); std::fs::create_dir_all(&nested).unwrap(); @@ -4770,7 +4851,7 @@ mod fork_endpoints { async fn a_failed_fetch_stays_on_the_record() { let world = import_world(); let _ = kelp_source(&world, &[]).await; - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; // point the stored source at a host that never answers, so the fetch fails rewrite_sidecar(&world, &did, |job| { job["source"] = json!("https://barnacle.nel.pet/did:plc:nowhere/uni"); @@ -4791,7 +4872,7 @@ mod fork_endpoints { async fn a_fetch_interrupted_by_a_restart_is_reported_not_resumed() { let world = import_world(); let _ = kelp_source(&world, &[]).await; - let did = import_of(&world, &source_url("kelp")).await; + let did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; rewrite_sidecar(&world, &did, |job| job["state"] = json!("fetching")); crate::imports::recover(&world.state); @@ -4829,7 +4910,7 @@ mod fork_endpoints { .await; let tip = tips[1]; - let imported_did = import_of(&world, &source_url("kelp")).await; + let imported_did = import_of(&world, &source_url(&RepoRkey::new("kelp").unwrap())).await; crate::imports::run_one(&world.state, &imported_did).await; let imported = world.layout.open(&imported_did).unwrap(); assert_eq!( @@ -4879,8 +4960,8 @@ mod fork_endpoints { }); let fork_did = fork_repo( &world, - source_did.as_str(), - &format!("https://{KNOT_HOST}"), + &Did::new_owned(source_did.as_str()).unwrap(), + &KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(), "uni", ) .await; @@ -4902,7 +4983,7 @@ mod fork_endpoints { world, crate::forks::fork_sync, actor, - FORK_SYNC, + &nsid(FORK_SYNC), json!({ "repo": fork_did, "branch": branch }), ) .await @@ -4918,7 +4999,7 @@ mod fork_endpoints { as_member( world, crate::forks::hidden_ref, - HIDDEN_REF, + &nsid(HIDDEN_REF), json!({ "repo": fork_did, "forkRef": fork_ref, @@ -4971,15 +5052,19 @@ mod fork_endpoints { #[tokio::test] async fn forking_a_source_this_knot_does_not_host_is_not_found() { const GHOST: &str = "did:plc:whelkwhelkwhelkwhelkwhel"; + let ghost = Did::new_owned(GHOST).unwrap(); let world = World::new(); world.grandfather(MEMBER_HOST); - world.publish_plc_doc(GHOST, &format!("https://{KNOT_HOST}")); + world.publish_plc_doc( + &ghost, + &KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(), + ); assert_eq!( as_member( &world, crate::repos::create_repo, - CREATE, - json!({ "name": "uni", "source": tangled(GHOST) }) + &nsid(CREATE), + json!({ "name": "uni", "source": tangled(&ghost) }) ) .await .status(), @@ -5047,8 +5132,8 @@ mod fork_endpoints { let fork_did = fork_repo( &world, - source_did.as_str(), - &format!("https://{KNOT_HOST}"), + &Did::new_owned(source_did.as_str()).unwrap(), + &KnotServiceUrl::new(format!("https://{KNOT_HOST}")).unwrap(), "Nautilus", ) .await; @@ -5266,9 +5351,15 @@ mod fork_endpoints { "a repo with no upstream still uses this knot's configured format" ); - let squid = "did:plc:squidsquidsquidsquidsqui"; - let remote = format!("https://barnacle.nel.pet/{squid}"); - let fork_did = fork_repo(&world, squid, "https://barnacle.nel.pet", "uni").await; + let squid = Did::new_owned("did:plc:squidsquidsquidsquidsqui").unwrap(); + let remote = format!("https://barnacle.nel.pet/{}", squid.as_str()); + let fork_did = fork_repo( + &world, + &squid, + &KnotServiceUrl::new("https://barnacle.nel.pet").unwrap(), + "uni", + ) + .await; let fork = world.layout.open(&fork_did).unwrap(); assert_eq!( fork.object_format(), @@ -5334,7 +5425,12 @@ mod legacy_admin_route { const SECRET: &str = "nekomilk2"; - async fn call(router: &axum::Router, user: &str, password: &str, subject: &str) -> StatusCode { + async fn call( + router: &axum::Router, + user: &str, + password: &str, + subject: &AccountDid, + ) -> StatusCode { call_at(router, ADD_MEMBER_ROUTE, user, password, subject).await } @@ -5343,7 +5439,7 @@ mod legacy_admin_route { route: &str, user: &str, password: &str, - subject: &str, + subject: &AccountDid, ) -> StatusCode { let encoded = base64::engine::general_purpose::STANDARD .encode(format!("{user}:{password}").as_bytes()); @@ -5366,7 +5462,7 @@ mod legacy_admin_route { Arc::clone(&world.state), LegacyAdminSecret::new(SECRET).unwrap(), )); - let subject = format!("did:web:{MEMBER_HOST}"); + let subject = account(MEMBER_HOST); let version = http::Request::builder() .method("GET") @@ -5390,14 +5486,25 @@ mod legacy_admin_route { .all(|status| *status == StatusCode::UNAUTHORIZED), "the knot refuses a wrong user or secret, got {refused:?}" ); - assert_eq!( - call( - &router, - "admin", - SECRET, - &"n".repeat(world.state.byte_limits.body.get() + 1) + let oversized = http::Request::builder() + .method("POST") + .uri(ADD_MEMBER_ROUTE) + .header( + AUTHORIZATION, + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("admin:{SECRET}").as_bytes()) + ), ) - .await, + .header(http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from( + json!({ "subject": "n".repeat(world.state.byte_limits.body.get() + 1) }) + .to_string(), + )) + .unwrap(); + assert_eq!( + router.clone().oneshot(oversized).await.unwrap().status(), StatusCode::PAYLOAD_TOO_LARGE ); assert_eq!( @@ -5413,20 +5520,20 @@ mod legacy_admin_route { assert_eq!( world.state.index.effective_member(&account(MEMBER_HOST)), Resolved::Ready(true), - "the legacy route adds a member outright, the same as the xrpc one" + "the legacy route adds a member outright, just like the xrpc route" ); let Resolved::Ready(members) = world.state.index.member_entries() else { - panic!("Member roll is warm in this test"); + panic!("The member grant list is warm in this test"); }; assert_eq!( members .iter() .find(|(subject, _)| *subject == account(MEMBER_HOST)) - .expect("Member is on roll") + .expect("Member is on the grant list") .1 .added_by, world.state.service_owner, - "the legacy route has no actor of its own, so `added_by` is the service owner" + "the legacy route doesn't take an actor, so `added_by` is the service owner" ); assert_eq!( @@ -5447,7 +5554,7 @@ mod legacy_admin_route { Arc::clone(&world.state), LegacyAdminSecret::new(SECRET).unwrap(), ); - let stranger = format!("did:web:{STRANGER_HOST}"); + let stranger = account(STRANGER_HOST); let blocked = || world.state.index.is_blocked(&account(STRANGER_HOST)); assert_eq!( @@ -5475,14 +5582,14 @@ mod legacy_admin_route { BAN_USER_ROUTE, "admin", SECRET, - &format!("did:web:{ADMIN_HOST}") + &account(ADMIN_HOST) ) .await, StatusCode::FORBIDDEN, "the secret doesn't lift the admin carve-out" ); - let member = format!("did:web:{MEMBER_HOST}"); + let member = account(MEMBER_HOST); assert_eq!( call_at(&router, TEMP_ADD_MEMBER_ROUTE, "admin", SECRET, &member).await, StatusCode::OK @@ -5564,58 +5671,58 @@ mod legacy_admin_route { } } -mod rolls { +mod grant_lists { use super::*; - const DENIED: &str = "you have no live collaboration on this repository"; + const DENIED: &str = "you don't have a live collaboration on this repository"; const GRACE: u64 = 21_600; #[derive(Debug, Clone, Copy)] - enum WhichRoll { + enum WhichGrantList { Knot, Repository, } - struct Seat { + struct Fixture { world: World, from: String, repo: Option, subject: AccountDid, } - impl Seat { - async fn opened(which: WhichRoll) -> Self { + impl Fixture { + async fn opened(which: WhichGrantList) -> Self { let world = World::new(); let repo = match which { - WhichRoll::Knot => None, - WhichRoll::Repository => { + WhichGrantList::Knot => None, + WhichGrantList::Repository => { world.grandfather(MEMBER_HOST); Some(create_repo_helper(&world, "nautilus").await) } }; let subject = account(STRANGER_HOST); world.seed(repo.as_ref(), STRANGER_HOST); - let seat = Self { + let fixture = Self { world, from: format!("{which:?}"), repo, subject, }; assert_eq!( - seat.on_roll(), + fixture.on_grant_list(), Resolved::Ready(true), - "{}: the fixture must open on the roll", - seat.from + "{}: the fixture must open on the grant list", + fixture.from ); - seat + fixture } fn repo(&self) -> Option<&RepoDid> { self.repo.as_ref() } - fn on_roll(&self) -> Resolved { - self.world.on_roll(self.repo(), &self.subject) + fn on_grant_list(&self) -> Resolved { + self.world.on_grant_list(self.repo(), &self.subject) } async fn allowed(&self) -> bool { @@ -5628,18 +5735,18 @@ mod rolls { } } - async fn every_roll(check: impl AsyncFn(Seat)) { - check(Seat::opened(WhichRoll::Knot).await).await; - check(Seat::opened(WhichRoll::Repository).await).await; + async fn every_grant_list(check: impl AsyncFn(Fixture)) { + check(Fixture::opened(WhichGrantList::Knot).await).await; + check(Fixture::opened(WhichGrantList::Repository).await).await; } #[tokio::test] async fn a_second_add_leaves_the_entry_and_the_admins_revocation_clears_it() { - every_roll(async |seated: Seat| { - let from = &seated.from; - let world = &seated.world; + every_grant_list(async |fixture: Fixture| { + let from = &fixture.from; + let world = &fixture.world; - let repeated = offer(world, seated.repo(), &seated.subject).await; + let repeated = offer(world, fixture.repo(), &fixture.subject).await; assert_eq!( repeated, StatusCode::OK, @@ -5647,15 +5754,15 @@ mod rolls { than appending another" ); assert_eq!( - seated.on_roll(), + fixture.on_grant_list(), Resolved::Ready(true), "{from}: a second add leaves the entry alone" ); - let body = subject_body(seated.repo(), &seated.subject); - let revoked = match seated.repo() { - None => as_admin(world, remove_member, REMOVE_MEMBER, body).await, - Some(_) => as_member(world, remove_collaborator, REMOVE_COLLAB, body).await, + let body = subject_body(fixture.repo(), &fixture.subject); + let revoked = match fixture.repo() { + None => as_admin(world, remove_member, &nsid(REMOVE_MEMBER), body).await, + Some(_) => as_member(world, remove_collaborator, &nsid(REMOVE_COLLAB), body).await, } .status(); assert_eq!( @@ -5664,7 +5771,7 @@ mod rolls { "{from}: a revocation is one of the two changes an acl consumer reads" ); assert_eq!( - seated.on_roll(), + fixture.on_grant_list(), Resolved::Ready(false), "{from}: the revocation has to leave a Removal behind" ); @@ -5673,19 +5780,19 @@ mod rolls { } #[tokio::test] - async fn the_roll_alone_decides_every_gated_call_and_never_reads_a_pds() { - every_roll(async |seated: Seat| { - let from = &seated.from; + async fn the_knot_authorizes_from_the_grant_list_and_never_a_pds() { + every_grant_list(async |fixture: Fixture| { + let from = &fixture.from; assert_eq!( - (seated.allowed().await, seated.world.record_reads()), + (fixture.allowed().await, fixture.world.record_reads()), (true, 0), - "{from}: the roll says this account is seated, and that is the whole answer" + "{from}: the knot authorizes from the grant list with zero pds reads" ); - seated.world.advance(GRACE * 2); + fixture.world.advance(GRACE * 2); assert_eq!( - (seated.allowed().await, seated.world.record_reads()), + (fixture.allowed().await, fixture.world.record_reads()), (true, 0), - "{from}: an entry never expires, so no amount of time sends the knot to a pds" + "{from}: an entry never expires, and the knot still hasn't read a pds" ); }) .await; @@ -5706,7 +5813,7 @@ mod rolls { } #[tokio::test] - async fn no_roll_change_ever_publishes_a_record() { + async fn an_offered_member_becomes_a_record_on_the_knot_account() { let world = World::new(); let state = world.state(); let (code, _) = state.invite_codes.mint(&*state.entropy, state.now()); @@ -5716,11 +5823,22 @@ mod rolls { ); assert_eq!(redeem(&world, &world.stranger, &code).await, StatusCode::OK); + let meta_path = world.state.meta_path.clone(); + let mut leaves = leaves_at(move || Repo::open(meta_path).unwrap()).await; + leaves.sort(); + assert_eq!( + leaves, + vec![ + format!("org.tangled.knot.member/{}", account(MEMBER_HOST).as_str()), + format!("org.tangled.knot.member/{}", world.stranger.did.as_str()), + ], + "both the offer and the redemption become member records on the knot account" + ); assert!( members_changes(&world) .iter() .all(|change| change.record.bytes().is_none()), - "the knot holds the list and nothing about it reaches atproto" + "the record is a projection, and the member change log doesn't store it" ); } @@ -5759,7 +5877,7 @@ mod rolls { knot_record::chain::materialize( &meta, &did, - &records, + &knot_record::chain::in_tree(records), &signer, &mut jacquard_common::types::tid::Ticker::new(), UnixSeconds::new(2_000), @@ -5782,20 +5900,35 @@ mod rolls { .events } + async fn leaves_at(open: impl FnOnce() -> Repo + Send + 'static) -> Vec { + tokio::task::spawn_blocking(move || { + futures::executor::block_on(knot_record::chain::leaves(&open())) + .unwrap() + .into_keys() + .map(|key| key.as_str().to_owned()) + .collect() + }) + .await + .unwrap() + } + #[tokio::test] - async fn a_roll_never_puts_a_chain_on_the_knots_own_repository() { + async fn every_member_of_the_grant_list_gets_a_record_on_the_knot_account() { let world = World::new(); world.seed(None, "limpet.nel.pet"); world.seed(None, "whelk.nel.pet"); - let meta = Repo::open(world.state.meta_path.clone()).unwrap(); - crate::materialize::enable_surfaces(&world.state); - assert!( - knot_record::chain::tip(&meta).unwrap().is_none(), - "the roll is the knot's own truth, and none of it is mirrored to atproto" - ); - assert!( - framed_events(&world).is_empty(), - "a roll write reaches no firehose consumer" + world.refresh(None); + let surfaces = world.state.clone(); + tokio::task::spawn_blocking(move || crate::materialize::enable_surfaces(&surfaces)) + .await + .unwrap(); + let meta_path = world.state.meta_path.clone(); + assert_eq!( + leaves_at(move || Repo::open(meta_path).unwrap()) + .await + .len(), + 2, + "each member of the grant list gets one record on the knot account" ); } @@ -5808,16 +5941,16 @@ mod rolls { let world = World::new(); let app = crate::router(Arc::clone(&world.state)); - let post = |nsid: &'static str, subject: &str| { + let post = |method: &'static str, subject: &AccountDid| { let mut request = http::Request::builder() .method("POST") - .uri(match nsid { + .uri(match method { ADD_MEMBER => crate::members::ADD_ROUTE, _ => crate::members::REMOVE_ROUTE, }) .header( http::header::AUTHORIZATION, - format!("Bearer {}", mint(&world.admin, nsid)), + format!("Bearer {}", mint(&world.admin, &nsid(method))), ) .body(Body::from( serde_json::to_vec(&json!({ "subject": subject })).unwrap(), @@ -5829,46 +5962,88 @@ mod rolls { app.clone().oneshot(request) }; - let holds = |world: &World, subject: &str| match world.state.index.member_entries() { + let holds = + |world: &World, subject: &AccountDid| match world.state.index.member_entries() { knot_index::Resolved::Ready(entries) => { - entries.iter().any(|(did, _)| did.as_str() == subject) + entries.iter().any(|(did, _)| did.as_str() == subject.as_str()) } knot_index::Resolved::Warming => panic!("The members projection is warming"), }; - let response = post(ADD_MEMBER, "did:plc:limpet").await.unwrap(); + let limpet = AccountDid::new("did:plc:limpet").unwrap(); + let response = post(ADD_MEMBER, &limpet).await.unwrap(); assert_eq!(response.status(), StatusCode::OK, "the healthy add lands"); - assert!(holds(&world, "did:plc:limpet")); + assert!(holds(&world, &limpet)); let meta = Repo::open(world.state.meta_path.clone()).unwrap(); let junk = meta.git().write_blob(b"not a commit").unwrap().detach(); - meta.update_ref(&knot_git::RefUpdate::Create { - name: knot_types::RefName::new(knot_record::chain::CHAIN_REF).unwrap(), + let chain_ref = knot_types::RefName::new(knot_record::chain::CHAIN_REF).unwrap(); + let healthy = knot_record::chain::tip(&meta).unwrap().unwrap().git; + meta.update_ref(&knot_git::RefUpdate::Update { + name: chain_ref, + old: healthy, new: knot_types::Oid::from(junk), }) .unwrap(); - let response = post(ADD_MEMBER, "did:plc:whelk").await.unwrap(); + let whelk = AccountDid::new("did:plc:whelk").unwrap(); + let response = post(ADD_MEMBER, &whelk).await.unwrap(); assert_eq!( response.status(), StatusCode::OK, "an add stands even when the chain is unreadable" ); assert!( - holds(&world, "did:plc:whelk"), + holds(&world, &whelk), "the projection refreshes even with a broken chain ref" ); - let response = post(REMOVE_MEMBER, "did:plc:whelk").await.unwrap(); + let response = post(REMOVE_MEMBER, &whelk).await.unwrap(); assert_eq!( response.status(), StatusCode::OK, "the removal stands even when the chain is unreadable" ); assert!( - !holds(&world, "did:plc:whelk"), + !holds(&world, &whelk), "the projection drops the removed member" ); } + #[tokio::test] + async fn a_legacy_repository_with_social_records_gets_its_chain_at_boot() { + let world = World::new(); + world.grandfather(MEMBER_HOST); + let repo = create_repo_helper(&world, "anemone").await; + let ticket = Ticket::on(&world, &repo); + let (uri, _version) = ticket.opened(&world.member, "kelp").await; + + let git = world.layout.open(&repo).unwrap(); + let chain_ref = knot_types::RefName::new(knot_record::chain::CHAIN_REF).unwrap(); + let healthy = knot_record::chain::tip(&git).unwrap().unwrap().git; + git.update_ref(&knot_git::RefUpdate::Delete { + name: chain_ref, + old: healthy, + }) + .unwrap(); + assert!( + knot_record::chain::tip(&git).unwrap().is_none(), + "the fixture now matches a repository from before chains existed" + ); + + let surfaces = world.state.clone(); + tokio::task::spawn_blocking(move || crate::materialize::enable_surfaces(&surfaces)) + .await + .unwrap(); + + let leaves = leaves_at(move || world.layout.open(&repo).unwrap()).await; + let record = uri.as_str().rsplit('/').next().unwrap().to_owned(); + assert!( + leaves + .iter() + .any(|key| key == &format!("{TICKET}/{record}")), + "boot materializes the repository's record chain, and the social record that \ + predates chains is on it, among {leaves:?}" + ); + } #[tokio::test] async fn a_frameless_tip_from_before_the_upgrade_gets_a_sync_frame_at_boot() { @@ -6078,7 +6253,15 @@ impl<'a> Contribution<'a> { actor: &Actor, record: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - create_as(self.world, actor, self.repo, COMMENT, None, record).await + create_as( + self.world, + actor, + self.repo, + &RecordCollection::new(COMMENT).unwrap(), + None, + record, + ) + .await } #[allow(clippy::too_many_arguments)] @@ -6106,7 +6289,7 @@ impl<'a> Contribution<'a> { self.world, actor, self.repo, - COMMENT, + &RecordCollection::new(COMMENT).unwrap(), rkey_of(uri), json!({ "swapRecord": from }), record, @@ -6125,7 +6308,7 @@ impl<'a> Contribution<'a> { self.world, actor, self.repo, - REACTION, + &RecordCollection::new(REACTION).unwrap(), None, json!({ "$type": REACTION, @@ -6140,7 +6323,7 @@ impl<'a> Contribution<'a> { async fn erase( &self, actor: &Actor, - collection: &str, + collection: &RecordCollection, uri: &AtUri, from: &crate::out::CidString, ) -> (StatusCode, serde_json::Value) { @@ -6215,7 +6398,7 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { .starts_with(&format!("at://{}/{COMMENT}/", repo.as_str())) ); assert_eq!( - records_in(&world, &repo, COMMENT).await, + records_in(&world, &repo, &RecordCollection::new(COMMENT).unwrap()).await, vec![comment_uri.as_str().to_owned()] ); let record = ok(record_at(&world, &repo, &comment_uri, None).await); @@ -6281,7 +6464,7 @@ async fn comment_opens_edits_and_deletes_along_its_version_chain() { assert!(denied["message"].as_str().unwrap().contains("author")); let deleted = ok(contribution - .erase(&world.member, COMMENT, &comment_uri, &v3) + .erase(&world.member, &RecordCollection::new(COMMENT).unwrap(), &comment_uri, &v3) .await); assert!( deleted.get("uri").is_none() && deleted.get("cid").is_none(), @@ -6347,7 +6530,7 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { let missing = at_of( &repo, knot_types::RecordCollection::new(TICKET).unwrap(), - "3lubrptx57d33", + &RecordRkey::new("3lubrptx57d33").unwrap(), ); let rejected = failed( contribution @@ -6375,8 +6558,8 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { let on_state = at_of( &repo, - knot_types::RecordCollection::new("com.example.gripe").unwrap(), - "3lubrptx57d44", + knot_types::RecordCollection::new("cafe.oyster.gripe").unwrap(), + &RecordRkey::new("3lubrptx57d44").unwrap(), ); let wrong_collection = failed( contribution @@ -6394,7 +6577,7 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { &world, &world.stranger, &repo, - LEGACY_COMMENT, + &RecordCollection::new(LEGACY_COMMENT).unwrap(), None, retype( comment_record(&issue.uri, ¤t, "old world"), @@ -6411,8 +6594,8 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { &world, &world.stranger, &repo, - COMMENT, - Some("3lubrptx57d55".to_owned()), + &RecordCollection::new(COMMENT).unwrap(), + Some(RecordRkey::new("3lubrptx57d55").unwrap()), comment_record(&issue.uri, ¤t, "mine"), ) .await, @@ -6452,9 +6635,8 @@ async fn comment_rejects_subjects_this_knot_doesnt_serve() { .await); } -fn at_of(repo: &RepoDid, collection: knot_types::RecordCollection, rkey: &str) -> AtUri { - knot_types::RecordAddress::new(collection, knot_types::RecordRkey::new(rkey).unwrap()) - .at_uri(repo) +fn at_of(repo: &RepoDid, collection: RecordCollection, rkey: &RecordRkey) -> AtUri { + knot_types::RecordAddress::new(collection, rkey.clone()).at_uri(repo) } #[tokio::test] @@ -6487,7 +6669,7 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { again["commit"]["rev"], liked["commit"]["rev"], "the second identical react should return the first commit" ); - assert_eq!(records_in(&world, &repo, REACTION).await.len(), 1); + assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 1); let rocket = ok(issue.react(&world.stranger, "🚀").await); let rocket_uri = uri_of(&rocket); @@ -6496,14 +6678,14 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { let members_uri = uri_of(&members); assert_ne!(members_uri, like_uri); assert_ne!(members_uri, rocket_uri); - assert_eq!(records_in(&world, &repo, REACTION).await.len(), 3); + assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 3); let put = failed( put_as( &world, &world.stranger, &repo, - REACTION, + &RecordCollection::new(REACTION).unwrap(), rkey_of(&like_uri), json!({ "swapRecord": like_v1 }), json!({ @@ -6518,7 +6700,7 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { message_contains(&put, &["never edited"]); let erased = ok(contribution - .erase(&world.member, REACTION, &like_uri, &like_v1) + .erase(&world.member, &RecordCollection::new(REACTION).unwrap(), &like_uri, &like_v1) .await); assert!(erased.get("uri").is_none()); let (status, gone) = record_at(&world, &repo, &like_uri, None).await; @@ -6534,7 +6716,12 @@ async fn reaction_answers_one_live_record_per_actor_subject_and_kind() { let denied = failed( contribution - .erase(&world.stranger, REACTION, &members_uri, &cid_of(&members)) + .erase( + &world.stranger, + &RecordCollection::new(REACTION).unwrap(), + &members_uri, + &cid_of(&members), + ) .await, StatusCode::FORBIDDEN, ); @@ -6563,7 +6750,7 @@ async fn reaction_opens_only_on_live_subjects() { let missing = at_of( &repo, knot_types::RecordCollection::new(TICKET).unwrap(), - "3lubrptx57d33", + &RecordRkey::new("3lubrptx57d33").unwrap(), ); let rejected = failed( contribution @@ -6609,10 +6796,10 @@ async fn comment_outlives_its_subject_on_record_reads() { "the comment must stay readable after its issue is deleted" ); assert_eq!( - records_in(&world, &repo, COMMENT).await, + records_in(&world, &repo, &RecordCollection::new(COMMENT).unwrap()).await, vec![comment_uri.as_str().to_owned()] ); - assert!(records_in(&world, &repo, TICKET).await.is_empty()); + assert!(records_in(&world, &repo, &RecordCollection::new(TICKET).unwrap()).await.is_empty()); assert_eq!( versions_page(&world, &comment_uri, None).await["versions"] .as_array() @@ -6797,7 +6984,7 @@ async fn reaction_accepts_any_live_record_as_subject() { .react(&world.member, &reaction_uri, &reaction_cid, "❤️") .await); - assert_eq!(records_in(&world, &repo, REACTION).await.len(), 3); + assert_eq!(records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 3); assert_eq!( ok(record_at(&world, &repo, &reaction_uri, None).await)["value"]["subject"]["uri"], drifter_uri.as_str(), @@ -6882,7 +7069,7 @@ async fn reaction_dedup_wont_answer_when_policy_rejects() { let again = ok(issue.react(&world.stranger, "❤️").await); assert_eq!(again["uri"], like_uri.as_str()); assert_eq!( - records_in(&world, &repo, REACTION).await.len(), + records_in(&world, &repo, &RecordCollection::new(REACTION).unwrap()).await.len(), 1, "the rejected second react shouldn't create a duplicate" ); @@ -6913,8 +7100,8 @@ async fn define_label( world, &world.member, repo, - LABEL_DEF, - Some(slug.to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new(slug).unwrap()), def_body(name, color), ) .await); @@ -6934,8 +7121,8 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.stranger, &repo, - LABEL_DEF, - Some("wontfix".to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new("wontfix").unwrap()), def_body("wontfix", Some("#79ffe1")), ) .await, @@ -6948,8 +7135,8 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.admin, &repo, - LABEL_DEF, - Some("wontfix".to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new("wontfix").unwrap()), def_body("wontfix", Some("#79ffe1")), ) .await, @@ -6965,7 +7152,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.member, &repo, - LABEL_DEF, + &RecordCollection::new(LABEL_DEF).unwrap(), None, def_body("wontfix", Some("#79ffe1")), ) @@ -6981,7 +7168,7 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { "the def's record key is the slug the owner sent" ); assert_eq!( - records_in(&world, &repo, LABEL_DEF).await, + records_in(&world, &repo, &RecordCollection::new(LABEL_DEF).unwrap()).await, vec![uri.as_str().to_owned()] ); let served = ok(record_at(&world, &repo, &uri, None).await); @@ -6994,8 +7181,8 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.member, &repo, - LABEL_DEF, - Some("wontfix".to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new("wontfix").unwrap()), def_body("wontfix2", Some("#79ffe1")), ) .await, @@ -7007,8 +7194,8 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.member, &repo, - LABEL_DEF, - "wontfix".to_owned(), + &RecordCollection::new(LABEL_DEF).unwrap(), + RecordRkey::new("wontfix").unwrap(), json!({ "swapRecord": cid }), def_body("never_happening", Some("#ff0000")), ) @@ -7022,8 +7209,8 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.stranger, &repo, - LABEL_DEF, - "wontfix".to_owned(), + &RecordCollection::new(LABEL_DEF).unwrap(), + RecordRkey::new("wontfix").unwrap(), json!({ "swapRecord": cid_of(&edited) }), def_body("taken_over", Some("#79ffe1")), ) @@ -7039,13 +7226,13 @@ async fn label_defs_belong_to_the_owner_and_go_by_their_slug() { &world, &world.member, &repo, - LABEL_DEF, - "wontfix".to_owned(), + &RecordCollection::new(LABEL_DEF).unwrap(), + RecordRkey::new("wontfix").unwrap(), json!({ "swapRecord": cid_of(&edited) }), ) .await); assert_eq!( - records_in(&world, &repo, LABEL_DEF).await, + records_in(&world, &repo, &RecordCollection::new(LABEL_DEF).unwrap()).await, Vec::::new(), "the erased def stops serving, the way every social record does" ); @@ -7059,8 +7246,8 @@ async fn erased_def_slug_stays_retired() { &world, &world.member, &repo, - LABEL_DEF, - "wontfix".to_owned(), + &RecordCollection::new(LABEL_DEF).unwrap(), + RecordRkey::new("wontfix").unwrap(), json!({ "swapRecord": cid }), ) .await); @@ -7069,8 +7256,8 @@ async fn erased_def_slug_stays_retired() { &world, &world.member, &repo, - LABEL_DEF, - Some("wontfix".to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new("wontfix").unwrap()), def_body("wontfix", Some("#79ffe1")), ) .await, @@ -7078,7 +7265,7 @@ async fn erased_def_slug_stays_retired() { ); message_contains(&refused, &["was erased at", "slug stays retired"]); assert_eq!( - records_in(&world, &repo, LABEL_DEF).await, + records_in(&world, &repo, &RecordCollection::new(LABEL_DEF).unwrap()).await, Vec::::new(), "the erased def doesn't serve, and the slug stays retired: one record address is \ one object for life" @@ -7115,8 +7302,8 @@ async fn tickets_cite_label_defs_the_repository_actually_serves() { &world, &world.member, &repo, - LABEL_DEF, - "wontfix".to_owned(), + &RecordCollection::new(LABEL_DEF).unwrap(), + RecordRkey::new("wontfix").unwrap(), json!({ "swapRecord": def_cid }), ) .await); @@ -7136,8 +7323,8 @@ async fn a_stranger_probing_a_taken_slug_hears_ownership_first() { &world, &world.stranger, &repo, - LABEL_DEF, - Some("wontfix".to_owned()), + &RecordCollection::new(LABEL_DEF).unwrap(), + Some(RecordRkey::new("wontfix").unwrap()), def_body("wontfix", Some("#79ffe1")), ) .await; @@ -7276,7 +7463,15 @@ mod pulls { actor: &Actor, record: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - create_as(self.world, actor, self.repo, PULL, None, record).await + create_as( + self.world, + actor, + self.repo, + &RecordCollection::new(PULL).unwrap(), + None, + record, + ) + .await } async fn opened(&self, actor: &Actor, record: serde_json::Value) -> AtUri { @@ -7290,23 +7485,27 @@ mod pulls { ) } - async fn merge(&self, actor: &Actor, rkey: &str) -> (StatusCode, serde_json::Value) { + async fn merge( + &self, + actor: &Actor, + rkey: &RecordRkey, + ) -> (StatusCode, serde_json::Value) { on_repo( self.world, crate::social::merge_pull, actor, - "sh.tangled.repo.pull.merge", + &nsid("sh.tangled.repo.pull.merge"), self.repo, json!({ "repo": self.repo, "rkey": rkey }), ) .await } - async fn merged(&self, rkey: &str) -> serde_json::Value { + async fn merged(&self, rkey: &RecordRkey) -> serde_json::Value { ok(self.merge(&self.world.member, rkey).await) } - async fn refused_merge(&self, rkey: &str) -> serde_json::Value { + async fn refused_merge(&self, rkey: &RecordRkey) -> serde_json::Value { failed( self.merge(&self.world.member, rkey).await, StatusCode::CONFLICT, @@ -7360,7 +7559,7 @@ mod pulls { ); let address = knot_types::RecordAddress::new( knot_types::RecordCollection::new(PULL).unwrap(), - knot_types::RecordRkey::new(rkey_of(&uri)).unwrap(), + rkey_of(&uri), ); for pinned in [base, head] { let keep = crate::git::keep_ref(repo.as_str(), &address, pinned).unwrap(); @@ -7411,7 +7610,7 @@ mod pulls { let (world, repo, base, head, ticket, uri, cid) = kelp_patch().await; let pulls = Pulls::on(&world, &repo); assert_eq!( - rkey_of(&uri), + rkey_of(&uri).as_str(), cid.to_string(), "the patch's record key is the CID of its own record" ); @@ -7422,7 +7621,7 @@ mod pulls { &world, &world.stranger, &repo, - PULL, + &RecordCollection::new(PULL).unwrap(), rkey_of(&uri), json!({ "swapRecord": cid }), pulls.record(&ticket, base, again), @@ -7442,7 +7641,7 @@ mod pulls { "resubmission is a new patch record, not an edit" ); assert_eq!( - rkey_of(&resubmitted_uri), + rkey_of(&resubmitted_uri).as_str(), resubmitted_cid.to_string(), "the new patch is keyed by its own CID too" ); diff --git a/knot2/crates/knot-xrpc/tests/atproto.rs b/knot2/crates/knot-xrpc/tests/atproto.rs index b909ab55d..f539f89a0 100644 --- a/knot2/crates/knot-xrpc/tests/atproto.rs +++ b/knot2/crates/knot-xrpc/tests/atproto.rs @@ -7,7 +7,7 @@ use std::time::Duration; use axum::body::Body; use cid::Cid; use common::{ - FIXTURE_COLLECTION as COLLECTION, KNOT_HOST, OWNER, World, percent_encode, post_authed, + World, fixture_collection, knot_host, nsid, owner, percent_encode, post_authed, }; use futures::StreamExt; use http::{HeaderValue, StatusCode, header}; @@ -15,11 +15,15 @@ use jacquard_repo::Mst; use jacquard_repo::car::parse_car_bytes; use jacquard_repo::storage::MemoryBlockStore; use knot_git::Repo; +use knot_types::{DidRkey, RecordRkey, RepoDid, RepoName, UnixSeconds}; use tower::ServiceExt; -const KNOT_DID: &str = "did:web:knot.nel.pet"; const FIVE_SECONDS: Duration = Duration::from_secs(5); +fn knot_repo(world: &World) -> RepoDid { + RepoDid::new(world.state.knot_did.as_str()).unwrap() +} + async fn get(world: &World, path_and_query: &str) -> (StatusCode, HeaderValue, Vec) { let request = http::Request::builder() .method("GET") @@ -61,8 +65,14 @@ async fn error_of(world: &World, path_and_query: &str) -> (StatusCode, serde_jso fn published_world() -> World { let world = World::new(); - world.publish_meta(&["did:plc:limpet"], 1_000); - world.publish_meta(&["did:plc:limpet", "did:plc:whelk"], 1_100); + world.publish_meta(&[DidRkey::new("did:plc:limpet").unwrap()], UnixSeconds::new(1_000)); + world.publish_meta( + &[ + DidRkey::new("did:plc:limpet").unwrap(), + DidRkey::new("did:plc:whelk").unwrap(), + ], + UnixSeconds::new(1_100), + ); world } @@ -144,11 +154,13 @@ async fn drain_binaries(ws: &mut Ws) -> Vec> { #[tokio::test] async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { let world = published_world(); - let key = format!("{COLLECTION}/did:plc:limpet"); + let knot = knot_repo(&world); + let collection = fixture_collection(); + let key = format!("{collection}/did:plc:limpet"); let (status, content_type, body) = get( &world, &format!( - "/xrpc/com.atproto.sync.getRecord?did={KNOT_DID}&collection={COLLECTION}&rkey=did%3Aplc%3Alimpet" + "/xrpc/com.atproto.sync.getRecord?did={knot}&collection={collection}&rkey=did%3Aplc%3Alimpet" ), ) .await; @@ -156,7 +168,7 @@ async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { assert_eq!(content_type, "application/vnd.ipld.car"); let latest = get_json( &world, - &format!("/xrpc/com.atproto.sync.getLatestCommit?did={KNOT_DID}"), + &format!("/xrpc/com.atproto.sync.getLatestCommit?did={knot}"), ) .await; let head: Cid = latest["cid"].as_str().unwrap().parse().unwrap(); @@ -180,23 +192,23 @@ async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { let record: serde_json::Value = serde_ipld_dagcbor::from_slice(blocks.get(&found).unwrap()).unwrap(); assert_eq!(record["createdAt"], "1970-01-01T00:18:20Z"); - assert_eq!(record["x-tngl-editor"], OWNER); + assert_eq!(record["x-tngl-editor"], owner().as_str()); assert_eq!( - record["$type"], COLLECTION, + record["$type"], collection.as_str(), "the stored bytes carry their own $type, so a cold car reader can type the record" ); let (_status, _type, absent) = get( &world, &format!( - "/xrpc/com.atproto.sync.getRecord?did={KNOT_DID}&collection={COLLECTION}&rkey=did%3Aplc%3Aabsent" + "/xrpc/com.atproto.sync.getRecord?did={knot}&collection={collection}&rkey=did%3Aplc%3Aabsent" ), ) .await; assert!( cold_loaded_tree(&absent) .await - .get(&format!("{COLLECTION}/did:plc:absent")) + .get(&format!("{collection}/did:plc:absent")) .await .unwrap() .is_none(), @@ -205,7 +217,7 @@ async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { let (_, error) = error_of( &world, &format!( - "/xrpc/com.atproto.repo.getRecord?repo={KNOT_DID}&collection={COLLECTION}&rkey=did%3Aplc%3Aabsent" + "/xrpc/com.atproto.repo.getRecord?repo={knot}&collection={collection}&rkey=did%3Aplc%3Aabsent" ), ) .await; @@ -213,27 +225,27 @@ async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { let described = get_json( &world, - &format!("/xrpc/com.atproto.repo.describeRepo?repo={KNOT_DID}"), + &format!("/xrpc/com.atproto.repo.describeRepo?repo={knot}"), ) .await; assert_eq!( - described["handle"], KNOT_HOST, + described["handle"], knot_host().as_str(), "the knot's own handle is its hostname" ); assert!( described["didDoc"].is_object(), "the knot's own repo carries a did doc" ); - assert_eq!(described["collections"], serde_json::json!([COLLECTION])); + assert_eq!(described["collections"], serde_json::json!([&collection])); let described = get_json(&world, "/xrpc/com.atproto.server.describeServer").await; - assert_eq!(described["did"], KNOT_DID); + assert_eq!(described["did"], knot.as_str()); let resolved = get_json( &world, - &format!("/xrpc/com.atproto.identity.resolveHandle?handle={KNOT_HOST}"), + &format!("/xrpc/com.atproto.identity.resolveHandle?handle={}", knot_host()), ) .await; - assert_eq!(resolved["did"], KNOT_DID); + assert_eq!(resolved["did"], knot.as_str()); let (_, error) = error_of( &world, "/xrpc/com.atproto.identity.resolveHandle?handle=stranger.example", @@ -245,16 +257,17 @@ async fn an_unmodified_cold_client_verifies_a_record_off_the_proof_car() { #[tokio::test] async fn get_repo_serves_the_whole_tree_and_get_blocks_serves_named_cids() { let world = published_world(); + let knot = knot_repo(&world); let latest = get_json( &world, - &format!("/xrpc/com.atproto.sync.getLatestCommit?did={KNOT_DID}"), + &format!("/xrpc/com.atproto.sync.getLatestCommit?did={knot}"), ) .await; let head: Cid = latest["cid"].as_str().unwrap().parse().unwrap(); let (status, _content_type, body) = get( &world, - &format!("/xrpc/com.atproto.sync.getRepo?did={KNOT_DID}"), + &format!("/xrpc/com.atproto.sync.getRepo?did={knot}"), ) .await; assert_eq!(status, StatusCode::OK); @@ -263,12 +276,12 @@ async fn get_repo_serves_the_whole_tree_and_get_blocks_serves_named_cids() { assert_eq!( cold_loaded_tree(&body).await.leaves().await.unwrap().len(), 2, - "Whole roll exports" + "the whole grant list exports" ); for wanted in [head, "bafkqaaa".parse::().unwrap()] { let (status, _content_type, body) = get( &world, - &format!("/xrpc/com.atproto.sync.getBlocks?did={KNOT_DID}&cids={wanted}"), + &format!("/xrpc/com.atproto.sync.getBlocks?did={knot}&cids={wanted}"), ) .await; assert_eq!(status, StatusCode::OK); @@ -285,15 +298,17 @@ async fn get_repo_serves_the_whole_tree_and_get_blocks_serves_named_cids() { #[tokio::test] async fn a_removal_takes_the_record_off_the_surface() { let world = published_world(); - world.publish_meta(&["did:plc:whelk"], 1_200); + let knot = knot_repo(&world); + let collection = fixture_collection(); + world.publish_meta(&[DidRkey::new("did:plc:whelk").unwrap()], UnixSeconds::new(1_200)); let listed = get_json( &world, - &format!("/xrpc/com.atproto.repo.listRecords?repo={KNOT_DID}&collection={COLLECTION}"), + &format!("/xrpc/com.atproto.repo.listRecords?repo={knot}&collection={collection}"), ) .await; assert_eq!( listed["records"][0]["uri"], - format!("at://{KNOT_DID}/{COLLECTION}/did:plc:whelk"), + format!("at://{knot}/{collection}/did:plc:whelk"), "the record the sync left out is gone, and whelk is the only row left" ); } @@ -301,24 +316,25 @@ async fn a_removal_takes_the_record_off_the_surface() { #[tokio::test] async fn hosted_repositories_read_over_com_atproto_until_a_chain_exists() { let world = World::new(); - let (repo, bare, _kept) = common::empty_repo(&world, "squid"); + let collection = fixture_collection(); + let (repo, bare, _kept) = common::empty_repo(&world, &RepoName::new("squid").unwrap()); world.publish( &repo, std::path::Path::new(&bare), - &["did:plc:limpet"], - 1_000, + &[RecordRkey::new("did:plc:limpet").unwrap()], + UnixSeconds::new(1_000), ); let listed = get_json( &world, &format!( - "/xrpc/com.atproto.repo.listRecords?repo={}&collection={COLLECTION}", + "/xrpc/com.atproto.repo.listRecords?repo={}&collection={collection}", percent_encode(repo.as_str()) ), ) .await; assert_eq!( listed["records"][0]["uri"], - format!("at://{}/{COLLECTION}/did:plc:limpet", repo.as_str()), + format!("at://{}/{collection}/did:plc:limpet", repo.as_str()), "a repo with a chain lists its own records" ); let repos = get_json(&world, "/xrpc/com.atproto.sync.listRepos").await; @@ -331,7 +347,7 @@ async fn hosted_repositories_read_over_com_atproto_until_a_chain_exists() { "a repo with a chain is listed" ); - let (lonely, _name, _kept) = common::empty_repo(&world, "lonely"); + let (lonely, _name, _kept) = common::empty_repo(&world, &RepoName::new("lonely").unwrap()); let did = percent_encode(lonely.as_str()); let (_, error) = error_of( &world, @@ -363,6 +379,7 @@ async fn hosted_repositories_read_over_com_atproto_until_a_chain_exists() { #[tokio::test] async fn an_evicted_cursor_answers_an_out_of_cursor_info_frame() { let world = published_world(); + let knot = knot_repo(&world); let addr = common::serve(&world).await; let (mut ws, _response) = tokio_tungstenite::connect_async(format!( "ws://{addr}/xrpc/com.atproto.sync.subscribeRepos?cursor=0" @@ -377,18 +394,20 @@ async fn an_evicted_cursor_answers_an_out_of_cursor_info_frame() { header["t"], "#commit", "a zero cursor against a window that lost nothing replays without an info frame" ); - assert_eq!(payload.repo, KNOT_DID); + assert_eq!(payload.repo, knot.as_str()); assert!(!payload.rebase); assert!(payload.seq > 0); let second = next_binary(&mut ws).await; let (header, _rest) = frame_header(&second); assert_eq!(header["t"], "#commit", "both records reach the stream"); - let mut rkeys = vec!["did:plc:limpet".to_owned(), "did:plc:whelk".to_owned()]; + let mut rkeys = vec![ + DidRkey::new("did:plc:limpet").unwrap(), + DidRkey::new("did:plc:whelk").unwrap(), + ]; (0..10).for_each(|n| { - rkeys.push(format!("did:plc:subject{n}")); - let view: Vec<&str> = rkeys.iter().map(String::as_str).collect(); - world.publish_meta(&view, 2_000 + n); + rkeys.push(DidRkey::new(format!("did:plc:subject{n}")).unwrap()); + world.publish_meta(&rkeys, UnixSeconds::new(2_000 + n)); }); let (mut ws, _response) = tokio_tungstenite::connect_async(format!( "ws://{addr}/xrpc/com.atproto.sync.subscribeRepos?cursor=0" @@ -419,7 +438,7 @@ async fn an_evicted_cursor_answers_an_out_of_cursor_info_frame() { #[tokio::test] async fn account_creation_announces_identity_account_and_an_empty_commit() { let world = World::administered(); - world.add_member(OWNER, OWNER, 1_000); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); let addr = common::serve(&world).await; let (mut ws, _response) = tokio_tungstenite::connect_async(format!( "ws://{addr}/xrpc/com.atproto.sync.subscribeRepos" @@ -428,8 +447,8 @@ async fn account_creation_announces_identity_account_and_an_empty_commit() { .unwrap(); let (status, body) = post_authed( &world, - "/xrpc/org.tangled.repo.createRepo", - OWNER, + &nsid("org.tangled.repo.createRepo"), + &owner(), serde_json::json!({ "name": "limpet" }), ) .await; @@ -503,8 +522,8 @@ async fn account_creation_announces_identity_account_and_an_empty_commit() { let (status, body) = post_authed( &world, - "/xrpc/sh.tangled.repo.delete", - OWNER, + &nsid("sh.tangled.repo.delete"), + &owner(), serde_json::json!({ "repo": repo_did, "force": true }), ) .await; @@ -579,19 +598,21 @@ async fn refusal(world: &World, query: String) { #[tokio::test] async fn malformed_collections_rkeys_and_cid_lists_answer_invalid_request() { let world = published_world(); + let knot = knot_repo(&world); + let collection = fixture_collection(); [ format!( - "/xrpc/com.atproto.sync.getRecord?did={KNOT_DID}&collection=not%20an%20nsid&rkey=did%3Aplc%3Alimpet" + "/xrpc/com.atproto.sync.getRecord?did={knot}&collection=not%20an%20nsid&rkey=did%3Aplc%3Alimpet" ), format!( - "/xrpc/com.atproto.sync.getRecord?did={KNOT_DID}&collection={COLLECTION}&rkey=has%20spaces" + "/xrpc/com.atproto.sync.getRecord?did={knot}&collection={collection}&rkey=has%20spaces" ), format!( - "/xrpc/com.atproto.repo.getRecord?repo={KNOT_DID}&collection=all+wrong&rkey=did%3Aplc%3Alimpet" + "/xrpc/com.atproto.repo.getRecord?repo={knot}&collection=all+wrong&rkey=did%3Aplc%3Alimpet" ), - format!("/xrpc/com.atproto.repo.listRecords?repo={KNOT_DID}&collection=also+wrong"), + format!("/xrpc/com.atproto.repo.listRecords?repo={knot}&collection=also+wrong"), format!( - "/xrpc/com.atproto.sync.getBlocks?did={KNOT_DID}&{}", + "/xrpc/com.atproto.sync.getBlocks?did={knot}&{}", (0..1001).map(|n| format!("cids=bafkqaa{n}")).collect::>().join("&") ), ] diff --git a/knot2/crates/knot-xrpc/tests/atproto_interop.rs b/knot2/crates/knot-xrpc/tests/atproto_interop.rs index 5277add84..0676429a4 100644 --- a/knot2/crates/knot-xrpc/tests/atproto_interop.rs +++ b/knot2/crates/knot-xrpc/tests/atproto_interop.rs @@ -1,9 +1,10 @@ mod common; use common::{ - FIXTURE_COLLECTION as COLLECTION, OWNER, World, commit_file, empty_repo, enable_chain, - http_push, seeded_feature_branch, serve_pushes, sh_git, + World, commit_file, empty_repo, enable_chain, fixture_collection, http_push, knot_host, nsid, + owner, seeded_feature_branch, serve_pushes, sh_git, }; +use knot_types::{AtUri, Did, DidRkey, RecordRkey, RepoName, UnixSeconds}; const KNOT_DID: &str = "did:web:knot.nel.pet"; const RKEY: &str = "did:plc:limpet"; @@ -33,8 +34,14 @@ async fn go_test(run: &str, env: &[(&str, String)]) { #[tokio::test] async fn indigo_client_reads_and_verifies_whole_knot() { let world = World::new(); - world.publish_meta(&[RKEY], 1_000); - world.publish_meta(&[RKEY, "did:plc:whelk"], 1_100); + world.publish_meta(&[DidRkey::new(RKEY).unwrap()], UnixSeconds::new(1_000)); + world.publish_meta( + &[ + DidRkey::new(RKEY).unwrap(), + DidRkey::new("did:plc:whelk").unwrap(), + ], + UnixSeconds::new(1_100), + ); let addr = common::serve(&world).await; @@ -43,8 +50,8 @@ async fn indigo_client_reads_and_verifies_whole_knot() { &[ ("KNOT_TEST_BASE_URL", format!("http://{addr}")), ("KNOT_TEST_DID", KNOT_DID.to_owned()), - ("KNOT_TEST_HANDLE", common::KNOT_HOST.to_owned()), - ("KNOT_TEST_COLLECTION", COLLECTION.to_owned()), + ("KNOT_TEST_HANDLE", knot_host().to_string()), + ("KNOT_TEST_COLLECTION", fixture_collection().to_string()), ("KNOT_TEST_RKEY", RKEY.to_owned()), ], ) @@ -54,7 +61,7 @@ async fn indigo_client_reads_and_verifies_whole_knot() { #[tokio::test] async fn an_indigo_client_consumes_the_firehose() { let world = World::administered(); - world.add_member(common::OWNER, common::OWNER, 1_000); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); let addr = common::serve(&world).await; go_test( @@ -63,11 +70,11 @@ async fn an_indigo_client_consumes_the_firehose() { ("KNOT_TEST_BASE_URL", format!("http://{addr}")), ( "KNOT_TEST_CREATE_TOKEN", - common::service_jwt("org.tangled.repo.createRepo", common::OWNER), + common::service_jwt(&nsid("org.tangled.repo.createRepo"), &owner()), ), ( "KNOT_TEST_DELETE_TOKEN", - common::service_jwt("sh.tangled.repo.delete", common::OWNER), + common::service_jwt(&nsid("sh.tangled.repo.delete"), &owner()), ), ], ) @@ -77,7 +84,7 @@ async fn an_indigo_client_consumes_the_firehose() { #[tokio::test] async fn a_knotfeed_consumer_reads_the_ref_records() { let world = World::new(); - let (did, _bare, work) = empty_repo(&world, "conch"); + let (did, _bare, work) = empty_repo(&world, &RepoName::new("conch").unwrap()); commit_file( work.path(), "README.md", @@ -86,7 +93,7 @@ async fn a_knotfeed_consumer_reads_the_ref_records() { "2026-06-01T12:30:00+02:00", ); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &did, OWNER, &["-o", "skip-ci", "main"]).await; + http_push(work.path(), addr, &did, &owner(), &["-o", "skip-ci", "main"]).await; let sha = sh_git(work.path(), &["rev-parse", "HEAD"]); go_test( @@ -94,7 +101,7 @@ async fn a_knotfeed_consumer_reads_the_ref_records() { &[ ("KNOT_TEST_ADDR", format!("127.0.0.1:{}", addr.port())), ("KNOT_TEST_REPO_DID", did.as_str().to_owned()), - ("KNOT_TEST_EDITOR", OWNER.to_owned()), + ("KNOT_TEST_EDITOR", owner().to_string()), ("KNOT_TEST_SHA", sha.trim().to_owned()), ], ) @@ -104,7 +111,7 @@ async fn a_knotfeed_consumer_reads_the_ref_records() { #[tokio::test] async fn indigo_client_reads_tickets_and_patches_in_the_org_tangled_shapes() { let world = World::new(); - let (did, base, head) = seeded_feature_branch(&world, "conch"); + let (did, base, head) = seeded_feature_branch(&world, &RepoName::new("conch").unwrap()); enable_chain(&world, &did); let (ticket_rkey, ticket_cid) = common::create_ticket(&world, &did, "kelp", "a forest").await; @@ -118,11 +125,11 @@ async fn indigo_client_reads_tickets_and_patches_in_the_org_tangled_shapes() { ("KNOT_TEST_BASE_URL", format!("http://{addr}")), ("KNOT_TEST_REPO_DID", did.as_str().to_owned()), ("KNOT_TEST_TICKET_RKEY", ticket_rkey.to_string()), - ("KNOT_TEST_TICKET_CID", ticket_cid), + ("KNOT_TEST_TICKET_CID", ticket_cid.cid().to_string()), ("KNOT_TEST_TICKET_TITLE", "kelp".to_owned()), - ("KNOT_TEST_EDITOR", OWNER.to_owned()), + ("KNOT_TEST_EDITOR", owner().to_string()), ("KNOT_TEST_PATCH_RKEY", patch_rkey.to_string()), - ("KNOT_TEST_PATCH_CID", patch_cid), + ("KNOT_TEST_PATCH_CID", patch_cid.cid().to_string()), ], ) .await; @@ -131,15 +138,21 @@ async fn indigo_client_reads_tickets_and_patches_in_the_org_tangled_shapes() { #[tokio::test] async fn indigo_client_resolves_bobs_issue_and_matches_his_attestation_from_the_local_plc() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "conch"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("conch").unwrap()); enable_chain(&world, &did); let (ticket_rkey, ticket_cid) = common::create_ticket(&world, &did, "kelp", "a forest").await; - let ticket_uri = format!("at://{did}/{}/{}", common::TICKET_COLLECTION, ticket_rkey); + let ticket_uri = format!("at://{did}/{}/{}", common::ticket_collection(), ticket_rkey); let addr = common::serve(&world).await; let plc_addr = common::serve_local_plc(&world).await; - let pds_addr = common::serve_bob_pds(BOB_DID, ATTESTATION_RKEY, &ticket_uri, &ticket_cid).await; + let pds_addr = common::serve_bob_pds( + &Did::new_owned(BOB_DID).unwrap(), + &RecordRkey::new(ATTESTATION_RKEY).unwrap(), + &AtUri::new_owned(ticket_uri).unwrap(), + &ticket_cid, + ) + .await; go_test( "TestIndigoClientResolvesBobsIssueAndMatchesHisAttestation", @@ -149,8 +162,8 @@ async fn indigo_client_resolves_bobs_issue_and_matches_his_attestation_from_the_ ("KNOT_TEST_PDS_URL", format!("http://{pds_addr}")), ("KNOT_TEST_REPO_DID", did.as_str().to_owned()), ("KNOT_TEST_TICKET_RKEY", ticket_rkey.to_string()), - ("KNOT_TEST_TICKET_CID", ticket_cid), - ("KNOT_TEST_EDITOR", OWNER.to_owned()), + ("KNOT_TEST_TICKET_CID", ticket_cid.cid().to_string()), + ("KNOT_TEST_EDITOR", owner().to_string()), ("KNOT_TEST_BOB_DID", BOB_DID.to_owned()), ], ) diff --git a/knot2/crates/knot-xrpc/tests/blob_properties.rs b/knot2/crates/knot-xrpc/tests/blob_properties.rs index ca1070309..a0458da64 100644 --- a/knot2/crates/knot-xrpc/tests/blob_properties.rs +++ b/knot2/crates/knot-xrpc/tests/blob_properties.rs @@ -3,11 +3,11 @@ mod common; use std::collections::BTreeMap; use axum::body::Body; -use common::{OWNER, World, empty_repo, enable_chain, get, post_as, service_jwt_for}; +use common::{World, empty_repo, enable_chain, get, nsid, owner, post_as, service_jwt_for}; use futures::{StreamExt, TryStreamExt}; use http::{Request, StatusCode, header}; use jacquard_common::types::tid::Tid; -use knot_types::{BlobCid, RecordRkey, RepoDid, Sha256Digest}; +use knot_types::{BlobCid, RecordRkey, RepoDid, RepoName, ServiceDid, Sha256Digest}; use proptest::prelude::*; use serde_json::json; use tower::ServiceExt; @@ -107,9 +107,9 @@ impl Model { } } - fn ticket_json(&self, repo: &str, title: &str, token: u16) -> serde_json::Value { + fn ticket_json(&self, repo: &RepoDid, title: &str, token: u16) -> serde_json::Value { json!({ - "$type": "org.tangled.track.ticket", + "$type": common::ticket_collection(), "repo": repo, "title": title, "body": { @@ -167,7 +167,11 @@ async fn upload(world: &World, repo: &RepoDid, body: &[u8]) -> (StatusCode, serd header::AUTHORIZATION, format!( "Bearer {}", - service_jwt_for("com.atproto.repo.uploadBlob", OWNER, repo.as_str()) + service_jwt_for( + &nsid("com.atproto.repo.uploadBlob"), + &owner(), + &ServiceDid::new(repo.as_str()).unwrap(), + ) ), ) .header(header::CONTENT_TYPE, "application/octet-stream") @@ -186,9 +190,8 @@ async fn upload(world: &World, repo: &RepoDid, body: &[u8]) -> (StatusCode, serd async fn drive(ops: Vec) -> Result<(), TestCaseError> { let world = World::unshed(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); - let repo = did.as_str(); let model = Model { uploaded: BTreeMap::new(), live: Vec::new(), @@ -197,7 +200,7 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { let model = futures::stream::iter(ops) .map(Ok) .try_fold(model, |mut model, op| { - let (world, did, repo) = (&world, &did, repo); + let (world, did) = (&world, &did); async move { match op { Op::Upload(token) => { @@ -212,13 +215,13 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { } let (status, written) = post_as( world, - "/xrpc/com.atproto.repo.createRecord", - OWNER, - repo, + &nsid("com.atproto.repo.createRecord"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), json!({ - "repo": repo, - "collection": "org.tangled.track.ticket", - "record": model.ticket_json(repo, "kelp under test", token), + "repo": did, + "collection": common::ticket_collection(), + "record": model.ticket_json(did, "kelp under test", token), }), ) .await; @@ -242,15 +245,15 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { } let (status, written) = post_as( world, - "/xrpc/com.atproto.repo.putRecord", - OWNER, - repo, + &nsid("com.atproto.repo.putRecord"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), json!({ - "repo": repo, - "collection": "org.tangled.track.ticket", + "repo": did, + "collection": common::ticket_collection(), "rkey": rkey.to_string(), "swapRecord": swap.to_string(), - "record": model.ticket_json(repo, "kelp under test, again", token), + "record": model.ticket_json(did, "kelp under test, again", token), }), ) .await; @@ -271,12 +274,12 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { }; let (status, response) = post_as( world, - "/xrpc/com.atproto.repo.deleteRecord", - OWNER, - repo, + &nsid("com.atproto.repo.deleteRecord"), + &owner(), + &ServiceDid::new(did.as_str()).unwrap(), json!({ - "repo": repo, - "collection": "org.tangled.track.ticket", + "repo": did, + "collection": common::ticket_collection(), "rkey": rkey.to_string(), "swapRecord": swap.to_string(), }), @@ -289,7 +292,7 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { let (status, _headers, listed) = get( world, - &format!("/xrpc/com.atproto.sync.listBlobs?did={repo}"), + &format!("/xrpc/com.atproto.sync.listBlobs?did={did}"), ) .await; let listed: serde_json::Value = serde_json::from_slice(&listed).unwrap_or_default(); @@ -311,12 +314,12 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { futures::stream::iter(&model.uploaded) .map(Ok) .try_for_each(|(token, bytes)| { - let (world, repo) = (&world, repo); + let (world, did) = (&world, &did); async move { let (status, headers, served) = get( world, &format!( - "/xrpc/com.atproto.sync.getBlob?did={repo}&cid={}", + "/xrpc/com.atproto.sync.getBlob?did={did}&cid={}", blob_cid(bytes) ), ) @@ -332,12 +335,12 @@ async fn drive(ops: Vec) -> Result<(), TestCaseError> { futures::stream::iter(&model.live) .map(Ok) .try_for_each(|(rkey, record)| { - let (world, repo, uploaded) = (&world, &repo, &model.uploaded); + let (world, did, uploaded) = (&world, &did, &model.uploaded); async move { let (status, _headers, response) = get( world, &format!( - "/xrpc/com.atproto.repo.getRecord?repo={repo}&collection=org.tangled.track.ticket&rkey={rkey}" + "/xrpc/com.atproto.repo.getRecord?repo={did}&collection=org.tangled.track.ticket&rkey={rkey}" ), ) .await; diff --git a/knot2/crates/knot-xrpc/tests/blobs.rs b/knot2/crates/knot-xrpc/tests/blobs.rs index dc7c94a80..96d702db8 100644 --- a/knot2/crates/knot-xrpc/tests/blobs.rs +++ b/knot2/crates/knot-xrpc/tests/blobs.rs @@ -8,9 +8,10 @@ use serde_json::json; use tower::ServiceExt; use common::{ - OWNER, World, empty_repo, enable_chain, get, post_as, seeded_feature_branch, service_jwt_for, + World, empty_repo, enable_chain, get, nsid, owner, post_as, seeded_feature_branch, + service_jwt_for, }; -use knot_types::RepoDid; +use knot_types::{AccountDid, RepoDid, RepoName, ServiceDid}; const PNG: &[u8] = &[ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, @@ -24,6 +25,10 @@ const SECOND: &[u8] = &[ ]; const STRANGER: &str = "did:web:stranger.nel.pet"; +fn stranger() -> AccountDid { + AccountDid::new(STRANGER).unwrap() +} + fn blob_cid_link(bytes: &[u8]) -> knot_types::BlobCid { knot_types::BlobCid::from_digest(knot_types::Sha256Digest::hash(bytes)) } @@ -47,7 +52,11 @@ async fn upload_as( header::AUTHORIZATION, format!( "Bearer {}", - service_jwt_for("com.atproto.repo.uploadBlob", OWNER, repo.as_str()) + service_jwt_for( + &nsid("com.atproto.repo.uploadBlob"), + &owner(), + &ServiceDid::new(repo.as_str()).unwrap(), + ) ), ) .header(header::CONTENT_TYPE, hint) @@ -74,16 +83,16 @@ async fn get_json(world: &World, path_and_query: &str) -> (StatusCode, serde_jso async fn create_with_blob( world: &World, - actor: Option<&str>, + actor: Option<&AccountDid>, repo: &RepoDid, blob: serde_json::Value, ) -> (StatusCode, serde_json::Value) { let record = json!({ - "repo": repo.as_str(), - "collection": "org.tangled.track.ticket", + "repo": repo, + "collection": common::ticket_collection(), "record": { - "$type": "org.tangled.track.ticket", - "repo": repo.as_str(), + "$type": common::ticket_collection(), + "repo": repo, "title": "kelp with a picture", "body": { "$type": "org.tangled.markup.markdown", @@ -101,9 +110,9 @@ async fn create_with_blob( Some(actor) => { post_as( world, - "/xrpc/com.atproto.repo.createRecord", + &nsid("com.atproto.repo.createRecord"), actor, - repo.as_str(), + &ServiceDid::new(repo.as_str()).unwrap(), record, ) .await @@ -125,7 +134,7 @@ fn aged_blob_path(world: &World, repo: &RepoDid, body: &[u8]) -> std::path::Path #[tokio::test] async fn attachment_round_trips_from_upload_to_getblob_and_listblobs() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); let repo = did.as_str().to_owned(); @@ -140,7 +149,7 @@ async fn attachment_round_trips_from_upload_to_getblob_and_listblobs() { ); let blob = uploaded["blob"].clone(); - let (status, written) = create_with_blob(&world, Some(OWNER), &did, blob).await; + let (status, written) = create_with_blob(&world, Some(&owner()), &did, blob).await; assert_eq!(status, StatusCode::OK, "{written}"); let uri = written["uri"].as_str().unwrap().to_owned(); let rkey = uri.rsplit('/').next().unwrap().to_owned(); @@ -202,7 +211,7 @@ async fn attachment_round_trips_from_upload_to_getblob_and_listblobs() { .for_each(|(blob, refused)| { let (world, did) = (&world, &did); async move { - let (status, response) = create_with_blob(world, Some(OWNER), did, blob).await; + let (status, response) = create_with_blob(world, Some(&owner()), did, blob).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); assert!( response["message"].as_str().unwrap().contains(refused), @@ -216,7 +225,7 @@ async fn attachment_round_trips_from_upload_to_getblob_and_listblobs() { #[tokio::test] async fn uploads_without_lfs_store_return_503_with_the_setting() { let world = World::lfs_less(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); let (status, refused) = upload(&world, &did, PNG).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{refused}"); assert!( @@ -229,7 +238,7 @@ async fn uploads_without_lfs_store_return_503_with_the_setting() { enable_chain(&world, &did); let referenced = blob_ref_json(blob_cid_link(PNG), "image/png", PNG.len() as u64); - let (status, refused) = create_with_blob(&world, Some(OWNER), &did, referenced).await; + let (status, refused) = create_with_blob(&world, Some(&owner()), &did, referenced).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{refused}"); assert!( refused["message"] @@ -243,7 +252,7 @@ async fn uploads_without_lfs_store_return_503_with_the_setting() { #[tokio::test] async fn upload_bounds_take_any_bytes_under_one_mime_budget() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); let mut boundary = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; boundary.resize(1_000_000, 0); let (status, accepted) = upload(&world, &did, &boundary).await; @@ -255,7 +264,7 @@ async fn upload_bounds_take_any_bytes_under_one_mime_budget() { #[tokio::test] async fn unsniffable_bytes_take_the_hint_except_html() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); let mystery = b"an unsniffable body"; let (status, trusted) = upload_as(&world, &did, mystery, "application/x-whelk-report").await; assert_eq!(status, StatusCode::OK, "{trusted}"); @@ -285,7 +294,7 @@ async fn pdf_svg_and_mp4_uploads_take_the_sniffed_mime() { assert_eq!(response["blob"]["mimeType"], mime, "{response}"); } let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); upload_returns_mime( &world, &did, @@ -312,7 +321,7 @@ async fn pdf_svg_and_mp4_uploads_take_the_sniffed_mime() { #[tokio::test] async fn getblob_returns_blob_not_found_for_unknown_cid() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); let repo = did.as_str().to_owned(); let (status, response) = get_json( @@ -330,7 +339,7 @@ async fn getblob_returns_blob_not_found_for_unknown_cid() { #[tokio::test] async fn listblobs_pages_and_applies_since_at_each_records_rev() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); let repo = did.as_str().to_owned(); @@ -342,7 +351,7 @@ async fn listblobs_pages_and_applies_since_at_each_records_rev() { let (status, uploaded) = upload(world, did, body).await; assert_eq!(status, StatusCode::OK, "{uploaded}"); let (status, written) = - create_with_blob(world, Some(OWNER), did, uploaded["blob"].clone()).await; + create_with_blob(world, Some(&owner()), did, uploaded["blob"].clone()).await; assert_eq!(status, StatusCode::OK, "{written}"); (common::commit_rev_of(&written), blob_cid_link(body)) } @@ -469,7 +478,7 @@ fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> &'a str { #[tokio::test] async fn attachments_round_trip_over_real_tcp_against_live_listener() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "conch"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("conch").unwrap()); enable_chain(&world, &did); let repo = did.as_str(); let addr = common::serve(&world).await; @@ -478,7 +487,11 @@ async fn attachments_round_trip_over_real_tcp_against_live_listener() { addr, "POST", "/xrpc/com.atproto.repo.uploadBlob", - Some(&service_jwt_for("com.atproto.repo.uploadBlob", OWNER, repo)), + Some(&service_jwt_for( + &nsid("com.atproto.repo.uploadBlob"), + &owner(), + &ServiceDid::new(repo).unwrap(), + )), Some("image/png"), PNG, ) @@ -511,7 +524,7 @@ async fn attachments_round_trip_over_real_tcp_against_live_listener() { #[tokio::test] async fn refused_writers_leave_stored_blobs_grace_window_untouched() { let world = World::under(knot_types::ContributionPolicy::Collaborators); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); let (status, uploaded) = upload(&world, &did, PNG).await; assert_eq!(status, StatusCode::OK, "{uploaded}"); @@ -528,7 +541,7 @@ async fn refused_writers_leave_stored_blobs_grace_window_untouched() { let blob = uploaded["blob"].clone(); let (status, refused) = create_with_blob(&world, None, &did, blob.clone()).await; assert_eq!(status, StatusCode::UNAUTHORIZED, "{refused}"); - let (status, refused) = create_with_blob(&world, Some(STRANGER), &did, blob).await; + let (status, refused) = create_with_blob(&world, Some(&stranger()), &did, blob).await; assert_eq!(status, StatusCode::FORBIDDEN, "{refused}"); assert_eq!( std::fs::metadata(&path).unwrap().modified().unwrap(), @@ -540,16 +553,16 @@ async fn refused_writers_leave_stored_blobs_grace_window_untouched() { #[tokio::test] async fn listblobs_keeps_shared_blob_at_latest_referencing_record() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "anemone"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); enable_chain(&world, &did); let repo = did.as_str().to_owned(); let (status, uploaded) = upload(&world, &did, PNG).await; assert_eq!(status, StatusCode::OK, "{uploaded}"); let blob = uploaded["blob"].clone(); - let (status, first) = create_with_blob(&world, Some(OWNER), &did, blob.clone()).await; + let (status, first) = create_with_blob(&world, Some(&owner()), &did, blob.clone()).await; assert_eq!(status, StatusCode::OK, "{first}"); - let (status, second) = create_with_blob(&world, Some(OWNER), &did, blob).await; + let (status, second) = create_with_blob(&world, Some(&owner()), &did, blob).await; assert_eq!(status, StatusCode::OK, "{second}"); let at_first = common::commit_rev_of(&first); @@ -569,14 +582,14 @@ async fn listblobs_keeps_shared_blob_at_latest_referencing_record() { #[tokio::test] async fn pull_attachments_live_on_the_ticket_not_the_patch() { let world = World::new(); - let (did, base, head) = seeded_feature_branch(&world, "whelk"); + let (did, base, head) = seeded_feature_branch(&world, &RepoName::new("whelk").unwrap()); enable_chain(&world, &did); let repo = did.as_str().to_owned(); let (status, uploaded) = upload(&world, &did, PNG).await; assert_eq!(status, StatusCode::OK, "{uploaded}"); let (status, ticket) = - create_with_blob(&world, Some(OWNER), &did, uploaded["blob"].clone()).await; + create_with_blob(&world, Some(&owner()), &did, uploaded["blob"].clone()).await; assert_eq!(status, StatusCode::OK, "{ticket}"); let ticket_rkey = common::rkey_of(&ticket); diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index 9fdfe5c81..16da7e80f 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -19,74 +19,79 @@ async fn serve_router(router: axum::Router) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { - axum::serve(listener, router.into_make_service()).await.unwrap(); + axum::serve(listener, router.into_make_service()) + .await + .unwrap(); }); addr } -fn knot_multikey(world: &World) -> String { +fn knot_multikey(world: &World) -> ActorId { let signer = world.state.secrets.signer(&world.state.knot_did).unwrap(); - let sec1 = knot_runtime::Signer::public_key(&signer).as_bytes().to_vec(); - knot_types::crypto::multikey(0xe7, &sec1) + let sec1 = knot_runtime::Signer::public_key(&signer) + .as_bytes() + .to_vec(); + ActorId::new(knot_types::crypto::multikey(0xe7, &sec1)).unwrap() } pub async fn serve_local_plc(world: &World) -> std::net::SocketAddr { let multikey = knot_multikey(world); - let endpoint = world.state.knot_service_url.as_str().to_string(); - serve_router( - axum::Router::new().route( - "/{did}", - axum::routing::get( - move |axum::extract::Path(did): axum::extract::Path| { - let multikey = multikey.clone(); - let endpoint = endpoint.clone(); - async move { - let doc = did_doc(&did, &multikey, &endpoint); - let mut headers = axum::http::HeaderMap::new(); - headers.insert( - axum::http::header::CONTENT_TYPE, - axum::http::HeaderValue::from_static("application/did+json"), - ); - (headers, axum::Json(doc)) - } - }, - ), + let endpoint = world.state.knot_service_url.clone(); + serve_router(axum::Router::new().route( + "/{did}", + axum::routing::get( + move |axum::extract::Path(did): axum::extract::Path| { + let multikey = multikey.clone(); + let endpoint = endpoint.clone(); + async move { + let did = Did::new_owned(did).expect("the plc request path is a DID"); + let doc = did_doc(&did, &multikey, &endpoint); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/did+json"), + ); + (headers, axum::Json(doc)) + } + }, ), - ) + )) .await } pub async fn serve_bob_pds( - bob_did: &str, - attestation_rkey: &str, - subject_uri: &str, - subject_cid: &str, + bob_did: &Did, + attestation_rkey: &RecordRkey, + subject_uri: &AtUri, + subject_cid: &RecordCid, ) -> std::net::SocketAddr { - let collection = "org.tangled.graph.attestation"; - let uri = format!("at://{bob_did}/{collection}/{attestation_rkey}"); - let cid = subject_cid.to_string(); + let subject_cid = *subject_cid; + let collection = RecordCollection::new("org.tangled.graph.attestation").unwrap(); + let uri = format!( + "at://{}/{}/{}", + bob_did.as_str(), + collection.as_str(), + attestation_rkey.as_str() + ); let value = serde_json::json!({ - "$type": collection, + "$type": collection.as_str(), "createdAt": "2026-09-11T00:00:00Z", - "subject": { "uri": subject_uri, "cid": subject_cid } + "subject": { "uri": subject_uri.as_str(), "cid": subject_cid.cid().to_string() } }); - serve_router( - axum::Router::new().route( - "/xrpc/com.atproto.repo.getRecord", - axum::routing::get(move || { - let uri = uri.clone(); - let cid = cid.clone(); - let value = value.clone(); - async move { - axum::Json(serde_json::json!({ - "uri": uri, - "cid": cid, - "value": value, - })) - } - }), - ), - ) + serve_router(axum::Router::new().route( + "/xrpc/com.atproto.repo.getRecord", + axum::routing::get(move || { + let uri = uri.clone(); + let value = value.clone(); + async move { + axum::Json(serde_json::json!({ + "uri": uri, + "cid": subject_cid.cid().to_string(), + "value": value, + })) + } + }), + )) .await } @@ -129,14 +134,14 @@ pub async fn http_push( work: &Path, addr: std::net::SocketAddr, did: &knot_types::RepoDid, - actor: &str, + actor: &knot_types::AccountDid, refspecs: &[&str], ) -> (bool, String) { let header = format!( "http.extraHeader=Authorization: Basic {}", base64::engine::general_purpose::STANDARD.encode(format!( "x-tangled-token:{}", - service_jwt("sh.tangled.repo.push", actor) + service_jwt(&nsid("sh.tangled.repo.push"), actor) )) ); let url = format!("http://{addr}/{did}"); @@ -211,21 +216,51 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, AuthorName, Email, KnotHostname, KnotId, ObjectFormat, Oid, OwnerDid, RecordRkey, - RepoDid, RepoName, RepoRkey, UnixSeconds, + AccountDid, ActorId, AtUri, AuthorName, Did, DidRkey, Email, KnotHostname, KnotId, + KnotServiceUrl, Nsid, ObjectFormat, Oid, OwnerDid, RecordAddress, RecordCid, RecordCollection, + RecordRkey, RepoDid, RepoName, RepoRkey, ServiceDid, UnixSeconds, }; use knot_xrpc::{ ArchiveLimit, Budgets, ByteLimits, CobLocks, GlobalQuota, LimitConfig, PerActorQuota, PreAuthLimiter, Reservations, ResponseLimit, XrpcState, }; -pub const KNOT_HOST: &str = "knot.nel.pet"; -pub const FIXTURE_COLLECTION: &str = "sh.tangled.test.record"; -pub const TICKET_COLLECTION: &str = "org.tangled.track.ticket"; -pub const PATCH_COLLECTION: &str = "org.tangled.review.patch"; +const KNOT_HOST: &str = "knot.nel.pet"; +const OWNER: &str = "did:web:olaren.dev"; +const FIXTURE_COLLECTION: &str = "sh.tangled.test.record"; +const TICKET_COLLECTION: &str = "org.tangled.track.ticket"; +const PATCH_COLLECTION: &str = "org.tangled.review.patch"; + +pub fn knot_host() -> KnotHostname { + KnotHostname::new(KNOT_HOST).unwrap() +} + +pub fn knot_service_did() -> ServiceDid { + ServiceDid::new(format!("did:web:{KNOT_HOST}")).unwrap() +} + +pub fn owner() -> AccountDid { + AccountDid::new(OWNER).unwrap() +} + +fn owner_did() -> OwnerDid { + OwnerDid::new(OWNER).unwrap() +} + +pub fn nsid(name: &str) -> Nsid { + Nsid::new_owned(name).expect("the xrpc method is an NSID") +} + +pub fn fixture_collection() -> RecordCollection { + RecordCollection::new(FIXTURE_COLLECTION).unwrap() +} + +pub fn ticket_collection() -> RecordCollection { + RecordCollection::new(TICKET_COLLECTION).unwrap() +} -pub fn fixture_collection() -> knot_types::RecordCollection { - knot_types::RecordCollection::new(FIXTURE_COLLECTION).unwrap() +pub fn patch_collection() -> RecordCollection { + RecordCollection::new(PATCH_COLLECTION).unwrap() } pub fn blob_cid_of(value: &serde_json::Value) -> knot_types::BlobCid { @@ -234,6 +269,12 @@ pub fn blob_cid_of(value: &serde_json::Value) -> knot_types::BlobCid { .expect("the CID is raw sha2-256") } +pub fn record_cid_of(value: &serde_json::Value) -> RecordCid { + let sent = value.as_str().expect("a record CID is a string"); + RecordCid::new(cid::Cid::try_from(sent).expect("the CID parses")) + .expect("the CID is dag-cbor sha2-256") +} + pub fn listed_cids(response: &serde_json::Value) -> Vec { response["cids"] .as_array() @@ -267,18 +308,19 @@ pub async fn create_ticket( repo: &RepoDid, title: &str, body: &str, -) -> (RecordRkey, String) { +) -> (RecordRkey, RecordCid) { + let collection = ticket_collection(); let (status, written) = post_as( world, - "/xrpc/com.atproto.repo.createRecord", - OWNER, - repo.as_str(), + &nsid("com.atproto.repo.createRecord"), + &owner(), + &ServiceDid::new(repo.as_str()).unwrap(), serde_json::json!({ - "repo": repo.as_str(), - "collection": TICKET_COLLECTION, + "repo": repo, + "collection": &collection, "record": { - "$type": TICKET_COLLECTION, - "repo": repo.as_str(), + "$type": &collection, + "repo": repo, "title": title, "body": body, "state": "open", @@ -290,7 +332,7 @@ pub async fn create_ticket( assert_eq!(status, StatusCode::OK, "{written}"); ( rkey_of(&written), - written["cid"].as_str().unwrap().to_owned(), + record_cid_of(&written["cid"]), ) } @@ -300,17 +342,18 @@ pub async fn create_patch( ticket: &RecordRkey, base: &Oid, head: &Oid, -) -> (RecordRkey, String) { +) -> (RecordRkey, RecordCid) { + let collection = patch_collection(); let (status, written) = post_as( world, - "/xrpc/com.atproto.repo.createRecord", - OWNER, - repo.as_str(), + &nsid("com.atproto.repo.createRecord"), + &owner(), + &ServiceDid::new(repo.as_str()).unwrap(), serde_json::json!({ - "repo": repo.as_str(), - "collection": PATCH_COLLECTION, + "repo": repo, + "collection": &collection, "record": { - "$type": PATCH_COLLECTION, + "$type": &collection, "ticket": ticket, "base": { "oid": base.to_hex() }, "head": { "oid": head.to_hex() }, @@ -322,34 +365,34 @@ pub async fn create_patch( assert_eq!(status, StatusCode::OK, "{written}"); ( rkey_of(&written), - written["cid"].as_str().unwrap().to_owned(), + record_cid_of(&written["cid"]), ) } #[derive(serde::Serialize)] -struct FixtureRecord { +struct FixtureRecord<'a> { #[serde(rename = "$type")] - record_type: &'static str, + record_type: &'a RecordCollection, #[serde(rename = "createdAt")] created_at: String, #[serde(rename = "x-tngl-editor")] - editor: &'static str, + editor: &'a AccountDid, } -pub fn fixture_record(created_at: i64) -> knot_types::RecordBody { +pub fn fixture_record(created_at: UnixSeconds) -> knot_types::RecordBody { + let editor = owner(); knot_types::RecordBody::new( serde_ipld_dagcbor::to_vec(&FixtureRecord { - record_type: FIXTURE_COLLECTION, - created_at: chrono::DateTime::from_timestamp(created_at, 0) + record_type: &fixture_collection(), + created_at: chrono::DateTime::from_timestamp(created_at.get(), 0) .unwrap() .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - editor: OWNER, + editor: &editor, }) .unwrap(), ) .unwrap() } -pub const OWNER: &str = "did:web:olaren.dev"; pub type Responder = Box Result + Send + Sync>; @@ -361,15 +404,15 @@ pub struct World { pub lfs_dir: std::path::PathBuf, pub router: Router, pub state: Arc, ManualClock>>, - plc_pds: Arc>>, + plc_pds: Arc>>, } impl World { - pub fn publish_plc_pds(&self, did: &str, pds: &str) { + pub fn publish_plc_pds(&self, did: &Did, pds: &KnotServiceUrl) { self.plc_pds .lock() .unwrap() - .insert(did.to_string(), pds.to_string()); + .insert(did.clone(), pds.clone()); } pub fn new() -> Self { @@ -501,22 +544,24 @@ impl World { let responder: Responder = Box::new(move |request| { let host = request.url.host_str().unwrap_or_default(); let did = match host { - "plc.directory" => request.url.path().trim_start_matches('/').to_string(), + "plc.directory" => Some(request.url.path().trim_start_matches('/').to_string()), host if request.url.path().ends_with("/.well-known/did.json") => { - format!("did:web:{host}") + Some(format!("did:web:{host}")) } - _ => String::new(), - }; - let body = match did.starts_with("did:") { - true => did_doc_at( + _ => None, + } + .and_then(|did| Did::new_owned(did).ok()); + let body = match did { + Some(did) => did_doc_at( &did, - pds_of + &pds_of .lock() .unwrap() .get(&did) - .map_or("https://pds.oyster.cafe", String::as_str), + .cloned() + .unwrap_or_else(default_pds), ), - false => Bytes::new(), + None => Bytes::new(), }; Ok(HttpResponse { status: StatusCode::OK, @@ -569,6 +614,7 @@ impl World { admins, admission: knot_types::AdmissionPolicy::Closed, contribution_policy: policy, + knot_repo: knot_xrpc::knot_repo(&knot).unwrap(), knot_did: knot, knot_hostname: KnotHostname::new(KNOT_HOST).unwrap(), meta_path, @@ -650,15 +696,15 @@ impl World { } } - pub fn register(&self, did: &RepoDid, rkey: &str) { + pub fn register(&self, did: &RepoDid, name: &RepoName, rkey: &RepoRkey) { let meta = Repo::open(&self.state.meta_path).unwrap(); let store = CobStore::new(&meta); let home = CobHome::from(&self.state.knot_did); let signer = self.state.secrets.signer(&self.state.knot_did).unwrap(); let registration = Registration { - owner: OwnerDid::new(OWNER).unwrap(), - rkey: RepoRkey::new(rkey).unwrap(), - name: RepoName::new(rkey).unwrap(), + owner: owner_did(), + rkey: rkey.clone(), + name: name.clone(), repo: did.clone(), created_at: UnixSeconds::new(1_000), policy: self.state.contribution_policy, @@ -690,26 +736,36 @@ impl World { self.state.index.refresh_registry().unwrap(); } - pub fn add_member(&self, subject: &str, added_by: &str, at: i64) { + pub fn add_member(&self, subject: &AccountDid, added_by: &AccountDid, at: UnixSeconds) { self.member(MembersChange::Add(grant(subject, added_by, at)), at); } - pub fn remove_member(&self, subject: &str, at: i64) { - let subject = AccountDid::new(subject).unwrap(); - self.member(MembersChange::Remove(Removal { subject }), at); + pub fn remove_member(&self, subject: &AccountDid, at: UnixSeconds) { + self.member( + MembersChange::Remove(Removal { + subject: subject.clone(), + }), + at, + ); } - pub fn ban(&self, subject: &str, at: i64) { - self.on_meta::(BlocklistChange::Add(grant(subject, OWNER, at)), at); + pub fn ban(&self, subject: &AccountDid, at: UnixSeconds) { + self.on_meta::(BlocklistChange::Add(grant(subject, &owner(), at)), at); self.state.index.refresh_blocklist().unwrap(); } - pub fn add_collaborator(&self, repo: &RepoDid, subject: &str, added_by: &str, at: i64) { + pub fn add_collaborator( + &self, + repo: &RepoDid, + subject: &AccountDid, + added_by: &AccountDid, + at: UnixSeconds, + ) { let change = CollaboratorsChange::Add(grant(subject, added_by, at)); self.collaborator(repo, change, at); } - pub fn remove_collaborator(&self, repo: &RepoDid, subject: &AccountDid, at: i64) { + pub fn remove_collaborator(&self, repo: &RepoDid, subject: &AccountDid, at: UnixSeconds) { self.collaborator( repo, CollaboratorsChange::Remove(Removal { @@ -719,12 +775,12 @@ impl World { ); } - fn member(&self, change: MembersChange, at: i64) { + fn member(&self, change: MembersChange, at: UnixSeconds) { self.on_meta::(change, at); self.state.index.refresh_members().unwrap(); } - fn collaborator(&self, repo: &RepoDid, change: CollaboratorsChange, at: i64) { + fn collaborator(&self, repo: &RepoDid, change: CollaboratorsChange, at: UnixSeconds) { self.append::( self.layout.open(repo).unwrap(), CobHome::from(repo), @@ -734,19 +790,23 @@ impl World { self.state.index.refresh_collaborators(repo).unwrap(); } - pub fn publish_meta(&self, rkeys: &[&str], at: i64) { + pub fn publish_meta(&self, rkeys: &[DidRkey], at: UnixSeconds) { let did = RepoDid::new(self.state.knot_did.as_str()).unwrap(); let path = self.state.meta_path.clone(); - self.publish(&did, &path, rkeys, at); + let rkeys = rkeys + .iter() + .map(|rkey| RecordRkey::Subject(rkey.clone())) + .collect::>(); + self.publish(&did, &path, &rkeys, at); } - pub fn publish(&self, did: &RepoDid, path: &Path, rkeys: &[&str], at: i64) { - let now = UnixSeconds::new(at); + pub fn publish(&self, did: &RepoDid, path: &Path, rkeys: &[RecordRkey], at: UnixSeconds) { + let now = at; let records: std::collections::BTreeMap = rkeys .iter() .map(|rkey| { ( - knot_types::MstKey::new(format!("{FIXTURE_COLLECTION}/{rkey}")).unwrap(), + RecordAddress::new(fixture_collection(), rkey.clone()).mst_key(), fixture_record(at), ) }) @@ -762,24 +822,17 @@ impl World { .enable_all() .build() .unwrap(); - match knot_record::chain::tip(&git).unwrap() { - None => runtime - .block_on(knot_record::chain::materialize( - &git, did, &records, &signer, &mut emit, now, - )) - .unwrap(), - Some(_) => runtime - .block_on(knot_record::chain::sync( - &git, - did, - std::slice::from_ref(&fixture_collection()), - &knot_record::chain::in_tree(records), - &signer, - &mut emit, - now, - )) - .unwrap(), - }; + runtime + .block_on(knot_record::chain::settle( + &git, + did, + std::slice::from_ref(&fixture_collection()), + &knot_record::chain::in_tree(records), + &signer, + &mut emit, + now, + )) + .unwrap(); emit.finish().unwrap(); }) .join() @@ -787,7 +840,7 @@ impl World { }); } - fn on_meta(&self, change: E::Change, at: i64) + fn on_meta(&self, change: E::Change, at: UnixSeconds) where E::Change: knot_cob::KnotAuthored, { @@ -795,14 +848,13 @@ impl World { self.append::(meta, CobHome::from(&self.state.knot_did), change, at); } - fn append(&self, git: Repo, home: CobHome, change: E::Change, at: i64) + fn append(&self, git: Repo, home: CobHome, change: E::Change, at: UnixSeconds) where E::Change: knot_cob::KnotAuthored, { let store = CobStore::new(&git); let signer = self.state.secrets.signer(&self.state.knot_did).unwrap(); let kind = ::TYPE; - let at = UnixSeconds::new(at); match store.list::().unwrap().as_slice() { [] => _ = store.create(&home, &change, &signer, at).unwrap(), [object] => _ = store.update(&home, *object, &change, &signer, at).unwrap(), @@ -811,11 +863,11 @@ impl World { } } -fn grant(subject: &str, added_by: &str, at: i64) -> Grant { +fn grant(subject: &AccountDid, added_by: &AccountDid, at: UnixSeconds) -> Grant { Grant { - subject: AccountDid::new(subject).unwrap(), - added_by: AccountDid::new(added_by).unwrap(), - created_at: UnixSeconds::new(at), + subject: subject.clone(), + added_by: added_by.clone(), + created_at: at, } } @@ -823,51 +875,55 @@ fn actor_key() -> SigningKey { SigningKey::from_bytes(&[9u8; 32].into()).unwrap() } -fn did_doc(did: &str, multikey: &str, endpoint: &str) -> serde_json::Value { +fn did_doc(did: &Did, multikey: &ActorId, endpoint: &KnotServiceUrl) -> serde_json::Value { serde_json::json!({ - "id": did, + "id": did.as_str(), "alsoKnownAs": [], "verificationMethod": [{ - "id": format!("{did}#atproto"), + "id": format!("{}#atproto", did.as_str()), "type": "Multikey", - "controller": did, - "publicKeyMultibase": multikey + "controller": did.as_str(), + "publicKeyMultibase": multikey.as_str() }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", - "serviceEndpoint": endpoint + "serviceEndpoint": endpoint.as_str() }] }) } -fn did_doc_at(did: &str, endpoint: &str) -> Bytes { +fn did_doc_at(did: &Did, endpoint: &KnotServiceUrl) -> Bytes { let sec1 = actor_key() .verifying_key() .to_encoded_point(true) .as_bytes() .to_vec(); - let multikey = knot_types::crypto::multikey(0xe7, &sec1); + let multikey = ActorId::new(knot_types::crypto::multikey(0xe7, &sec1)).unwrap(); let doc = did_doc(did, &multikey, endpoint); Bytes::from(serde_json::to_vec(&doc).unwrap()) } +fn default_pds() -> KnotServiceUrl { + KnotServiceUrl::new("https://pds.oyster.cafe").unwrap() +} + static JTI: AtomicU64 = AtomicU64::new(0); -pub fn service_jwt(nsid: &str, actor: &str) -> String { - service_jwt_for(nsid, actor, &format!("did:web:{KNOT_HOST}")) +pub fn service_jwt(method: &Nsid, actor: &AccountDid) -> String { + service_jwt_for(method, actor, &knot_service_did()) } -pub fn service_jwt_for(nsid: &str, actor: &str, audience: &str) -> String { +pub fn service_jwt_for(method: &Nsid, actor: &AccountDid, audience: &ServiceDid) -> String { let nonce = JTI.fetch_add(1, Ordering::SeqCst); let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); let claims = serde_json::json!({ - "iss": actor, - "aud": audience, + "iss": actor.as_str(), + "aud": audience.as_str(), "exp": 1_001, "iat": 999, "jti": format!("nonce-{nonce}"), - "lxm": nsid, + "lxm": method.as_str(), }); let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); let signing_input = format!("{header}.{payload}"); @@ -890,27 +946,24 @@ async fn oneshot_json(world: &World, request: Request) -> (StatusCode, ser pub async fn post_authed( world: &World, - path: &str, - actor: &str, + method: &Nsid, + actor: &AccountDid, value: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - post_as(world, path, actor, &format!("did:web:{KNOT_HOST}"), value).await + post_as(world, method, actor, &knot_service_did(), value).await } pub async fn post_as( world: &World, - path: &str, - actor: &str, - audience: &str, + method: &Nsid, + actor: &AccountDid, + audience: &ServiceDid, value: serde_json::Value, ) -> (StatusCode, serde_json::Value) { - let nsid = path - .strip_prefix("/xrpc/") - .expect("post_authed path names an xrpc method"); - let token = service_jwt_for(nsid, actor, audience); + let token = service_jwt_for(method, actor, audience); let request = http::Request::builder() .method("POST") - .uri(path) + .uri(format!("/xrpc/{}", method.as_str())) .header(header::CONTENT_TYPE, "application/json") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::from(serde_json::to_vec(&value).unwrap())) @@ -930,9 +983,9 @@ pub fn enable_chain(world: &World, did: &RepoDid) { pub fn repo_did_document(world: &World, did: &RepoDid) -> serde_json::Value { did_doc( - did.as_str(), + &Did::new_owned(did.as_str()).unwrap(), &knot_multikey(world), - world.state.knot_service_url.as_str(), + &world.state.knot_service_url, ) } @@ -985,10 +1038,10 @@ pub fn commit_file(work: &Path, file: &str, contents: &[u8], message: &str, when sh_git_at(work, when, &["commit", "-q", "-m", message]); } -pub fn seeded(world: &World, rkey: &str) -> (RepoDid, tempfile::TempDir) { - let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); +pub fn seeded(world: &World, name: &RepoName) -> (RepoDid, tempfile::TempDir) { + let did = RepoDid::new(format!("did:plc:{name}fixture")).unwrap(); let object_format = world.layout.create(&did).unwrap().object_format(); - world.register(&did, rkey); + world.register(&did, name, &RepoRkey::new(name.as_str()).unwrap()); let bare = world.layout.repo_path(&did).unwrap(); let work_dir = tempfile::tempdir().unwrap(); let work = work_dir.path(); @@ -1041,10 +1094,10 @@ fn init_args(object_format: ObjectFormat) -> Vec<&'static str> { } } -pub fn empty_repo(world: &World, rkey: &str) -> (RepoDid, String, tempfile::TempDir) { - let did = RepoDid::new(format!("did:plc:{rkey}fixture")).unwrap(); +pub fn empty_repo(world: &World, name: &RepoName) -> (RepoDid, String, tempfile::TempDir) { + let did = RepoDid::new(format!("did:plc:{name}fixture")).unwrap(); let object_format = world.layout.create(&did).unwrap().object_format(); - world.register(&did, rkey); + world.register(&did, name, &RepoRkey::new(name.as_str()).unwrap()); let bare = world .layout .repo_path(&did) @@ -1069,8 +1122,8 @@ fn at(clock: &str) -> String { format!("2026-06-01T{clock}+02:00") } -pub fn seeded_feature_branch(world: &World, rkey: &str) -> (RepoDid, Oid, Oid) { - let (did, bare, work_dir) = empty_repo(world, rkey); +pub fn seeded_feature_branch(world: &World, name: &RepoName) -> (RepoDid, Oid, Oid) { + let (did, bare, work_dir) = empty_repo(world, name); let work = work_dir.path(); let commits = |list: Vec<(&str, Vec, &str, &str)>| { list.into_iter().for_each(|(file, bytes, message, minute)| { @@ -1213,7 +1266,7 @@ pub async fn archive_full(world: &World, did: &RepoDid) -> (String, String, Byte (etag, last_modified, body) } -fn immutable_target(headers: &HeaderMap) -> &str { +fn immutable_target<'a>(headers: &'a HeaderMap, origin: &KnotServiceUrl) -> &'a str { headers[header::LINK] .to_str() .unwrap() @@ -1221,7 +1274,7 @@ fn immutable_target(headers: &HeaderMap) -> &str { .split('>') .next() .unwrap() - .strip_prefix(&format!("https://{KNOT_HOST}")) + .strip_prefix(origin.as_str()) .expect("the immutable link points at this knot") } @@ -1231,7 +1284,7 @@ pub async fn assert_immutable_round_trip( full: &Bytes, etag: &str, ) { - let immutable = immutable_target(headers); + let immutable = immutable_target(headers, &world.state.knot_service_url); let (status, immutable_headers, immutable_body) = get(world, immutable).await; assert_eq!(status, StatusCode::OK); @@ -1245,7 +1298,7 @@ pub async fn assert_immutable_round_trip( "the immutable link shares the etag of the response that advertised it" ); assert_eq!( - immutable_target(&immutable_headers), + immutable_target(&immutable_headers, &world.state.knot_service_url), immutable, "the immutable response advertises the same link, so one commit has one archive URL" ); @@ -1261,7 +1314,7 @@ pub async fn assert_immutable_round_trip( assert_eq!(status, StatusCode::NOT_MODIFIED); assert!(body.is_empty()); assert_eq!( - immutable_target(&revalidated), + immutable_target(&revalidated, &world.state.knot_service_url), immutable, "the 304 advertises the immutable link too, because a proxy that only revalidates never sees the 200" ); @@ -1277,11 +1330,11 @@ pub async fn assert_warming(world: &World, path: &str, expected_error: Option<&s pub async fn assert_post_rejected( world: &World, - path: &str, - actor: &str, + method: &Nsid, + actor: &AccountDid, value: serde_json::Value, ) { - let (status, body) = post_authed(world, path, actor, value).await; - assert_eq!(status, StatusCode::BAD_REQUEST, "{path}: {body}"); - assert_eq!(body["error"], "InvalidRequest", "{path}"); + let (status, body) = post_authed(world, method, actor, value).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{}: {body}", method.as_str()); + assert_eq!(body["error"], "InvalidRequest", "{}", method.as_str()); } diff --git a/knot2/crates/knot-xrpc/tests/grant_records.rs b/knot2/crates/knot-xrpc/tests/grant_records.rs new file mode 100644 index 000000000..6e11a7d14 --- /dev/null +++ b/knot2/crates/knot-xrpc/tests/grant_records.rs @@ -0,0 +1,190 @@ +mod common; + +use common::{World, nsid, owner}; +use futures::StreamExt; + +use knot_record::grant::GrantCollection; +use knot_types::{AccountDid, RecordCollection, RepoDid, RepoName}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +struct CommitLine { + repo: String, + ops: Vec, +} + +#[derive(Deserialize)] +struct OpLine { + action: String, + path: String, +} + +type Ws = + tokio_tungstenite::WebSocketStream>; + +#[derive(Deserialize)] +struct FrameHeader { + t: String, +} + +fn commit_of(bytes: &[u8]) -> Option { + let mut read = bytes; + let header: FrameHeader = serde_ipld_dagcbor::de::from_reader_once(&mut read).ok()?; + if header.t != "#commit" { + return None; + } + serde_ipld_dagcbor::from_slice(read).ok() +} + +async fn commits(ws: &mut Ws) -> Vec { + let mut seen = Vec::new(); + while let Ok(Some(next)) = + tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()).await + { + if let tokio_tungstenite::tungstenite::Message::Binary(bytes) = next.unwrap() { + seen.extend(commit_of(&bytes)); + } + } + seen +} + +fn ops(frames: &[CommitLine], repo: &RepoDid) -> Vec<(String, String)> { + frames + .iter() + .filter(|commit| commit.repo == repo.as_str()) + .flat_map(|commit| commit.ops.iter()) + .map(|op| (op.action.clone(), op.path.clone())) + .collect() +} + +async fn records_of( + world: &World, + repo: &RepoDid, + collection: &RecordCollection, +) -> Vec { + let (status, _headers, body) = common::get( + world, + format!( + "/xrpc/com.atproto.repo.listRecords?repo={}&collection={}", + common::percent_encode(repo.as_str()), + collection.as_str() + ) + .as_str(), + ) + .await; + assert_eq!(status, 200, "listRecords serves {collection} on {repo}"); + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); + parsed["records"].as_array().cloned().unwrap_or_default() +} + +#[tokio::test] +async fn the_offer_is_a_record_the_generic_surface_serves() { + let world = World::administered(); + let (repo, _bare, _work) = common::empty_repo(&world, &RepoName::new("squid").unwrap()); + let addr = common::serve(&world).await; + let (mut ws, _response) = tokio_tungstenite::connect_async(format!( + "ws://{addr}/xrpc/com.atproto.sync.subscribeRepos" + )) + .await + .unwrap(); + + let knot_repo = RepoDid::new(world.state.knot_did.as_str()).unwrap(); + let member = AccountDid::new("did:plc:limpet").unwrap(); + let collaborator = AccountDid::new("did:plc:whelk").unwrap(); + let member_collection = GrantCollection::Member.records(); + let collaborator_collection = GrantCollection::Collaborator.records(); + + let (status, body) = common::post_authed( + &world, + &nsid("sh.tangled.knot.addMember"), + &owner(), + json!({ "subject": &member }), + ) + .await; + assert_eq!(status, 200, "addMember served the offer: {body}"); + let (status, body) = common::post_authed( + &world, + &nsid("sh.tangled.repo.addCollaborator"), + &owner(), + json!({ "repo": &repo, "subject": &collaborator }), + ) + .await; + assert_eq!(status, 200, "addCollaborator served the offer: {body}"); + + let during = commits(&mut ws).await; + assert_eq!( + ops(&during, &knot_repo), + vec![("create".into(), format!("{member_collection}/{member}"))], + "addMember emits one commit on the knot account: a create of the member record" + ); + assert_eq!( + ops(&during, &repo), + vec![( + "create".into(), + format!("{collaborator_collection}/{collaborator}") + )], + "addCollaborator emits one commit on the repository: a create of the collaborator record" + ); + + let members = records_of(&world, &knot_repo, &member_collection).await; + assert_eq!( + members.len(), + 1, + "one member record, keyed by the offered member's DID" + ); + assert_eq!( + members[0]["uri"].as_str().unwrap(), + format!("at://{knot_repo}/{member_collection}/{member}") + ); + assert_eq!( + members[0]["value"]["$type"].as_str().unwrap(), + member_collection.as_str() + ); + assert!( + members[0]["value"]["createdAt"].as_str().is_some(), + "the record has the offer's createdAt" + ); + + let (status, _headers, body) = common::get( + &world, + &format!( + "/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", + common::percent_encode(knot_repo.as_str()), + member_collection.as_str(), + common::percent_encode(member.as_str()) + ), + ) + .await; + assert_eq!(status, 200, "getRecord returns the member record"); + let fetched: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + fetched["value"]["$type"].as_str().unwrap(), + member_collection.as_str(), + "getRecord finds the record by its DID rkey" + ); + + let (status, _body) = common::post_authed( + &world, + &nsid("sh.tangled.repo.removeCollaborator"), + &owner(), + json!({ "repo": &repo, "subject": &collaborator }), + ) + .await; + assert_eq!(status, 200, "removeCollaborator succeeds"); + assert_eq!( + ops(&commits(&mut ws).await, &repo), + vec![( + "delete".into(), + format!("{collaborator_collection}/{collaborator}") + )], + "removeCollaborator emits one commit on the repository: a delete of the collaborator record" + ); + assert_eq!( + records_of(&world, &repo, &collaborator_collection) + .await + .len(), + 0, + "listRecords returns zero collaborator records after the removal" + ); +} diff --git a/knot2/crates/knot-xrpc/tests/lfs.rs b/knot2/crates/knot-xrpc/tests/lfs.rs index b61b47426..afa586ae4 100644 --- a/knot2/crates/knot-xrpc/tests/lfs.rs +++ b/knot2/crates/knot-xrpc/tests/lfs.rs @@ -6,9 +6,9 @@ use sha2::{Digest, Sha256}; use tower::ServiceExt; use knot_lfs::{LfsOid, LfsStore, Pool}; -use knot_types::RepoDid; +use knot_types::{RepoDid, RepoName}; -use common::{OWNER, World, empty_repo, get}; +use common::{World, empty_repo, get, owner}; const MEDIA: &[u8] = b"\xff\x00heavy media bytes that live outside the odb"; @@ -67,7 +67,7 @@ fn batch_body(operation: &str, oids: &[(String, u64)]) -> String { #[tokio::test] async fn the_batch_download_surface_answers_every_addressing_form() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "barnacle"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("barnacle").unwrap()); let (oid, size) = seeded_object(&world, &did); let missing = absent_oid(); @@ -81,8 +81,8 @@ async fn the_batch_download_surface_answers_every_addressing_form() { let paths = [ format!("/{did}/info/lfs/objects/batch"), format!("/{did}.git/info/lfs/objects/batch"), - format!("/{OWNER}/barnacle/info/lfs/objects/batch"), - format!("/{OWNER}/barnacle.git/info/lfs/objects/batch"), + format!("/{}/barnacle/info/lfs/objects/batch", owner()), + format!("/{}/barnacle.git/info/lfs/objects/batch", owner()), ]; futures::future::join_all(paths.iter().map(|path| { let world = &world; @@ -123,7 +123,7 @@ async fn the_batch_download_surface_answers_every_addressing_form() { #[tokio::test] async fn the_object_route_streams_ranges_and_stays_anonymous() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "limpet"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("limpet").unwrap()); let (oid, size) = seeded_object(&world, &did); let request = Request::get(format!("/{did}/info/lfs/objects/{oid}")) @@ -144,7 +144,7 @@ async fn the_object_route_streams_ranges_and_stays_anonymous() { .unwrap(); assert_eq!(&bytes[..], MEDIA); - let request = Request::get(format!("/{OWNER}/limpet.git/info/lfs/objects/{oid}")) + let request = Request::get(format!("/{}/limpet.git/info/lfs/objects/{oid}", owner())) .header(header::RANGE, "bytes=3-6") .body(Body::empty()) .unwrap(); @@ -171,7 +171,7 @@ async fn the_object_route_streams_ranges_and_stays_anonymous() { #[tokio::test] async fn missing_and_hostile_objects_get_typed_404s() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "scallop"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("scallop").unwrap()); seeded_object(&world, &did); let absent = absent_oid(); @@ -232,7 +232,7 @@ async fn readiness_reflects_the_lfs_store() { #[tokio::test] async fn hostile_batches_get_clean_typed_rejections() { let world = World::new(); - let (did, _bare, _work) = empty_repo(&world, "conch"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("conch").unwrap()); let (oid, size) = seeded_object(&world, &did); let base = format!("/{did}/info/lfs/objects/batch"); diff --git a/knot2/crates/knot-xrpc/tests/lfs_soak.rs b/knot2/crates/knot-xrpc/tests/lfs_soak.rs index 131271920..e33cac910 100644 --- a/knot2/crates/knot-xrpc/tests/lfs_soak.rs +++ b/knot2/crates/knot-xrpc/tests/lfs_soak.rs @@ -4,7 +4,7 @@ use axum::body::Body; use futures::StreamExt; use http::{Request, StatusCode, header}; use knot_lfs::{LfsOid, LfsStore, Pool}; -use knot_types::RepoDid; +use knot_types::{RepoDid, RepoName}; use sha2::{Digest, Sha256}; use tower::ServiceExt; @@ -112,7 +112,7 @@ async fn batch(world: &World, did: &RepoDid, oids: &[LfsOid], tag: &str) { #[tokio::test(flavor = "multi_thread")] async fn sustained_anonymous_downloads_stay_bounded_and_leak_nothing() { let world = World::unshed(); - let (did, _bare, _work) = empty_repo(&world, "nautilus"); + let (did, _bare, _work) = empty_repo(&world, &RepoName::new("nautilus").unwrap()); let oids = seed_objects(&world, &did); let storm = |round: usize| { diff --git a/knot2/crates/knot-xrpc/tests/mint.rs b/knot2/crates/knot-xrpc/tests/mint.rs index e2b39af6d..1a447c08d 100644 --- a/knot2/crates/knot-xrpc/tests/mint.rs +++ b/knot2/crates/knot-xrpc/tests/mint.rs @@ -6,14 +6,12 @@ use http::StatusCode; use knot_atproto::ServiceJwt; use knot_types::service_auth::{PublicKey, parse_jwt, verify_signature}; -use knot_types::{AccountDid, DidService, Nsid, RepoDid}; +use knot_types::{AccountDid, DidService, Nsid, RepoDid, RepoName, ServiceDid, UnixSeconds}; -use common::{OWNER, World, empty_repo, post_as, post_authed}; +use common::{World, empty_repo, owner, post_as, post_authed}; -const MINT: &str = "/xrpc/org.tangled.repo.mintToken"; - -fn owner() -> AccountDid { - AccountDid::new(OWNER).expect("OWNER parses as an account DID") +fn mint_method() -> Nsid { + Nsid::new_owned("org.tangled.repo.mintToken").expect("the mint method NSID parses") } fn stranger() -> AccountDid { @@ -52,12 +50,22 @@ fn mint_as( exp: i64, ) -> (StatusCode, serde_json::Value) { let body = mint_body(repo, exp); - futures::executor::block_on(post_as(world, MINT, actor.as_str(), repo.as_str(), body)) + futures::executor::block_on(post_as( + world, + &mint_method(), + actor, + &ServiceDid::new(repo.as_str()).unwrap(), + body, + )) } fn token_in(response: &serde_json::Value) -> ServiceJwt { - ServiceJwt::new(response["token"].as_str().expect("The mint returns a token.")) - .expect("This minted token has three JWT segments.") + ServiceJwt::new( + response["token"] + .as_str() + .expect("The mint returns a token."), + ) + .expect("This minted token has three JWT segments.") } fn claims_of(token: &ServiceJwt) -> serde_json::Value { @@ -77,7 +85,7 @@ fn claims_of(token: &ServiceJwt) -> serde_json::Value { #[tokio::test] async fn owner_mint_should_be_signed_by_knot_key_for_repo() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); let (status, response) = mint_as(&world, &repo, &owner(), 1_030); assert_eq!(status, StatusCode::OK, "{response}"); @@ -109,7 +117,7 @@ async fn owner_mint_should_be_signed_by_knot_key_for_repo() { #[tokio::test] async fn stranger_mint_ought_to_return_403_mentioning_roster() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); let (status, response) = mint_as(&world, &repo, &stranger(), 1_030); assert_eq!(status, StatusCode::FORBIDDEN, "{response}"); @@ -123,14 +131,14 @@ async fn stranger_mint_ought_to_return_403_mentioning_roster() { #[tokio::test] async fn freshly_revoked_collaborator_must_not_be_able_to_mint() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); let collaborator = collaborator(); - world.add_collaborator(&repo, collaborator.as_str(), owner().as_str(), 1_000); + world.add_collaborator(&repo, &collaborator, &owner(), UnixSeconds::new(1_000)); let (admitted, _) = mint_as(&world, &repo, &collaborator, 1_030); assert_eq!(admitted, StatusCode::OK); - world.remove_collaborator(&repo, &collaborator, 1_001); + world.remove_collaborator(&repo, &collaborator, UnixSeconds::new(1_001)); let (status, response) = mint_as(&world, &repo, &collaborator, 1_030); assert_eq!(status, StatusCode::FORBIDDEN, "{response}"); assert!( @@ -145,7 +153,7 @@ async fn freshly_revoked_collaborator_must_not_be_able_to_mint() { #[tokio::test] async fn out_of_bounds_exp_should_be_refused() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); for (exp, want) in [(999, "future"), (1_100, "60")] { let (status, response) = mint_as(&world, &repo, &owner(), exp); @@ -158,33 +166,39 @@ async fn out_of_bounds_exp_should_be_refused() { #[tokio::test] async fn mint_without_lxm_ought_to_be_refused() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); let body = serde_json::json!({ "repo": repo, "aud": spindle(), "exp": 1_030, }); - let (status, response) = - futures::executor::block_on(post_as(&world, MINT, owner().as_str(), repo.as_str(), body)); + + let (status, response) = futures::executor::block_on(post_as( + &world, + &mint_method(), + &owner(), + &ServiceDid::new(repo.as_str()).unwrap(), + body, + )); assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); } #[tokio::test] async fn calling_session_should_be_addressed_to_repo() { let world = World::new(); - let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let (repo, _bare, _work) = empty_repo(&world, &RepoName::new("kelp").unwrap()); let body = mint_body(&repo, 1_030); let (status, response) = - futures::executor::block_on(post_authed(&world, MINT, owner().as_str(), body)); + futures::executor::block_on(post_authed(&world, &mint_method(), &owner(), body)); assert_eq!(status, StatusCode::UNAUTHORIZED, "{response}"); } #[tokio::test] async fn mint_for_unhosted_repo_should_return_404() { let world = World::new(); - let _ = empty_repo(&world, "kelp"); + let _ = empty_repo(&world, &RepoName::new("kelp").unwrap()); let body = serde_json::json!({ "repo": foreign_repo(), @@ -194,10 +208,10 @@ async fn mint_for_unhosted_repo_should_return_404() { }); let (status, response) = futures::executor::block_on(post_as( &world, - MINT, - owner().as_str(), - foreign_repo().as_str(), + &mint_method(), + &owner(), + &ServiceDid::new(foreign_repo().as_str()).unwrap(), body, )); assert_eq!(status, StatusCode::NOT_FOUND, "{response}"); -} \ No newline at end of file +} diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 72f47f0e6..0e1b2aeaf 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -9,19 +9,19 @@ use tokio_tungstenite::tungstenite; use knot_capability::Capability; use knot_index::Resolved; -use knot_types::{AccountDid, Oid, RepoDid}; +use knot_types::{AccountDid, Nsid, Oid, RepoDid, RepoName, RepoRkey, UnixSeconds}; use knot_xrpc::{ArchiveLimit, ResponseLimit}; use common::{ - OWNER, World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, - column, commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, + World, archive_full, assert_immutable_round_trip, assert_post_rejected, assert_warming, column, + commit_file, empty_repo, get, get_error, get_json, get_with_headers, git_run, nsid, owner, post_authed, post_json, ref_names, repo_dids, seeded, seeded_feature_branch, sh_git, sh_git_at, }; #[tokio::test] async fn the_seeded_read_surface_renders_each_wire_shape_once() { let world = World::new(); - let (did, work) = seeded(&world, "coral"); + let (did, work) = seeded(&world, &RepoName::new("coral").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let parent = sh_git(work.path(), &["rev-parse", "HEAD~1"]); let tag_object = sh_git(work.path(), &["rev-parse", "v1.0.0"]); @@ -301,7 +301,7 @@ async fn the_seeded_read_surface_renders_each_wire_shape_once() { #[tokio::test] async fn tree_directory_last_commit_is_the_newest_touching_commit() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "periwinkle"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("periwinkle").unwrap()); let work = work.path(); commit_file( work, @@ -354,7 +354,7 @@ async fn tree_directory_last_commit_is_the_newest_touching_commit() { #[tokio::test] async fn languages_timeout_yields_a_partial_answer_not_an_error() { let world = World::new(); - let (did, work) = seeded(&world, "scallop"); + let (did, work) = seeded(&world, &RepoName::new("scallop").unwrap()); let head = Oid::from_hex(&sh_git(work.path(), &["rev-parse", "HEAD"])).unwrap(); let repo = world.layout.open(&did).unwrap(); @@ -378,7 +378,7 @@ async fn languages_timeout_yields_a_partial_answer_not_an_error() { #[tokio::test] async fn compare_format_patch_keeps_a_non_ascii_author_raw() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "mussel"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("mussel").unwrap()); let work = work.path(); commit_file( work, @@ -439,7 +439,7 @@ async fn compare_format_patch_keeps_a_non_ascii_author_raw() { #[tokio::test] async fn compare_reports_the_merge_base_of_diverged_branches() { let world = World::new(); - let (did, work) = seeded(&world, "woofie"); + let (did, work) = seeded(&world, &RepoName::new("woofie").unwrap()); let work = work.path(); let bare = world.layout.repo_path(&did).unwrap(); let merge_base = sh_git(work, &["rev-parse", "HEAD"]); @@ -484,7 +484,7 @@ async fn compare_reports_the_merge_base_of_diverged_branches() { #[tokio::test] async fn diff_reports_structured_fragments_and_stats() { let world = World::new(); - let (did, work) = seeded(&world, "whelk"); + let (did, work) = seeded(&world, &RepoName::new("whelk").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let value = get_json( @@ -520,7 +520,7 @@ async fn diff_reports_structured_fragments_and_stats() { #[tokio::test] async fn compare_produces_format_patches_and_a_combined_patch() { let world = World::new(); - let (did, work) = seeded(&world, "conch"); + let (did, work) = seeded(&world, &RepoName::new("conch").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let base = sh_git(work.path(), &["rev-parse", "HEAD~3"]); @@ -575,7 +575,7 @@ async fn compare_produces_format_patches_and_a_combined_patch() { #[tokio::test] async fn archive_conditional_and_range_semantics() { let world = World::new(); - let (did, _work) = seeded(&world, "nautilus"); + let (did, _work) = seeded(&world, &RepoName::new("nautilus").unwrap()); let path = format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main"); let (status, headers, full) = get(&world, &path).await; @@ -730,7 +730,7 @@ async fn archive_conditional_and_range_semantics() { #[tokio::test] async fn archive_etag_distinguishes_refs_that_share_a_commit() { let world = World::new(); - let (did, work) = seeded(&world, "scallop"); + let (did, work) = seeded(&world, &RepoName::new("scallop").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); sh_git(work.path(), &["branch", "release", "main"]); sh_git( @@ -778,7 +778,7 @@ async fn archive_etag_distinguishes_refs_that_share_a_commit() { #[tokio::test] async fn archive_link_advertises_the_prefix_it_served() { let world = World::new(); - let (did, _work) = seeded(&world, "periwinkle"); + let (did, _work) = seeded(&world, &RepoName::new("periwinkle").unwrap()); let query = |suffix: &str| format!("/xrpc/sh.tangled.repo.archive?repo={did}&ref=main{suffix}"); for (suffix, prefix, filename) in [ @@ -796,7 +796,7 @@ async fn archive_link_advertises_the_prefix_it_served() { assert_eq!( headers[header::CONTENT_DISPOSITION], format!("attachment; filename=\"{filename}\""), - "the filename follows the prefix with the separator flattened, and the knot spells a did-addressed repo with the rkey it registered under" + "the filename follows the prefix with the separator flattened, and a did-addressed repo's filename gets its rkey" ); let etag = headers[header::ETAG].to_str().unwrap().to_string(); assert_immutable_round_trip(&world, &headers, &full, &etag).await; @@ -814,7 +814,7 @@ async fn archive_link_advertises_the_prefix_it_served() { #[tokio::test] async fn archive_serves_a_sha256_repo_with_a_stable_etag() { let world = World::sha256(); - let (did, _work) = seeded(&world, "nautilus"); + let (did, _work) = seeded(&world, &RepoName::new("nautilus").unwrap()); let (status, headers, full) = get( &world, @@ -855,7 +855,7 @@ async fn archive_serves_a_sha256_repo_with_a_stable_etag() { #[tokio::test] async fn languages_detect_rust_and_markdown_stays_out() { let world = World::new(); - let (did, _work) = seeded(&world, "uni"); + let (did, _work) = seeded(&world, &RepoName::new("uni").unwrap()); let value = get_json( &world, @@ -877,7 +877,7 @@ async fn languages_detect_rust_and_markdown_stays_out() { #[tokio::test] async fn repo_metadata_resolves_and_fails_closed() { let world = World::new(); - let (did, _work) = seeded(&world, "cuttle"); + let (did, _work) = seeded(&world, &RepoName::new("cuttle").unwrap()); let value = get_json( &world, @@ -894,7 +894,7 @@ async fn repo_metadata_resolves_and_fails_closed() { ) .await; assert_eq!(described["repoDid"], did.as_str()); - assert_eq!(described["ownerDid"], OWNER); + assert_eq!(described["ownerDid"], owner().as_str()); assert_eq!(described["rkey"], "cuttle"); assert_eq!( get_error( @@ -907,7 +907,7 @@ async fn repo_metadata_resolves_and_fails_closed() { let by_owner = get_json( &world, - &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={OWNER}/cuttle"), + &format!("/xrpc/sh.tangled.repo.getDefaultBranch?repo={}/cuttle", owner()), ) .await; assert_eq!(by_owner["name"], "main"); @@ -941,7 +941,7 @@ async fn repo_metadata_resolves_and_fails_closed() { #[tokio::test] async fn list_refs_reports_paginates_and_drains() { let world = World::new(); - let (did, work) = seeded(&world, "whelk"); + let (did, work) = seeded(&world, &RepoName::new("whelk").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; @@ -1001,7 +1001,7 @@ async fn list_refs_reports_paginates_and_drains() { #[tokio::test] async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { let world = World::new(); - let (did, work) = seeded(&world, "limpet"); + let (did, work) = seeded(&world, &RepoName::new("limpet").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); sh_git(work.path(), &["checkout", "-q", "-b", "upstream"]); @@ -1072,7 +1072,7 @@ async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { #[tokio::test] async fn the_cob_ref_namespace_is_invisible_across_every_read() { let world = World::new(); - let (did, work) = seeded(&world, "anemone"); + let (did, work) = seeded(&world, &RepoName::new("anemone").unwrap()); let head = sh_git(work.path(), &["rev-parse", "HEAD"]); let bare = world.layout.repo_path(&did).unwrap(); let cob = "refs/cobs/sh.tangled.repo.collaborator/x"; @@ -1192,7 +1192,7 @@ async fn the_cob_ref_namespace_is_invisible_across_every_read() { #[tokio::test] async fn the_list_reads_reject_malformed_paging_params() { let world = World::new(); - let (did, _work) = seeded(&world, "barnacle"); + let (did, _work) = seeded(&world, &RepoName::new("barnacle").unwrap()); let queries: Vec = vec![ format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=abc"), @@ -1225,9 +1225,9 @@ async fn the_list_reads_reject_malformed_paging_params() { #[tokio::test] async fn list_repos_lists_hosted_repos_with_order_and_pagination() { let world = World::new(); - let (mussel, _a) = seeded(&world, "mussel"); - let (nautilus, _b) = seeded(&world, "nautilus"); - let (scallop, _c) = seeded(&world, "scallop"); + let (mussel, _a) = seeded(&world, &RepoName::new("mussel").unwrap()); + let (nautilus, _b) = seeded(&world, &RepoName::new("nautilus").unwrap()); + let (scallop, _c) = seeded(&world, &RepoName::new("scallop").unwrap()); let desc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos").await; assert_eq!( @@ -1300,7 +1300,7 @@ async fn every_projection_read_fails_closed_while_warming() { #[tokio::test] async fn branch_tips_render_edge_shapes() { let world = World::new(); - let (did, work) = seeded(&world, "trochus"); + let (did, work) = seeded(&world, &RepoName::new("trochus").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); let bare_str = bare.to_str().unwrap().to_string(); @@ -1391,7 +1391,7 @@ async fn branch_tips_render_edge_shapes() { #[tokio::test] async fn a_submodule_path_in_the_tree_is_path_not_found() { let world = World::new(); - let (did, work) = seeded(&world, "razorclam"); + let (did, work) = seeded(&world, &RepoName::new("razorclam").unwrap()); let bare = world .layout .repo_path(&did) @@ -1425,7 +1425,7 @@ async fn a_submodule_path_in_the_tree_is_path_not_found() { #[tokio::test] async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { let world = World::with_response_limit(ResponseLimit::new(1024)); - let (did, bare, work) = empty_repo(&world, "auger"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("auger").unwrap()); commit_file( work.path(), "big.txt", @@ -1458,7 +1458,7 @@ async fn a_blob_past_the_derived_serving_limit_is_a_named_error() { #[tokio::test] async fn blame_refuses_a_blob_past_its_work_limit() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "whelk"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("whelk").unwrap()); commit_file( work.path(), "big.txt", @@ -1480,7 +1480,7 @@ async fn blame_refuses_a_blob_past_its_work_limit() { #[tokio::test] async fn blame_accepts_many_short_lines_below_the_byte_limit() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "limpet"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("limpet").unwrap()); commit_file( work.path(), "many.txt", @@ -1502,7 +1502,7 @@ async fn blame_accepts_many_short_lines_below_the_byte_limit() { #[tokio::test] async fn blame_refuses_a_large_historical_blob_behind_a_small_target() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "barnacle"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("barnacle").unwrap()); let large = "x\n".repeat(524_289); commit_file( work.path(), @@ -1531,7 +1531,7 @@ async fn blame_refuses_a_large_historical_blob_behind_a_small_target() { #[tokio::test] async fn an_oversized_readme_is_omitted_from_the_tree() { let world = World::with_response_limit(ResponseLimit::new(2_048)); - let (did, bare, work) = empty_repo(&world, "cowrie"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("cowrie").unwrap()); commit_file( work.path(), "README.md", @@ -1556,7 +1556,7 @@ async fn an_oversized_readme_is_omitted_from_the_tree() { #[tokio::test] async fn a_comparison_spanning_too_many_commits_is_refused() { let world = World::new(); - let (did, work) = seeded(&world, "abalone"); + let (did, work) = seeded(&world, &RepoName::new("abalone").unwrap()); let bare = world .layout .repo_path(&did) @@ -1586,7 +1586,7 @@ async fn a_comparison_spanning_too_many_commits_is_refused() { #[tokio::test] async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { let world = World::new(); - let (did, work) = seeded(&world, "cockle"); + let (did, work) = seeded(&world, &RepoName::new("cockle").unwrap()); let bare = world .layout .repo_path(&did) @@ -1625,7 +1625,7 @@ async fn archive_rejects_traversal_prefixes_and_sanitizes_the_filename() { #[tokio::test] async fn an_archive_larger_than_the_configured_limit_is_refused() { let world = World::with_archive_limit(ArchiveLimit::new(64)); - let (did, _work) = seeded(&world, "murex"); + let (did, _work) = seeded(&world, &RepoName::new("murex").unwrap()); let (status, error) = get_error( &world, @@ -1639,7 +1639,7 @@ async fn an_archive_larger_than_the_configured_limit_is_refused() { #[tokio::test] async fn an_oversized_read_response_is_refused() { let world = World::with_response_limit(ResponseLimit::new(256)); - let (did, _work) = seeded(&world, "clam"); + let (did, _work) = seeded(&world, &RepoName::new("clam").unwrap()); let (status, error) = get_error( &world, @@ -1668,7 +1668,7 @@ async fn an_oversized_read_response_is_refused() { #[tokio::test] async fn languages_omit_files_with_zero_size() { let world = World::new(); - let (did, bare, work) = empty_repo(&world, "topshell"); + let (did, bare, work) = empty_repo(&world, &RepoName::new("topshell").unwrap()); commit_file( work.path(), "lib.rs", @@ -1702,7 +1702,8 @@ async fn a_sha256_compare_patch_round_trips_through_merge_check() { } async fn compare_round_trip(world: World) { - let (did, main_sha, feature_sha) = seeded_feature_branch(&world, "periwinkle"); + let (did, main_sha, feature_sha) = + seeded_feature_branch(&world, &RepoName::new("periwinkle").unwrap()); let compared = get_json( &world, @@ -1793,9 +1794,21 @@ async fn compare_round_trip(world: World) { #[tokio::test] async fn list_members_pages_in_the_wire_shape() { let world = World::new(); - world.add_member("did:plc:limpet", OWNER, 1_000); - world.add_member("did:plc:scallop", OWNER, 2_000); - world.add_member("did:plc:whelk", OWNER, 3_000); + world.add_member( + &AccountDid::new("did:plc:limpet").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.add_member( + &AccountDid::new("did:plc:scallop").unwrap(), + &owner(), + UnixSeconds::new(2_000), + ); + world.add_member( + &AccountDid::new("did:plc:whelk").unwrap(), + &owner(), + UnixSeconds::new(3_000), + ); let page = get_json( &world, @@ -1805,7 +1818,7 @@ async fn list_members_pages_in_the_wire_shape() { let items = page["items"].as_array().unwrap(); assert_eq!(items.len(), 2, "default order is createdAt descending"); assert_eq!(items[0]["subject"], "did:plc:whelk"); - assert_eq!(items[0]["addedBy"], OWNER); + assert_eq!(items[0]["addedBy"], owner().as_str()); assert_eq!(items[0]["createdAt"], "1970-01-01T00:50:00Z"); assert!( items[0].get("uri").is_none() && items[0].get("cid").is_none(), @@ -1837,9 +1850,21 @@ async fn list_members_pages_in_the_wire_shape() { #[tokio::test] async fn a_members_page_orders_on_when_each_member_was_added() { let world = World::new(); - world.add_member("did:plc:cowrie", OWNER, 500); - world.add_member("did:plc:limpet", OWNER, 1_000); - world.add_member("did:plc:whelk", OWNER, 2_000); + world.add_member( + &AccountDid::new("did:plc:cowrie").unwrap(), + &owner(), + UnixSeconds::new(500), + ); + world.add_member( + &AccountDid::new("did:plc:limpet").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.add_member( + &AccountDid::new("did:plc:whelk").unwrap(), + &owner(), + UnixSeconds::new(2_000), + ); let page = get_json( &world, @@ -1849,7 +1874,7 @@ async fn a_members_page_orders_on_when_each_member_was_added() { assert_eq!( column(&page, "subject"), ["did:plc:cowrie", "did:plc:limpet", "did:plc:whelk"], - "the roll is the truth, so every entry lists, ordered on when it was added" + "the listMembers page returns every grant list entry, ordered by when each member was added" ); assert_eq!( column(&page, "createdAt"), @@ -1858,27 +1883,31 @@ async fn a_members_page_orders_on_when_each_member_was_added() { "1970-01-01T00:16:40Z", "1970-01-01T00:33:20Z" ], - "createdAt is the add time, and nothing later moves a row a reader is walking" + "createdAt is the add time, and a later change never moves a row that a reader is paging through" ); - assert_eq!(page["items"][0]["addedBy"], OWNER); + assert_eq!(page["items"][0]["addedBy"], owner().as_str()); assert!( page.get("cursor").is_none(), - "three rows fit inside the default limit, so the page ends with no cursor" + "three rows fit inside the default limit, so the page doesn't end with a cursor" ); } #[tokio::test] -async fn a_banned_member_keeps_its_entry_on_the_members_roll() { +async fn a_banned_member_keeps_its_entry_on_the_members_grant_list() { let world = World::new(); - world.add_member("did:plc:whelk", OWNER, 1_000); - world.ban("did:plc:whelk", 4_000); + world.add_member( + &AccountDid::new("did:plc:whelk").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.ban(&AccountDid::new("did:plc:whelk").unwrap(), UnixSeconds::new(4_000)); assert_eq!( world .state .index .effective_member(&AccountDid::new("did:plc:whelk").unwrap()), Resolved::Ready(true), - "the blocklist runs its own roll, and whelk's entry on the members roll survives a ban" + "the blocklist runs its own grant list, and a ban doesn't remove whelk's entry on the members grant list" ); } @@ -1893,14 +1922,18 @@ async fn the_member_listings_reject_a_subject_that_names_another_knot() { assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!( error, "InvalidSubject", - "subject must name this knot, whatever roll the method reads" + "subject must identify this knot, whatever grant list the method reads" ); } #[tokio::test] async fn list_members_rejects_malformed_params_and_clamps_the_limit() { let world = World::new(); - world.add_member("did:plc:limpet", OWNER, 1_000); + world.add_member( + &AccountDid::new("did:plc:limpet").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); let queries: &[&str] = &[ "limit=abc&subject=did:web:knot.nel.pet", @@ -1933,10 +1966,28 @@ async fn list_collaborators_is_scoped_to_the_repo() { let clam = RepoDid::new("did:plc:clam").unwrap(); world.layout.create(&squid).unwrap(); world.layout.create(&clam).unwrap(); - world.register(&squid, "squid"); - world.register(&clam, "clam"); - world.add_collaborator(&squid, "did:plc:lyna", OWNER, 1_000); - world.add_collaborator(&clam, "did:plc:bailey", OWNER, 2_000); + world.register( + &squid, + &RepoName::new("squid").unwrap(), + &RepoRkey::new("squid").unwrap(), + ); + world.register( + &clam, + &RepoName::new("clam").unwrap(), + &RepoRkey::new("clam").unwrap(), + ); + world.add_collaborator( + &squid, + &AccountDid::new("did:plc:lyna").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.add_collaborator( + &clam, + &AccountDid::new("did:plc:bailey").unwrap(), + &owner(), + UnixSeconds::new(2_000), + ); let page = get_json( &world, @@ -1946,7 +1997,7 @@ async fn list_collaborators_is_scoped_to_the_repo() { let items = page["items"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["subject"], "did:plc:lyna"); - assert_eq!(items[0]["addedBy"], OWNER); + assert_eq!(items[0]["addedBy"], owner().as_str()); assert_eq!(items[0]["createdAt"], "1970-01-01T00:16:40Z"); assert!(items[0].get("uri").is_none() && items[0].get("cid").is_none()); @@ -1971,8 +2022,16 @@ async fn list_collaborators_is_scoped_to_the_repo() { #[tokio::test] async fn the_subject_tie_break_stays_ascending_in_both_directions() { let world = World::new(); - world.add_member("did:plc:whelk", OWNER, 1_000); - world.add_member("did:plc:limpet", OWNER, 1_000); + world.add_member( + &AccountDid::new("did:plc:whelk").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); + world.add_member( + &AccountDid::new("did:plc:limpet").unwrap(), + &owner(), + UnixSeconds::new(1_000), + ); let asc = get_json( &world, @@ -2028,7 +2087,7 @@ async fn service_metadata_endpoints_answer() { assert_eq!(wire["capabilities"], serde_json::json!(served)); let owner = get_json(&world, "/xrpc/sh.tangled.owner").await; - assert_eq!(owner["owner"], OWNER); + assert_eq!(owner["owner"], common::owner().as_str()); } async fn serve_router(world: &World) -> std::net::SocketAddr { @@ -2107,7 +2166,7 @@ async fn one_peer_opens_four_subscriptions_and_fifth_is_refused() { #[tokio::test] async fn set_default_branch_resolves_a_repo_did_and_an_existing_branch() { let world = World::new(); - let (did, work) = seeded(&world, "coral"); + let (did, work) = seeded(&world, &RepoName::new("coral").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); sh_git(work.path(), &["branch", "release", "main"]); sh_git( @@ -2117,8 +2176,8 @@ async fn set_default_branch_resolves_a_repo_did_and_an_existing_branch() { let (status, _) = post_authed( &world, - "/xrpc/sh.tangled.repo.setDefaultBranch", - OWNER, + &nsid("sh.tangled.repo.setDefaultBranch"), + &owner(), serde_json::json!({ "repo": did, "defaultBranch": "release", @@ -2138,7 +2197,7 @@ async fn set_default_branch_resolves_a_repo_did_and_an_existing_branch() { #[tokio::test] async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { let world = World::new(); - let (did, work) = seeded(&world, "kelp"); + let (did, work) = seeded(&world, &RepoName::new("kelp").unwrap()); let bare = world.layout.repo_path(&did).unwrap(); sh_git(work.path(), &["branch", "feature", "main"]); sh_git( @@ -2148,8 +2207,8 @@ async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { let (status, _) = post_authed( &world, - "/xrpc/sh.tangled.repo.deleteBranch", - OWNER, + &nsid("sh.tangled.repo.deleteBranch"), + &owner(), serde_json::json!({ "repo": did, "branch": "feature" }), ) .await; @@ -2157,8 +2216,8 @@ async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { let (status, body) = post_authed( &world, - "/xrpc/sh.tangled.repo.deleteBranch", - OWNER, + &nsid("sh.tangled.repo.deleteBranch"), + &owner(), serde_json::json!({ "repo": did, "branch": "feature" }), ) .await; @@ -2168,34 +2227,34 @@ async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { #[tokio::test] async fn bad_post_bodies_are_invalid_request() { let world = World::new(); - let (_kelp, _wk) = seeded(&world, "kelp"); - let (_barnacle, _wb) = seeded(&world, "barnacle"); + let (_kelp, _wk) = seeded(&world, &RepoName::new("kelp").unwrap()); + let (_barnacle, _wb) = seeded(&world, &RepoName::new("barnacle").unwrap()); - let cases: &[(&str, serde_json::Value)] = &[ + let cases: &[(Nsid, serde_json::Value)] = &[ ( - "/xrpc/sh.tangled.repo.setDefaultBranch", + nsid("sh.tangled.repo.setDefaultBranch"), serde_json::json!({ "repo": "not-a-repo-did", "defaultBranch": "main" }), ), ( - "/xrpc/sh.tangled.repo.deleteBranch", + nsid("sh.tangled.repo.deleteBranch"), serde_json::json!({ "repo": "did:plc:kelpfixture", "branch": "bad branch", }), ), ( - "/xrpc/sh.tangled.repo.forkSync", + nsid("sh.tangled.repo.forkSync"), serde_json::json!({ "repo": "did:plc:barnaclefixture", "branch": "bad branch" }), ), ( - "/xrpc/sh.tangled.repo.hiddenRef", + nsid("sh.tangled.repo.hiddenRef"), serde_json::json!({ "repo": "nope", "forkRef": "feature", "remoteRef": "main" }), ), ]; let w = &world; stream::iter(cases) - .for_each(|(path, value)| async move { - assert_post_rejected(w, path, OWNER, value.clone()).await; + .for_each(|(method, value)| async move { + assert_post_rejected(w, method, &owner(), value.clone()).await; }) .await; } @@ -2204,7 +2263,8 @@ async fn bad_post_bodies_are_invalid_request() { #[ignore = "sh.tangled.repo.merge is unrouted since web calls gitmirror for git.mergeCommit"] async fn merge_applies_a_patch_under_the_supplied_author() { let world = World::new(); - let (_did, main_sha, feature_sha) = seeded_feature_branch(&world, "mussel"); + let (_did, main_sha, feature_sha) = + seeded_feature_branch(&world, &RepoName::new("mussel").unwrap()); let registered = RepoDid::new("did:plc:musselfixture").unwrap(); let compared = get_json( @@ -2218,8 +2278,8 @@ async fn merge_applies_a_patch_under_the_supplied_author() { let (status, body) = post_authed( &world, - "/xrpc/sh.tangled.repo.merge", - OWNER, + &nsid("sh.tangled.repo.merge"), + &owner(), serde_json::json!({ "repo": registered, "branch": "main", @@ -2252,11 +2312,11 @@ async fn merge_applies_a_patch_under_the_supplied_author() { #[tokio::test] async fn create_mints_a_did_plc_repo_with_the_requested_default_branch() { let world = World::new(); - world.add_member(OWNER, OWNER, 1_000); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); let (status, body) = post_authed( &world, - "/xrpc/org.tangled.repo.createRepo", - OWNER, + &nsid("org.tangled.repo.createRepo"), + &owner(), serde_json::json!({ "name": "squid", "defaultBranch": "trunk" }), ) .await; diff --git a/knot2/crates/knot-xrpc/tests/ref_records.rs b/knot2/crates/knot-xrpc/tests/ref_records.rs index 91f4e5102..092c1cb86 100644 --- a/knot2/crates/knot-xrpc/tests/ref_records.rs +++ b/knot2/crates/knot-xrpc/tests/ref_records.rs @@ -1,16 +1,20 @@ mod common; use common::{ - KNOT_HOST, OWNER, World, commit_file, empty_repo, get_json, http_push, percent_encode, - post_authed, serve_pushes, sh_git, + World, commit_file, empty_repo, get_json, http_push, nsid, owner, percent_encode, post_authed, + serve_pushes, sh_git, }; use knot_events::{EventCursor, ReplayBounds, ReplayBytes, ReplayEvents}; use knot_record::{chain, git_ref}; -use knot_types::{RepoDid, UnixMicros}; +use knot_types::{ + AccountDid, Did, KnotServiceUrl, RecordCollection, RepoDid, RepoName, UnixMicros, UnixSeconds +}; use serde::Deserialize; use serde_json::Value; -const COLLECTION: &str = "sh.tangled.git.ref"; +fn collection() -> RecordCollection { + RecordCollection::new("sh.tangled.git.ref").unwrap() +} fn seed(work: &std::path::Path, contents: &[u8]) { commit_file( @@ -26,8 +30,9 @@ async fn records(world: &World, did: &RepoDid) -> Vec { get_json( world, &format!( - "/xrpc/com.atproto.repo.listRecords?repo={}&collection={COLLECTION}", - percent_encode(did.as_str()) + "/xrpc/com.atproto.repo.listRecords?repo={}&collection={}", + percent_encode(did.as_str()), + collection() ), ) .await["records"] @@ -42,7 +47,7 @@ fn record_of<'r>(items: &'r [Value], rkey: &str) -> &'r Value { .find(|item| { item["uri"] .as_str() - .is_some_and(|uri| uri.ends_with(&format!("{COLLECTION}/{rkey}"))) + .is_some_and(|uri| uri.ends_with(&format!("{}/{rkey}", collection()))) }) .unwrap_or_else(|| panic!("no record for rkey {rkey} in {items:?}")) } @@ -93,17 +98,17 @@ fn frames(world: &World) -> Vec<(&'static str, Option)> { #[tokio::test] async fn a_push_lands_one_record_per_ref_the_knot_serves_over_com_atproto() { let world = World::new(); - let (did, _bare, work) = empty_repo(&world, "squid"); + let (did, _bare, work) = empty_repo(&world, &RepoName::new("squid").unwrap()); seed(work.path(), b"hello\n"); sh_git(work.path(), &["tag", "v1"]); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &did, OWNER, &["main", "v1"]).await; + http_push(work.path(), addr, &did, &owner(), &["main", "v1"]).await; let items = records(&world, &did).await; assert_eq!(items.len(), 2, "one record per live ref: {items:?}"); let main = record_of(&items, "refs~2fheads~2fmain"); assert_eq!( - main["value"]["$type"], COLLECTION, + main["value"]["$type"], collection().as_str(), "the stored bytes carry their own type" ); assert_eq!( @@ -112,7 +117,7 @@ async fn a_push_lands_one_record_per_ref_the_knot_serves_over_com_atproto() { "the record value is the ref's present object" ); assert_eq!( - main["value"]["x-tngl-editor"], OWNER, + main["value"]["x-tngl-editor"], owner().as_str(), "the pusher rides the undeclared editor field" ); assert_eq!( @@ -135,14 +140,14 @@ async fn a_push_lands_one_record_per_ref_the_knot_serves_over_com_atproto() { ); sh_git(work.path(), &["branch", "feature"]); - http_push(work.path(), addr, &did, OWNER, &["feature"]).await; + http_push(work.path(), addr, &did, &owner(), &["feature"]).await; assert_eq!( records(&world, &did).await.len(), 3, "the branch's record landed before anything deletes it" ); - http_push(work.path(), addr, &did, OWNER, &[":feature"]).await; + http_push(work.path(), addr, &did, &owner(), &[":feature"]).await; let items = records(&world, &did).await; assert_eq!(items.len(), 2, "the deleted branch's record went with it"); assert!(record_of(&items, "refs~2fheads~2fmain")["value"].is_object()); @@ -155,7 +160,7 @@ async fn a_push_lands_one_record_per_ref_the_knot_serves_over_com_atproto() { .collect::>(); assert_eq!( deletes, - [format!("{COLLECTION}/refs~2fheads~2ffeature")], + [format!("{}/refs~2fheads~2ffeature", collection())], "the deletion rides the op list as a deleteRecord" ); } @@ -163,11 +168,12 @@ async fn a_push_lands_one_record_per_ref_the_knot_serves_over_com_atproto() { #[tokio::test] async fn the_pusher_and_the_skip_ci_option_ride_the_undeclared_fields() { let world = World::new(); - let (did, _bare, work) = empty_repo(&world, "anemone"); + let (did, _bare, work) = empty_repo(&world, &RepoName::new("anemone").unwrap()); seed(work.path(), b"first\n"); - world.add_collaborator(&did, "did:plc:limpet", OWNER, 1_000); + let limpet = AccountDid::new("did:plc:limpet").unwrap(); + world.add_collaborator(&did, &limpet, &owner(), UnixSeconds::new(1_000)); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &did, OWNER, &["-o", "skip-ci", "main"]).await; + http_push(work.path(), addr, &did, &owner(), &["-o", "skip-ci", "main"]).await; commit_file( work.path(), @@ -177,13 +183,13 @@ async fn the_pusher_and_the_skip_ci_option_ride_the_undeclared_fields() { "2026-06-01T12:31:00+02:00", ); sh_git(work.path(), &["checkout", "-q", "-b", "feature"]); - http_push(work.path(), addr, &did, "did:plc:limpet", &["feature"]).await; + http_push(work.path(), addr, &did, &limpet, &["feature"]).await; let items = records(&world, &did).await; assert_eq!(items.len(), 2, "main and feature, one record each"); let main = record_of(&items, "refs~2fheads~2fmain"); assert_eq!( - main["value"]["x-tngl-editor"], OWNER, + main["value"]["x-tngl-editor"], owner().as_str(), "a push that doesn't touch main leaves its stored record bytes alone" ); assert_eq!( @@ -204,7 +210,7 @@ async fn the_pusher_and_the_skip_ci_option_ride_the_undeclared_fields() { .unwrap() .unwrap() .git; - http_push(work.path(), addr, &did, OWNER, &["feature"]).await; + http_push(work.path(), addr, &did, &owner(), &["feature"]).await; assert_eq!( chain::tip(&world.layout.open(&did).unwrap()) .unwrap() @@ -223,13 +229,13 @@ async fn the_pusher_and_the_skip_ci_option_ride_the_undeclared_fields() { #[tokio::test] async fn a_mirror_sized_first_push_chunks_at_two_hundred_ops() { let world = World::new(); - let (did, _bare, work) = empty_repo(&world, "atoll"); + let (did, _bare, work) = empty_repo(&world, &RepoName::new("atoll").unwrap()); seed(work.path(), b"first\n"); (1..=250).for_each(|branch| { sh_git(work.path(), &["branch", &format!("b{branch}")]); }); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &did, OWNER, &["--all"]).await; + http_push(work.path(), addr, &did, &owner(), &["--all"]).await; let repo = world.layout.open(&did).unwrap(); let tip = chain::tip(&repo).unwrap().expect("the chain exists"); @@ -260,7 +266,7 @@ async fn a_mirror_sized_first_push_chunks_at_two_hundred_ops() { op.action == "create" && (op .path - .starts_with(&format!("{COLLECTION}/refs~2fheads~2f")) + .starts_with(&format!("{}/refs~2fheads~2f", collection())) || op.path == "org.tangled.git.refs/self") }), "every op names a head ref record or the summary seeded at birth: {:?}", @@ -271,7 +277,7 @@ async fn a_mirror_sized_first_push_chunks_at_two_hundred_ops() { .iter() .filter(|op| { op.path - .starts_with(&format!("{COLLECTION}/refs~2fheads~2f")) + .starts_with(&format!("{}/refs~2fheads~2f", collection())) }) .count(); *seq @@ -287,10 +293,10 @@ async fn a_mirror_sized_first_push_chunks_at_two_hundred_ops() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_boot_pass_catches_up_a_ref_that_moved_without_a_record() { let world = World::new(); - let (did, _bare, work) = empty_repo(&world, "coral"); + let (did, _bare, work) = empty_repo(&world, &RepoName::new("coral").unwrap()); seed(work.path(), b"first\n"); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &did, OWNER, &["main"]).await; + http_push(work.path(), addr, &did, &owner(), &["main"]).await; let repo = world.layout.open(&did).unwrap(); let main = repo @@ -343,7 +349,7 @@ async fn the_boot_pass_catches_up_a_ref_that_moved_without_a_record() { let long = "a".repeat(git_ref::MAX_RKEY_BYTES); sh_git(work.path(), &["branch", &long]); - http_push(work.path(), addr, &did, OWNER, &["main", &long]).await; + http_push(work.path(), addr, &did, &owner(), &["main", &long]).await; let items = records(&world, &did).await; assert_eq!( items.len(), @@ -360,23 +366,26 @@ async fn the_boot_pass_catches_up_a_ref_that_moved_without_a_record() { #[tokio::test] async fn created_fork_serves_its_upstream_refs_at_birth() { let world = World::administered(); - world.add_member(OWNER, OWNER, 1_000); - let (source_did, _bare, work) = empty_repo(&world, "anchorage"); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); + let (source_did, _bare, work) = empty_repo(&world, &RepoName::new("anchorage").unwrap()); seed(work.path(), b"base\n"); sh_git(work.path(), &["tag", "v1"]); let addr = serve_pushes(&world).await; - http_push(work.path(), addr, &source_did, OWNER, &["main", "v1"]).await; + http_push(work.path(), addr, &source_did, &owner(), &["main", "v1"]).await; - world.publish_plc_pds(source_did.as_str(), &format!("https://{KNOT_HOST}")); + world.publish_plc_pds( + &Did::new_owned(source_did.as_str()).unwrap(), + &KnotServiceUrl::new("https://knot.nel.pet").unwrap(), + ); let (status, created) = post_authed( &world, - "/xrpc/org.tangled.repo.createRepo", - OWNER, + &nsid("org.tangled.repo.createRepo"), + &owner(), serde_json::json!({ "name": "reef", "source": { "$type": "org.tangled.repo.defs#sourceTangled", - "did": source_did.as_str(), + "did": &source_did, }, }), ) @@ -411,7 +420,7 @@ async fn created_fork_serves_its_upstream_refs_at_birth() { "the seeded record names the upstream tip" ); assert_eq!( - main["value"]["$type"], COLLECTION, + main["value"]["$type"], collection().as_str(), "the seeded bytes carry the collection type" ); assert!( @@ -423,11 +432,11 @@ async fn created_fork_serves_its_upstream_refs_at_birth() { #[tokio::test] async fn create_repo_writes_its_manifest_into_the_repo_account() { let world = World::new(); - world.add_member(OWNER, OWNER, 1_000); + world.add_member(&owner(), &owner(), UnixSeconds::new(1_000)); let (status, created) = post_authed( &world, - "/xrpc/org.tangled.repo.createRepo", - OWNER, + &nsid("org.tangled.repo.createRepo"), + &owner(), serde_json::json!({ "name": "Lagoon", "description": "a shallow lagoon", @@ -448,7 +457,7 @@ async fn create_repo_writes_its_manifest_into_the_repo_account() { .await; let value = &record["value"]; assert_eq!(value["$type"], "org.tangled.repo.manifest"); - assert_eq!(value["declaration"]["owner"], OWNER); + assert_eq!(value["declaration"]["owner"], owner().as_str()); assert_eq!(value["declaration"]["name"], "Lagoon"); assert_eq!(value["description"], "a shallow lagoon"); assert_eq!(value["topics"], serde_json::json!(["reef", "tide"])); @@ -472,8 +481,8 @@ async fn the_legacy_create_route_is_gone() { let world = World::new(); let (status, _) = post_authed( &world, - "/xrpc/sh.tangled.repo.create", - OWNER, + &nsid("sh.tangled.repo.create"), + &owner(), serde_json::json!({ "rkey": "lagoon", "name": "lagoon" }), ) .await; diff --git a/lexicons/knot/member.json b/lexicons/knot/member.json index 599aadf76..94a4d33f1 100644 --- a/lexicons/knot/member.json +++ b/lexicons/knot/member.json @@ -6,11 +6,11 @@ "defs": { "main": { "type": "record", - "description": "DEPRECATED: knot member list is not published any more.", + "description": "DEPRECATED: replaced by org.tangled.knot.member.", "key": "tid", "record": { "type": "object", - "description": "DEPRECATED: knot member list is not published any more.", + "description": "DEPRECATED: replaced by org.tangled.knot.member.", "required": [ "subject", "domain", diff --git a/lexicons/org/tangled/knot/member.json b/lexicons/org/tangled/knot/member.json new file mode 100644 index 000000000..207ca96fd --- /dev/null +++ b/lexicons/org/tangled/knot/member.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "org.tangled.knot.member", + "defs": { + "main": { + "type": "record", + "description": "Offer of membership on this knot, w/ member-DID as rkey.", + "key": "any", + "record": { + "type": "object", + "required": [ + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} diff --git a/lexicons/repo/setContributionPolicy.json b/lexicons/repo/setContributionPolicy.json index 82d7c813b..a00afce1b 100644 --- a/lexicons/repo/setContributionPolicy.json +++ b/lexicons/repo/setContributionPolicy.json @@ -26,7 +26,7 @@ "members", "collaborators" ], - "description": "anyone: any account with a DID can contribute. Members: accounts on this knot's member roll. Collaborators: the owner and the repository's collaborators." + "description": "anyone: any account with a DID can contribute. Members: accounts on this knot's member grant list. Collaborators: the owner and the repository's collaborators." } } }