1module cli23import os4import compress.zlib5import crypto.sha2566import json27import src.exchange8import src.release9import src.remote10import src.events11import src.history12import src.snapshot13import src.storage1415fn testsuite_begin() {16 os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17}1819fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {20 saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },21 history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]22 assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))23 assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))24 assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],25 'aaaaaaaaaaaa').any(it.contains('missing parent'))26 assert doctor_history([]history.Save{}, '').len == 027 assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))28}2930fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {31 mut report := DoctorJson{}32 doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },33 snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},34 mut report)35 assert report.problems.any(it.contains('unsafe path'))36 assert report.problems.any(it.contains('file and a directory'))37}3839fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {40 dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')41 os.rmdir_all(dir) or {}42 os.mkdir_all(dir)!43 defer {44 os.rmdir_all(dir) or {}45 }46 store := storage.create(dir)!47 defer {48 store.close()49 }50 hash := store.put_object('unsafe'.bytes())!51 store.begin()!52 history.import_save(store, history.Save{53 id: 'badbadbadbad'54 }, [snapshot.FileEntry{55 path: "../June's file"56 hash: hash57 }])!58 history.set_head(store, 'badbadbadbad')!59 store.end()!60 mut report := DoctorJson{}61 doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)62 assert report.problems == [63 "save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",64 ]65}6667struct DoctorReadOnly {68mut:69 items map[string][]u870}7172fn (mut backend DoctorReadOnly) get(key string) !remote.Object {73 if body := backend.items[key] {74 return remote.Object{ found: true, body: body }75 }76 return remote.Object{}77}7879fn (mut backend DoctorReadOnly) list(prefix string) ![]string {80 return backend.items.keys().filter(it.starts_with('${prefix}/'))81}8283fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {84 panic('Doctor attempted a remote write')85}8687fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {88 panic('Doctor attempted a remote write')89}9091fn (mut backend DoctorReadOnly) delete(key string) ! {92 panic('Doctor attempted a remote delete')93}9495fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {96 data := 'file'.bytes()97 hash := storage.hash_hex(data)98 mut backend := DoctorReadOnly{99 items: {100 'repo/head': 'aaaaaaaaaaaa'.bytes()101 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{102 id: 'aaaaaaaaaaaa'103 files: [exchange.FileRecord{ path: 'file', hash: hash }]104 }).bytes()105 'repo/objects/${hash}': zlib.compress(data)!106 'repo/releases/latest': '1.0.0'.bytes()107 'repo/releases/1.0.0/release.json': json2.encode(release.Record{108 repo: 'repo'109 version: '1.0.0'110 save: 'aaaaaaaaaaaa'111 files: [release.File{112 platform: 'linux-aarch64'113 name: 'app'114 size: data.len115 blake3: hash116 sha256: sha256.sum256(data).hex()117 }]118 }).bytes()119 'repo/releases/1.0.0/linux-aarch64/app': data120 }121 }122 mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }123 doctor_remote_content(mut backend, 'repo', mut report)!124 assert report.problems.len == 0125 assert report.remote_saves == 1126 assert report.remote_releases == 1127 backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()128 backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()129 mut damaged := DoctorJson{}130 doctor_remote_content(mut backend, 'repo', mut damaged)!131 assert damaged.problems.any(it.contains('damaged remote object'))132 assert damaged.problems.any(it.contains('checksum verification'))133}134135fn test_doctor_reports_event_errors_strip_residue_and_flags() {136 mut report := DoctorJson{}137 saves := [history.Save{ id: 'aaaaaaaaaaaa' }]138 event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },139 events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },140 events.Event{ id: 'bad', kind: 'unknown' }]141 doctor_events(event_list, saves, mut report)142 doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)143 assert report.flagged_saves == 1144 assert report.problems.any(it.contains('unknown kind'))145 assert report.problems.any(it.contains('stripped path'))146}147148struct DoctorMemory {149mut:150 items map[string][]u8151}152153fn (mut backend DoctorMemory) get(key string) !remote.Object {154 if body := backend.items[key] {155 return remote.Object{ found: true, body: body }156 }157 return remote.Object{}158}159160fn (mut backend DoctorMemory) list(prefix string) ![]string {161 return backend.items.keys().filter(it.starts_with('${prefix}/'))162}163164fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {165 if key in backend.items {166 return false167 }168 backend.items[key] = body169 return true170}171172fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {173 return false174}175176fn (mut backend DoctorMemory) delete(key string) ! {177 backend.items.delete(key)178}179180fn doctor_store(name string) !(storage.Store, string) {181 dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')182 os.rmdir_all(dir) or {}183 os.mkdir_all(dir)!184 return storage.create(dir)!, dir185}186187fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {188 store, dir := doctor_store('fetch')!189 defer {190 store.close()191 os.rmdir_all(dir) or {}192 }193 data := 'parent'.bytes()194 hash := storage.hash_hex(data)195 mut backend := DoctorReadOnly{196 items: {197 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{198 id: 'aaaaaaaaaaaa'199 files: [exchange.FileRecord{ path: 'file', hash: hash }]200 }).bytes()201 'repo/objects/${hash}': zlib.compress(data)!202 }203 }204 store.db.exec('PRAGMA foreign_keys=OFF')!205 store.begin()!206 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },207 []snapshot.FileEntry{})!208 history.set_head(store, 'bbbbbbbbbbbb')!209 store.end()!210 store.db.exec('PRAGMA foreign_keys=ON')!211 missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')212 assert missing == ['aaaaaaaaaaaa']213 assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1214 assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0215 assert store.get_object(hash)! == data216 assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {217 assert err.msg().contains('does not have save')218 0219 } == 0220}221222fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {223 store, dir := doctor_store('strip')!224 defer {225 store.close()226 os.rmdir_all(dir) or {}227 }228 secret := store.put_object('secret'.bytes())!229 kept := store.put_object('kept'.bytes())!230 store.begin()!231 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [232 snapshot.FileEntry{ path: 'keep', hash: kept },233 snapshot.FileEntry{ path: 'private/key', hash: secret },234 ])!235 history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },236 [snapshot.FileEntry{ path: 'keep', hash: kept }])!237 events.strip(store, '', 'private')!238 store.end()!239 assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'240 mut report := DoctorJson{}241 doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut242 report)243 assert report.strip_residue244 assert exchange.reapply_strips(store)! == 1245 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']246 assert !store.has_object(secret)!247 assert events.strip_events(store)!.len == 1248}249250fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {251 store, dir := doctor_store('objects')!252 defer {253 store.close()254 os.rmdir_all(dir) or {}255 }256 data := 'content'.bytes()257 hash := store.put_object(data)!258 store.begin()!259 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [260 snapshot.FileEntry{ path: 'app', hash: hash },261 ])!262 history.set_head(store, 'aaaaaaaaaaaa')!263 store.end()!264 store.db.exec("UPDATE objects SET data = x'00'")!265 problems := store.check_objects()!266 assert problems.map(it.hash) == [hash]267 assert local_copy(store, hash) == none268 os.write_file(os.join_path(dir, 'app'), 'content')!269 store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!270 assert store.check_objects()!.len == 0271 store.repair_object(hash, 'other'.bytes()) or {272 assert err.msg().contains('does not match')273 }274 mut backend := DoctorMemory{275 items: {276 'repo/objects/${hash}': 'damaged'.bytes()277 }278 }279 assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2280 assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data281 assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash282}283284285286fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {287 store, dir := doctor_store('strip_safety')!288 defer {289 store.close()290 os.rmdir_all(dir) or {}291 }292 app := store.put_object('app'.bytes())!293 secret := store.put_object('secret'.bytes())!294 orphan := store.put_object('orphan'.bytes())!295 store.begin()!296 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [297 snapshot.FileEntry{ path: 'app', hash: app },298 snapshot.FileEntry{ path: 'secret', hash: secret },299 ])!300 events.receive(store, events.Event{301 id: 'invalidstrip'302 kind: events.kind_strip303 path: 'app'304 created_at: -1305 machine: 'test'306 })!307 events.strip(store, '', 'secret')!308 store.end()!309 mut report := DoctorJson{}310 doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut311 report)312 assert !report.strip_residue313 assert exchange.reapply_strips(store)! == 1314 assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']315 assert store.has_object(app)!316 assert store.has_object(orphan)!317 assert !store.has_object(secret)!318}319320321322fn test_doctor_guesses_head_only_when_nothing_knows_better() {323 store, dir := doctor_store('only_tip')!324 defer {325 store.close()326 os.rmdir_all(dir) or {}327 }328 store.begin()!329 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!330 store.end()!331 mut report := DoctorJson{332 metadata_available: true333 saves: 1334 source: 'folder/repo'335 remote_head: 'bbbbbbbbbbbb'336 }337 assert !plan_repairs(store, report).any(it.action.contains('point head'))338 report.remote_head = ''339 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}340 assert !plan_repairs(store, report).any(it.action.contains('point head'))341 report.checks = []342 repairs := plan_repairs(store, report).filter(it.action.contains('point head'))343 assert repairs.len == 1344 history.set_head(store, 'cccccccccccc')!345 if _ := repairs[0].run() {346 assert false, 'a head another command set must not be replaced'347 } else {348 assert err.msg().contains('changed'), err.msg()349 }350 assert store.state(storage.head_key) or { '' } == 'cccccccccccc'351 store.remove_state(storage.head_key)!352 repairs[0].run()!353 assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'354}355356fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {357 store, dir := doctor_store('partial')!358 defer {359 store.close()360 os.rmdir_all(dir) or {}361 }362 kept := store.put_object('kept'.bytes())!363 lost := store.put_object('lost'.bytes())!364 store.begin()!365 history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [366 snapshot.FileEntry{ path: 'kept', hash: kept },367 snapshot.FileEntry{ path: 'lost', hash: lost },368 ])!369 store.end()!370 store.db.exec("UPDATE objects SET data = x'00'")!371 os.write_file(os.join_path(dir, 'kept'), 'kept')!372 if _ := repair_objects(store, [kept, lost], '', '') {373 assert false, 'an object with no verified copy must fail its repair'374 } else {375 assert err is PartialRepair376 assert (err as PartialRepair).done.contains('1 damaged or missing object')377 assert err.msg().contains(lost)378 }379 assert store.get_object(kept)! == 'kept'.bytes()380}381382fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {383 store, dir := doctor_store('invalid_fetch')!384 defer {385 store.close()386 os.rmdir_all(dir) or {}387 }388 mut backend := DoctorReadOnly{389 items: {390 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{391 id: 'aaaaaaaaaaaa'392 created_at: -1393 }).bytes()394 }395 }396 if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {397 assert false, 'a record with invalid metadata must not be imported'398 } else {399 assert err.msg().contains('invalid metadata')400 }401 assert history.all_saves(store)!.len == 0402}403404405406fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {407 store, dir := doctor_store('empty_object')!408 defer {409 store.close()410 os.rmdir_all(dir) or {}411 }412 hash := store.put_object([]u8{})!413 mut backend := DoctorMemory{414 items: {415 'repo/objects/${hash}': 'not zlib'.bytes()416 }417 }418 assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1419 assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0420}