12345module exchange67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage131415fn testsuite_begin() {16 os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17}1819fn test_arriving_record_drops_unsafe_paths_and_reports_them() {20 mut record := SaveRecord{21 files: [22 FileRecord{ path: 'safe.txt' },23 FileRecord{ path: '../outside.txt' },24 FileRecord{ path: 'bad\\name.txt' },25 FileRecord{ path: 'Icon\r' },26 ]27 }28 dropped := drop_unsafe_paths(mut record)29 assert record.files.map(it.path) == ['safe.txt']30 assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']31}32333435fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {36 body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'37 mut record := decode_save(body.bytes())!38 dropped := drop_unsafe_paths(mut record)39 assert record.files.map(it.path) == ['ok😀']40 assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']41}42434445fn machine(name string) !string {46 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')47 os.rmdir_all(os.dir(dir)) or {}48 os.mkdir_all(dir, os.MkdirParams{})!49 return dir50}5152fn scratch(name string) !string {53 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')54 os.rmdir_all(dir) or {}55 os.mkdir_all(dir, os.MkdirParams{})!56 return dir57}5859fn open_repo(dir string) !storage.Store {60 mut store := storage.create(dir)!61 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!62 return store63}6465fn put(root string, rel string, text string) ! {66 full := os.join_path(root, rel)67 os.mkdir_all(os.dir(full), os.MkdirParams{})!68 os.write_file(full, text)!69}7071fn make_save(store &storage.Store, message string) !history.Save {72 return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{73 message: message74 })!75}7677fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {78 return offer(store, mut backend)!79}8081fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {82 return steal(store, mut backend, StealOptions{83 theirs: true84 })!85}8687fn test_first_offer_publishes_the_whole_line() {88 base := scratch('first')!89 defer {90 os.rmdir_all(base) or {}91 }92 mut a := open_repo(machine('first_a')!)!93 defer {94 a.close()95 }96 put(a.root, 'a.txt', 'one\n')!97 make_save(a, 'first')!98 put(a.root, 'a.txt', 'two\n')!99 make_save(a, 'second')!100 mut backend := remote.new_folder(os.join_path(base, 'remote'))!101102 report := offer_now(a, mut backend)!103 assert report.repo == 'proj'104 assert report.saves == 2105 assert report.objects >= 2106 assert backend.get(head_key('proj'))!.found107 assert backend.list('proj/saves')!.len == 2108109 110 again := offer_now(a, mut backend)!111 assert again.saves == 0112}113114fn test_the_repo_name_is_remembered_and_head_offered() {115 base := scratch('remember')!116 defer {117 os.rmdir_all(base) or {}118 }119 mut a := open_repo(machine('remember_a')!)!120 defer {121 a.close()122 }123 put(a.root, 'a.txt', 'one\n')!124 make_save(a, 'first')!125 mut backend := remote.new_folder(os.join_path(base, 'remote'))!126 offer_now(a, mut backend)!127 assert a.state('remote_repo') or { '' } == 'proj'128 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id129 130 assert a.state('remote_head') or { '' } == head_of(a).id131132 133 134 put(a.root, 'a.txt', 'two\n')!135 make_save(a, 'second')!136 offer_now(a, mut backend)!137 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id138}139140fn test_a_repo_name_is_lowercased_at_first_offer() {141 base := scratch('lower')!142 defer {143 os.rmdir_all(base) or {}144 }145 dir := os.join_path(base, 'Haven')146 os.mkdir_all(dir, os.MkdirParams{})!147 mut a := open_repo(dir)!148 defer {149 a.close()150 }151 put(a.root, 'a.txt', 'one\n')!152 make_save(a, 'first')!153 mut backend := remote.new_folder(os.join_path(base, 'remote'))!154 assert offer_now(a, mut backend)!.repo == 'haven'155 assert backend.get(head_key('haven'))!.found156}157158fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {159 base := scratch('renamed')!160 defer {161 os.rmdir_all(base) or {}162 }163 mut a := open_repo(machine('renamed_a')!)!164 defer {165 a.close()166 }167 put(a.root, 'a.txt', 'one\n')!168 make_save(a, 'first')!169 remote_dir := os.join_path(base, 'remote')170 mut backend := remote.new_folder(remote_dir)!171 offer_now(a, mut backend)!172 173 os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!174 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!175 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!176177 put(a.root, 'a.txt', 'two\n')!178 make_save(a, 'second')!179 assert offer_now(a, mut backend)!.repo == 'tool'180 assert a.state('remote_repo') or { '' } == 'tool'181 assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id182 assert !backend.get(head_key('proj'))!.found183}184185fn test_a_rename_marker_naming_a_path_is_not_followed() {186 base := scratch('badmarker')!187 defer {188 os.rmdir_all(base) or {}189 }190 mut a := open_repo(machine('badmarker_a')!)!191 defer {192 a.close()193 }194 put(a.root, 'a.txt', 'one\n')!195 make_save(a, 'first')!196 remote_dir := os.join_path(base, 'remote')197 mut backend := remote.new_folder(remote_dir)!198 offer_now(a, mut backend)!199 os.rmdir_all(os.join_path(remote_dir, 'proj'))!200 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!201 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!202 assert offer_now(a, mut backend)!.repo == 'proj'203}204205fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {206 base := scratch('forward')!207 defer {208 os.rmdir_all(base) or {}209 }210 mut backend := remote.new_folder(os.join_path(base, 'remote'))!211 mut a := open_repo(machine('fwd_a')!)!212 defer {213 a.close()214 }215 put(a.root, 'a.txt', 'from A\n')!216 make_save(a, 'A writes')!217 offer_now(a, mut backend)!218219 220 mut b := open_repo(machine('fwd_b')!)!221 defer {222 b.close()223 }224 fetched := fetch_repo(mut backend, 'proj')!225 b.begin()!226 write_fetched(b, fetched)!227 history.set_head(b, fetched.head)!228 b.end()!229 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!230 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'231 assert head_of(b).id == fetched.head232}233234235236fn test_steal_while_only_ahead_leaves_the_line_alone() {237 base := scratch('ahead')!238 defer {239 os.rmdir_all(base) or {}240 }241 mut backend := remote.new_folder(os.join_path(base, 'remote'))!242 mut a := open_repo(machine('ahead_a')!)!243 defer {244 a.close()245 }246 put(a.root, 'a.txt', 'one\n')!247 make_save(a, 'offered')!248 offer_now(a, mut backend)!249 put(a.root, 'a.txt', 'two\n')!250 ahead := make_save(a, 'not offered yet')!251 report := steal_now(a, mut backend)!252 assert report.replayed == 0253 assert head_of(a).id == ahead.id254 255 assert !snapshot.restore_pending(a)256 assert history.all_saves(a)!.len == 2257 offer_now(a, mut backend)!258 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id259}260261262263fn test_steal_never_writes_through_a_symlinked_folder() {264 base := scratch('steal_link')!265 defer {266 os.rmdir_all(base) or {}267 }268 mut backend := remote.new_folder(os.join_path(base, 'remote'))!269 mut a := open_repo(machine('steal_link_a')!)!270 defer {271 a.close()272 }273 mut b := open_repo(machine('steal_link_b')!)!274 defer {275 b.close()276 }277 put(a.root, 'a.txt', 'one\n')!278 make_save(a, 'first')!279 offer_now(a, mut backend)!280 join(b, mut backend)!281 outside := os.join_path(base, 'outside')282 put(outside, 'a.md', 'precious\n')!283 os.symlink(outside, os.join_path(b.root, 'docs'))!284 put(a.root, 'docs/a.md', 'from A\n')!285 make_save(a, 'A adds docs')!286 offer_now(a, mut backend)!287 head_before := head_of(b).id288 if _ := steal_now(b, mut backend) {289 assert false, 'a steal writing through a symlinked folder must refuse'290 } else {291 assert err.msg().contains('docs')292 }293 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'294 assert head_of(b).id == head_before295}296297298fn test_steal_refuses_after_a_restore_cut_short() {299 base := scratch('steal_cut')!300 defer {301 os.rmdir_all(base) or {}302 }303 mut backend := remote.new_folder(os.join_path(base, 'remote'))!304 mut a := open_repo(machine('steal_cut_a')!)!305 defer {306 a.close()307 }308 put(a.root, 'a.txt', 'one\n')!309 make_save(a, 'first')!310 offer_now(a, mut backend)!311 a.set_state('restoring', 'yes')!312 if _ := steal_now(a, mut backend) {313 assert false, 'steal must refuse while a restore is unfinished'314 } else {315 assert err.msg().contains('koh revert')316 }317}318319320321fn test_a_steal_imports_parents_before_children() {322 base := scratch('steal_order')!323 defer {324 os.rmdir_all(base) or {}325 }326 mut backend := remote.new_folder(os.join_path(base, 'remote'))!327 mut a := open_repo(machine('order_a')!)!328 defer {329 a.close()330 }331 put(a.root, 'a.txt', 'one\n')!332 parent := make_save(a, 'parent')!333 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{334 message: 'replayed, older than its parent'335 created_at: parent.created_at - 3600336 })!337 offer_now(a, mut backend)!338 mut b := open_repo(machine('order_b')!)!339 defer {340 b.close()341 }342 join(b, mut backend)!343 assert head_of(b).id == child.id344}345346347fn test_steal_refuses_an_unsaved_executable_bit_change() {348 base := scratch('chmod')!349 defer {350 os.rmdir_all(base) or {}351 }352 mut backend := remote.new_folder(os.join_path(base, 'remote'))!353 mut a := open_repo(machine('chmod_a')!)!354 defer {355 a.close()356 }357 put(a.root, 'run.sh', 'echo hi\n')!358 make_save(a, 'script')!359 offer_now(a, mut backend)!360 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!361 if _ := steal_now(a, mut backend) {362 assert false, 'a chmod-only change should make the tree dirty'363 } else {364 assert err.msg() == dirty_tree365 }366}367368fn test_divergence_without_clashes_keeps_both_machines_work() {369 base := scratch('diverge')!370 defer {371 os.rmdir_all(base) or {}372 }373 mut backend := remote.new_folder(os.join_path(base, 'remote'))!374 mut a := open_repo(machine('div_a')!)!375 defer {376 a.close()377 }378 mut b := open_repo(machine('div_b')!)!379 defer {380 b.close()381 }382 put(a.root, 'shared.txt', 'base\n')!383 make_save(a, 'base')!384 offer_now(a, mut backend)!385386 387 join(b, mut backend)!388 put(a.root, 'fromA.txt', 'A\n')!389 make_save(a, 'A adds a file')!390 put(b.root, 'fromB.txt', 'B\n')!391 make_save(b, 'B adds a file')!392 offer_now(a, mut backend)!393394 395 if _ := offer_now(b, mut backend) {396 assert false, 'offering on top of unknown remote saves should be refused'397 } else {398 assert err.msg().contains('run koh steal')399 }400401 report := steal_now(b, mut backend)!402 assert !report.forward403 assert report.replayed == 1404 assert report.clashes.len == 0405 406 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'407 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'408 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'409 410 mut saw_replay := false411 for save in history.all_saves(b)! {412 if save.replayed_from != '' {413 saw_replay = true414 }415 }416 assert saw_replay417 418 offer_now(b, mut backend)!419 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()420 assert line_head == head_of(b).id421}422423424425426427fn test_a_local_change_undone_does_not_block_a_combine() {428 base := scratch('undone')!429 defer {430 os.rmdir_all(base) or {}431 }432 mut backend := remote.new_folder(os.join_path(base, 'remote'))!433 mut a := open_repo(machine('undone_a')!)!434 defer {435 a.close()436 }437 mut b := open_repo(machine('undone_b')!)!438 defer {439 b.close()440 }441 put(a.root, 'f.txt', 'base\n')!442 put(a.root, 'g.txt', 'base\n')!443 make_save(a, 'base')!444 offer_now(a, mut backend)!445 join(b, mut backend)!446447 put(a.root, 'f.txt', 'from A\n')!448 put(a.root, 'g.txt', 'same\n')!449 make_save(a, 'A edits both')!450 offer_now(a, mut backend)!451452 put(b.root, 'f.txt', 'tried on B\n')!453 put(b.root, 'g.txt', 'first try\n')!454 tried := make_save(b, 'B tries')!455 put(b.root, 'f.txt', 'base\n')!456 put(b.root, 'g.txt', 'same\n')!457 put(b.root, 'h.txt', 'B\n')!458 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!459460 report := steal_now(b, mut backend)!461 assert report.clashes.len == 0462 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'463 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'464 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'465 466 467 assert report.replayed == 1468 assert head_of(b).replayed_from == undone.id469 ids := history.all_saves(b)!.map(it.id)470 assert tried.id in ids && undone.id in ids471 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')472}473474475476fn test_clash_questions_are_asked_before_the_write_lock() {477 base := scratch('ask_unlocked')!478 defer {479 os.rmdir_all(base) or {}480 }481 mut backend := remote.new_folder(os.join_path(base, 'remote'))!482 mut a := open_repo(machine('ask_a')!)!483 defer {484 a.close()485 }486 mut b := open_repo(machine('ask_b')!)!487 defer {488 b.close()489 }490 put(a.root, 'same.txt', 'base\n')!491 make_save(a, 'base')!492 offer_now(a, mut backend)!493 join(b, mut backend)!494 put(a.root, 'same.txt', 'A version\n')!495 make_save(a, 'A edits same')!496 offer_now(a, mut backend)!497 put(b.root, 'same.txt', 'B version\n')!498 make_save(b, 'B edits same')!499 root := b.root500 501 if _ := steal(b, mut backend, StealOptions{502 guide: fn [root] (preview StealPreview) !map[string]Side {503 other := storage.open(root) or { panic(err) }504 defer {505 other.close()506 }507 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }508 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{509 message: 'agent saves during the question'510 }) or { panic('steal held the write lock while asking: ${err}') }511 return {512 preview.clashes[0].path: Side.mine513 }514 }515 })516 {517 assert false, 'a steal whose line moved during the question must refuse'518 } else {519 assert err.msg().contains('changed while')520 }521 522 assert head_of(b).message == 'agent saves during the question'523 report := steal(b, mut backend, StealOptions{524 mine: true525 })!526 assert report.kept_mine == 1527 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'528 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'529}530531fn test_clashing_files_are_chosen_one_at_a_time() {532 base := scratch('clash')!533 defer {534 os.rmdir_all(base) or {}535 }536 mut backend := remote.new_folder(os.join_path(base, 'remote'))!537 mut a := open_repo(machine('clash_a')!)!538 defer {539 a.close()540 }541 mut b := open_repo(machine('clash_b')!)!542 defer {543 b.close()544 }545 put(a.root, 'same.txt', 'base\n')!546 put(a.root, 'other.txt', 'quiet\n')!547 make_save(a, 'base')!548 offer_now(a, mut backend)!549 join(b, mut backend)!550551 552 put(a.root, 'same.txt', 'A version\n')!553 make_save(a, 'A edits same')!554 offer_now(a, mut backend)!555 put(b.root, 'same.txt', 'B version\n')!556 make_save(b, 'B edits same')!557558 559 if _ := steal(b, mut backend, StealOptions{}) {560 assert false, 'an undecided clash must be refused'561 } else {562 assert err.msg().contains('--mine') || err.msg().contains('--theirs')563 564 if err is ClashError {565 assert err.paths == ['same.txt']566 } else {567 assert false, 'an undecided clash must be a ClashError, not ${err}'568 }569 }570 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,571 head_of(b).id)!).len == 0572573 574 report := steal_now(b, mut backend)!575 assert report.clashes == ['same.txt']576 assert report.kept_mine == 0577 assert report.kept_versions == ['same.txt:${report.previous_head}']578 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'579 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'580}581582fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {583 base := scratch('guided_cancel')!584 defer { os.rmdir_all(base) or {} }585 mut backend := remote.new_folder(os.join_path(base, 'remote'))!586 mut a := open_repo(machine('guided_cancel_a')!)!587 defer { a.close() }588 mut b := open_repo(machine('guided_cancel_b')!)!589 defer { b.close() }590 put(a.root, 'same.txt', 'base\n')!591 make_save(a, 'base')!592 offer_now(a, mut backend)!593 join(b, mut backend)!594 put(a.root, 'same.txt', 'remote\n')!595 remote_head := make_save(a, 'remote change')!596 offer_now(a, mut backend)!597 put(b.root, 'same.txt', 'mine\n')!598 local_head := make_save(b, 'local change')!599600 if _ := steal(b, mut backend, StealOptions{601 guide: fn (preview StealPreview) !map[string]Side {602 assert preview.clashes.len == 1603 return error('cancelled')604 }605 }) {606 assert false, 'the guide cancelled'607 } else {608 assert err.msg() == 'cancelled'609 }610 assert head_of(b).id == local_head.id611 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'612 assert history.by_id(b, remote_head.id) != none613 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1614}615616fn test_choosing_mine_keeps_the_local_version() {617 base := scratch('mine')!618 defer {619 os.rmdir_all(base) or {}620 }621 mut backend := remote.new_folder(os.join_path(base, 'remote'))!622 mut a := open_repo(machine('mine_a')!)!623 defer {624 a.close()625 }626 mut b := open_repo(machine('mine_b')!)!627 defer {628 b.close()629 }630 put(a.root, 'same.txt', 'base\n')!631 make_save(a, 'base')!632 offer_now(a, mut backend)!633 join(b, mut backend)!634 put(a.root, 'same.txt', 'A version\n')!635 make_save(a, 'A edits')!636 offer_now(a, mut backend)!637 put(b.root, 'same.txt', 'B version\n')!638 make_save(b, 'B edits')!639640 report := steal(b, mut backend, StealOptions{641 mine: true642 })!643 assert report.kept_mine == 1644 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'645 646 assert head_of(b).message.contains('combine: kept mine for 1 file')647 648 assert head_of(b).restores == ''649 assert history.all_saves(b)!.len >= 4650}651652fn test_flags_travel_with_the_saves() {653 base := scratch('flags')!654 defer {655 os.rmdir_all(base) or {}656 }657 mut backend := remote.new_folder(os.join_path(base, 'remote'))!658 mut a := open_repo(machine('flag_a')!)!659 defer {660 a.close()661 }662 mut b := open_repo(machine('flag_b')!)!663 defer {664 b.close()665 }666 put(a.root, 'a.txt', 'one\n')!667 save := make_save(a, 'suspect save')!668 events.flag(a, save.id, 'look at this')!669 offer_now(a, mut backend)!670671 672 fetched := fetch_back_to_known(b, mut backend, 'proj')!673 assert fetched.events_out.len == 1674 assert fetched.events_out[0].kind == events.kind_flag675 b.begin()!676 write_fetched(b, fetched)!677 history.set_head(b, fetched.head)!678 b.end()!679 state := events.flag_state(b, save.id)!680 assert state.flagged681 assert state.reason == 'look at this'682}683684fn test_a_second_offer_racing_head_is_refused_not_forced() {685 base := scratch('race')!686 defer {687 os.rmdir_all(base) or {}688 }689 mut backend := remote.new_folder(os.join_path(base, 'remote'))!690 mut a := open_repo(machine('race_a')!)!691 defer {692 a.close()693 }694 put(a.root, 'a.txt', 'one\n')!695 make_save(a, 'base')!696 offer_now(a, mut backend)!697698 699 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),700 backend.get(head_key('proj'))!.tag)!701702 put(a.root, 'a.txt', 'two\n')!703 make_save(a, 'second')!704 if _ := offer_now(a, mut backend) {705 assert false, 'offering while the remote head moved must be refused'706 } else {707 assert err.msg().contains('run koh steal')708 }709}710711712fn join(store &storage.Store, mut backend remote.Backend) ! {713 fetched := fetch_repo(mut backend, 'proj')!714 store.begin()!715 write_fetched(store, fetched)!716 history.set_head(store, fetched.head)!717 store.end()!718 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,719 fetched.head)!, [])!720 store.set_state('remote_repo', 'proj')!721}722723struct FaultyBackend {724mut:725 inner remote.Backend726 writes int727 reads int728 fail_write int 729 fail_read int730 731 fail_delete bool732 733 734 race_format bool735 raced bool736 737 fail_list string738}739740fn (mut f FaultyBackend) get(key string) !remote.Object {741 f.reads++742 if f.fail_read > 0 && f.reads >= f.fail_read {743 return error('injected read failure')744 }745 return f.inner.get(key)!746}747748fn (mut f FaultyBackend) list(prefix string) ![]string {749 if f.fail_list != '' && prefix.ends_with(f.fail_list) {750 return error('injected list failure')751 }752 return f.inner.list(prefix)!753}754755fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {756 f.writes++757 if f.fail_write > 0 && f.writes >= f.fail_write {758 return error('injected write failure')759 }760 if f.race_format && !f.raced && key.ends_with('/format') {761 f.raced = true762 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!763 return false764 }765 return f.inner.put_if_absent(key, body)!766}767768fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {769 f.writes++770 if f.fail_write > 0 && f.writes >= f.fail_write {771 return error('injected write failure')772 }773 return f.inner.put_if_match(key, body, tag)!774}775776fn (mut f FaultyBackend) delete(key string) ! {777 if f.fail_delete {778 return error('injected delete failure')779 }780 f.inner.delete(key)!781}782783784785786fn test_an_offer_sends_only_the_objects_a_save_adds() {787 base := scratch('offer_diff')!788 defer {789 os.rmdir_all(base) or {}790 }791 mut a := open_repo(machine('offer_diff_a')!)!792 defer {793 a.close()794 }795 for i in 0 .. 20 {796 put(a.root, 'f${i}.txt', 'file ${i}\n')!797 }798 make_save(a, 'twenty files')!799 mut backend := FaultyBackend{800 inner: remote.new_folder(os.join_path(base, 'remote'))!801 }802 offer(a, mut backend)!803 put(a.root, 'f3.txt', 'changed\n')!804 make_save(a, 'one change')!805 before := backend.writes806 report := offer(a, mut backend)!807 assert report.objects == 1808 809 assert backend.writes - before == 3810}811812fn test_a_failed_offer_is_safe_to_re_run() {813 base := scratch('fault_offer')!814 defer {815 os.rmdir_all(base) or {}816 }817 remote_dir := os.join_path(base, 'remote')818 mut a := open_repo(machine('fault_a')!)!819 defer {820 a.close()821 }822 put(a.root, 'a.txt', 'one\n')!823 make_save(a, 'first')!824 put(a.root, 'a.txt', 'two\n')!825 make_save(a, 'second')!826827 828 mut flaky := FaultyBackend{829 inner: remote.new_folder(remote_dir)!830 fail_write: 2831 }832 if _ := offer(a, mut flaky) {833 assert false, 'the injected failure should have surfaced'834 }835836 837 838 mut healthy := remote.new_folder(remote_dir)!839 report := offer(a, mut healthy)!840 assert report.saves == 2841 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id842 assert healthy.list('proj/saves')!.len == 2843}844845846847fn test_a_strip_with_an_illegal_path_is_ignored() {848 base := scratch('illegal_strip')!849 defer {850 os.rmdir_all(base) or {}851 }852 mut backend := remote.new_folder(os.join_path(base, 'remote'))!853 mut a := open_repo(machine('illegal_strip_a')!)!854 defer {855 a.close()856 }857 mut b := open_repo(machine('illegal_strip_b')!)!858 defer {859 b.close()860 }861 put(a.root, 'a/x.txt', 'kept\n')!862 make_save(a, 'add a folder')!863 offer_now(a, mut backend)!864 for id, path in {865 'aaaaaaaaaaaa': 'a//'866 'bbbbbbbbbbbb': 'a\\ud800'867 } {868 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!869 }870 join(b, mut backend)!871 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)872 put(a.root, 'more.txt', 'more\n')!873 make_save(a, 'more')!874 offer_now(a, mut backend)!875}876877fn test_a_failed_steal_leaves_nothing_behind() {878 base := scratch('fault_steal')!879 defer {880 os.rmdir_all(base) or {}881 }882 remote_dir := os.join_path(base, 'remote')883 mut a := open_repo(machine('fault_s_a')!)!884 defer {885 a.close()886 }887 mut b := open_repo(machine('fault_s_b')!)!888 defer {889 b.close()890 }891 put(a.root, 'a.txt', 'from A\n')!892 make_save(a, 'A writes')!893 mut healthy := remote.new_folder(remote_dir)!894 offer(a, mut healthy)!895896 897 mut flaky := FaultyBackend{898 inner: remote.new_folder(remote_dir)!899 fail_read: 2900 }901 if _ := fetch_back_to_known(b, mut flaky, 'proj') {902 assert false, 'the injected read failure should have surfaced'903 }904 905 assert history.head(b) == none906 assert b.object_count()! == 0907908 909 fetched := fetch_back_to_known(b, mut healthy, 'proj')!910 b.begin()!911 write_fetched(b, fetched)!912 history.set_head(b, fetched.head)!913 b.end()!914 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!915 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'916}917918fn test_an_interrupted_strip_is_idempotent() {919 920 921 922 base := scratch('fault_strip')!923 defer {924 os.rmdir_all(base) or {}925 }926 mut a := open_repo(machine('fault_strip_a')!)!927 defer {928 a.close()929 }930 put(a.root, 'secret.txt', 'shh\n')!931 put(a.root, 'keep.txt', 'fine\n')!932 save := make_save(a, 'add both')!933934 for _ in 0 .. 2 {935 strip_local(a, 'secret.txt', '')!936 }937 files := history.files_of(a, save.id)!938 939 940 assert files.map(it.path) == ['.kohignore', 'keep.txt']941 assert events.strip_events(a)!.len == 1942}943944945946947948fn test_a_strip_matches_the_path_exactly() {949 mut a := open_repo(machine('strip_exact')!)!950 defer {951 a.close()952 }953 hash := a.put_object('x\n'.bytes())!954 mut files := []snapshot.FileEntry{}955 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {956 files << snapshot.FileEntry{957 path: path958 hash: hash959 }960 }961 save := history.save_tree(a, '', files, history.SaveInput{962 message: 'add all'963 })!964 assert strip_scope(a, 'docs', '')!.files == 1965 strip_local(a, 'docs', '')!966 strip_local(a, 'a_b', '')!967 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']968}969970fn test_a_strip_reaches_the_remote_and_other_machines() {971 base := scratch('strip_sync')!972 defer {973 os.rmdir_all(base) or {}974 }975 mut a := open_repo(machine('strip_a')!)!976 defer {977 a.close()978 }979 mut b := open_repo(machine('strip_b')!)!980 defer {981 b.close()982 }983 put(a.root, 'secrets.env', 'api_key=1\n')!984 put(a.root, 'keep.txt', 'fine\n')!985 save := make_save(a, 'add both')!986 mut backend := remote.new_folder(os.join_path(base, 'remote'))!987 offer_now(a, mut backend)!988 join(b, mut backend)!989 assert b.object_count()! > 0990991 992 993 mut secret_hash := ''994 for file in history.files_of(a, save.id)! {995 if file.path == 'secrets.env' {996 secret_hash = file.hash997 }998 }999 assert secret_hash != ''1000 strip_local(a, 'secrets.env', '')!1001 report := send_strip(a, mut backend, 'secrets.env')!1002 assert report.event_sent1003 assert report.records == 110041005 1006 1007 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1008 for file in record.files {1009 assert file.path != 'secrets.env'1010 }1011 1012 1013 assert !backend.get(object_key('proj', secret_hash))!.found1014 assert backend.list('proj/objects')!.len == 210151016 1017 b.begin()!1018 fetched := fetch_back_to_known(b, mut backend, 'proj')!1019 write_fetched(b, fetched)!1020 b.end()!1021 mut paths := []string{}1022 for file in history.files_of(b, save.id)! {1023 paths << file.path1024 }1025 assert 'secrets.env' !in paths1026 assert 'keep.txt' in paths10271028 1029 1030 1031 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1032 put(b.root, 'secrets.env', 'rotated\n')!1033 assert !dirty(b, history.head(b))!1034 put(b.root, 'keep.txt', 'still fine\n')!1035 later := make_save(b, 'edit keep')!1036 offer_now(b, mut backend)!1037 assert backend.list('proj/events')!.len == 11038 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1039}10401041104210431044fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1045 base := scratch('strip_later')!1046 defer {1047 os.rmdir_all(base) or {}1048 }1049 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1050 mut a := open_repo(machine('strip_later_a')!)!1051 defer {1052 a.close()1053 }1054 mut b := open_repo(machine('strip_later_b')!)!1055 defer {1056 b.close()1057 }1058 put(a.root, 'secrets.env', 'api_key=1\n')!1059 old := make_save(a, 'leak the key')!1060 offer_now(a, mut backend)!1061 join(b, mut backend)!1062 strip_local(a, 'secrets.env', '')!1063 send_strip(a, mut backend, 'secrets.env')!1064 1065 put(b.root, 'secrets.env', 'api_key=2\n')!1066 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1067 message: 'rotate the key'1068 created_at: events.strip_events(a)!.last().created_at + 36001069 })!1070 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1071 b.begin()!1072 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1073 b.end()!1074 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1075 offer_now(b, mut backend)!10761077 mut c := open_repo(machine('strip_later_c')!)!1078 defer {1079 c.close()1080 }1081 join(c, mut backend)!1082 for save in [old.id, later.id] {1083 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1084 }1085 assert !os.exists(os.join_path(c.root, 'secrets.env'))1086}1087108810891090fn test_a_held_strip_reaches_saves_arriving_after_it() {1091 base := scratch('strip_held')!1092 defer {1093 os.rmdir_all(base) or {}1094 }1095 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1096 mut a := open_repo(machine('strip_held_a')!)!1097 defer {1098 a.close()1099 }1100 mut b := open_repo(machine('strip_held_b')!)!1101 defer {1102 b.close()1103 }1104 put(a.root, 'secrets.env', 'api_key=1\n')!1105 make_save(a, 'leak the key')!1106 offer_now(a, mut backend)!1107 join(b, mut backend)!1108 strip_local(b, 'secrets.env', '')!1109 put(a.root, 'keep.txt', 'fine\n')!1110 later := make_save(a, 'add keep')!1111 offer_now(a, mut backend)!1112 b.begin()!1113 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1114 b.end()!1115 paths := history.files_of(b, later.id)!.map(it.path)1116 assert 'secrets.env' !in paths1117 assert 'keep.txt' in paths1118}1119112011211122struct StripRace {1123mut:1124 inner remote.Backend1125 other &storage.Store1126 path string1127 done bool1128 1129 fail_after_race bool1130 raced_put bool1131 fail_delete bool1132 fail_match bool1133 1134 on_objects bool1135 1136 fail_events_after_race bool1137}11381139fn (mut r StripRace) get(key string) !remote.Object {1140 return r.inner.get(key)!1141}11421143fn (mut r StripRace) list(prefix string) ![]string {1144 if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1145 return error('injected list failure')1146 }1147 return r.inner.list(prefix)!1148}11491150fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1151 if r.fail_after_race && r.raced_put {1152 return error('injected write failure')1153 }1154 if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1155 r.done = true1156 r.raced_put = true1157 strip_local(r.other, r.path, '')!1158 send_strip(r.other, mut r.inner, r.path)!1159 }1160 return r.inner.put_if_absent(key, body)!1161}11621163fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1164 if r.fail_match {1165 return error('injected rewrite failure')1166 }1167 return r.inner.put_if_match(key, body, tag)!1168}11691170fn (mut r StripRace) delete(key string) ! {1171 if r.fail_delete {1172 return error('injected delete failure')1173 }1174 r.inner.delete(key)!1175}11761177117811791180fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1181 base := scratch('strip_race')!1182 defer {1183 os.rmdir_all(base) or {}1184 }1185 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1186 mut a := open_repo(machine('strip_race_a')!)!1187 defer {1188 a.close()1189 }1190 mut b := open_repo(machine('strip_race_b')!)!1191 defer {1192 b.close()1193 }1194 put(a.root, 'secrets.env', 'api_key=1\n')!1195 first := make_save(a, 'leak the key')!1196 offer_now(a, mut backend)!1197 join(b, mut backend)!1198 put(a.root, 'secrets.env', 'api_key=2\n')!1199 later := make_save(a, 'rotate the key')!1200 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1201 mut race := StripRace{1202 inner: backend1203 other: &b1204 path: 'secrets.env'1205 }1206 if _ := offer(a, mut race) {1207 assert false, 'an offer that raced a strip must refuse'1208 } else {1209 assert err.msg().contains('koh steal'), err.msg()1210 }1211 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1212 assert !backend.get(object_key('proj', secret))!.found1213 1214 assert backend.get(save_key('proj', later.id))!.found1215 for key in backend.list('proj/saves')! {1216 record := decode_save(backend.get(key)!.body)!1217 assert 'secrets.env' !in record.files.map(it.path), record.id1218 }1219}1220122112221223fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1224 base := scratch('resend_race')!1225 defer {1226 os.rmdir_all(base) or {}1227 }1228 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1229 mut a := open_repo(machine('resend_race_a')!)!1230 defer {1231 a.close()1232 }1233 mut b := open_repo(machine('resend_race_b')!)!1234 defer {1235 b.close()1236 }1237 put(a.root, 'secrets.env', 'api_key=1\n')!1238 first := make_save(a, 'leak the key')!1239 offer_now(a, mut backend)!1240 join(b, mut backend)!1241 backend.delete(save_key('proj', first.id))!1242 mut race := StripRace{1243 inner: backend1244 other: &b1245 path: 'secrets.env'1246 }1247 if _ := resend(a, mut race, 'proj', [], [first.id]) {1248 assert false, 'a resend that raced a strip must refuse'1249 } else {1250 assert err.msg().contains('koh steal'), err.msg()1251 1252 assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1253 }1254 1255 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1256 assert 'secrets.env' !in record.files.map(it.path)1257 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1258}1259126012611262fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1263 base := scratch('resend_shared')!1264 defer {1265 os.rmdir_all(base) or {}1266 }1267 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1268 mut a := open_repo(machine('resend_shared_a')!)!1269 defer {1270 a.close()1271 }1272 mut b := open_repo(machine('resend_shared_b')!)!1273 defer {1274 b.close()1275 }1276 put(a.root, 'secrets.env', 'same\n')!1277 put(a.root, 'copy.txt', 'same\n')!1278 first := make_save(a, 'two paths, one object')!1279 offer_now(a, mut backend)!1280 join(b, mut backend)!1281 put(a.root, 'later.txt', 'later\n')!1282 second := make_save(a, 'not offered')!1283 shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1284 backend.delete(save_key('proj', first.id))!1285 backend.delete(shared)!1286 mut race := StripRace{1287 inner: backend1288 other: &b1289 path: 'secrets.env'1290 fail_after_race: true1291 }1292 if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1293 assert false, 'the injected failure should have surfaced'1294 } else {1295 assert err.msg().contains('injected'), err.msg()1296 }1297 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1298 paths := record.files.map(it.path)1299 assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1300 assert backend.get(shared)!.found1301}13021303130413051306fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1307 base := scratch('strip_note')!1308 defer {1309 os.rmdir_all(base) or {}1310 }1311 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1312 mut a := open_repo(machine('strip_note_a')!)!1313 defer {1314 a.close()1315 }1316 put(a.root, 'app', 'app\n')!1317 save := make_save(a, 'app')!1318 offer_now(a, mut backend)!1319 events.strip(a, '', 'other')!1320 for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1321 a.set_state(pending_key('app'), note)!1322 if _ := send_strip(a, mut backend, 'app') {1323 assert false, 'an untrusted note must be refused'1324 } else {1325 assert err.msg().contains('kept for recovery'), err.msg()1326 }1327 assert a.state(pending_key('app')) or { '' } == note1328 assert backend.list(events_prefix('proj'))!.len == 01329 assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1330 }1331 1332 events.strip(a, '', 'app')!1333 victim := os.join_path(base, 'remote', 'victim', 'head')1334 os.mkdir_all(os.dir(victim))!1335 os.write_file(victim, 'another repo')!1336 a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1337 if _ := send_strip(a, mut backend, 'app') {1338 assert false, 'a note naming a path outside the objects must be refused'1339 }1340 assert os.read_file(victim)! == 'another repo'1341 1342 a.remove_state(pending_key('app'))!1343 requeue_strip(a, 'app')!1344 backend.delete(save_key('proj', save.id))!1345 backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1346 id: save.id1347 files: [FileRecord{1348 path: 'app'1349 hash: '../../victim/head'1350 }]1351 }).bytes())!1352 send_strip(a, mut backend, 'app')!1353 assert os.read_file(victim)! == 'another repo'1354}13551356135713581359fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1360 base := scratch('settle_record')!1361 defer {1362 os.rmdir_all(base) or {}1363 }1364 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1365 mut a := open_repo(machine('settle_record_a')!)!1366 defer {1367 a.close()1368 }1369 mut b := open_repo(machine('settle_record_b')!)!1370 defer {1371 b.close()1372 }1373 mut c := open_repo(machine('settle_record_c')!)!1374 defer {1375 c.close()1376 }1377 put(a.root, 'readme.txt', 'hello\n')!1378 make_save(a, 'start')!1379 offer_now(a, mut backend)!1380 join(c, mut backend)!1381 put(a.root, 'secrets.env', 'api_key=1\n')!1382 leak := make_save(a, 'leak the key')!1383 offer_now(a, mut backend)!1384 join(b, mut backend)!1385 secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1386 backend.delete(save_key('proj', leak.id))!1387 backend.delete(secret)!1388 mut race := StripRace{1389 inner: backend1390 other: &b1391 path: 'secrets.env'1392 fail_match: true1393 }1394 if _ := resend(a, mut race, 'proj', [], [leak.id]) {1395 assert false, 'the injected rewrite failure should have surfaced'1396 }1397 assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1398 steal_now(c, mut backend)!1399 assert history.head(c) or { history.Save{} }.id == leak.id1400 assert strip_pending(a, 'secrets.env')1401 steal_now(a, mut backend)!1402 send_strip(a, mut backend, 'secrets.env')!1403 cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1404 assert 'secrets.env' !in cleaned.files.map(it.path)1405 assert !backend.get(secret)!.found1406}1407140814091410fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1411 base := scratch('settle_object')!1412 defer {1413 os.rmdir_all(base) or {}1414 }1415 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1416 mut a := open_repo(machine('settle_object_a')!)!1417 defer {1418 a.close()1419 }1420 mut b := open_repo(machine('settle_object_b')!)!1421 defer {1422 b.close()1423 }1424 put(a.root, 'secrets.env', 'api_key=1\n')!1425 make_save(a, 'leak the key')!1426 offer_now(a, mut backend)!1427 join(b, mut backend)!1428 hash := storage.hash_hex('api_key=1\n'.bytes())1429 backend.delete(object_key('proj', hash))!1430 mut race := StripRace{1431 inner: backend1432 other: &b1433 path: 'secrets.env'1434 fail_delete: true1435 on_objects: true1436 }1437 if _ := resend(a, mut race, 'proj', [hash], []) {1438 assert false, 'the injected delete failure should have surfaced'1439 }1440 assert backend.get(object_key('proj', hash))!.found1441 assert strip_pending(a, 'secrets.env')1442 steal_now(a, mut backend)!1443 send_strip(a, mut backend, 'secrets.env')!1444 assert !backend.get(object_key('proj', hash))!.found1445 assert !strip_pending(a, 'secrets.env')1446}1447144814491450fn test_an_unsettled_upload_is_finished_after_steal() {1451 base := scratch('unsettled')!1452 defer {1453 os.rmdir_all(base) or {}1454 }1455 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1456 mut a := open_repo(machine('unsettled_a')!)!1457 defer {1458 a.close()1459 }1460 mut b := open_repo(machine('unsettled_b')!)!1461 defer {1462 b.close()1463 }1464 put(a.root, 'secrets.env', 'api_key=1\n')!1465 make_save(a, 'leak the key')!1466 offer_now(a, mut backend)!1467 join(b, mut backend)!1468 hash := storage.hash_hex('api_key=1\n'.bytes())1469 backend.delete(object_key('proj', hash))!1470 mut race := StripRace{1471 inner: backend1472 other: &b1473 path: 'secrets.env'1474 on_objects: true1475 fail_events_after_race: true1476 }1477 if _ := resend(a, mut race, 'proj', [hash], []) {1478 assert false, 'the injected list failure should have surfaced'1479 }1480 assert unsettled(a)! == [hash]1481 if _ := settle_unsettled(a, mut backend, 'proj') {1482 assert false, 'settling before the strip is here would keep its bytes'1483 }1484 steal_now(a, mut backend)!1485 settle_unsettled(a, mut backend, 'proj')!1486 assert !backend.get(object_key('proj', hash))!.found1487 assert unsettled(a)!.len == 01488}148914901491fn test_a_strip_that_fails_after_its_event_reports_it() {1492 base := scratch('strip_event_partway')!1493 defer {1494 os.rmdir_all(base) or {}1495 }1496 remote_dir := os.join_path(base, 'remote')1497 mut backend := remote.new_folder(remote_dir)!1498 mut a := open_repo(machine('strip_event_partway_a')!)!1499 defer {1500 a.close()1501 }1502 put(a.root, 'secrets.env', 'api_key=1\n')!1503 make_save(a, 'leak the key')!1504 offer_now(a, mut backend)!1505 strip_local(a, 'secrets.env', '')!1506 mut flaky := FaultyBackend{1507 inner: remote.new_folder(remote_dir)!1508 fail_list: '/saves'1509 }1510 if _ := send_strip(a, mut flaky, 'secrets.env') {1511 assert false, 'the injected list failure should have surfaced'1512 } else {1513 assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1514 }1515}1516151715181519fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1520 base := scratch('strip_found')!1521 defer {1522 os.rmdir_all(base) or {}1523 }1524 remote_dir := os.join_path(base, 'remote')1525 mut backend := remote.new_folder(remote_dir)!1526 mut a := open_repo(machine('strip_found_a')!)!1527 defer {1528 a.close()1529 }1530 put(a.root, 'secrets.env', 'api_key=1\n')!1531 make_save(a, 'leak the key')!1532 offer_now(a, mut backend)!1533 strip_local(a, 'secrets.env', '')!1534 1535 a.remove_state(pending_key('secrets.env'))!1536 requeue_strip(a, 'secrets.env')!1537 mut flaky := FaultyBackend{1538 inner: remote.new_folder(remote_dir)!1539 fail_delete: true1540 }1541 if _ := send_strip(a, mut flaky, 'secrets.env') {1542 assert false, 'the injected delete failure should have surfaced'1543 }1544 send_strip(a, mut backend, 'secrets.env')!1545 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1546 assert !strip_pending(a, 'secrets.env')1547}15481549155015511552struct MovingRecord {1553mut:1554 inner remote.Backend1555 other string1556 moved bool1557}15581559fn (mut m MovingRecord) get(key string) !remote.Object {1560 return m.inner.get(key)!1561}15621563fn (mut m MovingRecord) list(prefix string) ![]string {1564 return m.inner.list(prefix)!1565}15661567fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1568 return m.inner.put_if_absent(key, body)!1569}15701571fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1572 if !m.moved {1573 m.moved = true1574 current := m.inner.get(key)!1575 record := decode_save(current.body)!1576 other := record.files.filter(it.path != m.other)1577 m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1578 }1579 return m.inner.put_if_match(key, body, tag)!1580}15811582fn (mut m MovingRecord) delete(key string) ! {1583 m.inner.delete(key)!1584}15851586fn test_a_stripped_record_that_moves_is_read_again() {1587 base := scratch('moving_record')!1588 defer {1589 os.rmdir_all(base) or {}1590 }1591 mut folder := remote.new_folder(os.join_path(base, 'remote'))!1592 key := save_key('proj', 'aaaaaaaaaaaa')1593 folder.put_if_absent(key, encode_save(SaveRecord{1594 id: 'aaaaaaaaaaaa'1595 files: [FileRecord{1596 path: 'secret-a'1597 }, FileRecord{1598 path: 'secret-b'1599 }, FileRecord{1600 path: 'keep'1601 }]1602 }).bytes())!1603 mut moving := MovingRecord{1604 inner: folder1605 other: 'secret-b'1606 }1607 strip_record(mut moving, key, 'aaaaaaaaaaaa', [1608 events.Event{1609 kind: events.kind_strip1610 path: 'secret-a'1611 },1612 ])!1613 assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1614}161516161617fn test_an_invalid_remote_strip_event_is_not_unapplied() {1618 base := scratch('invalid_remote_strip')!1619 defer {1620 os.rmdir_all(base) or {}1621 }1622 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1623 mut a := open_repo(machine('invalid_remote_strip_a')!)!1624 defer {1625 a.close()1626 }1627 bad := events.Event{1628 id: 'badstripevnt'1629 kind: events.kind_strip1630 path: 'app'1631 created_at: -11632 machine: 'other'1633 }1634 backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1635 assert unapplied_strips(a, mut backend, 'proj')!.len == 01636}163716381639fn test_a_failed_resend_reports_what_landed() {1640 base := scratch('resend_partway')!1641 defer {1642 os.rmdir_all(base) or {}1643 }1644 remote_dir := os.join_path(base, 'remote')1645 mut a := open_repo(machine('resend_partway_a')!)!1646 defer {1647 a.close()1648 }1649 put(a.root, 'one.txt', 'one\n')!1650 put(a.root, 'two.txt', 'two\n')!1651 make_save(a, 'two objects')!1652 hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1653 mut flaky := FaultyBackend{1654 inner: remote.new_folder(remote_dir)!1655 fail_write: 21656 }1657 if _ := resend(a, mut flaky, 'proj', hashes, []) {1658 assert false, 'the injected failure should have surfaced'1659 } else {1660 assert err is Partway1661 assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1662 }1663}16641665166616671668fn test_an_interrupted_remote_strip_finishes_when_re_run() {1669 base := scratch('strip_resume')!1670 defer {1671 os.rmdir_all(base) or {}1672 }1673 remote_dir := os.join_path(base, 'remote')1674 mut backend := remote.new_folder(remote_dir)!1675 mut a := open_repo(machine('resume_a')!)!1676 defer {1677 a.close()1678 }1679 mut b := open_repo(machine('resume_b')!)!1680 defer {1681 b.close()1682 }1683 put(a.root, 'secrets.env', 'api_key=1\n')!1684 put(a.root, 'shared.env', 'same bytes on B\n')!1685 save := make_save(a, 'add secrets')!1686 offer_now(a, mut backend)!1687 join(b, mut backend)!1688 put(b.root, 'copy.txt', 'same bytes on B\n')!1689 b_save := make_save(b, 'B keeps a copy')!1690 offer_now(b, mut backend)!1691 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1692 secret_hash := storage.hash_hex('api_key=1\n'.bytes())16931694 strip_local(a, 'secrets.env', '')!1695 1696 1697 mut flaky := FaultyBackend{1698 inner: remote.new_folder(remote_dir)!1699 fail_write: 31700 }1701 if _ := send_strip(a, mut flaky, 'secrets.env') {1702 assert false, 'the injected failure should have surfaced'1703 }1704 assert strip_pending(a, 'secrets.env')1705 assert strip_local(a, 'secrets.env', '')!.files_removed == 01706 report := send_strip(a, mut backend, 'secrets.env')!1707 assert report.records == 11708 assert !strip_pending(a, 'secrets.env')1709 assert !backend.get(object_key('proj', secret_hash))!.found1710 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1711 assert 'secrets.env' !in record.files.map(it.path)1712 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1713 assert 'secrets.env' !in b_record.files.map(it.path)17141715 1716 strip_local(a, 'shared.env', '')!1717 send_strip(a, mut backend, 'shared.env')!1718 assert backend.get(object_key('proj', shared_hash))!.found1719}1720172117221723fn test_offer_waits_for_an_unapplied_strip() {1724 base := scratch('strip_first')!1725 defer {1726 os.rmdir_all(base) or {}1727 }1728 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1729 mut a := open_repo(machine('strip_first_a')!)!1730 defer {1731 a.close()1732 }1733 mut b := open_repo(machine('strip_first_b')!)!1734 defer {1735 b.close()1736 }1737 put(a.root, 'secrets.env', 'api_key=1\n')!1738 make_save(a, 'add secrets')!1739 offer_now(a, mut backend)!1740 join(b, mut backend)!1741 strip_local(a, 'secrets.env', '')!1742 send_strip(a, mut backend, 'secrets.env')!1743 put(b.root, 'more.txt', 'B keeps working\n')!1744 later := make_save(b, 'B saves with the secret still in its tree')!1745 if _ := offer_now(b, mut backend) {1746 assert false, 'B offered records that still list the stripped path'1747 }1748 steal_now(b, mut backend)!1749 offer_now(b, mut backend)!1750 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1751 assert 'secrets.env' !in record.files.map(it.path)1752}1753175417551756fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1757 base := scratch('strip_twice')!1758 defer {1759 os.rmdir_all(base) or {}1760 }1761 remote_dir := os.join_path(base, 'remote')1762 mut backend := remote.new_folder(remote_dir)!1763 mut a := open_repo(machine('strip_twice_a')!)!1764 defer {1765 a.close()1766 }1767 put(a.root, 'secrets.env', 'first key\n')!1768 make_save(a, 'first key')!1769 offer_now(a, mut backend)!1770 strip_local(a, 'secrets.env', '')!1771 mut flaky := FaultyBackend{1772 inner: remote.new_folder(remote_dir)!1773 fail_delete: true1774 }1775 if _ := send_strip(a, mut flaky, 'secrets.env') {1776 assert false, 'the injected delete failure should have surfaced'1777 }1778 put(a.root, 'secrets.env', 'second key\n')!1779 put(a.root, 'other.txt', 'more work\n')!1780 make_save(a, 'more work')!1781 offer_now(a, mut backend)!1782 send_strip(a, mut backend, 'secrets.env')!1783 for key in ['first key\n', 'second key\n'] {1784 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1785 }1786}178717881789fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1790 base := scratch('strip_scopes')!1791 defer {1792 os.rmdir_all(base) or {}1793 }1794 remote_dir := os.join_path(base, 'remote')1795 mut backend := remote.new_folder(remote_dir)!1796 mut a := open_repo(machine('strip_scopes_a')!)!1797 defer {1798 a.close()1799 }1800 put(a.root, 'secrets.env', 'first key\n')!1801 first := make_save(a, 'first key')!1802 put(a.root, 'secrets.env', 'second key\n')!1803 second := make_save(a, 'second key')!1804 offer_now(a, mut backend)!1805 strip_local(a, 'secrets.env', first.id)!1806 1807 mut flaky := FaultyBackend{1808 inner: remote.new_folder(remote_dir)!1809 fail_write: 21810 }1811 if _ := send_strip(a, mut flaky, 'secrets.env') {1812 assert false, 'the injected failure should have surfaced'1813 }1814 strip_local(a, 'secrets.env', second.id)!1815 send_strip(a, mut backend, 'secrets.env')!1816 assert !strip_pending(a, 'secrets.env')1817 for save in [first, second] {1818 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1819 assert 'secrets.env' !in record.files.map(it.path)1820 }1821 for key in ['first key\n', 'second key\n'] {1822 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1823 }1824}1825182618271828fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1829 base := scratch('strip_again')!1830 defer {1831 os.rmdir_all(base) or {}1832 }1833 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1834 mut a := open_repo(machine('strip_again_a')!)!1835 defer {1836 a.close()1837 }1838 mut b := open_repo(machine('strip_again_b')!)!1839 defer {1840 b.close()1841 }1842 put(a.root, 'secrets.env', 'first key\n')!1843 make_save(a, 'first key')!1844 offer_now(a, mut backend)!1845 join(b, mut backend)!1846 strip_local(a, 'secrets.env', '')!1847 send_strip(a, mut backend, 'secrets.env')!1848 steal_now(b, mut backend)!1849 for repo in [a, b] {1850 put(repo.root, 'secrets.env', 'second key\n')!1851 if _ := make_save(repo, 'second key') {1852 assert false, 'a banned path must not make a save'1853 }1854 }1855}1856185718581859fn test_a_stripped_path_is_not_asked_about() {1860 base := scratch('clash_strip')!1861 defer {1862 os.rmdir_all(base) or {}1863 }1864 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1865 mut a := open_repo(machine('clash_strip_a')!)!1866 defer {1867 a.close()1868 }1869 mut b := open_repo(machine('clash_strip_b')!)!1870 defer {1871 b.close()1872 }1873 put(a.root, 'secrets.env', 'key one\n')!1874 make_save(a, 'base')!1875 offer_now(a, mut backend)!1876 join(b, mut backend)!1877 put(a.root, 'secrets.env', 'key two\n')!1878 make_save(a, 'A rotates')!1879 offer_now(a, mut backend)!1880 strip_local(a, 'secrets.env', '')!1881 send_strip(a, mut backend, 'secrets.env')!1882 put(b.root, 'secrets.env', 'key three\n')!1883 mine := make_save(b, 'B rotates')!1884 report := steal(b, mut backend, StealOptions{})!1885 assert report.clashes.len == 01886 for save in history.all_saves(b)! {1887 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1888 }1889 assert history.by_id(b, mine.id) != none1890}1891189218931894fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1895 base := scratch('clash_base_strip')!1896 defer {1897 os.rmdir_all(base) or {}1898 }1899 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1900 mut a := open_repo(machine('base_strip_a')!)!1901 defer {1902 a.close()1903 }1904 mut b := open_repo(machine('base_strip_b')!)!1905 defer {1906 b.close()1907 }1908 put(a.root, 'notes.txt', 'shared\n')!1909 common := make_save(a, 'base')!1910 offer_now(a, mut backend)!1911 join(b, mut backend)!1912 put(a.root, 'other.txt', 'A moves on\n')!1913 make_save(a, 'A moves on')!1914 offer_now(a, mut backend)!1915 strip_local(a, 'notes.txt', common.id)!1916 send_strip(a, mut backend, 'notes.txt')!1917 put(b.root, 'notes.txt', 'B edits\n')!1918 make_save(b, 'B edits notes')!1919 if _ := steal(b, mut backend, StealOptions{}) {1920 assert false, 'with the base stripped, notes.txt differs on both sides'1921 } else {1922 assert err.msg().contains('notes.txt changed on both machines')1923 }1924 report := steal(b, mut backend, StealOptions{1925 mine: true1926 })!1927 assert report.clashes == ['notes.txt']1928 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1929}1930193119321933fn test_steal_into_a_folder_with_unsaved_files_refuses() {1934 base := scratch('steal_unsaved')!1935 defer {1936 os.rmdir_all(base) or {}1937 }1938 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1939 mut a := open_repo(machine('unsaved_a')!)!1940 defer {1941 a.close()1942 }1943 put(a.root, 'notes.txt', 'from A\n')!1944 make_save(a, 'A writes')!1945 offer_now(a, mut backend)!1946 b_root := machine('unsaved_b')!1947 mut b := storage.create(b_root)!1948 defer {1949 b.close()1950 }1951 put(b_root, 'notes.txt', 'never saved\n')!1952 1953 b.set_state('remote_repo', 'proj')!1954 if _ := steal(b, mut backend, StealOptions{}) {1955 assert false, 'steal overwrote a folder with unsaved files'1956 } else {1957 assert err.msg() == no_saves_here1958 }1959 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1960 1961 os.rm(os.join_path(b_root, 'notes.txt'))!1962 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1963 steal(b, mut backend, StealOptions{})!1964 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1965}1966196719681969fn test_steal_reports_incoming_flagged_saves() {1970 base := scratch('flag_warn')!1971 defer {1972 os.rmdir_all(base) or {}1973 }1974 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1975 mut a := open_repo(machine('flag_warn_a')!)!1976 defer {1977 a.close()1978 }1979 mut b := open_repo(machine('flag_warn_b')!)!1980 defer {1981 b.close()1982 }1983 put(a.root, 'a.txt', 'one\n')!1984 make_save(a, 'fine')!1985 offer_now(a, mut backend)!1986 join(b, mut backend)!1987 put(a.root, 'a.txt', 'two\n')!1988 suspect := make_save(a, 'suspect')!1989 put(a.root, 'a.txt', 'three\n')!1990 make_save(a, 'also fine')!1991 events.flag(a, suspect.id, 'broke the build')!1992 offer_now(a, mut backend)!1993 report := steal_now(b, mut backend)!1994 assert report.flagged.map(it.save_id) == [suspect.id]1995 assert report.flagged[0].reason == 'broke the build'1996}199719981999fn head_of(store &storage.Store) history.Save {2000 return history.head(store) or { panic('this repo has no head') }2001}2002200320042005fn test_the_first_offer_records_the_remote_format() {2006 base := scratch('format_write')!2007 defer {2008 os.rmdir_all(base) or {}2009 }2010 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2011 mut a := open_repo(machine('format_write_a')!)!2012 defer {2013 a.close()2014 }2015 put(a.root, 'a.txt', 'one\n')!2016 make_save(a, 'first')!2017 offer_now(a, mut backend)!2018 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2019}2020202120222023fn test_a_remote_without_a_format_still_syncs() {2024 base := scratch('format_old')!2025 defer {2026 os.rmdir_all(base) or {}2027 }2028 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2029 mut a := open_repo(machine('format_old_a')!)!2030 defer {2031 a.close()2032 }2033 put(a.root, 'a.txt', 'one\n')!2034 make_save(a, 'first')!2035 offer_now(a, mut backend)!2036 2037 backend.delete(format_key('proj'))!2038 mut b := open_repo(machine('format_old_b')!)!2039 defer {2040 b.close()2041 }2042 join(b, mut backend)!2043 assert head_of(b).id == head_of(a).id2044 put(a.root, 'a.txt', 'two\n')!2045 make_save(a, 'second')!2046 offer_now(a, mut backend)!2047 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2048 report := steal_now(b, mut backend)!2049 assert report.head == head_of(a).id2050}2051205220532054fn test_an_unknown_remote_format_is_refused() {2055 base := scratch('format_unknown')!2056 defer {2057 os.rmdir_all(base) or {}2058 }2059 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2060 mut a := open_repo(machine('format_bad_a')!)!2061 defer {2062 a.close()2063 }2064 put(a.root, 'a.txt', 'one\n')!2065 make_save(a, 'first')!2066 offer_now(a, mut backend)!2067 backend.delete(format_key('proj'))!2068 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2069 if _ := offer_now(a, mut backend) {2070 assert false, 'offer must refuse a format it does not speak'2071 } else {2072 assert err.msg().contains('koh-remote 2')2073 }2074 if _ := steal_now(a, mut backend) {2075 assert false, 'steal must refuse a format it does not speak'2076 } else {2077 assert err.msg().contains('koh-remote 2')2078 }2079 if _ := fetch_repo(mut backend, 'proj') {2080 assert false, 'a steal must refuse a format it does not speak'2081 } else {2082 assert err.msg().contains('koh-remote 2')2083 }2084}20852086208720882089fn test_a_lost_format_write_is_read_back_and_refused() {2090 base := scratch('format_race')!2091 defer {2092 os.rmdir_all(base) or {}2093 }2094 mut inner := remote.new_folder(os.join_path(base, 'remote'))!2095 mut a := open_repo(machine('format_race_a')!)!2096 defer {2097 a.close()2098 }2099 put(a.root, 'a.txt', 'one\n')!2100 make_save(a, 'first')!2101 mut backend := remote.Backend(FaultyBackend{2102 inner: inner2103 race_format: true2104 })2105 if _ := offer_now(a, mut backend) {2106 assert false, 'offer must refuse the format another machine wrote first'2107 } else {2108 assert err.msg().contains('koh-remote 2')2109 }2110}21112112fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2113 whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2114 for size in [1, 2, 5, whole.len] {2115 mut bundle := Bundle{}2116 for at := 0; at < whole.len; at += size {2117 bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2118 }2119 bundle.finish()!2120 assert bundle.fetched.repo_objects == {2121 'aa': 'abc'.bytes()2122 'bb': []u8{}2123 }2124 }2125 for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2126 'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2127 mut bundle := Bundle{}2128 bundle.feed(cut.bytes()) or { continue }2129 bundle.finish() or { continue }2130 assert false, cut2131 }2132}21332134fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2135 mut store := open_repo(machine('strip_batch')!)!2136 defer { store.close() }2137 put(store.root, 'private/a', 'a')!2138 put(store.root, 'secret', 'secret')!2139 put(store.root, 'keep', 'keep')!2140 save := make_save(store, 'initial')!2141 store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2142 WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2143 if _ := strip_local_batch(store, ['private', 'secret'], '') {2144 assert false, 'batch succeeded despite the second path failing'2145 }2146 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2147 'secret']2148 assert events.strip_events(store)!.len == 02149 assert !strip_pending(store, 'private')2150 store.db.exec('DROP TRIGGER block_secret')!2151 reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2152 assert reports.map(it.files_removed) == [1, 0, 1]2153 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2154 assert events.strip_events(store)!.len == 22155}21562157fn test_strip_batch_limits_every_path_to_selected_save() {2158 mut store := open_repo(machine('strip_batch_scope')!)!2159 defer { store.close() }2160 put(store.root, 'one', 'one')!2161 put(store.root, 'two', 'two')!2162 first := make_save(store, 'initial')!2163 put(store.root, 'keep', 'keep')!2164 second := make_save(store, 'next')!2165 strip_local_batch(store, ['one', 'two'], first.id)!2166 assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2167 assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2168 assert events.strip_events(store)!.all(it.save_id == first.id)2169}21702171fn test_strip_batch_keeps_independent_remote_retry_notes() {2172 mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2173 mut store := open_repo(machine('strip_batch_retry')!)!2174 defer { store.close() }2175 put(store.root, 'one', 'secret')!2176 put(store.root, 'two', 'secret')!2177 save := make_save(store, 'initial')!2178 offer_now(store, mut backend)!2179 strip_local_batch(store, ['one', 'two'], '')!2180 mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2181 if _ := send_strip(store, mut flaky, 'one') {2182 assert false, 'injected remote failure was not reported'2183 }2184 send_strip(store, mut backend, 'two')!2185 assert strip_pending(store, 'one') && !strip_pending(store, 'two')2186 partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2187 assert partial.files.map(it.path) == ['.kohignore', 'one']2188 assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2189 send_strip(store, mut backend, 'one')!2190 assert !strip_pending(store, 'one')2191 finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2192 assert finished.files.map(it.path) == ['.kohignore']2193 assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2194}