This repository has no description
1mod common;
2
3use common::{
4 account, at, build_members, cob_ref, fixture, forked_members, forked_members_object, grant,
5 home, members_store, owner_of, registration, registry_with, rkey, signer, write_cob_commit,
6};
7use knot_cob::{ChangePayload, CobError, CobHome, CobId, CobStore};
8use knot_cobs::{
9 CollaboratorsChange, ImportError, MembersChange, MembersCob, RegistryChange, RegistryError,
10 Removal, RepoRef, RepoRegistryCob, add_member, deregister_repo, register_repo, verify_cob_ref,
11};
12use knot_git::RefUpdate;
13use knot_runtime::Signer;
14use knot_types::{ActorId, OwnerDid, RepoDid, TypeName};
15use serde::Serialize;
16
17#[test]
18fn forked_acl_is_rejected_not_merged() {
19 let forked = forked_members(
20 1,
21 (MembersChange::Add(grant("seed", "seed", 1)), 1),
22 (
23 MembersChange::Remove(Removal {
24 subject: account("nel"),
25 }),
26 2,
27 ),
28 (MembersChange::Add(grant("nel", "olaren", 3)), 3),
29 (MembersChange::Add(grant("teq", "teq", 4)), 4),
30 );
31 assert!(
32 matches!(forked, Err(CobError::ForkedHistory { .. })),
33 "forked ACL is refused regardless of which branch a merge would favor"
34 );
35}
36
37#[test]
38fn linear_member_semantics() {
39 let readd = build_members(
40 2,
41 &[
42 (MembersChange::Add(grant("nel", "olaren", 1)), 1),
43 (
44 MembersChange::Remove(Removal {
45 subject: account("nel"),
46 }),
47 2,
48 ),
49 (MembersChange::Add(grant("nel", "teq", 3)), 3),
50 ],
51 );
52 assert!(
53 readd.contains(&account("nel")),
54 "linear re-add after a remove is a legitimate decision and takes effect"
55 );
56
57 let stale_remove = build_members(
58 20,
59 &[
60 (MembersChange::Add(grant("nel", "olaren", 5)), 5),
61 (
62 MembersChange::Remove(Removal {
63 subject: account("nel"),
64 }),
65 2,
66 ),
67 ],
68 );
69 assert!(
70 !stale_remove.contains(&account("nel")),
71 "in a linear chain Remove is Add's child, so it applies last even with an older timestamp"
72 );
73
74 let signed_by_one = build_members(1, &[(MembersChange::Add(grant("nel", "olaren", 9)), 1)]);
75 assert_eq!(
76 signed_by_one.get(&account("nel")).unwrap().added_by,
77 account("olaren"),
78 "added_by is whatever the payload claims, unrelated to who signed"
79 );
80 let signed_by_another =
81 build_members(99, &[(MembersChange::Add(grant("nel", "olaren", 9)), 1)]);
82 assert_eq!(
83 signed_by_one, signed_by_another,
84 "a different signing key over an identical payload yields identical state"
85 );
86
87 let once = build_members(
88 10,
89 &[
90 (MembersChange::Add(grant("nel", "nel", 1)), 1),
91 (
92 MembersChange::Remove(Removal {
93 subject: account("nel"),
94 }),
95 2,
96 ),
97 ],
98 );
99 let twice = build_members(
100 10,
101 &[
102 (MembersChange::Add(grant("nel", "nel", 1)), 1),
103 (
104 MembersChange::Remove(Removal {
105 subject: account("nel"),
106 }),
107 2,
108 ),
109 (
110 MembersChange::Remove(Removal {
111 subject: account("nel"),
112 }),
113 3,
114 ),
115 ],
116 );
117 assert_eq!(once, twice, "replaying a remove is idempotent");
118 assert!(once.is_empty());
119
120 let created_at = build_members(
121 13,
122 &[
123 (MembersChange::Add(grant("nel", "olaren", 100)), 1),
124 (
125 MembersChange::Remove(Removal {
126 subject: account("nel"),
127 }),
128 2,
129 ),
130 (MembersChange::Add(grant("nel", "teq", 50)), 3),
131 ],
132 );
133 let entry = created_at.get(&account("nel")).unwrap();
134 assert_eq!(entry.added_by, account("teq"), "last linear Add wins");
135 assert_eq!(
136 entry.created_at,
137 at(50),
138 "the later Add's created_at takes effect even though it is older than an earlier entry's"
139 );
140}
141
142#[test]
143fn verify_rejects_a_change_with_a_forged_signature() {
144 let (_dir, repo) = fixture();
145 let nsid = MembersChange::type_name();
146 let owner = owner_of(32);
147 let payload = MembersChange::Add(grant("nel", "nel", 1)).encode().unwrap();
148 let root = write_cob_commit(&repo, &nsid, &payload, &[], &owner, 1);
149 let object = CobId::new(root);
150 repo.update_ref(&RefUpdate::Create {
151 name: cob_ref(&nsid, object),
152 new: root,
153 })
154 .unwrap();
155
156 let store = CobStore::new(&repo);
157 assert!(
158 store.get::<MembersCob>(object).is_ok(),
159 "read path materializes without checking signatures, by design"
160 );
161 assert!(
162 matches!(
163 store.verify::<MembersCob>(&home(), object, &owner),
164 Err(CobError::UnverifiedChange { .. })
165 ),
166 "import verification catches forged signature the read path trusts"
167 );
168}
169
170#[derive(Serialize)]
171struct WireRegister<'a> {
172 op: &'a str,
173 data: WireRegistration<'a>,
174}
175
176#[derive(Serialize)]
177struct WireRegistration<'a> {
178 owner: &'a str,
179 rkey: &'a str,
180 name: &'a str,
181 repo: &'a str,
182 created_at: i64,
183}
184
185fn encode_register(rkey: &str, name: &str) -> Vec<u8> {
186 serde_ipld_dagcbor::to_vec(&WireRegister {
187 op: "register",
188 data: WireRegistration {
189 owner: "did:plc:nel",
190 rkey,
191 name,
192 repo: "did:plc:squid",
193 created_at: 1,
194 },
195 })
196 .unwrap()
197}
198
199#[test]
200fn malformed_repo_name_or_rkey_is_rejected_at_decode() {
201 assert!(
202 RegistryChange::decode(&encode_register("anemone", "anemone")).is_ok(),
203 "control: well-formed wire payload decodes"
204 );
205 assert!(
206 RegistryChange::decode(&encode_register("anemone", "../../etc/passwd")).is_err(),
207 "traversal repo name fails newtype validation during decode, never reaching a ref"
208 );
209 assert!(
210 RegistryChange::decode(&encode_register("anemone", "refs/heads/main")).is_err(),
211 "name with path separators is rejected at decode"
212 );
213 assert!(
214 RegistryChange::decode(&encode_register("not a record key", "anemone")).is_err(),
215 "rkey outside record-key grammar is rejected at decode"
216 );
217 assert!(
218 RegistryChange::decode(&encode_register("..", "anemone")).is_err(),
219 "reserved '..' rkey is rejected at decode"
220 );
221}
222
223#[test]
224fn registry_handler_guards() {
225 let (_dir, repo) = fixture();
226 let key = signer(70);
227 let store = CobStore::new(&repo);
228 let object = registry_with(&repo, &key, "anemone", "squid");
229 let nel = || OwnerDid::new("did:plc:nel").unwrap();
230 let squid = || RepoDid::new("did:plc:squid").unwrap();
231
232 let already = register_repo(
233 &store,
234 &home(),
235 object,
236 registration("olaren", "fork", "squid", 2),
237 &key,
238 at(2),
239 );
240 assert!(
241 matches!(already, Err(RegistryError::AlreadyRegistered { .. })),
242 "a repo DID already registered elsewhere cannot be claimed again"
243 );
244
245 let unregistered = deregister_repo(
246 &store,
247 &home(),
248 object,
249 RepoRef {
250 owner: nel(),
251 rkey: rkey("barnacle"),
252 },
253 squid(),
254 &key,
255 at(3),
256 );
257 assert!(matches!(
258 unregistered,
259 Err(RegistryError::NotRegistered { .. })
260 ));
261
262 let mismatch = deregister_repo(
263 &store,
264 &home(),
265 object,
266 RepoRef {
267 owner: nel(),
268 rkey: rkey("anemone"),
269 },
270 RepoDid::new("did:plc:whelk").unwrap(),
271 &key,
272 at(4),
273 );
274 assert!(
275 matches!(mismatch, Err(RegistryError::RepoMismatch { .. })),
276 "deregister whose expected repo doesn't match the keyed one is refused"
277 );
278 assert_eq!(
279 store
280 .get::<RepoRegistryCob>(object)
281 .unwrap()
282 .into_state()
283 .resolve(&nel(), &rkey("anemone")),
284 Some(&squid()),
285 "a refused deregister left the registration intact"
286 );
287
288 register_repo(
289 &store,
290 &home(),
291 object,
292 registration("nel", "barnacle", "whelk", 5),
293 &key,
294 at(5),
295 )
296 .unwrap();
297 assert_eq!(
298 store
299 .get::<RepoRegistryCob>(object)
300 .unwrap()
301 .into_state()
302 .owner_of(&RepoDid::new("did:plc:whelk").unwrap()),
303 Some(nel()),
304 "a fresh repo DID lands"
305 );
306
307 deregister_repo(
308 &store,
309 &home(),
310 object,
311 RepoRef {
312 owner: nel(),
313 rkey: rkey("anemone"),
314 },
315 squid(),
316 &key,
317 at(6),
318 )
319 .unwrap();
320 let after_deregister = store.get::<RepoRegistryCob>(object).unwrap().into_state();
321 assert!(
322 after_deregister.resolve(&nel(), &rkey("anemone")).is_none(),
323 "a matching deregister removes the keyed repo"
324 );
325 assert_eq!(
326 after_deregister.resolve(&nel(), &rkey("barnacle")),
327 Some(&RepoDid::new("did:plc:whelk").unwrap()),
328 "deregistering one repo leaves its sibling resolving"
329 );
330}
331
332#[test]
333fn add_member_handler_lands_a_grant() {
334 let (_dir, repo) = fixture();
335 let key = signer(81);
336 let store = CobStore::new(&repo);
337 let created = store
338 .create(
339 &home(),
340 &MembersChange::Add(grant("nel", "nel", 1)),
341 &key,
342 at(1),
343 )
344 .unwrap();
345
346 add_member(
347 &store,
348 &home(),
349 created.object,
350 grant("olaren", "nel", 2),
351 &key,
352 at(2),
353 )
354 .unwrap();
355
356 let members = store
357 .get::<MembersCob>(created.object)
358 .unwrap()
359 .into_state();
360 assert!(members.contains(&account("olaren")));
361}
362
363#[test]
364fn verify_cob_ref_boundary_cases() {
365 let (_dir, repo, key, object) = members_store(
366 90,
367 &[
368 (MembersChange::Add(grant("nel", "nel", 1)), 1),
369 (MembersChange::Add(grant("olaren", "nel", 2)), 2),
370 ],
371 );
372 let store = CobStore::new(&repo);
373 let owner = ActorId::from_secp256k1(key.public_key().as_bytes());
374 let refname = cob_ref(&MembersChange::type_name(), object);
375
376 assert!(
377 store.verify::<MembersCob>(&home(), object, &owner).is_ok(),
378 "every change is validly signed by the owning key"
379 );
380 assert!(
381 matches!(
382 store.verify::<MembersCob>(&home(), object, &owner_of(31)),
383 Err(CobError::UnverifiedChange { .. })
384 ),
385 "a change not authored by the claimed owner is refused at import"
386 );
387
388 assert_eq!(
389 verify_cob_ref(&store, &home(), &refname, &owner).unwrap(),
390 object,
391 "a genuine object verifies through the namespace dispatcher"
392 );
393 assert!(matches!(
394 verify_cob_ref(&store, &home(), &refname, &owner_of(91)),
395 Err(ImportError::Cob(CobError::UnverifiedChange { .. }))
396 ));
397
398 let elsewhere = CobHome::from(&RepoDid::new("did:plc:limpet").unwrap());
399 assert!(
400 matches!(
401 verify_cob_ref(&store, &elsewhere, &refname, &owner),
402 Err(ImportError::Cob(CobError::UnverifiedChange { .. }))
403 ),
404 "an object pushed under a different repo home is refused at import"
405 );
406
407 assert!(matches!(
408 verify_cob_ref(
409 &store,
410 &home(),
411 &knot_types::RefName::new("refs/heads/main").unwrap(),
412 &owner,
413 ),
414 Err(ImportError::NotCobRef(_))
415 ));
416
417 let stray = cob_ref(&TypeName::new("sh.tangled.test.unknown").unwrap(), object);
418 assert!(matches!(
419 verify_cob_ref(&store, &home(), &stray, &owner),
420 Err(ImportError::UnknownType(_))
421 ));
422
423 let collaborators = store
424 .create(
425 &home(),
426 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
427 &key,
428 at(1),
429 )
430 .unwrap()
431 .object;
432 let collab_ref = cob_ref(&CollaboratorsChange::type_name(), collaborators);
433 assert_eq!(
434 verify_cob_ref(&store, &home(), &collab_ref, &owner).unwrap(),
435 collaborators,
436 "the dispatcher routes a second namespace to its own resolver, not a hardcoded type"
437 );
438
439 let (_forked_dir, forked_repo, forked) = forked_members_object(
440 95,
441 (MembersChange::Add(grant("seed", "seed", 1)), 1),
442 (MembersChange::Add(grant("nel", "olaren", 2)), 2),
443 (
444 MembersChange::Remove(Removal {
445 subject: account("nel"),
446 }),
447 3,
448 ),
449 (MembersChange::Add(grant("teq", "teq", 4)), 4),
450 );
451 let forked_store = CobStore::new(&forked_repo);
452 let forked_ref = cob_ref(&MembersChange::type_name(), forked);
453 assert!(
454 matches!(
455 verify_cob_ref(&forked_store, &home(), &forked_ref, &owner_of(95)),
456 Err(ImportError::Cob(CobError::ForkedHistory { .. }))
457 ),
458 "forked linear history is refused at import alongside the signature check"
459 );
460}