This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-cobs / tests / invariants.rs
13 kB 460 lines
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}