1module cli23import os4import compress.zlib5import crypto.sha2566import json27import src.exchange8import src.release9import src.remote10import src.events11import src.history12import src.machine13import src.snapshot14import src.storage1516fn testsuite_begin() {17 os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)18}1920fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {21 saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },22 history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]23 assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))24 assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))25 assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],26 'aaaaaaaaaaaa').any(it.contains('missing parent'))27 assert doctor_history([]history.Save{}, '').len == 028 assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))29}3031fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {32 mut report := DoctorJson{}33 doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },34 snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},35 mut report)36 assert report.problems.any(it.contains('unsafe path'))37 assert report.problems.any(it.contains('file and a directory'))38}3940fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {41 dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')42 os.rmdir_all(dir) or {}43 os.mkdir_all(dir)!44 defer {45 os.rmdir_all(dir) or {}46 }47 store := storage.create(dir)!48 defer {49 store.close()50 }51 hash := store.put_object('unsafe'.bytes())!52 store.begin()!53 history.import_save(store, history.Save{54 id: 'badbadbadbad'55 }, [snapshot.FileEntry{56 path: "../June's file"57 hash: hash58 }])!59 history.set_head(store, 'badbadbadbad')!60 store.end()!61 mut report := DoctorJson{}62 doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)63 assert report.problems == [64 "save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",65 ]66}6768struct DoctorReadOnly {69mut:70 items map[string][]u871}7273fn (mut backend DoctorReadOnly) get(key string) !remote.Object {74 if body := backend.items[key] {75 return remote.Object{ found: true, body: body }76 }77 return remote.Object{}78}7980fn (mut backend DoctorReadOnly) list(prefix string) ![]string {81 return backend.items.keys().filter(it.starts_with('${prefix}/'))82}8384fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {85 panic('Doctor attempted a remote write')86}8788fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {89 panic('Doctor attempted a remote write')90}9192fn (mut backend DoctorReadOnly) delete(key string) ! {93 panic('Doctor attempted a remote delete')94}9596fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {97 data := 'file'.bytes()98 hash := storage.hash_hex(data)99 mut backend := DoctorReadOnly{100 items: {101 'repo/head': 'aaaaaaaaaaaa'.bytes()102 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{103 id: 'aaaaaaaaaaaa'104 files: [exchange.FileRecord{ path: 'file', hash: hash }]105 }).bytes()106 'repo/objects/${hash}': zlib.compress(data)!107 'repo/releases/latest': '1.0.0'.bytes()108 'repo/releases/1.0.0/release.json': json2.encode(release.Record{109 repo: 'repo'110 version: '1.0.0'111 save: 'aaaaaaaaaaaa'112 files: [release.File{113 platform: 'linux-aarch64'114 name: 'app'115 size: data.len116 blake3: hash117 sha256: sha256.sum256(data).hex()118 }]119 }).bytes()120 'repo/releases/1.0.0/linux-aarch64/app': data121 }122 }123 mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }124 doctor_remote_content(mut backend, 'repo', mut report)!125 assert report.problems.len == 0126 assert report.remote_saves == 1127 assert report.remote_releases == 1128 backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()129 backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()130 mut damaged := DoctorJson{}131 doctor_remote_content(mut backend, 'repo', mut damaged)!132 assert damaged.problems.any(it.contains('damaged remote object'))133 assert damaged.problems.any(it.contains('checksum verification'))134}135136fn test_doctor_remote_keeps_a_complete_report_for_invalid_records() {137 mut backend := DoctorReadOnly{138 items: {139 'repo/head': 'aaaaaaaaaaaa'.bytes()140 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{141 id: 'aaaaaaaaaaaa'142 created_at: -1143 }).bytes()144 'repo/events/bad': json2.encode(exchange.EventRecord{145 id: 'bad'146 kind: 'unknown'147 created_at: 1148 }).bytes()149 }150 }151 mut report := DoctorJson{}152 doctor_remote_content(mut backend, 'repo', mut report)!153 assert report.problems.any(it.contains('invalid metadata'))154 assert report.problems.any(it.contains('invalid ID'))155 assert report.problems.any(it.contains('unknown kind'))156}157158fn test_doctor_remote_binds_a_save_to_its_exact_key() {159 mut backend := DoctorReadOnly{160 items: {161 'repo/head': 'aaaaaaaaaaaa'.bytes()162 'repo/saves/nested/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{163 id: 'aaaaaaaaaaaa'164 }).bytes()165 }166 }167 mut report := DoctorJson{}168 if _ := doctor_remote_content(mut backend, 'repo', mut report) {169 assert false, 'a nested save key must not stand in for the canonical key'170 } else {171 assert err.msg().contains('disagrees with key'), err.msg()172 }173}174175fn test_doctor_reports_event_errors_strip_residue_and_flags() {176 mut report := DoctorJson{}177 saves := [history.Save{ id: 'aaaaaaaaaaaa' }]178 event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },179 events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },180 events.Event{ id: 'bad', kind: 'unknown' }]181 doctor_events(event_list, saves, mut report)182 doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)183 assert report.flagged_saves == 1184 assert report.problems.any(it.contains('invalid ID'))185 assert report.problems.any(it.contains('unknown kind'))186 assert report.problems.any(it.contains('stripped path'))187 assert report.strip_residue_paths == ['private']188}189190struct DoctorMemory {191mut:192 items map[string][]u8193}194195fn (mut backend DoctorMemory) get(key string) !remote.Object {196 if body := backend.items[key] {197 return remote.Object{ found: true, body: body }198 }199 return remote.Object{}200}201202fn (mut backend DoctorMemory) list(prefix string) ![]string {203 return backend.items.keys().filter(it.starts_with('${prefix}/'))204}205206fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {207 if key in backend.items {208 return false209 }210 backend.items[key] = body211 return true212}213214fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {215 return false216}217218fn (mut backend DoctorMemory) delete(key string) ! {219 backend.items.delete(key)220}221222fn doctor_store(name string) !(storage.Store, string) {223 dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')224 os.rmdir_all(dir) or {}225 os.mkdir_all(dir)!226 return storage.create(dir)!, dir227}228229fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {230 store, dir := doctor_store('fetch')!231 defer {232 store.close()233 os.rmdir_all(dir) or {}234 }235 data := 'parent'.bytes()236 hash := storage.hash_hex(data)237 mut backend := DoctorReadOnly{238 items: {239 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{240 id: 'aaaaaaaaaaaa'241 files: [exchange.FileRecord{ path: 'file', hash: hash }]242 }).bytes()243 'repo/objects/${hash}': zlib.compress(data)!244 }245 }246 store.db.exec('PRAGMA foreign_keys=OFF')!247 store.begin()!248 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },249 []snapshot.FileEntry{})!250 history.set_head(store, 'bbbbbbbbbbbb')!251 store.end()!252 store.db.exec('PRAGMA foreign_keys=ON')!253 missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')254 assert missing == ['aaaaaaaaaaaa']255 assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1256 assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0257 assert store.get_object(hash)! == data258 assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {259 assert err.msg().contains('does not have save')260 0261 } == 0262}263264fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {265 store, dir := doctor_store('strip')!266 defer {267 store.close()268 os.rmdir_all(dir) or {}269 }270 secret := store.put_object('secret'.bytes())!271 kept := store.put_object('kept'.bytes())!272 store.begin()!273 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [274 snapshot.FileEntry{ path: 'keep', hash: kept },275 snapshot.FileEntry{ path: 'private/key', hash: secret },276 ])!277 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },278 [snapshot.FileEntry{ path: 'keep', hash: kept }])!279 events.strip(store, '', 'private')!280 store.end()!281 assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'282 mut report := DoctorJson{}283 doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut284 report)285 assert report.strip_residue286 assert exchange.reapply_strips(store)! == 1287 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']288 assert !store.has_object(secret)!289 assert events.strip_events(store)!.len == 1290}291292fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {293 store, dir := doctor_store('objects')!294 defer {295 store.close()296 os.rmdir_all(dir) or {}297 }298 data := 'content'.bytes()299 hash := store.put_object(data)!300 store.begin()!301 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [302 snapshot.FileEntry{ path: 'app', hash: hash },303 ])!304 history.set_head(store, 'aaaaaaaaaaaa')!305 store.end()!306 store.db.exec("UPDATE objects SET data = x'00'")!307 problems := store.check_objects()!308 assert problems.map(it.hash) == [hash]309 assert local_copy(store, hash) == none310 os.write_file(os.join_path(dir, 'app'), 'content')!311 store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!312 assert store.check_objects()!.len == 0313 store.repair_object(hash, 'other'.bytes()) or {314 assert err.msg().contains('does not match')315 }316 mut backend := DoctorMemory{317 items: {318 'repo/objects/${hash}': 'damaged'.bytes()319 }320 }321 assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2322 assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data323 assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash324}325326327328fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {329 store, dir := doctor_store('strip_safety')!330 defer {331 store.close()332 os.rmdir_all(dir) or {}333 }334 app := store.put_object('app'.bytes())!335 secret := store.put_object('secret'.bytes())!336 orphan := store.put_object('orphan'.bytes())!337 store.begin()!338 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [339 snapshot.FileEntry{ path: 'app', hash: app },340 snapshot.FileEntry{ path: 'secret', hash: secret },341 ])!342 events.receive(store, events.Event{343 id: 'invalidstrip'344 kind: events.kind_strip345 path: 'app'346 created_at: -1347 machine: 'test'348 })!349 events.strip(store, '', 'secret')!350 store.end()!351 mut report := DoctorJson{}352 doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut353 report)354 assert !report.strip_residue355 assert exchange.reapply_strips(store)! == 1356 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']357 assert store.has_object(app)!358 assert store.has_object(orphan)!359 assert !store.has_object(secret)!360}361362363364fn test_doctor_guesses_head_only_when_nothing_knows_better() {365 store, dir := doctor_store('only_tip')!366 defer {367 store.close()368 os.rmdir_all(dir) or {}369 }370 store.begin()!371 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!372 store.end()!373 mut report := DoctorJson{374 metadata_available: true375 saves: 1376 source: 'folder/repo'377 remote_head: 'bbbbbbbbbbbb'378 }379 assert !plan_repairs(store, report).any(it.action.contains('point head'))380 report.remote_head = ''381 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}382 assert !plan_repairs(store, report).any(it.action.contains('point head'))383 report.checks = [DoctorCheck{'remote stability', 'fail', 'remote head changed during audit', ''}]384 assert !plan_repairs(store, report).any(it.action.contains('point head'))385 report.checks = []386 assert plan_repairs(store, report).any(it.action.contains('point head'))387 report.source = ''388 repairs := plan_repairs(store, report).filter(it.action.contains('point head'))389 assert repairs.len == 1390 history.set_head(store, 'cccccccccccc')!391 if _ := repairs[0].run() {392 assert false, 'a head another command set must not be replaced'393 } else {394 assert err.msg().contains('changed'), err.msg()395 }396 assert store.state(storage.head_key) or { '' } == 'cccccccccccc'397 store.remove_state(storage.head_key)!398 repairs[0].run()!399 assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'400}401402fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {403 store, dir := doctor_store('partial')!404 defer {405 store.close()406 os.rmdir_all(dir) or {}407 }408 kept := store.put_object('kept'.bytes())!409 lost := store.put_object('lost'.bytes())!410 store.begin()!411 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [412 snapshot.FileEntry{ path: 'kept', hash: kept },413 snapshot.FileEntry{ path: 'lost', hash: lost },414 ])!415 store.end()!416 store.db.exec("UPDATE objects SET data = x'00'")!417 os.write_file(os.join_path(dir, 'kept'), 'kept')!418 if _ := repair_objects(store, [kept, lost], '', '') {419 assert false, 'an object with no verified copy must fail its repair'420 } else {421 assert err is exchange.Partway422 assert (err as exchange.Partway).done == 'restored 1 damaged or missing object'423 assert err.msg().contains(lost)424 }425 assert store.get_object(kept)! == 'kept'.bytes()426}427428fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {429 store, dir := doctor_store('invalid_fetch')!430 defer {431 store.close()432 os.rmdir_all(dir) or {}433 }434 mut backend := DoctorReadOnly{435 items: {436 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{437 id: 'aaaaaaaaaaaa'438 created_at: -1439 }).bytes()440 }441 }442 if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {443 assert false, 'a record with invalid metadata must not be imported'444 } else {445 assert err.msg().contains('invalid metadata')446 }447 assert history.all_saves(store)!.len == 0448}449450451452fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {453 store, dir := doctor_store('empty_object')!454 defer {455 store.close()456 os.rmdir_all(dir) or {}457 }458 hash := store.put_object([]u8{})!459 mut backend := DoctorMemory{460 items: {461 'repo/objects/${hash}': 'not zlib'.bytes()462 }463 }464 assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1465 assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0466}467468469470fn test_doctor_rechecks_the_remote_head_before_guessing() {471 store, dir := doctor_store('remote_tip')!472 remote_dir := os.join_path(dir, 'remote')473 defer {474 store.close()475 machine.forget_remote() or {}476 os.rmdir_all(dir) or {}477 }478 store.begin()!479 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!480 store.end()!481 mut folder := remote.new_folder(remote_dir)!482 machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!483 report := DoctorJson{484 metadata_available: true485 saves: 1486 remote_repo: 'repo'487 }488 repairs := plan_repairs(store, report).filter(it.action.contains('point head'))489 assert repairs.len == 1490 folder.put_if_absent(exchange.head_key('repo'), 'bbbbbbbbbbbb'.bytes())!491 if _ := repairs[0].run() {492 assert false, 'a head offered since the audit must stop the guess'493 } else {494 assert err.msg().contains('another head'), err.msg()495 }496 assert store.state(storage.head_key) == none497}498499500fn test_doctor_reports_restored_objects_when_the_remote_then_fails() {501 store, dir := doctor_store('partial_remote')!502 defer {503 store.close()504 machine.forget_remote() or {}505 os.rmdir_all(dir) or {}506 }507 kept := store.put_object('kept'.bytes())!508 lost := store.put_object('lost'.bytes())!509 store.begin()!510 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [511 snapshot.FileEntry{ path: 'kept', hash: kept },512 snapshot.FileEntry{ path: 'lost', hash: lost },513 ])!514 store.end()!515 store.db.exec("UPDATE objects SET data = x'00'")!516 os.write_file(os.join_path(dir, 'kept'), 'kept')!517 machine.save_remote(machine.RemoteSettings{518 kind: machine.kind_folder519 path: os.join_path(dir, 'unmounted')520 })!521 if _ := repair_objects(store, [kept, lost], '', '') {522 assert false, 'an unreachable remote must fail the repair'523 } else {524 assert err is exchange.Partway525 assert (err as exchange.Partway).done == 'restored 1 damaged or missing object'526 assert err.msg().contains('not there'), err.msg()527 }528}529530531fn test_doctor_fetch_ignores_invalid_strip_events() {532 store, dir := doctor_store('fetch_invalid_strip')!533 defer {534 store.close()535 os.rmdir_all(dir) or {}536 }537 data := 'app'.bytes()538 hash := storage.hash_hex(data)539 mut backend := DoctorReadOnly{540 items: {541 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{542 id: 'aaaaaaaaaaaa'543 files: [exchange.FileRecord{ path: 'app', hash: hash }]544 }).bytes()545 'repo/objects/${hash}': zlib.compress(data)!546 }547 }548 events.receive(store, events.Event{549 id: 'invalidstrip'550 kind: events.kind_strip551 path: 'app'552 created_at: -1553 machine: 'test'554 })!555 assert exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa'])! == 1556 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']557}558559560561562fn test_doctor_sends_a_recorded_strip_again_for_remote_residue() {563 store, dir := doctor_store('remote_residue')!564 remote_dir := os.join_path(dir, 'remote')565 defer {566 store.close()567 machine.forget_remote() or {}568 os.rmdir_all(dir) or {}569 }570 secret := 'secret'.bytes()571 hash := storage.hash_hex(secret)572 mut folder := remote.new_folder(remote_dir)!573 folder.put_if_absent(exchange.save_key('repo', 'aaaaaaaaaaaa'), json2.encode(exchange.SaveRecord{574 id: 'aaaaaaaaaaaa'575 files: [exchange.FileRecord{ path: 'app', hash: hash }]576 }).bytes())!577 folder.put_if_absent(exchange.object_key('repo', hash), zlib.compress(secret)!)!578 machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!579 store.set_state('remote_repo', 'repo')!580 events.strip(store, '', 'app')!581 store.set_state('strip_pending:bad', 'not JSON')!582 report := DoctorJson{583 remote_repo: 'repo'584 remote_strip_residue: ['app', 'bad']585 upload_unsettled: true586 }587 assert plan_repairs(store, report).any(it.action.contains('interrupted upload'))588 repairs := plan_repairs(store, report).filter(it.action.contains('remote still holds'))589 assert repairs.len == 1590 if _ := repairs[0].run() {591 assert false, 'an untrusted note must stop the second path'592 } else {593 assert err is exchange.Partway594 assert (err as exchange.Partway).done == 'sent 1 strip', (err as exchange.Partway).done595 }596 record := exchange.decode_save(folder.get(exchange.save_key('repo', 'aaaaaaaaaaaa'))!.body)!597 assert record.files.len == 0598 assert !folder.get(exchange.object_key('repo', hash))!.found599 assert !exchange.strip_pending(store, 'app')600}