This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-migrate / tests / migrate.rs
26 kB 715 lines
1use std::collections::BTreeMap; 2use std::path::{Path, PathBuf}; 3 4use knot_index::{Index, Resolved}; 5use knot_migrate::adopt; 6use knot_migrate::casbin; 7use knot_migrate::emit::MasterKeyEnv; 8use knot_migrate::emit::{self, ConfigValues}; 9use knot_migrate::mapping::{self, SkipReason}; 10use knot_migrate::source::{ 11 SourceDb, SourceDid, SourceError, SourceRepoDid, SourceRkey, SourceSchema, 12}; 13use knot_runtime::{K256Signer, SeededEntropy}; 14use knot_types::{AccountDid, KnotHostname, KnotId, ObjectFormat, RepoDid}; 15use url::Url; 16 17const SCHEMA: &str = " 18create table repo_keys ( 19 repo_did text primary key, 20 signing_key blob, 21 created_at text not null, 22 owner_did text, 23 repo_name text, 24 key_type text not null default 'k256' 25); 26create table repo_aliases ( 27 owner_did text not null, 28 rkey text not null, 29 repo_did text not null, 30 rev text not null, 31 primary key (owner_did, rkey) 32); 33create table knot_members ( 34 id integer primary key autoincrement, 35 did text not null, 36 rkey text, 37 subject text not null, 38 created text not null 39); 40create table collaborators ( 41 id integer primary key autoincrement, 42 repo_did text not null, 43 subject_did text not null, 44 added_by_did text not null, 45 created text not null 46); 47create table acl ( 48 p_type varchar(32) default '' not null, 49 v0 varchar(255) default '' not null, 50 v1 varchar(255) default '' not null, 51 v2 varchar(255) default '' not null, 52 v3 varchar(255) default '' not null, 53 v4 varchar(255) default '' not null, 54 v5 varchar(255) default '' not null 55); 56"; 57 58fn fixture_db(path: &Path, with_collaborators_table: bool) { 59 let conn = rusqlite::Connection::open(path).unwrap(); 60 conn.execute_batch(SCHEMA).unwrap(); 61 conn.execute_batch( 62 " 63 insert into repo_keys (repo_did, signing_key, created_at, owner_did, repo_name) values 64 ('did:plc:squid', x'0101010101010101010101010101010101010101010101010101010101010101', '2026-01-05T10:00:00Z', 'did:plc:nel', 'anemone'), 65 ('did:plc:limpet', x'0202020202020202020202020202020202020202020202020202020202020202', '2026-02-01T09:30:00Z', 'did:plc:nel', 'barnacle'), 66 ('did:plc:conch', x'0303030303030303030303030303030303030303030303030303030303030303', '2026-03-10T14:00:00Z', 'did:plc:isabel', 'Test knot'), 67 ('did:plc:whelk', x'0404040404040404040404040404040404040404040404040404040404040404', '2026-04-20T08:15:00Z', 'did:plc:isabel', 'mussel'), 68 ('did:plc:nautilus', x'0505050505050505050505050505050505050505050505050505050505050505', '2026-05-01T10:00:00Z', 'did:plc:isabel', 'coralline'), 69 ('did:plc:scallop', x'0606060606060606060606060606060606060606060606060606060606060606', '2026-05-02T10:00:00Z', 'did:plc:isabel', 'seagrass'), 70 ('did:plc:clam', x'0707070707070707070707070707070707070707070707070707070707070707', '2026-05-03T10:00:00Z', 'did:plc:isabel', '&#124;'), 71 ('did:web:nel.pet', x'0808080808080808080808080808080808080808080808080808080808080808', '2026-06-01T10:00:00Z', 'did:plc:nel', 'seashell'); 72 insert into repo_aliases (owner_did, rkey, repo_did, rev) values 73 ('did:plc:nel', 'anemone-old', 'did:plc:squid', '1_2026-01-05T10:00:00Z'), 74 ('did:plc:nel', 'anemone', 'did:plc:squid', '3mq2bmuwq7v2t'), 75 ('did:plc:isabel', 'Test knot', 'did:plc:conch', '3mniy6vtxn22y'), 76 ('did:plc:isabel', 'mussel', 'did:plc:whelk', '3moo4vihsva2t'), 77 ('did:plc:isabel', 'seagrass', 'did:plc:nautilus', '3mpwduty3pw2z'), 78 ('did:plc:isabel', '&#124;', 'did:plc:clam', '3mq3bmuwq7v2t'), 79 ('did:plc:nel', 'vanished', 'did:plc:kelp', '3mq4bmuwq7v2t'); 80 insert into knot_members (did, subject, created) values 81 ('did:plc:bailey', 'did:plc:nel', '2026-01-02T00:00:00Z'), 82 ('did:plc:nel', 'did:plc:teq', '2026-01-03T00:00:00Z'), 83 ('did:plc:bailey', 'did:plc:teq', '2026-01-04T00:00:00Z'), 84 ('did:plc:nel', 'did:plc:olaren', '2026-01-05T00:00:00Z'), 85 ('did:plc:teq', 'did:plc:bailey', '2026-01-06T00:00:00Z'); 86 insert into acl (p_type, v0, v1, v2, v3) values 87 ('g', 'did:plc:bailey', 'server:owner', 'thisserver', ''), 88 ('g', 'server:owner', 'server:member', 'thisserver', ''), 89 ('g', 'did:plc:bailey', 'server:member', 'thisserver', ''), 90 ('g', 'did:plc:nel', 'server:member', 'thisserver', ''), 91 ('g', 'did:plc:teq', 'server:member', 'thisserver', ''), 92 ('g', 'did:plc:uni', 'server:member', 'thisserver', ''), 93 ('p', 'did:plc:nel', 'thisserver', 'did:plc:squid', 'repo:owner'), 94 ('p', 'did:plc:nel', 'thisserver', 'did:plc:squid', 'repo:push'), 95 ('p', 'did:plc:nel', 'thisserver', 'did:plc:squid', 'repo:settings'), 96 ('p', 'did:plc:nel', 'thisserver', 'did:plc:squid', 'repo:invite'), 97 ('p', 'did:plc:nel', 'thisserver', 'did:plc:squid', 'repo:delete'), 98 ('p', 'did:plc:nel', 'thisserver', 'did:plc:limpet', 'repo:owner'), 99 ('p', 'did:plc:bailey', 'thisserver', 'did:plc:limpet', 'repo:owner'), 100 ('p', 'did:plc:isabel', 'thisserver', 'did:plc:whelk', 'repo:owner'), 101 ('p', 'did:plc:isabel', 'thisserver', 'did:plc:nautilus', 'repo:owner'), 102 ('p', 'did:plc:isabel', 'thisserver', 'did:plc:scallop', 'repo:owner'), 103 ('p', 'did:plc:isabel', 'thisserver', 'did:plc:clam', 'repo:owner'), 104 ('p', 'did:plc:nel', 'thisserver', 'did:plc:kelp', 'repo:owner'), 105 ('p', 'did:plc:isabel', 'thisserver', 'did:plc:squid', 'repo:collaborator'), 106 ('p', 'did:plc:teq', 'thisserver', 'did:plc:limpet', 'repo:collaborator'), 107 ('p', 'did:plc:uni', 'thisserver', 'did:plc:whelk', 'repo:collaborator'), 108 ('p', 'did:plc:cuttle', 'thisserver', 'did:plc:kelp', 'repo:collaborator'), 109 ('p', 'did:plc:periwinkle', 'thisserver', 'did:plc:nel/anemone', 'repo:collaborator'), 110 ('p', 'did:plc:teq', 'thisserver', 'did:plc:nel/anemone', 'repo:collaborator'), 111 ('p', 'did:plc:nel', 'thisserver', 'did:plc:nel/vanished', 'repo:owner'), 112 ('p', 'did:plc:nel', 'thisserver', 'did:web:nel.pet', 'repo:owner'), 113 ('p', 'did:plc:nel', 'thisserver', '', 'repo:create'), 114 ('p', 'did:plc:nel', 'thisserver', '', 'server:invite'); 115 ", 116 ) 117 .unwrap(); 118 if with_collaborators_table { 119 conn.execute_batch( 120 " 121 insert into collaborators (repo_did, subject_did, added_by_did, created) values 122 ('did:plc:squid', 'did:plc:isabel', 'did:plc:nel', '2026-01-06T11:00:00Z'), 123 ('did:plc:squid', 'did:plc:olaren', 'did:plc:nel', '2026-01-07T12:00:00Z'), 124 ('did:plc:kelp', 'did:plc:teq', 'did:plc:nel', '2026-01-08T13:00:00Z'), 125 ('did:plc:squid', 'did:plc:isabel', 'did:plc:bailey', '2026-01-09T14:00:00Z'), 126 ('did:plc:whelk', 'did:plc:uni', 'did:plc:isabel', '2026-01-10T15:00:00Z'), 127 ('did:plc:squid', 'did:plc:teq', 'did:plc:nel', '2026-01-11T16:00:00Z'); 128 ", 129 ) 130 .unwrap(); 131 } else { 132 conn.execute_batch("drop table collaborators;").unwrap(); 133 } 134} 135 136struct Fixture { 137 _dir: tempfile::TempDir, 138 db_path: PathBuf, 139 source_repos: PathBuf, 140 target: PathBuf, 141} 142 143fn fixture(with_collaborators_table: bool) -> Fixture { 144 let dir = tempfile::tempdir().unwrap(); 145 let db_path = dir.path().join("knotserver.db"); 146 fixture_db(&db_path, with_collaborators_table); 147 let source_repos = dir.path().join("source-repos"); 148 [ 149 "did:plc:squid", 150 "did:plc:limpet", 151 "did:plc:conch", 152 "did:plc:nautilus", 153 "did:plc:scallop", 154 "did:plc:clam", 155 "did:web:nel.pet", 156 ] 157 .iter() 158 .for_each(|did| { 159 knot_git::Repo::create_with_format(source_repos.join(did), ObjectFormat::SHA1).unwrap(); 160 }); 161 std::fs::create_dir_all(source_repos.join("did:plc:whelk")).unwrap(); 162 Fixture { 163 db_path, 164 source_repos, 165 target: dir.path().join("target"), 166 _dir: dir, 167 } 168} 169 170fn map(fx: &Fixture) -> mapping::Mapping { 171 let db = SourceDb::open(&fx.db_path).unwrap(); 172 let repos = db.repos().unwrap(); 173 let rkeys: BTreeMap<SourceRepoDid, SourceRkey> = repos 174 .iter() 175 .filter_map(|repo| { 176 db.current_rkey(&repo.repo_did) 177 .unwrap() 178 .map(|rkey| (repo.repo_did.clone(), rkey)) 179 }) 180 .collect(); 181 let resolver = casbin::resolver(repos.iter().map(|repo| { 182 ( 183 repo.owner_did.clone(), 184 repo.repo_name.clone(), 185 repo.repo_did.clone(), 186 ) 187 })); 188 let acl = casbin::decode(&db.acl().unwrap(), &resolver).unwrap(); 189 let exists = |did: &SourceRepoDid| adopt::source_is_repo(&fx.source_repos, did); 190 match db.schema().unwrap() { 191 SourceSchema::Tables => mapping::map_tables( 192 &repos, 193 &rkeys, 194 &db.members().unwrap(), 195 &db.collaborators().unwrap(), 196 &acl, 197 exists, 198 ) 199 .unwrap(), 200 SourceSchema::PreFlip => { 201 mapping::map_preflip(&repos, &rkeys, &db.members().unwrap(), &acl, exists).unwrap() 202 } 203 } 204} 205 206fn acc(suffix: &str) -> AccountDid { 207 AccountDid::new(format!("did:plc:{suffix}")).unwrap() 208} 209 210fn srepo(value: &str) -> SourceRepoDid { 211 SourceRepoDid::from_column(value) 212} 213 214fn sdid(value: &str) -> SourceDid { 215 SourceDid::from_column(value) 216} 217 218fn subjects(grants: &[mapping::MappedGrant]) -> Vec<&str> { 219 grants.iter().map(|grant| grant.subject.as_str()).collect() 220} 221 222#[test] 223fn table_mapping_reproduces_roster_and_drift() { 224 let fx = fixture(true); 225 let db = SourceDb::open(&fx.db_path).unwrap(); 226 assert_eq!(db.schema().unwrap(), SourceSchema::Tables); 227 assert_eq!(db.orphan_alias_count().unwrap(), 1); 228 229 let mapping = map(&fx); 230 assert_eq!(mapping.knot_owner, acc("bailey")); 231 232 assert_eq!( 233 subjects(&mapping.members), 234 [ 235 "did:plc:nel", 236 "did:plc:teq", 237 "did:plc:olaren", 238 "did:plc:uni" 239 ] 240 ); 241 let teq = &mapping.members[1]; 242 assert_eq!(teq.added_by, acc("nel")); 243 let uni = &mapping.members[3]; 244 assert!(uni.unioned); 245 assert_eq!(uni.added_by, acc("bailey")); 246 247 let dids: Vec<&str> = mapping.repos.iter().map(|repo| repo.did.as_str()).collect(); 248 assert_eq!( 249 dids, 250 [ 251 "did:plc:squid", 252 "did:plc:limpet", 253 "did:plc:nautilus", 254 "did:web:nel.pet" 255 ] 256 ); 257 let web = &mapping.repos[3]; 258 assert_eq!(web.rkey.as_str(), "seashell"); 259 assert_eq!(web.name.as_str(), "seashell"); 260 assert!(web.collaborators.is_empty()); 261 let nautilus = &mapping.repos[2]; 262 assert_eq!(nautilus.rkey.as_str(), "seagrass"); 263 assert_eq!(nautilus.name.as_str(), "coralline"); 264 let squid = &mapping.repos[0]; 265 assert_eq!(squid.rkey.as_str(), "anemone"); 266 assert_eq!( 267 subjects(&squid.collaborators), 268 ["did:plc:isabel", "did:plc:olaren", "did:plc:teq"] 269 ); 270 assert_eq!(squid.collaborators[0].added_by, acc("nel")); 271 let limpet = &mapping.repos[1]; 272 assert_eq!(limpet.rkey.as_str(), "barnacle"); 273 assert_eq!(subjects(&limpet.collaborators), ["did:plc:teq"]); 274 assert!(limpet.collaborators[0].unioned); 275 assert_eq!(limpet.collaborators[0].added_by, acc("nel")); 276 277 let reasons: Vec<(&str, &SkipReason)> = mapping 278 .skipped 279 .iter() 280 .map(|skip| (skip.repo_did.as_str(), &skip.reason)) 281 .collect(); 282 assert_eq!(reasons.len(), 4); 283 assert!(matches!( 284 reasons[0], 285 ("did:plc:conch", SkipReason::Name { .. }) 286 )); 287 assert!( 288 matches!(reasons[1], ("did:plc:whelk", SkipReason::NoSourceRepo)), 289 "a source directory that exists but holds no git repository skips the repo" 290 ); 291 match reasons[2] { 292 ("did:plc:clam", SkipReason::Rkey { value }) => { 293 assert_eq!( 294 value.as_str(), 295 "&#124;", 296 "an rkey RepoRkey rejects skips the repo even where RepoName accepts the same text" 297 ); 298 } 299 other => panic!("unexpected third skip {other:?}"), 300 } 301 match reasons[3] { 302 ("did:plc:scallop", SkipReason::RkeyCollision { rkey, winner }) => { 303 assert_eq!(rkey.as_str(), "seagrass"); 304 assert_eq!(winner.as_str(), "did:plc:nautilus"); 305 } 306 other => panic!("unexpected fourth skip {other:?}"), 307 } 308 let lost: Vec<Vec<&str>> = mapping 309 .skipped 310 .iter() 311 .map(|skip| { 312 skip.lost_collaborators 313 .iter() 314 .map(AccountDid::as_str) 315 .collect() 316 }) 317 .collect(); 318 assert_eq!(lost, [vec![], vec!["did:plc:uni"], vec![], vec![]]); 319 320 let drift = &mapping.drift; 321 assert_eq!( 322 drift.acl_only_collaborators, 323 [(srepo("did:plc:limpet"), sdid("did:plc:teq"))] 324 ); 325 assert_eq!( 326 drift.table_only_collaborators, 327 [(srepo("did:plc:squid"), sdid("did:plc:olaren"))] 328 ); 329 assert_eq!( 330 drift.slash_resolved_collaborators, 331 [(srepo("did:plc:squid"), sdid("did:plc:periwinkle"))] 332 ); 333 assert_eq!( 334 drift.orphan_collaborator_pairs, 335 [ 336 (srepo("did:plc:kelp"), sdid("did:plc:cuttle")), 337 (srepo("did:plc:kelp"), sdid("did:plc:teq")) 338 ] 339 ); 340 assert_eq!(drift.markerless_owner_repos, [srepo("did:plc:conch")]); 341 assert_eq!(drift.orphan_owner_markers, [srepo("did:plc:kelp")]); 342 assert_eq!( 343 drift.extra_owner_markers, 344 [(srepo("did:plc:limpet"), sdid("did:plc:bailey"))] 345 ); 346 assert_eq!(drift.acl_only_members, [sdid("did:plc:uni")]); 347 assert_eq!(drift.table_only_members, [sdid("did:plc:olaren")]); 348 assert_eq!(drift.slash_owner_markers, 1); 349 assert_eq!(drift.slash_collab_rows, 2); 350 assert_eq!(drift.unresolved_slash_forms, ["did:plc:nel/vanished"]); 351} 352 353#[test] 354fn collaborators_without_knot_members_is_rejected() { 355 let fx = fixture(true); 356 rusqlite::Connection::open(&fx.db_path) 357 .unwrap() 358 .execute_batch("drop table knot_members;") 359 .unwrap(); 360 let db = SourceDb::open(&fx.db_path).unwrap(); 361 assert!(matches!( 362 db.schema(), 363 Err(SourceError::CollaboratorsWithoutMembers) 364 )); 365} 366 367#[test] 368fn a_source_table_missing_expected_columns_is_rejected_early() { 369 let dir = tempfile::tempdir().unwrap(); 370 let db_path = dir.path().join("knotserver.db"); 371 rusqlite::Connection::open(&db_path) 372 .unwrap() 373 .execute_batch( 374 " 375 create table repo_keys ( 376 repo_did text primary key, 377 signing_key blob, 378 created_at text not null 379 ); 380 create table repo_aliases ( 381 owner_did text not null, 382 rkey text not null, 383 repo_did text not null, 384 rev text not null 385 ); 386 ", 387 ) 388 .unwrap(); 389 let db = SourceDb::open(&db_path).unwrap(); 390 match db.schema() { 391 Err(SourceError::SchemaMismatch { table, missing }) => { 392 assert_eq!(table, "repo_keys"); 393 assert_eq!(missing, ["owner_did", "repo_name", "key_type"]); 394 } 395 other => panic!("a repo_keys older than knot's schema must be rejected, got {other:?}"), 396 } 397} 398 399#[test] 400fn preflip_mapping_reads_casbin() { 401 let fx = fixture(false); 402 let db = SourceDb::open(&fx.db_path).unwrap(); 403 assert_eq!(db.schema().unwrap(), SourceSchema::PreFlip); 404 405 let mapping = map(&fx); 406 assert_eq!(mapping.knot_owner, acc("bailey")); 407 assert_eq!( 408 subjects(&mapping.members), 409 ["did:plc:nel", "did:plc:teq", "did:plc:uni"] 410 ); 411 let nel = &mapping.members[0]; 412 assert_eq!(nel.added_by, acc("bailey")); 413 assert_eq!(nel.created_at, knot_types::UnixSeconds::new(1767312000)); 414 let uni = &mapping.members[2]; 415 assert_eq!(uni.added_by, acc("bailey")); 416 assert_eq!(uni.created_at, knot_types::UnixSeconds::new(0)); 417 assert_eq!(mapping.drift.table_only_members, [sdid("did:plc:olaren")]); 418 419 let squid = &mapping.repos[0]; 420 assert_eq!( 421 subjects(&squid.collaborators), 422 ["did:plc:isabel", "did:plc:periwinkle", "did:plc:teq"] 423 ); 424 let limpet = &mapping.repos[1]; 425 assert_eq!(subjects(&limpet.collaborators), ["did:plc:teq"]); 426 assert_eq!( 427 mapping.drift.orphan_collaborator_pairs, 428 [(srepo("did:plc:kelp"), sdid("did:plc:cuttle"))] 429 ); 430 assert_eq!( 431 mapping.drift.extra_owner_markers, 432 [(srepo("did:plc:limpet"), sdid("did:plc:bailey"))] 433 ); 434 let whelk = mapping 435 .skipped 436 .iter() 437 .find(|skip| skip.repo_did.as_str() == "did:plc:whelk") 438 .unwrap(); 439 assert_eq!(whelk.lost_collaborators, [acc("uni")]); 440} 441 442#[test] 443fn adoption_and_cobs_boot_a_working_index() { 444 let fx = fixture(true); 445 let mapping = map(&fx); 446 let knot = KnotId::new("did:web:knot.oyster.cafe").unwrap(); 447 let scan_path = fx.target.join("repos"); 448 std::fs::create_dir_all(&scan_path).unwrap(); 449 let layout = knot_git::Layout::new(&scan_path) 450 .with_object_format(ObjectFormat::SHA1) 451 .reserving_meta(&knot) 452 .unwrap(); 453 let signer = K256Signer::generate(&SeededEntropy::new(7)); 454 std::os::unix::fs::symlink( 455 "config", 456 fx.source_repos.join("did:plc:squid").join("config-link"), 457 ) 458 .unwrap(); 459 460 let adoption = adopt::adopt_all( 461 &layout, 462 &fx.source_repos, 463 &mapping.repos, 464 adopt::SourcePolicy::Preserve, 465 ) 466 .unwrap(); 467 assert_eq!(adoption.adopted, 4); 468 assert_eq!(adoption.transfer, adopt::Transfer::Copy); 469 assert_eq!(adoption.already_present, 0); 470 assert_eq!(adoption.sha1, 4); 471 let adopted_link = layout 472 .repo_path(&RepoDid::new("did:plc:squid").unwrap()) 473 .unwrap() 474 .join("config-link"); 475 assert!( 476 std::fs::symlink_metadata(&adopted_link) 477 .unwrap() 478 .file_type() 479 .is_symlink() 480 ); 481 assert_eq!( 482 std::fs::read_link(&adopted_link).unwrap(), 483 Path::new("config") 484 ); 485 486 let cobs = emit::write_cobs(&layout, &knot, &mapping, &signer).unwrap(); 487 assert_eq!(cobs.members.appended, 4); 488 assert_eq!(cobs.registrations.appended, 4); 489 assert_eq!(cobs.collaborators.appended, 4); 490 491 let index = Index::new(layout.meta_path(&knot).unwrap(), layout.clone()); 492 index.rebuild().unwrap(); 493 assert_eq!(index.hosted_repos().len(), 4); 494 let web = RepoDid::new("did:web:nel.pet").unwrap(); 495 assert_eq!( 496 index.owner_of(&web), 497 Resolved::Ready(Some(knot_types::OwnerDid::new("did:plc:nel").unwrap())) 498 ); 499 let squid = RepoDid::new("did:plc:squid").unwrap(); 500 index.ensure_collaborators(&squid).unwrap(); 501 assert_eq!( 502 index.is_collaborator(&squid, &acc("isabel")), 503 Resolved::Ready(true) 504 ); 505 assert_eq!( 506 index.is_collaborator(&squid, &acc("olaren")), 507 Resolved::Ready(true) 508 ); 509 assert_eq!( 510 index.is_collaborator(&squid, &acc("teq")), 511 Resolved::Ready(true) 512 ); 513 assert_eq!( 514 index.is_collaborator(&squid, &acc("periwinkle")), 515 Resolved::Ready(false) 516 ); 517 assert_eq!( 518 index.owner_of(&squid), 519 Resolved::Ready(Some(knot_types::OwnerDid::new("did:plc:nel").unwrap())) 520 ); 521 522 let again = adopt::adopt_all( 523 &layout, 524 &fx.source_repos, 525 &mapping.repos, 526 adopt::SourcePolicy::Preserve, 527 ) 528 .unwrap(); 529 assert_eq!(again.adopted, 0); 530 assert_eq!(again.already_present, 4); 531 let recobs = emit::write_cobs(&layout, &knot, &mapping, &signer).unwrap(); 532 assert_eq!(recobs.members.appended, 0); 533 assert_eq!(recobs.members.already_present, 4); 534 assert_eq!(recobs.registrations.appended, 0); 535 assert_eq!(recobs.registrations.already_present, 4); 536 assert_eq!(recobs.collaborators.appended, 0); 537 assert_eq!(recobs.collaborators.already_present, 4); 538} 539 540#[test] 541fn consuming_the_source_moves_each_adopted_repo_out_of_the_scan_path() { 542 let fx = fixture(true); 543 let mapping = map(&fx); 544 let knot = KnotId::new("did:web:knot.oyster.cafe").unwrap(); 545 let scan_path = fx.target.join("repos"); 546 std::fs::create_dir_all(&scan_path).unwrap(); 547 let layout = knot_git::Layout::new(&scan_path) 548 .with_object_format(ObjectFormat::SHA1) 549 .reserving_meta(&knot) 550 .unwrap(); 551 552 let adoption = adopt::adopt_all( 553 &layout, 554 &fx.source_repos, 555 &mapping.repos, 556 adopt::SourcePolicy::Consume, 557 ) 558 .unwrap(); 559 assert_eq!(adoption.transfer, adopt::Transfer::Rename); 560 assert_eq!(adoption.adopted, 4); 561 assert_eq!(adoption.already_present, 0); 562 assert!( 563 mapping 564 .repos 565 .iter() 566 .all(|repo| !adopt::source_dir(&fx.source_repos, &repo.source_did).exists()), 567 "every adopted repo leaves the source tree" 568 ); 569 assert!( 570 fx.source_repos.join("did:plc:conch").is_dir(), 571 "a skipped repo stays where it was" 572 ); 573 574 let again = adopt::adopt_all( 575 &layout, 576 &fx.source_repos, 577 &mapping.repos, 578 adopt::SourcePolicy::Consume, 579 ) 580 .unwrap(); 581 assert_eq!(again.adopted, 0); 582 assert_eq!(again.already_present, 4); 583} 584 585#[test] 586fn adopting_a_repo_that_resolves_to_the_knot_meta_path_is_refused() { 587 let dir = tempfile::tempdir().unwrap(); 588 let db_path = dir.path().join("knotserver.db"); 589 let conn = rusqlite::Connection::open(&db_path).unwrap(); 590 conn.execute_batch(SCHEMA).unwrap(); 591 conn.execute_batch( 592 " 593 insert into repo_keys (repo_did, signing_key, created_at, owner_did, repo_name) values 594 ('did:web:knot.oyster.cafe', x'0909090909090909090909090909090909090909090909090909090909090909', '2026-06-01T10:00:00Z', 'did:plc:nel', 'seashell'); 595 insert into acl (p_type, v0, v1, v2, v3) values 596 ('g', 'did:plc:bailey', 'server:owner', 'thisserver', ''); 597 ", 598 ) 599 .unwrap(); 600 601 let source_repos = dir.path().join("source-repos"); 602 knot_git::Repo::create_with_format( 603 source_repos.join("did:web:knot.oyster.cafe"), 604 ObjectFormat::SHA1, 605 ) 606 .unwrap(); 607 608 let fx = Fixture { 609 db_path, 610 source_repos, 611 target: dir.path().join("target"), 612 _dir: dir, 613 }; 614 let mapping = map(&fx); 615 assert_eq!(mapping.repos.len(), 1); 616 617 let knot = KnotId::new("did:web:knot.oyster.cafe").unwrap(); 618 let scan_path = fx.target.join("repos"); 619 std::fs::create_dir_all(&scan_path).unwrap(); 620 let layout = knot_git::Layout::new(&scan_path) 621 .with_object_format(ObjectFormat::SHA1) 622 .reserving_meta(&knot) 623 .unwrap(); 624 625 let error = adopt::adopt_all( 626 &layout, 627 &fx.source_repos, 628 &mapping.repos, 629 adopt::SourcePolicy::Preserve, 630 ) 631 .unwrap_err(); 632 assert!(matches!( 633 error, 634 adopt::AdoptError::ReservesMeta { repo } if repo.as_str() == "did:web:knot.oyster.cafe" 635 )); 636 assert!(!layout.meta_path(&knot).unwrap().exists()); 637} 638 639#[test] 640fn rendered_config_loads() { 641 let dir = tempfile::tempdir().unwrap(); 642 let scan_path = dir.path().join("re\"pos\u{7f}"); 643 std::fs::create_dir_all(&scan_path).unwrap(); 644 let config = emit::render_config(&ConfigValues { 645 hostname: KnotHostname::new("knot.oyster.cafe").unwrap(), 646 admins: vec![acc("bailey")], 647 scan_path: scan_path.clone(), 648 ssh_host_key_file: dir.path().join("ssh_host_key"), 649 sealed_key_file: dir.path().join("sealed-keys"), 650 master_key_env: MasterKeyEnv::new("KNOT_MASTER_KEY").unwrap(), 651 object_format: ObjectFormat::SHA1, 652 plc_directory: Url::parse("https://plc.directory").unwrap(), 653 }) 654 .unwrap(); 655 assert!(config.contains("hostname = \"knot.oyster.cafe\"")); 656 assert!(config.contains("admins = [\"did:plc:bailey\"]")); 657 assert!(config.contains("admission = \"closed\"")); 658 assert!(config.contains("object_format = \"sha1\"")); 659 let path = dir.path().join("config.toml"); 660 std::fs::write(&path, &config).unwrap(); 661 knot_config::load(Some(&path)).unwrap(); 662} 663 664const HOST_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- 665b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 666QyNTUxOQAAACAuLv0N4MHTuclN/afhoL60chkky1gCLCFCA2T1qOKGhwAAAJgv0qFlL9Kh 667ZQAAAAtzc2gtZWQyNTUxOQAAACAuLv0N4MHTuclN/afhoL60chkky1gCLCFCA2T1qOKGhw 668AAAEAnnapXprdwlEwD6xIxSqm3szQrvfQdhRp6UfONp85Uky4u/Q3gwdO5yU39p+GgvrRy 669GSTLWAIsIUIDZPWo4oaHAAAAEWtub3QtbWlncmF0ZS10ZXN0AQIDBA== 670-----END OPENSSH PRIVATE KEY----- 671"; 672 673const ECDSA_HOST_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- 674b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS 6751zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQS6SLu5jEz+0ScKcByJBs53LlSkz8dT 676ELlhV5QrNPQvk+h5UduwxR7ShN3IL9AhjiVugVN3I9vHHB1BwcNXE6exAAAAsD5jy3k+Y8 677t5AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLpIu7mMTP7RJwpw 678HIkGzncuVKTPx1MQuWFXlCs09C+T6HlR27DFHtKE3cgv0CGOJW6BU3cj28ccHUHBw1cTp7 679EAAAAhAI2ARTG/6mM9qJfmdg8rASQudcrZ5KFLkH6FjB0V6EUgAAAAEWtub3QtbWlncmF0 680ZS10ZXN0AQIDBAUG 681-----END OPENSSH PRIVATE KEY----- 682"; 683 684#[test] 685fn host_key_import_preserves_every_algorithm() { 686 let dir = tempfile::tempdir().unwrap(); 687 [ 688 ( 689 "ssh_host_ed25519_key", 690 HOST_KEY, 691 ssh_key::Algorithm::Ed25519, 692 ), 693 ( 694 "ssh_host_ecdsa_key", 695 ECDSA_HOST_KEY, 696 ssh_key::Algorithm::Ecdsa { 697 curve: ssh_key::EcdsaCurve::NistP256, 698 }, 699 ), 700 ] 701 .into_iter() 702 .for_each(|(name, pem, algorithm)| { 703 let source = dir.path().join(name); 704 std::fs::write(&source, pem).unwrap(); 705 let destination = dir.path().join(format!("{name}.imported")); 706 let host_key = emit::load_host_key(&source).unwrap(); 707 assert_eq!(host_key.algorithm, algorithm); 708 host_key.write_to(&destination).unwrap(); 709 assert_eq!( 710 std::fs::read(&source).unwrap(), 711 std::fs::read(&destination).unwrap(), 712 "{name} must round-trip byte-for-byte so the pinned fingerprint survives" 713 ); 714 }); 715}