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_reports_event_errors_strip_residue_and_flags() {137 mut report := DoctorJson{}138 saves := [history.Save{ id: 'aaaaaaaaaaaa' }]139 event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },140 events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },141 events.Event{ id: 'bad', kind: 'unknown' }]142 doctor_events(event_list, saves, mut report)143 doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)144 assert report.flagged_saves == 1145 assert report.problems.any(it.contains('unknown kind'))146 assert report.problems.any(it.contains('stripped path'))147}148149struct DoctorMemory {150mut:151 items map[string][]u8152}153154fn (mut backend DoctorMemory) get(key string) !remote.Object {155 if body := backend.items[key] {156 return remote.Object{ found: true, body: body }157 }158 return remote.Object{}159}160161fn (mut backend DoctorMemory) list(prefix string) ![]string {162 return backend.items.keys().filter(it.starts_with('${prefix}/'))163}164165fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {166 if key in backend.items {167 return false168 }169 backend.items[key] = body170 return true171}172173fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {174 return false175}176177fn (mut backend DoctorMemory) delete(key string) ! {178 backend.items.delete(key)179}180181fn doctor_store(name string) !(storage.Store, string) {182 dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')183 os.rmdir_all(dir) or {}184 os.mkdir_all(dir)!185 return storage.create(dir)!, dir186}187188fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {189 store, dir := doctor_store('fetch')!190 defer {191 store.close()192 os.rmdir_all(dir) or {}193 }194 data := 'parent'.bytes()195 hash := storage.hash_hex(data)196 mut backend := DoctorReadOnly{197 items: {198 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{199 id: 'aaaaaaaaaaaa'200 files: [exchange.FileRecord{ path: 'file', hash: hash }]201 }).bytes()202 'repo/objects/${hash}': zlib.compress(data)!203 }204 }205 store.db.exec('PRAGMA foreign_keys=OFF')!206 store.begin()!207 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },208 []snapshot.FileEntry{})!209 history.set_head(store, 'bbbbbbbbbbbb')!210 store.end()!211 store.db.exec('PRAGMA foreign_keys=ON')!212 missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')213 assert missing == ['aaaaaaaaaaaa']214 assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1215 assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0216 assert store.get_object(hash)! == data217 assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {218 assert err.msg().contains('does not have save')219 0220 } == 0221}222223fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {224 store, dir := doctor_store('strip')!225 defer {226 store.close()227 os.rmdir_all(dir) or {}228 }229 secret := store.put_object('secret'.bytes())!230 kept := store.put_object('kept'.bytes())!231 store.begin()!232 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [233 snapshot.FileEntry{ path: 'keep', hash: kept },234 snapshot.FileEntry{ path: 'private/key', hash: secret },235 ])!236 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },237 [snapshot.FileEntry{ path: 'keep', hash: kept }])!238 events.strip(store, '', 'private')!239 store.end()!240 assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'241 mut report := DoctorJson{}242 doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut243 report)244 assert report.strip_residue245 assert exchange.reapply_strips(store)! == 1246 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']247 assert !store.has_object(secret)!248 assert events.strip_events(store)!.len == 1249}250251fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {252 store, dir := doctor_store('objects')!253 defer {254 store.close()255 os.rmdir_all(dir) or {}256 }257 data := 'content'.bytes()258 hash := store.put_object(data)!259 store.begin()!260 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [261 snapshot.FileEntry{ path: 'app', hash: hash },262 ])!263 history.set_head(store, 'aaaaaaaaaaaa')!264 store.end()!265 store.db.exec("UPDATE objects SET data = x'00'")!266 problems := store.check_objects()!267 assert problems.map(it.hash) == [hash]268 assert local_copy(store, hash) == none269 os.write_file(os.join_path(dir, 'app'), 'content')!270 store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!271 assert store.check_objects()!.len == 0272 store.repair_object(hash, 'other'.bytes()) or {273 assert err.msg().contains('does not match')274 }275 mut backend := DoctorMemory{276 items: {277 'repo/objects/${hash}': 'damaged'.bytes()278 }279 }280 assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2281 assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data282 assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash283}284285286287fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {288 store, dir := doctor_store('strip_safety')!289 defer {290 store.close()291 os.rmdir_all(dir) or {}292 }293 app := store.put_object('app'.bytes())!294 secret := store.put_object('secret'.bytes())!295 orphan := store.put_object('orphan'.bytes())!296 store.begin()!297 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [298 snapshot.FileEntry{ path: 'app', hash: app },299 snapshot.FileEntry{ path: 'secret', hash: secret },300 ])!301 events.receive(store, events.Event{302 id: 'invalidstrip'303 kind: events.kind_strip304 path: 'app'305 created_at: -1306 machine: 'test'307 })!308 events.strip(store, '', 'secret')!309 store.end()!310 mut report := DoctorJson{}311 doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut312 report)313 assert !report.strip_residue314 assert exchange.reapply_strips(store)! == 1315 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']316 assert store.has_object(app)!317 assert store.has_object(orphan)!318 assert !store.has_object(secret)!319}320321322323fn test_doctor_guesses_head_only_when_nothing_knows_better() {324 store, dir := doctor_store('only_tip')!325 defer {326 store.close()327 os.rmdir_all(dir) or {}328 }329 store.begin()!330 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!331 store.end()!332 mut report := DoctorJson{333 metadata_available: true334 saves: 1335 source: 'folder/repo'336 remote_head: 'bbbbbbbbbbbb'337 }338 assert !plan_repairs(store, report).any(it.action.contains('point head'))339 report.remote_head = ''340 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}341 assert !plan_repairs(store, report).any(it.action.contains('point head'))342 report.checks = [DoctorCheck{'remote stability', 'fail', 'remote head changed during audit', ''}]343 assert !plan_repairs(store, report).any(it.action.contains('point head'))344 report.checks = []345 assert plan_repairs(store, report).any(it.action.contains('point head'))346 report.source = ''347 repairs := plan_repairs(store, report).filter(it.action.contains('point head'))348 assert repairs.len == 1349 history.set_head(store, 'cccccccccccc')!350 if _ := repairs[0].run() {351 assert false, 'a head another command set must not be replaced'352 } else {353 assert err.msg().contains('changed'), err.msg()354 }355 assert store.state(storage.head_key) or { '' } == 'cccccccccccc'356 store.remove_state(storage.head_key)!357 repairs[0].run()!358 assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'359}360361fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {362 store, dir := doctor_store('partial')!363 defer {364 store.close()365 os.rmdir_all(dir) or {}366 }367 kept := store.put_object('kept'.bytes())!368 lost := store.put_object('lost'.bytes())!369 store.begin()!370 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [371 snapshot.FileEntry{ path: 'kept', hash: kept },372 snapshot.FileEntry{ path: 'lost', hash: lost },373 ])!374 store.end()!375 store.db.exec("UPDATE objects SET data = x'00'")!376 os.write_file(os.join_path(dir, 'kept'), 'kept')!377 if _ := repair_objects(store, [kept, lost], '', '') {378 assert false, 'an object with no verified copy must fail its repair'379 } else {380 assert err is PartialRepair381 assert (err as PartialRepair).done == 'restored 1 damaged or missing object'382 assert err.msg().contains(lost)383 }384 assert store.get_object(kept)! == 'kept'.bytes()385}386387fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {388 store, dir := doctor_store('invalid_fetch')!389 defer {390 store.close()391 os.rmdir_all(dir) or {}392 }393 mut backend := DoctorReadOnly{394 items: {395 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{396 id: 'aaaaaaaaaaaa'397 created_at: -1398 }).bytes()399 }400 }401 if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {402 assert false, 'a record with invalid metadata must not be imported'403 } else {404 assert err.msg().contains('invalid metadata')405 }406 assert history.all_saves(store)!.len == 0407}408409410411fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {412 store, dir := doctor_store('empty_object')!413 defer {414 store.close()415 os.rmdir_all(dir) or {}416 }417 hash := store.put_object([]u8{})!418 mut backend := DoctorMemory{419 items: {420 'repo/objects/${hash}': 'not zlib'.bytes()421 }422 }423 assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1424 assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0425}426427428429fn test_doctor_rechecks_the_remote_head_before_guessing() {430 store, dir := doctor_store('remote_tip')!431 remote_dir := os.join_path(dir, 'remote')432 defer {433 store.close()434 machine.forget_remote() or {}435 os.rmdir_all(dir) or {}436 }437 store.begin()!438 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!439 store.end()!440 mut folder := remote.new_folder(remote_dir)!441 machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!442 report := DoctorJson{443 metadata_available: true444 saves: 1445 remote_repo: 'repo'446 }447 repairs := plan_repairs(store, report).filter(it.action.contains('point head'))448 assert repairs.len == 1449 folder.put_if_absent(exchange.head_key('repo'), 'bbbbbbbbbbbb'.bytes())!450 if _ := repairs[0].run() {451 assert false, 'a head offered since the audit must stop the guess'452 } else {453 assert err.msg().contains('another head'), err.msg()454 }455 assert store.state(storage.head_key) == none456}457458459fn test_doctor_reports_restored_objects_when_the_remote_then_fails() {460 store, dir := doctor_store('partial_remote')!461 defer {462 store.close()463 machine.forget_remote() or {}464 os.rmdir_all(dir) or {}465 }466 kept := store.put_object('kept'.bytes())!467 lost := store.put_object('lost'.bytes())!468 store.begin()!469 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [470 snapshot.FileEntry{ path: 'kept', hash: kept },471 snapshot.FileEntry{ path: 'lost', hash: lost },472 ])!473 store.end()!474 store.db.exec("UPDATE objects SET data = x'00'")!475 os.write_file(os.join_path(dir, 'kept'), 'kept')!476 machine.save_remote(machine.RemoteSettings{477 kind: machine.kind_folder478 path: os.join_path(dir, 'unmounted')479 })!480 if _ := repair_objects(store, [kept, lost], '', '') {481 assert false, 'an unreachable remote must fail the repair'482 } else {483 assert err is PartialRepair484 assert (err as PartialRepair).done == 'restored 1 damaged or missing object'485 assert err.msg().contains('not there'), err.msg()486 }487}488489490fn test_doctor_fetch_ignores_invalid_strip_events() {491 store, dir := doctor_store('fetch_invalid_strip')!492 defer {493 store.close()494 os.rmdir_all(dir) or {}495 }496 data := 'app'.bytes()497 hash := storage.hash_hex(data)498 mut backend := DoctorReadOnly{499 items: {500 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{501 id: 'aaaaaaaaaaaa'502 files: [exchange.FileRecord{ path: 'app', hash: hash }]503 }).bytes()504 'repo/objects/${hash}': zlib.compress(data)!505 }506 }507 events.receive(store, events.Event{508 id: 'invalidstrip'509 kind: events.kind_strip510 path: 'app'511 created_at: -1512 machine: 'test'513 })!514 assert exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa'])! == 1515 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']516}