This repository has no description
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', '|'),
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', '|', '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 "|",
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}