12345module exchange67import json28import os9import src.events10import src.history11import src.remote12import src.snapshot13import src.storage141516fn testsuite_begin() {17 os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)18}1920fn test_arriving_record_drops_unsafe_paths_and_reports_them() {21 mut record := SaveRecord{22 files: [23 FileRecord{ path: 'safe.txt' },24 FileRecord{ path: '../outside.txt' },25 FileRecord{ path: 'bad\\name.txt' },26 FileRecord{ path: 'Icon\r' },27 ]28 }29 dropped := drop_unsafe_paths(mut record)30 assert record.files.map(it.path) == ['safe.txt']31 assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']32}33343536fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {37 body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'38 mut record := decode_save(body.bytes())!39 dropped := drop_unsafe_paths(mut record)40 assert record.files.map(it.path) == ['ok😀']41 assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']42}43444546fn machine(name string) !string {47 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')48 os.rmdir_all(os.dir(dir)) or {}49 os.mkdir_all(dir, os.MkdirParams{})!50 return dir51}5253fn scratch(name string) !string {54 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')55 os.rmdir_all(dir) or {}56 os.mkdir_all(dir, os.MkdirParams{})!57 return dir58}5960fn open_repo(dir string) !storage.Store {61 mut store := storage.create(dir)!62 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!63 return store64}6566fn put(root string, rel string, text string) ! {67 full := os.join_path(root, rel)68 os.mkdir_all(os.dir(full), os.MkdirParams{})!69 os.write_file(full, text)!70}7172fn make_save(store &storage.Store, message string) !history.Save {73 return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{74 message: message75 })!76}7778fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {79 return offer(store, mut backend)!80}8182fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {83 return steal(store, mut backend, StealOptions{84 theirs: true85 })!86}8788fn test_first_offer_publishes_the_whole_line() {89 base := scratch('first')!90 defer {91 os.rmdir_all(base) or {}92 }93 mut a := open_repo(machine('first_a')!)!94 defer {95 a.close()96 }97 put(a.root, 'a.txt', 'one\n')!98 make_save(a, 'first')!99 put(a.root, 'a.txt', 'two\n')!100 make_save(a, 'second')!101 mut backend := remote.new_folder(os.join_path(base, 'remote'))!102103 report := offer_now(a, mut backend)!104 assert report.repo == 'proj'105 assert report.saves == 2106 assert report.objects >= 2107 assert backend.get(head_key('proj'))!.found108 assert backend.list('proj/saves')!.len == 2109110 111 again := offer_now(a, mut backend)!112 assert again.saves == 0113}114115fn test_the_repo_name_is_remembered_and_head_offered() {116 base := scratch('remember')!117 defer {118 os.rmdir_all(base) or {}119 }120 mut a := open_repo(machine('remember_a')!)!121 defer {122 a.close()123 }124 put(a.root, 'a.txt', 'one\n')!125 make_save(a, 'first')!126 mut backend := remote.new_folder(os.join_path(base, 'remote'))!127 offer_now(a, mut backend)!128 assert a.state('remote_repo') or { '' } == 'proj'129 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id130 131 assert a.state('remote_head') or { '' } == head_of(a).id132133 134 135 put(a.root, 'a.txt', 'two\n')!136 make_save(a, 'second')!137 offer_now(a, mut backend)!138 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id139}140141fn test_a_repo_name_is_lowercased_at_first_offer() {142 base := scratch('lower')!143 defer {144 os.rmdir_all(base) or {}145 }146 dir := os.join_path(base, 'Haven')147 os.mkdir_all(dir, os.MkdirParams{})!148 mut a := open_repo(dir)!149 defer {150 a.close()151 }152 put(a.root, 'a.txt', 'one\n')!153 make_save(a, 'first')!154 mut backend := remote.new_folder(os.join_path(base, 'remote'))!155 assert offer_now(a, mut backend)!.repo == 'haven'156 assert backend.get(head_key('haven'))!.found157}158159fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {160 base := scratch('renamed')!161 defer {162 os.rmdir_all(base) or {}163 }164 mut a := open_repo(machine('renamed_a')!)!165 defer {166 a.close()167 }168 put(a.root, 'a.txt', 'one\n')!169 make_save(a, 'first')!170 remote_dir := os.join_path(base, 'remote')171 mut backend := remote.new_folder(remote_dir)!172 offer_now(a, mut backend)!173 174 os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!175 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!176 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!177178 put(a.root, 'a.txt', 'two\n')!179 make_save(a, 'second')!180 assert offer_now(a, mut backend)!.repo == 'tool'181 assert a.state('remote_repo') or { '' } == 'tool'182 assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id183 assert !backend.get(head_key('proj'))!.found184}185186fn test_a_rename_marker_naming_a_path_is_not_followed() {187 base := scratch('badmarker')!188 defer {189 os.rmdir_all(base) or {}190 }191 mut a := open_repo(machine('badmarker_a')!)!192 defer {193 a.close()194 }195 put(a.root, 'a.txt', 'one\n')!196 make_save(a, 'first')!197 remote_dir := os.join_path(base, 'remote')198 mut backend := remote.new_folder(remote_dir)!199 offer_now(a, mut backend)!200 os.rmdir_all(os.join_path(remote_dir, 'proj'))!201 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!202 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!203 assert offer_now(a, mut backend)!.repo == 'proj'204}205206fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {207 base := scratch('forward')!208 defer {209 os.rmdir_all(base) or {}210 }211 mut backend := remote.new_folder(os.join_path(base, 'remote'))!212 mut a := open_repo(machine('fwd_a')!)!213 defer {214 a.close()215 }216 put(a.root, 'a.txt', 'from A\n')!217 make_save(a, 'A writes')!218 offer_now(a, mut backend)!219220 221 mut b := open_repo(machine('fwd_b')!)!222 defer {223 b.close()224 }225 fetched := fetch_repo(mut backend, 'proj')!226 b.begin()!227 write_fetched(b, fetched)!228 history.set_head(b, fetched.head)!229 b.end()!230 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!231 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'232 assert head_of(b).id == fetched.head233}234235236237fn test_steal_while_only_ahead_leaves_the_line_alone() {238 base := scratch('ahead')!239 defer {240 os.rmdir_all(base) or {}241 }242 mut backend := remote.new_folder(os.join_path(base, 'remote'))!243 mut a := open_repo(machine('ahead_a')!)!244 defer {245 a.close()246 }247 put(a.root, 'a.txt', 'one\n')!248 make_save(a, 'offered')!249 offer_now(a, mut backend)!250 put(a.root, 'a.txt', 'two\n')!251 ahead := make_save(a, 'not offered yet')!252 report := steal_now(a, mut backend)!253 assert report.replayed == 0254 assert head_of(a).id == ahead.id255 256 assert !snapshot.restore_pending(a)257 assert history.all_saves(a)!.len == 2258 offer_now(a, mut backend)!259 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id260}261262263264fn test_steal_never_writes_through_a_symlinked_folder() {265 base := scratch('steal_link')!266 defer {267 os.rmdir_all(base) or {}268 }269 mut backend := remote.new_folder(os.join_path(base, 'remote'))!270 mut a := open_repo(machine('steal_link_a')!)!271 defer {272 a.close()273 }274 mut b := open_repo(machine('steal_link_b')!)!275 defer {276 b.close()277 }278 put(a.root, 'a.txt', 'one\n')!279 make_save(a, 'first')!280 offer_now(a, mut backend)!281 join(b, mut backend)!282 outside := os.join_path(base, 'outside')283 put(outside, 'a.md', 'precious\n')!284 os.symlink(outside, os.join_path(b.root, 'docs'))!285 put(a.root, 'docs/a.md', 'from A\n')!286 make_save(a, 'A adds docs')!287 offer_now(a, mut backend)!288 head_before := head_of(b).id289 if _ := steal_now(b, mut backend) {290 assert false, 'a steal writing through a symlinked folder must refuse'291 } else {292 assert err.msg().contains('docs')293 }294 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'295 assert head_of(b).id == head_before296}297298299fn test_steal_refuses_after_a_restore_cut_short() {300 base := scratch('steal_cut')!301 defer {302 os.rmdir_all(base) or {}303 }304 mut backend := remote.new_folder(os.join_path(base, 'remote'))!305 mut a := open_repo(machine('steal_cut_a')!)!306 defer {307 a.close()308 }309 put(a.root, 'a.txt', 'one\n')!310 make_save(a, 'first')!311 offer_now(a, mut backend)!312 a.set_state('restoring', 'yes')!313 if _ := steal_now(a, mut backend) {314 assert false, 'steal must refuse while a restore is unfinished'315 } else {316 assert err.msg().contains('koh revert')317 }318}319320321322fn test_a_steal_imports_parents_before_children() {323 base := scratch('steal_order')!324 defer {325 os.rmdir_all(base) or {}326 }327 mut backend := remote.new_folder(os.join_path(base, 'remote'))!328 mut a := open_repo(machine('order_a')!)!329 defer {330 a.close()331 }332 put(a.root, 'a.txt', 'one\n')!333 parent := make_save(a, 'parent')!334 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{335 message: 'replayed, older than its parent'336 created_at: parent.created_at - 3600337 })!338 offer_now(a, mut backend)!339 mut b := open_repo(machine('order_b')!)!340 defer {341 b.close()342 }343 join(b, mut backend)!344 assert head_of(b).id == child.id345}346347348fn test_steal_refuses_an_unsaved_executable_bit_change() {349 base := scratch('chmod')!350 defer {351 os.rmdir_all(base) or {}352 }353 mut backend := remote.new_folder(os.join_path(base, 'remote'))!354 mut a := open_repo(machine('chmod_a')!)!355 defer {356 a.close()357 }358 put(a.root, 'run.sh', 'echo hi\n')!359 make_save(a, 'script')!360 offer_now(a, mut backend)!361 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!362 if _ := steal_now(a, mut backend) {363 assert false, 'a chmod-only change should make the tree dirty'364 } else {365 assert err.msg() == dirty_tree366 }367}368369fn test_divergence_without_clashes_keeps_both_machines_work() {370 base := scratch('diverge')!371 defer {372 os.rmdir_all(base) or {}373 }374 mut backend := remote.new_folder(os.join_path(base, 'remote'))!375 mut a := open_repo(machine('div_a')!)!376 defer {377 a.close()378 }379 mut b := open_repo(machine('div_b')!)!380 defer {381 b.close()382 }383 put(a.root, 'shared.txt', 'base\n')!384 make_save(a, 'base')!385 offer_now(a, mut backend)!386387 388 join(b, mut backend)!389 put(a.root, 'fromA.txt', 'A\n')!390 make_save(a, 'A adds a file')!391 put(b.root, 'fromB.txt', 'B\n')!392 make_save(b, 'B adds a file')!393 offer_now(a, mut backend)!394395 396 if _ := offer_now(b, mut backend) {397 assert false, 'offering on top of unknown remote saves should be refused'398 } else {399 assert err.msg().contains('run koh steal')400 }401402 report := steal_now(b, mut backend)!403 assert !report.forward404 assert report.replayed == 1405 assert report.clashes.len == 0406 407 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'408 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'409 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'410 411 mut saw_replay := false412 for save in history.all_saves(b)! {413 if save.replayed_from != '' {414 saw_replay = true415 }416 }417 assert saw_replay418 419 offer_now(b, mut backend)!420 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()421 assert line_head == head_of(b).id422}423424425426427428fn test_a_local_change_undone_does_not_block_a_combine() {429 base := scratch('undone')!430 defer {431 os.rmdir_all(base) or {}432 }433 mut backend := remote.new_folder(os.join_path(base, 'remote'))!434 mut a := open_repo(machine('undone_a')!)!435 defer {436 a.close()437 }438 mut b := open_repo(machine('undone_b')!)!439 defer {440 b.close()441 }442 put(a.root, 'f.txt', 'base\n')!443 put(a.root, 'g.txt', 'base\n')!444 make_save(a, 'base')!445 offer_now(a, mut backend)!446 join(b, mut backend)!447448 put(a.root, 'f.txt', 'from A\n')!449 put(a.root, 'g.txt', 'same\n')!450 make_save(a, 'A edits both')!451 offer_now(a, mut backend)!452453 put(b.root, 'f.txt', 'tried on B\n')!454 put(b.root, 'g.txt', 'first try\n')!455 tried := make_save(b, 'B tries')!456 put(b.root, 'f.txt', 'base\n')!457 put(b.root, 'g.txt', 'same\n')!458 put(b.root, 'h.txt', 'B\n')!459 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!460461 report := steal_now(b, mut backend)!462 assert report.clashes.len == 0463 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'464 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'465 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'466 467 468 assert report.replayed == 1469 assert head_of(b).replayed_from == undone.id470 ids := history.all_saves(b)!.map(it.id)471 assert tried.id in ids && undone.id in ids472 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')473}474475476477fn test_clash_questions_are_asked_before_the_write_lock() {478 base := scratch('ask_unlocked')!479 defer {480 os.rmdir_all(base) or {}481 }482 mut backend := remote.new_folder(os.join_path(base, 'remote'))!483 mut a := open_repo(machine('ask_a')!)!484 defer {485 a.close()486 }487 mut b := open_repo(machine('ask_b')!)!488 defer {489 b.close()490 }491 put(a.root, 'same.txt', 'base\n')!492 make_save(a, 'base')!493 offer_now(a, mut backend)!494 join(b, mut backend)!495 put(a.root, 'same.txt', 'A version\n')!496 make_save(a, 'A edits same')!497 offer_now(a, mut backend)!498 put(b.root, 'same.txt', 'B version\n')!499 make_save(b, 'B edits same')!500 root := b.root501 502 if _ := steal(b, mut backend, StealOptions{503 guide: fn [root] (preview StealPreview) !map[string]Side {504 other := storage.open(root) or { panic(err) }505 defer {506 other.close()507 }508 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }509 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{510 message: 'agent saves during the question'511 }) or { panic('steal held the write lock while asking: ${err}') }512 return {513 preview.clashes[0].path: Side.mine514 }515 }516 })517 {518 assert false, 'a steal whose line moved during the question must refuse'519 } else {520 assert err.msg().contains('changed while')521 }522 523 assert head_of(b).message == 'agent saves during the question'524 report := steal(b, mut backend, StealOptions{525 mine: true526 })!527 assert report.kept_mine == 1528 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'529 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'530}531532fn test_clashing_files_are_chosen_one_at_a_time() {533 base := scratch('clash')!534 defer {535 os.rmdir_all(base) or {}536 }537 mut backend := remote.new_folder(os.join_path(base, 'remote'))!538 mut a := open_repo(machine('clash_a')!)!539 defer {540 a.close()541 }542 mut b := open_repo(machine('clash_b')!)!543 defer {544 b.close()545 }546 put(a.root, 'same.txt', 'base\n')!547 put(a.root, 'other.txt', 'quiet\n')!548 make_save(a, 'base')!549 offer_now(a, mut backend)!550 join(b, mut backend)!551552 553 put(a.root, 'same.txt', 'A version\n')!554 make_save(a, 'A edits same')!555 offer_now(a, mut backend)!556 put(b.root, 'same.txt', 'B version\n')!557 make_save(b, 'B edits same')!558559 560 if _ := steal(b, mut backend, StealOptions{}) {561 assert false, 'an undecided clash must be refused'562 } else {563 assert err.msg().contains('--mine') || err.msg().contains('--theirs')564 565 if err is ClashError {566 assert err.paths == ['same.txt']567 } else {568 assert false, 'an undecided clash must be a ClashError, not ${err}'569 }570 }571 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,572 head_of(b).id)!).len == 0573574 575 report := steal_now(b, mut backend)!576 assert report.clashes == ['same.txt']577 assert report.kept_mine == 0578 assert report.kept_versions == ['same.txt:${report.previous_head}']579 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'580 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'581}582583fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {584 base := scratch('guided_cancel')!585 defer { os.rmdir_all(base) or {} }586 mut backend := remote.new_folder(os.join_path(base, 'remote'))!587 mut a := open_repo(machine('guided_cancel_a')!)!588 defer { a.close() }589 mut b := open_repo(machine('guided_cancel_b')!)!590 defer { b.close() }591 put(a.root, 'same.txt', 'base\n')!592 make_save(a, 'base')!593 offer_now(a, mut backend)!594 join(b, mut backend)!595 put(a.root, 'same.txt', 'remote\n')!596 remote_head := make_save(a, 'remote change')!597 offer_now(a, mut backend)!598 put(b.root, 'same.txt', 'mine\n')!599 local_head := make_save(b, 'local change')!600601 if _ := steal(b, mut backend, StealOptions{602 guide: fn (preview StealPreview) !map[string]Side {603 assert preview.clashes.len == 1604 return error('cancelled')605 }606 }) {607 assert false, 'the guide cancelled'608 } else {609 assert err.msg() == 'cancelled'610 }611 assert head_of(b).id == local_head.id612 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'613 assert history.by_id(b, remote_head.id) != none614 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1615}616617fn test_choosing_mine_keeps_the_local_version() {618 base := scratch('mine')!619 defer {620 os.rmdir_all(base) or {}621 }622 mut backend := remote.new_folder(os.join_path(base, 'remote'))!623 mut a := open_repo(machine('mine_a')!)!624 defer {625 a.close()626 }627 mut b := open_repo(machine('mine_b')!)!628 defer {629 b.close()630 }631 put(a.root, 'same.txt', 'base\n')!632 make_save(a, 'base')!633 offer_now(a, mut backend)!634 join(b, mut backend)!635 put(a.root, 'same.txt', 'A version\n')!636 make_save(a, 'A edits')!637 offer_now(a, mut backend)!638 put(b.root, 'same.txt', 'B version\n')!639 make_save(b, 'B edits')!640641 report := steal(b, mut backend, StealOptions{642 mine: true643 })!644 assert report.kept_mine == 1645 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'646 647 assert head_of(b).message.contains('combine: kept mine for 1 file')648 649 assert head_of(b).restores == ''650 assert history.all_saves(b)!.len >= 4651}652653fn test_flags_travel_with_the_saves() {654 base := scratch('flags')!655 defer {656 os.rmdir_all(base) or {}657 }658 mut backend := remote.new_folder(os.join_path(base, 'remote'))!659 mut a := open_repo(machine('flag_a')!)!660 defer {661 a.close()662 }663 mut b := open_repo(machine('flag_b')!)!664 defer {665 b.close()666 }667 put(a.root, 'a.txt', 'one\n')!668 save := make_save(a, 'suspect save')!669 events.flag(a, save.id, 'look at this')!670 offer_now(a, mut backend)!671672 673 fetched := fetch_back_to_known(b, mut backend, 'proj')!674 assert fetched.events_out.len == 1675 assert fetched.events_out[0].kind == events.kind_flag676 b.begin()!677 write_fetched(b, fetched)!678 history.set_head(b, fetched.head)!679 b.end()!680 state := events.flag_state(b, save.id)!681 assert state.flagged682 assert state.reason == 'look at this'683}684685fn test_a_second_offer_racing_head_is_refused_not_forced() {686 base := scratch('race')!687 defer {688 os.rmdir_all(base) or {}689 }690 mut backend := remote.new_folder(os.join_path(base, 'remote'))!691 mut a := open_repo(machine('race_a')!)!692 defer {693 a.close()694 }695 put(a.root, 'a.txt', 'one\n')!696 make_save(a, 'base')!697 offer_now(a, mut backend)!698699 700 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),701 backend.get(head_key('proj'))!.tag)!702703 put(a.root, 'a.txt', 'two\n')!704 make_save(a, 'second')!705 if _ := offer_now(a, mut backend) {706 assert false, 'offering while the remote head moved must be refused'707 } else {708 assert err.msg().contains('run koh steal')709 }710}711712713fn join(store &storage.Store, mut backend remote.Backend) ! {714 fetched := fetch_repo(mut backend, 'proj')!715 store.begin()!716 write_fetched(store, fetched)!717 history.set_head(store, fetched.head)!718 store.end()!719 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,720 fetched.head)!, [])!721 store.set_state('remote_repo', 'proj')!722}723724struct FaultyBackend {725mut:726 inner remote.Backend727 writes int728 reads int729 fail_write int 730 fail_read int731 732 fail_delete bool733 734 735 race_format bool736 raced bool737 738 fail_list string739 740 fail_delete_after int741 deletes int742}743744fn (mut f FaultyBackend) get(key string) !remote.Object {745 f.reads++746 if f.fail_read > 0 && f.reads >= f.fail_read {747 return error('injected read failure')748 }749 return f.inner.get(key)!750}751752fn (mut f FaultyBackend) list(prefix string) ![]string {753 if f.fail_list != '' && prefix.ends_with(f.fail_list) {754 return error('injected list failure')755 }756 return f.inner.list(prefix)!757}758759fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {760 f.writes++761 if f.fail_write > 0 && f.writes >= f.fail_write {762 return error('injected write failure')763 }764 if f.race_format && !f.raced && key.ends_with('/format') {765 f.raced = true766 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!767 return false768 }769 return f.inner.put_if_absent(key, body)!770}771772fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {773 f.writes++774 if f.fail_write > 0 && f.writes >= f.fail_write {775 return error('injected write failure')776 }777 return f.inner.put_if_match(key, body, tag)!778}779780fn (mut f FaultyBackend) delete(key string) ! {781 if f.fail_delete || (f.fail_delete_after > 0 && f.deletes >= f.fail_delete_after) {782 return error('injected delete failure')783 }784 f.deletes++785 f.inner.delete(key)!786}787788789790791fn test_an_offer_sends_only_the_objects_a_save_adds() {792 base := scratch('offer_diff')!793 defer {794 os.rmdir_all(base) or {}795 }796 mut a := open_repo(machine('offer_diff_a')!)!797 defer {798 a.close()799 }800 for i in 0 .. 20 {801 put(a.root, 'f${i}.txt', 'file ${i}\n')!802 }803 make_save(a, 'twenty files')!804 mut backend := FaultyBackend{805 inner: remote.new_folder(os.join_path(base, 'remote'))!806 }807 offer(a, mut backend)!808 put(a.root, 'f3.txt', 'changed\n')!809 make_save(a, 'one change')!810 before := backend.writes811 report := offer(a, mut backend)!812 assert report.objects == 1813 814 assert backend.writes - before == 3815}816817fn test_a_failed_offer_is_safe_to_re_run() {818 base := scratch('fault_offer')!819 defer {820 os.rmdir_all(base) or {}821 }822 remote_dir := os.join_path(base, 'remote')823 mut a := open_repo(machine('fault_a')!)!824 defer {825 a.close()826 }827 put(a.root, 'a.txt', 'one\n')!828 make_save(a, 'first')!829 put(a.root, 'a.txt', 'two\n')!830 make_save(a, 'second')!831832 833 mut flaky := FaultyBackend{834 inner: remote.new_folder(remote_dir)!835 fail_write: 2836 }837 if _ := offer(a, mut flaky) {838 assert false, 'the injected failure should have surfaced'839 }840841 842 843 mut healthy := remote.new_folder(remote_dir)!844 report := offer(a, mut healthy)!845 assert report.saves == 2846 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id847 assert healthy.list('proj/saves')!.len == 2848}849850851852fn test_a_strip_with_an_illegal_path_is_ignored() {853 base := scratch('illegal_strip')!854 defer {855 os.rmdir_all(base) or {}856 }857 mut backend := remote.new_folder(os.join_path(base, 'remote'))!858 mut a := open_repo(machine('illegal_strip_a')!)!859 defer {860 a.close()861 }862 mut b := open_repo(machine('illegal_strip_b')!)!863 defer {864 b.close()865 }866 put(a.root, 'a/x.txt', 'kept\n')!867 make_save(a, 'add a folder')!868 offer_now(a, mut backend)!869 for id, path in {870 'aaaaaaaaaaaa': 'a//'871 'bbbbbbbbbbbb': 'a\\ud800'872 } {873 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!874 }875 join(b, mut backend)!876 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)877 put(a.root, 'more.txt', 'more\n')!878 make_save(a, 'more')!879 offer_now(a, mut backend)!880}881882fn test_a_failed_steal_leaves_nothing_behind() {883 base := scratch('fault_steal')!884 defer {885 os.rmdir_all(base) or {}886 }887 remote_dir := os.join_path(base, 'remote')888 mut a := open_repo(machine('fault_s_a')!)!889 defer {890 a.close()891 }892 mut b := open_repo(machine('fault_s_b')!)!893 defer {894 b.close()895 }896 put(a.root, 'a.txt', 'from A\n')!897 make_save(a, 'A writes')!898 mut healthy := remote.new_folder(remote_dir)!899 offer(a, mut healthy)!900901 902 mut flaky := FaultyBackend{903 inner: remote.new_folder(remote_dir)!904 fail_read: 2905 }906 if _ := fetch_back_to_known(b, mut flaky, 'proj') {907 assert false, 'the injected read failure should have surfaced'908 }909 910 assert history.head(b) == none911 assert b.object_count()! == 0912913 914 fetched := fetch_back_to_known(b, mut healthy, 'proj')!915 b.begin()!916 write_fetched(b, fetched)!917 history.set_head(b, fetched.head)!918 b.end()!919 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!920 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'921}922923fn test_an_interrupted_strip_is_idempotent() {924 925 926 927 base := scratch('fault_strip')!928 defer {929 os.rmdir_all(base) or {}930 }931 mut a := open_repo(machine('fault_strip_a')!)!932 defer {933 a.close()934 }935 put(a.root, 'secret.txt', 'shh\n')!936 put(a.root, 'keep.txt', 'fine\n')!937 save := make_save(a, 'add both')!938939 for _ in 0 .. 2 {940 strip_local(a, 'secret.txt', '')!941 }942 files := history.files_of(a, save.id)!943 944 945 assert files.map(it.path) == ['.kohignore', 'keep.txt']946 assert events.strip_events(a)!.len == 1947}948949950951952953fn test_a_strip_matches_the_path_exactly() {954 mut a := open_repo(machine('strip_exact')!)!955 defer {956 a.close()957 }958 hash := a.put_object('x\n'.bytes())!959 mut files := []snapshot.FileEntry{}960 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {961 files << snapshot.FileEntry{962 path: path963 hash: hash964 }965 }966 save := history.save_tree(a, '', files, history.SaveInput{967 message: 'add all'968 })!969 assert strip_scope(a, 'docs', '')!.files == 1970 strip_local(a, 'docs', '')!971 strip_local(a, 'a_b', '')!972 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']973}974975fn test_a_strip_reaches_the_remote_and_other_machines() {976 base := scratch('strip_sync')!977 defer {978 os.rmdir_all(base) or {}979 }980 mut a := open_repo(machine('strip_a')!)!981 defer {982 a.close()983 }984 mut b := open_repo(machine('strip_b')!)!985 defer {986 b.close()987 }988 put(a.root, 'secrets.env', 'api_key=1\n')!989 put(a.root, 'keep.txt', 'fine\n')!990 save := make_save(a, 'add both')!991 mut backend := remote.new_folder(os.join_path(base, 'remote'))!992 offer_now(a, mut backend)!993 join(b, mut backend)!994 assert b.object_count()! > 0995996 997 998 mut secret_hash := ''999 for file in history.files_of(a, save.id)! {1000 if file.path == 'secrets.env' {1001 secret_hash = file.hash1002 }1003 }1004 assert secret_hash != ''1005 strip_local(a, 'secrets.env', '')!1006 report := send_strip(a, mut backend, 'secrets.env')!1007 assert report.event_sent1008 assert report.records == 110091010 1011 1012 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1013 for file in record.files {1014 assert file.path != 'secrets.env'1015 }1016 1017 1018 assert !backend.get(object_key('proj', secret_hash))!.found1019 assert backend.list('proj/objects')!.len == 210201021 1022 b.begin()!1023 fetched := fetch_back_to_known(b, mut backend, 'proj')!1024 write_fetched(b, fetched)!1025 b.end()!1026 mut paths := []string{}1027 for file in history.files_of(b, save.id)! {1028 paths << file.path1029 }1030 assert 'secrets.env' !in paths1031 assert 'keep.txt' in paths10321033 1034 1035 1036 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1037 put(b.root, 'secrets.env', 'rotated\n')!1038 assert !dirty(b, history.head(b))!1039 put(b.root, 'keep.txt', 'still fine\n')!1040 later := make_save(b, 'edit keep')!1041 offer_now(b, mut backend)!1042 assert backend.list('proj/events')!.len == 11043 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1044}10451046104710481049fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1050 base := scratch('strip_later')!1051 defer {1052 os.rmdir_all(base) or {}1053 }1054 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1055 mut a := open_repo(machine('strip_later_a')!)!1056 defer {1057 a.close()1058 }1059 mut b := open_repo(machine('strip_later_b')!)!1060 defer {1061 b.close()1062 }1063 put(a.root, 'secrets.env', 'api_key=1\n')!1064 old := make_save(a, 'leak the key')!1065 offer_now(a, mut backend)!1066 join(b, mut backend)!1067 strip_local(a, 'secrets.env', '')!1068 send_strip(a, mut backend, 'secrets.env')!1069 1070 put(b.root, 'secrets.env', 'api_key=2\n')!1071 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1072 message: 'rotate the key'1073 created_at: events.strip_events(a)!.last().created_at + 36001074 })!1075 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1076 b.begin()!1077 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1078 b.end()!1079 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1080 offer_now(b, mut backend)!10811082 mut c := open_repo(machine('strip_later_c')!)!1083 defer {1084 c.close()1085 }1086 join(c, mut backend)!1087 for save in [old.id, later.id] {1088 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1089 }1090 assert !os.exists(os.join_path(c.root, 'secrets.env'))1091}1092109310941095fn test_a_held_strip_reaches_saves_arriving_after_it() {1096 base := scratch('strip_held')!1097 defer {1098 os.rmdir_all(base) or {}1099 }1100 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1101 mut a := open_repo(machine('strip_held_a')!)!1102 defer {1103 a.close()1104 }1105 mut b := open_repo(machine('strip_held_b')!)!1106 defer {1107 b.close()1108 }1109 put(a.root, 'secrets.env', 'api_key=1\n')!1110 make_save(a, 'leak the key')!1111 offer_now(a, mut backend)!1112 join(b, mut backend)!1113 strip_local(b, 'secrets.env', '')!1114 put(a.root, 'keep.txt', 'fine\n')!1115 later := make_save(a, 'add keep')!1116 offer_now(a, mut backend)!1117 b.begin()!1118 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1119 b.end()!1120 paths := history.files_of(b, later.id)!.map(it.path)1121 assert 'secrets.env' !in paths1122 assert 'keep.txt' in paths1123}1124112511261127struct StripRace {1128mut:1129 inner remote.Backend1130 other &storage.Store1131 path string1132 done bool1133 1134 fail_after_race bool1135 raced_put bool1136 fail_delete bool1137 fail_match bool1138 1139 on_objects bool1140 1141 fail_events_after_race bool1142}11431144fn (mut r StripRace) get(key string) !remote.Object {1145 return r.inner.get(key)!1146}11471148fn (mut r StripRace) list(prefix string) ![]string {1149 if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1150 return error('injected list failure')1151 }1152 return r.inner.list(prefix)!1153}11541155fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1156 if r.fail_after_race && r.raced_put {1157 return error('injected write failure')1158 }1159 if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1160 r.done = true1161 r.raced_put = true1162 strip_local(r.other, r.path, '')!1163 send_strip(r.other, mut r.inner, r.path)!1164 }1165 return r.inner.put_if_absent(key, body)!1166}11671168fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1169 if r.fail_match {1170 return error('injected rewrite failure')1171 }1172 return r.inner.put_if_match(key, body, tag)!1173}11741175fn (mut r StripRace) delete(key string) ! {1176 if r.fail_delete {1177 return error('injected delete failure')1178 }1179 r.inner.delete(key)!1180}11811182118311841185fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1186 base := scratch('strip_race')!1187 defer {1188 os.rmdir_all(base) or {}1189 }1190 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1191 mut a := open_repo(machine('strip_race_a')!)!1192 defer {1193 a.close()1194 }1195 mut b := open_repo(machine('strip_race_b')!)!1196 defer {1197 b.close()1198 }1199 put(a.root, 'secrets.env', 'api_key=1\n')!1200 first := make_save(a, 'leak the key')!1201 offer_now(a, mut backend)!1202 join(b, mut backend)!1203 put(a.root, 'secrets.env', 'api_key=2\n')!1204 later := make_save(a, 'rotate the key')!1205 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1206 mut race := StripRace{1207 inner: backend1208 other: &b1209 path: 'secrets.env'1210 }1211 if _ := offer(a, mut race) {1212 assert false, 'an offer that raced a strip must refuse'1213 } else {1214 assert err.msg().contains('koh steal'), err.msg()1215 }1216 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1217 assert !backend.get(object_key('proj', secret))!.found1218 1219 assert backend.get(save_key('proj', later.id))!.found1220 for key in backend.list('proj/saves')! {1221 record := decode_save(backend.get(key)!.body)!1222 assert 'secrets.env' !in record.files.map(it.path), record.id1223 }1224}1225122612271228fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1229 base := scratch('resend_race')!1230 defer {1231 os.rmdir_all(base) or {}1232 }1233 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1234 mut a := open_repo(machine('resend_race_a')!)!1235 defer {1236 a.close()1237 }1238 mut b := open_repo(machine('resend_race_b')!)!1239 defer {1240 b.close()1241 }1242 put(a.root, 'secrets.env', 'api_key=1\n')!1243 first := make_save(a, 'leak the key')!1244 offer_now(a, mut backend)!1245 join(b, mut backend)!1246 backend.delete(save_key('proj', first.id))!1247 mut race := StripRace{1248 inner: backend1249 other: &b1250 path: 'secrets.env'1251 }1252 if _ := resend(a, mut race, 'proj', [], [first.id]) {1253 assert false, 'a resend that raced a strip must refuse'1254 } else {1255 assert err.msg().contains('koh steal'), err.msg()1256 1257 assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1258 }1259 1260 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1261 assert 'secrets.env' !in record.files.map(it.path)1262 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1263}1264126512661267fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1268 base := scratch('resend_shared')!1269 defer {1270 os.rmdir_all(base) or {}1271 }1272 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1273 mut a := open_repo(machine('resend_shared_a')!)!1274 defer {1275 a.close()1276 }1277 mut b := open_repo(machine('resend_shared_b')!)!1278 defer {1279 b.close()1280 }1281 put(a.root, 'secrets.env', 'same\n')!1282 put(a.root, 'copy.txt', 'same\n')!1283 first := make_save(a, 'two paths, one object')!1284 offer_now(a, mut backend)!1285 join(b, mut backend)!1286 put(a.root, 'later.txt', 'later\n')!1287 second := make_save(a, 'not offered')!1288 shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1289 backend.delete(save_key('proj', first.id))!1290 backend.delete(shared)!1291 mut race := StripRace{1292 inner: backend1293 other: &b1294 path: 'secrets.env'1295 fail_after_race: true1296 }1297 if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1298 assert false, 'the injected failure should have surfaced'1299 } else {1300 assert err.msg().contains('injected'), err.msg()1301 }1302 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1303 paths := record.files.map(it.path)1304 assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1305 assert backend.get(shared)!.found1306}13071308130913101311fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1312 base := scratch('strip_note')!1313 defer {1314 os.rmdir_all(base) or {}1315 }1316 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1317 mut a := open_repo(machine('strip_note_a')!)!1318 defer {1319 a.close()1320 }1321 put(a.root, 'app', 'app\n')!1322 save := make_save(a, 'app')!1323 offer_now(a, mut backend)!1324 events.strip(a, '', 'other')!1325 for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1326 a.set_state(pending_key('app'), note)!1327 if _ := send_strip(a, mut backend, 'app') {1328 assert false, 'an untrusted note must be refused'1329 } else {1330 assert err.msg().contains('kept for recovery'), err.msg()1331 }1332 assert a.state(pending_key('app')) or { '' } == note1333 assert backend.list(events_prefix('proj'))!.len == 01334 assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1335 }1336 1337 events.strip(a, '', 'app')!1338 victim := os.join_path(base, 'remote', 'victim', 'head')1339 os.mkdir_all(os.dir(victim))!1340 os.write_file(victim, 'another repo')!1341 a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1342 if _ := send_strip(a, mut backend, 'app') {1343 assert false, 'a note naming a path outside the objects must be refused'1344 }1345 assert os.read_file(victim)! == 'another repo'1346 1347 a.remove_state(pending_key('app'))!1348 requeue_strip(a, 'app')!1349 backend.delete(save_key('proj', save.id))!1350 backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1351 id: save.id1352 files: [FileRecord{1353 path: 'app'1354 hash: '../../victim/head'1355 }]1356 }).bytes())!1357 send_strip(a, mut backend, 'app')!1358 assert os.read_file(victim)! == 'another repo'1359}13601361136213631364fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1365 base := scratch('settle_record')!1366 defer {1367 os.rmdir_all(base) or {}1368 }1369 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1370 mut a := open_repo(machine('settle_record_a')!)!1371 defer {1372 a.close()1373 }1374 mut b := open_repo(machine('settle_record_b')!)!1375 defer {1376 b.close()1377 }1378 mut c := open_repo(machine('settle_record_c')!)!1379 defer {1380 c.close()1381 }1382 put(a.root, 'readme.txt', 'hello\n')!1383 make_save(a, 'start')!1384 offer_now(a, mut backend)!1385 join(c, mut backend)!1386 put(a.root, 'secrets.env', 'api_key=1\n')!1387 leak := make_save(a, 'leak the key')!1388 offer_now(a, mut backend)!1389 join(b, mut backend)!1390 secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1391 backend.delete(save_key('proj', leak.id))!1392 backend.delete(secret)!1393 mut race := StripRace{1394 inner: backend1395 other: &b1396 path: 'secrets.env'1397 fail_match: true1398 }1399 if _ := resend(a, mut race, 'proj', [], [leak.id]) {1400 assert false, 'the injected rewrite failure should have surfaced'1401 }1402 assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1403 steal_now(c, mut backend)!1404 assert history.head(c) or { history.Save{} }.id == leak.id1405 assert strip_pending(a, 'secrets.env')1406 steal_now(a, mut backend)!1407 send_strip(a, mut backend, 'secrets.env')!1408 cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1409 assert 'secrets.env' !in cleaned.files.map(it.path)1410 assert !backend.get(secret)!.found1411}1412141314141415fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1416 base := scratch('settle_object')!1417 defer {1418 os.rmdir_all(base) or {}1419 }1420 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1421 mut a := open_repo(machine('settle_object_a')!)!1422 defer {1423 a.close()1424 }1425 mut b := open_repo(machine('settle_object_b')!)!1426 defer {1427 b.close()1428 }1429 put(a.root, 'secrets.env', 'api_key=1\n')!1430 make_save(a, 'leak the key')!1431 offer_now(a, mut backend)!1432 join(b, mut backend)!1433 hash := storage.hash_hex('api_key=1\n'.bytes())1434 backend.delete(object_key('proj', hash))!1435 mut race := StripRace{1436 inner: backend1437 other: &b1438 path: 'secrets.env'1439 fail_delete: true1440 on_objects: true1441 }1442 if _ := resend(a, mut race, 'proj', [hash], []) {1443 assert false, 'the injected delete failure should have surfaced'1444 }1445 assert backend.get(object_key('proj', hash))!.found1446 assert strip_pending(a, 'secrets.env')1447 steal_now(a, mut backend)!1448 send_strip(a, mut backend, 'secrets.env')!1449 assert !backend.get(object_key('proj', hash))!.found1450 assert !strip_pending(a, 'secrets.env')1451}1452145314541455fn test_an_unsettled_upload_is_finished_after_steal() {1456 base := scratch('unsettled')!1457 defer {1458 os.rmdir_all(base) or {}1459 }1460 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1461 mut a := open_repo(machine('unsettled_a')!)!1462 defer {1463 a.close()1464 }1465 mut b := open_repo(machine('unsettled_b')!)!1466 defer {1467 b.close()1468 }1469 put(a.root, 'secrets.env', 'api_key=1\n')!1470 make_save(a, 'leak the key')!1471 offer_now(a, mut backend)!1472 join(b, mut backend)!1473 hash := storage.hash_hex('api_key=1\n'.bytes())1474 backend.delete(object_key('proj', hash))!1475 mut race := StripRace{1476 inner: backend1477 other: &b1478 path: 'secrets.env'1479 on_objects: true1480 fail_events_after_race: true1481 }1482 if _ := resend(a, mut race, 'proj', [hash], []) {1483 assert false, 'the injected list failure should have surfaced'1484 }1485 assert unsettled(a)! == [hash]1486 if _ := settle_unsettled(a, mut backend, 'proj') {1487 assert false, 'settling before the strip is here would keep its bytes'1488 }1489 steal_now(a, mut backend)!1490 settle_unsettled(a, mut backend, 'proj')!1491 assert !backend.get(object_key('proj', hash))!.found1492 assert unsettled(a)!.len == 01493}1494149514961497fn test_settling_an_upload_keeps_named_objects_and_reports_deletions() {1498 base := scratch('settle_named')!1499 defer {1500 os.rmdir_all(base) or {}1501 }1502 remote_dir := os.join_path(base, 'remote')1503 mut backend := remote.new_folder(remote_dir)!1504 mut a := open_repo(machine('settle_named_a')!)!1505 defer {1506 a.close()1507 }1508 put(a.root, 'app', 'app\n')!1509 save := make_save(a, 'app')!1510 offer_now(a, mut backend)!1511 app := storage.hash_hex('app\n'.bytes())1512 gone := ['gone one', 'gone two'].map(storage.hash_hex(it.bytes()))1513 for hash in gone {1514 backend.put_if_absent(object_key('proj', hash), 'stripped'.bytes())!1515 }1516 backend.delete(save_key('proj', save.id))!1517 a.db.exec('PRAGMA foreign_keys=OFF')!1518 a.db.exec("DELETE FROM objects WHERE hash = '${app}'")!1519 a.set_state(unsettled_key, json2.encode([app, gone[0], gone[1]]))!1520 mut flaky := FaultyBackend{1521 inner: remote.new_folder(remote_dir)!1522 fail_delete_after: 11523 }1524 if _ := settle_unsettled(a, mut flaky, 'proj') {1525 assert false, 'the injected delete failure should have surfaced'1526 } else {1527 assert (err as Partway).done == 'deleted 1 remote object', (err as Partway).done1528 }1529 assert backend.get(object_key('proj', app))!.found1530 assert unsettled(a)!.len == 31531}153215331534fn test_a_strip_that_fails_after_its_event_reports_it() {1535 base := scratch('strip_event_partway')!1536 defer {1537 os.rmdir_all(base) or {}1538 }1539 remote_dir := os.join_path(base, 'remote')1540 mut backend := remote.new_folder(remote_dir)!1541 mut a := open_repo(machine('strip_event_partway_a')!)!1542 defer {1543 a.close()1544 }1545 put(a.root, 'secrets.env', 'api_key=1\n')!1546 make_save(a, 'leak the key')!1547 offer_now(a, mut backend)!1548 strip_local(a, 'secrets.env', '')!1549 mut flaky := FaultyBackend{1550 inner: remote.new_folder(remote_dir)!1551 fail_list: '/saves'1552 }1553 if _ := send_strip(a, mut flaky, 'secrets.env') {1554 assert false, 'the injected list failure should have surfaced'1555 } else {1556 assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1557 }1558}1559156015611562fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1563 base := scratch('strip_found')!1564 defer {1565 os.rmdir_all(base) or {}1566 }1567 remote_dir := os.join_path(base, 'remote')1568 mut backend := remote.new_folder(remote_dir)!1569 mut a := open_repo(machine('strip_found_a')!)!1570 defer {1571 a.close()1572 }1573 put(a.root, 'secrets.env', 'api_key=1\n')!1574 make_save(a, 'leak the key')!1575 offer_now(a, mut backend)!1576 strip_local(a, 'secrets.env', '')!1577 1578 a.remove_state(pending_key('secrets.env'))!1579 requeue_strip(a, 'secrets.env')!1580 mut flaky := FaultyBackend{1581 inner: remote.new_folder(remote_dir)!1582 fail_delete: true1583 }1584 if _ := send_strip(a, mut flaky, 'secrets.env') {1585 assert false, 'the injected delete failure should have surfaced'1586 }1587 send_strip(a, mut backend, 'secrets.env')!1588 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1589 assert !strip_pending(a, 'secrets.env')1590}15911592159315941595struct MovingRecord {1596mut:1597 inner remote.Backend1598 other string1599 moved bool1600}16011602fn (mut m MovingRecord) get(key string) !remote.Object {1603 return m.inner.get(key)!1604}16051606fn (mut m MovingRecord) list(prefix string) ![]string {1607 return m.inner.list(prefix)!1608}16091610fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1611 return m.inner.put_if_absent(key, body)!1612}16131614fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1615 if !m.moved {1616 m.moved = true1617 current := m.inner.get(key)!1618 record := decode_save(current.body)!1619 other := record.files.filter(it.path != m.other)1620 m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1621 }1622 return m.inner.put_if_match(key, body, tag)!1623}16241625fn (mut m MovingRecord) delete(key string) ! {1626 m.inner.delete(key)!1627}16281629fn test_a_stripped_record_that_moves_is_read_again() {1630 base := scratch('moving_record')!1631 defer {1632 os.rmdir_all(base) or {}1633 }1634 mut folder := remote.new_folder(os.join_path(base, 'remote'))!1635 key := save_key('proj', 'aaaaaaaaaaaa')1636 folder.put_if_absent(key, encode_save(SaveRecord{1637 id: 'aaaaaaaaaaaa'1638 files: [FileRecord{1639 path: 'secret-a'1640 }, FileRecord{1641 path: 'secret-b'1642 }, FileRecord{1643 path: 'keep'1644 }]1645 }).bytes())!1646 mut moving := MovingRecord{1647 inner: folder1648 other: 'secret-b'1649 }1650 strip_record(mut moving, key, 'aaaaaaaaaaaa', [1651 events.Event{1652 kind: events.kind_strip1653 path: 'secret-a'1654 },1655 ])!1656 assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1657}165816591660fn test_an_invalid_remote_strip_event_is_not_unapplied() {1661 base := scratch('invalid_remote_strip')!1662 defer {1663 os.rmdir_all(base) or {}1664 }1665 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1666 mut a := open_repo(machine('invalid_remote_strip_a')!)!1667 defer {1668 a.close()1669 }1670 bad := events.Event{1671 id: 'badstripevnt'1672 kind: events.kind_strip1673 path: 'app'1674 created_at: -11675 machine: 'other'1676 }1677 backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1678 assert unapplied_strips(a, mut backend, 'proj')!.len == 01679}168016811682fn test_a_failed_resend_reports_what_landed() {1683 base := scratch('resend_partway')!1684 defer {1685 os.rmdir_all(base) or {}1686 }1687 remote_dir := os.join_path(base, 'remote')1688 mut a := open_repo(machine('resend_partway_a')!)!1689 defer {1690 a.close()1691 }1692 put(a.root, 'one.txt', 'one\n')!1693 put(a.root, 'two.txt', 'two\n')!1694 make_save(a, 'two objects')!1695 hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1696 mut flaky := FaultyBackend{1697 inner: remote.new_folder(remote_dir)!1698 fail_write: 21699 }1700 if _ := resend(a, mut flaky, 'proj', hashes, []) {1701 assert false, 'the injected failure should have surfaced'1702 } else {1703 assert err is Partway1704 assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1705 }1706}17071708170917101711fn test_an_interrupted_remote_strip_finishes_when_re_run() {1712 base := scratch('strip_resume')!1713 defer {1714 os.rmdir_all(base) or {}1715 }1716 remote_dir := os.join_path(base, 'remote')1717 mut backend := remote.new_folder(remote_dir)!1718 mut a := open_repo(machine('resume_a')!)!1719 defer {1720 a.close()1721 }1722 mut b := open_repo(machine('resume_b')!)!1723 defer {1724 b.close()1725 }1726 put(a.root, 'secrets.env', 'api_key=1\n')!1727 put(a.root, 'shared.env', 'same bytes on B\n')!1728 save := make_save(a, 'add secrets')!1729 offer_now(a, mut backend)!1730 join(b, mut backend)!1731 put(b.root, 'copy.txt', 'same bytes on B\n')!1732 b_save := make_save(b, 'B keeps a copy')!1733 offer_now(b, mut backend)!1734 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1735 secret_hash := storage.hash_hex('api_key=1\n'.bytes())17361737 strip_local(a, 'secrets.env', '')!1738 1739 1740 mut flaky := FaultyBackend{1741 inner: remote.new_folder(remote_dir)!1742 fail_write: 31743 }1744 if _ := send_strip(a, mut flaky, 'secrets.env') {1745 assert false, 'the injected failure should have surfaced'1746 }1747 assert strip_pending(a, 'secrets.env')1748 assert strip_local(a, 'secrets.env', '')!.files_removed == 01749 report := send_strip(a, mut backend, 'secrets.env')!1750 assert report.records == 11751 assert !strip_pending(a, 'secrets.env')1752 assert !backend.get(object_key('proj', secret_hash))!.found1753 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1754 assert 'secrets.env' !in record.files.map(it.path)1755 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1756 assert 'secrets.env' !in b_record.files.map(it.path)17571758 1759 strip_local(a, 'shared.env', '')!1760 send_strip(a, mut backend, 'shared.env')!1761 assert backend.get(object_key('proj', shared_hash))!.found1762}1763176417651766fn test_offer_waits_for_an_unapplied_strip() {1767 base := scratch('strip_first')!1768 defer {1769 os.rmdir_all(base) or {}1770 }1771 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1772 mut a := open_repo(machine('strip_first_a')!)!1773 defer {1774 a.close()1775 }1776 mut b := open_repo(machine('strip_first_b')!)!1777 defer {1778 b.close()1779 }1780 put(a.root, 'secrets.env', 'api_key=1\n')!1781 make_save(a, 'add secrets')!1782 offer_now(a, mut backend)!1783 join(b, mut backend)!1784 strip_local(a, 'secrets.env', '')!1785 send_strip(a, mut backend, 'secrets.env')!1786 put(b.root, 'more.txt', 'B keeps working\n')!1787 later := make_save(b, 'B saves with the secret still in its tree')!1788 if _ := offer_now(b, mut backend) {1789 assert false, 'B offered records that still list the stripped path'1790 }1791 steal_now(b, mut backend)!1792 offer_now(b, mut backend)!1793 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1794 assert 'secrets.env' !in record.files.map(it.path)1795}1796179717981799fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1800 base := scratch('strip_twice')!1801 defer {1802 os.rmdir_all(base) or {}1803 }1804 remote_dir := os.join_path(base, 'remote')1805 mut backend := remote.new_folder(remote_dir)!1806 mut a := open_repo(machine('strip_twice_a')!)!1807 defer {1808 a.close()1809 }1810 put(a.root, 'secrets.env', 'first key\n')!1811 make_save(a, 'first key')!1812 offer_now(a, mut backend)!1813 strip_local(a, 'secrets.env', '')!1814 mut flaky := FaultyBackend{1815 inner: remote.new_folder(remote_dir)!1816 fail_delete: true1817 }1818 if _ := send_strip(a, mut flaky, 'secrets.env') {1819 assert false, 'the injected delete failure should have surfaced'1820 }1821 put(a.root, 'secrets.env', 'second key\n')!1822 put(a.root, 'other.txt', 'more work\n')!1823 make_save(a, 'more work')!1824 offer_now(a, mut backend)!1825 send_strip(a, mut backend, 'secrets.env')!1826 for key in ['first key\n', 'second key\n'] {1827 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1828 }1829}183018311832fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1833 base := scratch('strip_scopes')!1834 defer {1835 os.rmdir_all(base) or {}1836 }1837 remote_dir := os.join_path(base, 'remote')1838 mut backend := remote.new_folder(remote_dir)!1839 mut a := open_repo(machine('strip_scopes_a')!)!1840 defer {1841 a.close()1842 }1843 put(a.root, 'secrets.env', 'first key\n')!1844 first := make_save(a, 'first key')!1845 put(a.root, 'secrets.env', 'second key\n')!1846 second := make_save(a, 'second key')!1847 offer_now(a, mut backend)!1848 strip_local(a, 'secrets.env', first.id)!1849 1850 mut flaky := FaultyBackend{1851 inner: remote.new_folder(remote_dir)!1852 fail_write: 21853 }1854 if _ := send_strip(a, mut flaky, 'secrets.env') {1855 assert false, 'the injected failure should have surfaced'1856 }1857 strip_local(a, 'secrets.env', second.id)!1858 send_strip(a, mut backend, 'secrets.env')!1859 assert !strip_pending(a, 'secrets.env')1860 for save in [first, second] {1861 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1862 assert 'secrets.env' !in record.files.map(it.path)1863 }1864 for key in ['first key\n', 'second key\n'] {1865 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1866 }1867}1868186918701871fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1872 base := scratch('strip_again')!1873 defer {1874 os.rmdir_all(base) or {}1875 }1876 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1877 mut a := open_repo(machine('strip_again_a')!)!1878 defer {1879 a.close()1880 }1881 mut b := open_repo(machine('strip_again_b')!)!1882 defer {1883 b.close()1884 }1885 put(a.root, 'secrets.env', 'first key\n')!1886 make_save(a, 'first key')!1887 offer_now(a, mut backend)!1888 join(b, mut backend)!1889 strip_local(a, 'secrets.env', '')!1890 send_strip(a, mut backend, 'secrets.env')!1891 steal_now(b, mut backend)!1892 for repo in [a, b] {1893 put(repo.root, 'secrets.env', 'second key\n')!1894 if _ := make_save(repo, 'second key') {1895 assert false, 'a banned path must not make a save'1896 }1897 }1898}1899190019011902fn test_a_stripped_path_is_not_asked_about() {1903 base := scratch('clash_strip')!1904 defer {1905 os.rmdir_all(base) or {}1906 }1907 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1908 mut a := open_repo(machine('clash_strip_a')!)!1909 defer {1910 a.close()1911 }1912 mut b := open_repo(machine('clash_strip_b')!)!1913 defer {1914 b.close()1915 }1916 put(a.root, 'secrets.env', 'key one\n')!1917 make_save(a, 'base')!1918 offer_now(a, mut backend)!1919 join(b, mut backend)!1920 put(a.root, 'secrets.env', 'key two\n')!1921 make_save(a, 'A rotates')!1922 offer_now(a, mut backend)!1923 strip_local(a, 'secrets.env', '')!1924 send_strip(a, mut backend, 'secrets.env')!1925 put(b.root, 'secrets.env', 'key three\n')!1926 mine := make_save(b, 'B rotates')!1927 report := steal(b, mut backend, StealOptions{})!1928 assert report.clashes.len == 01929 for save in history.all_saves(b)! {1930 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1931 }1932 assert history.by_id(b, mine.id) != none1933}1934193519361937fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1938 base := scratch('clash_base_strip')!1939 defer {1940 os.rmdir_all(base) or {}1941 }1942 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1943 mut a := open_repo(machine('base_strip_a')!)!1944 defer {1945 a.close()1946 }1947 mut b := open_repo(machine('base_strip_b')!)!1948 defer {1949 b.close()1950 }1951 put(a.root, 'notes.txt', 'shared\n')!1952 common := make_save(a, 'base')!1953 offer_now(a, mut backend)!1954 join(b, mut backend)!1955 put(a.root, 'other.txt', 'A moves on\n')!1956 make_save(a, 'A moves on')!1957 offer_now(a, mut backend)!1958 strip_local(a, 'notes.txt', common.id)!1959 send_strip(a, mut backend, 'notes.txt')!1960 put(b.root, 'notes.txt', 'B edits\n')!1961 make_save(b, 'B edits notes')!1962 if _ := steal(b, mut backend, StealOptions{}) {1963 assert false, 'with the base stripped, notes.txt differs on both sides'1964 } else {1965 assert err.msg().contains('notes.txt changed on both machines')1966 }1967 report := steal(b, mut backend, StealOptions{1968 mine: true1969 })!1970 assert report.clashes == ['notes.txt']1971 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1972}1973197419751976fn test_steal_into_a_folder_with_unsaved_files_refuses() {1977 base := scratch('steal_unsaved')!1978 defer {1979 os.rmdir_all(base) or {}1980 }1981 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1982 mut a := open_repo(machine('unsaved_a')!)!1983 defer {1984 a.close()1985 }1986 put(a.root, 'notes.txt', 'from A\n')!1987 make_save(a, 'A writes')!1988 offer_now(a, mut backend)!1989 b_root := machine('unsaved_b')!1990 mut b := storage.create(b_root)!1991 defer {1992 b.close()1993 }1994 put(b_root, 'notes.txt', 'never saved\n')!1995 1996 b.set_state('remote_repo', 'proj')!1997 if _ := steal(b, mut backend, StealOptions{}) {1998 assert false, 'steal overwrote a folder with unsaved files'1999 } else {2000 assert err.msg() == no_saves_here2001 }2002 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'2003 2004 os.rm(os.join_path(b_root, 'notes.txt'))!2005 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!2006 steal(b, mut backend, StealOptions{})!2007 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'2008}2009201020112012fn test_steal_reports_incoming_flagged_saves() {2013 base := scratch('flag_warn')!2014 defer {2015 os.rmdir_all(base) or {}2016 }2017 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2018 mut a := open_repo(machine('flag_warn_a')!)!2019 defer {2020 a.close()2021 }2022 mut b := open_repo(machine('flag_warn_b')!)!2023 defer {2024 b.close()2025 }2026 put(a.root, 'a.txt', 'one\n')!2027 make_save(a, 'fine')!2028 offer_now(a, mut backend)!2029 join(b, mut backend)!2030 put(a.root, 'a.txt', 'two\n')!2031 suspect := make_save(a, 'suspect')!2032 put(a.root, 'a.txt', 'three\n')!2033 make_save(a, 'also fine')!2034 events.flag(a, suspect.id, 'broke the build')!2035 offer_now(a, mut backend)!2036 report := steal_now(b, mut backend)!2037 assert report.flagged.map(it.save_id) == [suspect.id]2038 assert report.flagged[0].reason == 'broke the build'2039}204020412042fn head_of(store &storage.Store) history.Save {2043 return history.head(store) or { panic('this repo has no head') }2044}2045204620472048fn test_the_first_offer_records_the_remote_format() {2049 base := scratch('format_write')!2050 defer {2051 os.rmdir_all(base) or {}2052 }2053 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2054 mut a := open_repo(machine('format_write_a')!)!2055 defer {2056 a.close()2057 }2058 put(a.root, 'a.txt', 'one\n')!2059 make_save(a, 'first')!2060 offer_now(a, mut backend)!2061 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2062}2063206420652066fn test_a_remote_without_a_format_still_syncs() {2067 base := scratch('format_old')!2068 defer {2069 os.rmdir_all(base) or {}2070 }2071 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2072 mut a := open_repo(machine('format_old_a')!)!2073 defer {2074 a.close()2075 }2076 put(a.root, 'a.txt', 'one\n')!2077 make_save(a, 'first')!2078 offer_now(a, mut backend)!2079 2080 backend.delete(format_key('proj'))!2081 mut b := open_repo(machine('format_old_b')!)!2082 defer {2083 b.close()2084 }2085 join(b, mut backend)!2086 assert head_of(b).id == head_of(a).id2087 put(a.root, 'a.txt', 'two\n')!2088 make_save(a, 'second')!2089 offer_now(a, mut backend)!2090 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2091 report := steal_now(b, mut backend)!2092 assert report.head == head_of(a).id2093}2094209520962097fn test_an_unknown_remote_format_is_refused() {2098 base := scratch('format_unknown')!2099 defer {2100 os.rmdir_all(base) or {}2101 }2102 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2103 mut a := open_repo(machine('format_bad_a')!)!2104 defer {2105 a.close()2106 }2107 put(a.root, 'a.txt', 'one\n')!2108 make_save(a, 'first')!2109 offer_now(a, mut backend)!2110 backend.delete(format_key('proj'))!2111 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2112 if _ := offer_now(a, mut backend) {2113 assert false, 'offer must refuse a format it does not speak'2114 } else {2115 assert err.msg().contains('koh-remote 2')2116 }2117 if _ := steal_now(a, mut backend) {2118 assert false, 'steal must refuse a format it does not speak'2119 } else {2120 assert err.msg().contains('koh-remote 2')2121 }2122 if _ := fetch_repo(mut backend, 'proj') {2123 assert false, 'a steal must refuse a format it does not speak'2124 } else {2125 assert err.msg().contains('koh-remote 2')2126 }2127}21282129213021312132fn test_a_lost_format_write_is_read_back_and_refused() {2133 base := scratch('format_race')!2134 defer {2135 os.rmdir_all(base) or {}2136 }2137 mut inner := remote.new_folder(os.join_path(base, 'remote'))!2138 mut a := open_repo(machine('format_race_a')!)!2139 defer {2140 a.close()2141 }2142 put(a.root, 'a.txt', 'one\n')!2143 make_save(a, 'first')!2144 mut backend := remote.Backend(FaultyBackend{2145 inner: inner2146 race_format: true2147 })2148 if _ := offer_now(a, mut backend) {2149 assert false, 'offer must refuse the format another machine wrote first'2150 } else {2151 assert err.msg().contains('koh-remote 2')2152 }2153}21542155fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2156 whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2157 for size in [1, 2, 5, whole.len] {2158 mut bundle := Bundle{}2159 for at := 0; at < whole.len; at += size {2160 bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2161 }2162 bundle.finish()!2163 assert bundle.fetched.repo_objects == {2164 'aa': 'abc'.bytes()2165 'bb': []u8{}2166 }2167 }2168 for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2169 'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2170 mut bundle := Bundle{}2171 bundle.feed(cut.bytes()) or { continue }2172 bundle.finish() or { continue }2173 assert false, cut2174 }2175}21762177fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2178 mut store := open_repo(machine('strip_batch')!)!2179 defer { store.close() }2180 put(store.root, 'private/a', 'a')!2181 put(store.root, 'secret', 'secret')!2182 put(store.root, 'keep', 'keep')!2183 save := make_save(store, 'initial')!2184 store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2185 WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2186 if _ := strip_local_batch(store, ['private', 'secret'], '') {2187 assert false, 'batch succeeded despite the second path failing'2188 }2189 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2190 'secret']2191 assert events.strip_events(store)!.len == 02192 assert !strip_pending(store, 'private')2193 store.db.exec('DROP TRIGGER block_secret')!2194 reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2195 assert reports.map(it.files_removed) == [1, 0, 1]2196 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2197 assert events.strip_events(store)!.len == 22198}21992200fn test_strip_batch_limits_every_path_to_selected_save() {2201 mut store := open_repo(machine('strip_batch_scope')!)!2202 defer { store.close() }2203 put(store.root, 'one', 'one')!2204 put(store.root, 'two', 'two')!2205 first := make_save(store, 'initial')!2206 put(store.root, 'keep', 'keep')!2207 second := make_save(store, 'next')!2208 strip_local_batch(store, ['one', 'two'], first.id)!2209 assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2210 assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2211 assert events.strip_events(store)!.all(it.save_id == first.id)2212}22132214fn test_strip_batch_keeps_independent_remote_retry_notes() {2215 mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2216 mut store := open_repo(machine('strip_batch_retry')!)!2217 defer { store.close() }2218 put(store.root, 'one', 'secret')!2219 put(store.root, 'two', 'secret')!2220 save := make_save(store, 'initial')!2221 offer_now(store, mut backend)!2222 strip_local_batch(store, ['one', 'two'], '')!2223 mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2224 if _ := send_strip(store, mut flaky, 'one') {2225 assert false, 'injected remote failure was not reported'2226 }2227 send_strip(store, mut backend, 'two')!2228 assert strip_pending(store, 'one') && !strip_pending(store, 'two')2229 partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2230 assert partial.files.map(it.path) == ['.kohignore', 'one']2231 assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2232 send_strip(store, mut backend, 'one')!2233 assert !strip_pending(store, 'one')2234 finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2235 assert finished.files.map(it.path) == ['.kohignore']2236 assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2237}