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}737738fn (mut f FaultyBackend) get(key string) !remote.Object {739 f.reads++740 if f.fail_read > 0 && f.reads >= f.fail_read {741 return error('injected read failure')742 }743 return f.inner.get(key)!744}745746fn (mut f FaultyBackend) list(prefix string) ![]string {747 return f.inner.list(prefix)!748}749750fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {751 f.writes++752 if f.fail_write > 0 && f.writes >= f.fail_write {753 return error('injected write failure')754 }755 if f.race_format && !f.raced && key.ends_with('/format') {756 f.raced = true757 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!758 return false759 }760 return f.inner.put_if_absent(key, body)!761}762763fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {764 f.writes++765 if f.fail_write > 0 && f.writes >= f.fail_write {766 return error('injected write failure')767 }768 return f.inner.put_if_match(key, body, tag)!769}770771fn (mut f FaultyBackend) delete(key string) ! {772 if f.fail_delete {773 return error('injected delete failure')774 }775 f.inner.delete(key)!776}777778779780781fn test_an_offer_sends_only_the_objects_a_save_adds() {782 base := scratch('offer_diff')!783 defer {784 os.rmdir_all(base) or {}785 }786 mut a := open_repo(machine('offer_diff_a')!)!787 defer {788 a.close()789 }790 for i in 0 .. 20 {791 put(a.root, 'f${i}.txt', 'file ${i}\n')!792 }793 make_save(a, 'twenty files')!794 mut backend := FaultyBackend{795 inner: remote.new_folder(os.join_path(base, 'remote'))!796 }797 offer(a, mut backend)!798 put(a.root, 'f3.txt', 'changed\n')!799 make_save(a, 'one change')!800 before := backend.writes801 report := offer(a, mut backend)!802 assert report.objects == 1803 804 assert backend.writes - before == 3805}806807fn test_a_failed_offer_is_safe_to_re_run() {808 base := scratch('fault_offer')!809 defer {810 os.rmdir_all(base) or {}811 }812 remote_dir := os.join_path(base, 'remote')813 mut a := open_repo(machine('fault_a')!)!814 defer {815 a.close()816 }817 put(a.root, 'a.txt', 'one\n')!818 make_save(a, 'first')!819 put(a.root, 'a.txt', 'two\n')!820 make_save(a, 'second')!821822 823 mut flaky := FaultyBackend{824 inner: remote.new_folder(remote_dir)!825 fail_write: 2826 }827 if _ := offer(a, mut flaky) {828 assert false, 'the injected failure should have surfaced'829 }830831 832 833 mut healthy := remote.new_folder(remote_dir)!834 report := offer(a, mut healthy)!835 assert report.saves == 2836 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id837 assert healthy.list('proj/saves')!.len == 2838}839840841842fn test_a_strip_with_an_illegal_path_is_ignored() {843 base := scratch('illegal_strip')!844 defer {845 os.rmdir_all(base) or {}846 }847 mut backend := remote.new_folder(os.join_path(base, 'remote'))!848 mut a := open_repo(machine('illegal_strip_a')!)!849 defer {850 a.close()851 }852 mut b := open_repo(machine('illegal_strip_b')!)!853 defer {854 b.close()855 }856 put(a.root, 'a/x.txt', 'kept\n')!857 make_save(a, 'add a folder')!858 offer_now(a, mut backend)!859 for id, path in {860 'aaaaaaaaaaaa': 'a//'861 'bbbbbbbbbbbb': 'a\\ud800'862 } {863 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!864 }865 join(b, mut backend)!866 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)867 put(a.root, 'more.txt', 'more\n')!868 make_save(a, 'more')!869 offer_now(a, mut backend)!870}871872fn test_a_failed_steal_leaves_nothing_behind() {873 base := scratch('fault_steal')!874 defer {875 os.rmdir_all(base) or {}876 }877 remote_dir := os.join_path(base, 'remote')878 mut a := open_repo(machine('fault_s_a')!)!879 defer {880 a.close()881 }882 mut b := open_repo(machine('fault_s_b')!)!883 defer {884 b.close()885 }886 put(a.root, 'a.txt', 'from A\n')!887 make_save(a, 'A writes')!888 mut healthy := remote.new_folder(remote_dir)!889 offer(a, mut healthy)!890891 892 mut flaky := FaultyBackend{893 inner: remote.new_folder(remote_dir)!894 fail_read: 2895 }896 if _ := fetch_back_to_known(b, mut flaky, 'proj') {897 assert false, 'the injected read failure should have surfaced'898 }899 900 assert history.head(b) == none901 assert b.object_count()! == 0902903 904 fetched := fetch_back_to_known(b, mut healthy, 'proj')!905 b.begin()!906 write_fetched(b, fetched)!907 history.set_head(b, fetched.head)!908 b.end()!909 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!910 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'911}912913fn test_an_interrupted_strip_is_idempotent() {914 915 916 917 base := scratch('fault_strip')!918 defer {919 os.rmdir_all(base) or {}920 }921 mut a := open_repo(machine('fault_strip_a')!)!922 defer {923 a.close()924 }925 put(a.root, 'secret.txt', 'shh\n')!926 put(a.root, 'keep.txt', 'fine\n')!927 save := make_save(a, 'add both')!928929 for _ in 0 .. 2 {930 strip_local(a, 'secret.txt', '')!931 }932 files := history.files_of(a, save.id)!933 934 935 assert files.map(it.path) == ['.kohignore', 'keep.txt']936 assert events.strip_events(a)!.len == 1937}938939940941942943fn test_a_strip_matches_the_path_exactly() {944 mut a := open_repo(machine('strip_exact')!)!945 defer {946 a.close()947 }948 hash := a.put_object('x\n'.bytes())!949 mut files := []snapshot.FileEntry{}950 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {951 files << snapshot.FileEntry{952 path: path953 hash: hash954 }955 }956 save := history.save_tree(a, '', files, history.SaveInput{957 message: 'add all'958 })!959 assert strip_scope(a, 'docs', '')!.files == 1960 strip_local(a, 'docs', '')!961 strip_local(a, 'a_b', '')!962 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']963}964965fn test_a_strip_reaches_the_remote_and_other_machines() {966 base := scratch('strip_sync')!967 defer {968 os.rmdir_all(base) or {}969 }970 mut a := open_repo(machine('strip_a')!)!971 defer {972 a.close()973 }974 mut b := open_repo(machine('strip_b')!)!975 defer {976 b.close()977 }978 put(a.root, 'secrets.env', 'api_key=1\n')!979 put(a.root, 'keep.txt', 'fine\n')!980 save := make_save(a, 'add both')!981 mut backend := remote.new_folder(os.join_path(base, 'remote'))!982 offer_now(a, mut backend)!983 join(b, mut backend)!984 assert b.object_count()! > 0985986 987 988 mut secret_hash := ''989 for file in history.files_of(a, save.id)! {990 if file.path == 'secrets.env' {991 secret_hash = file.hash992 }993 }994 assert secret_hash != ''995 strip_local(a, 'secrets.env', '')!996 report := send_strip(a, mut backend, 'secrets.env')!997 assert report.event_sent998 assert report.records == 19991000 1001 1002 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1003 for file in record.files {1004 assert file.path != 'secrets.env'1005 }1006 1007 1008 assert !backend.get(object_key('proj', secret_hash))!.found1009 assert backend.list('proj/objects')!.len == 210101011 1012 b.begin()!1013 fetched := fetch_back_to_known(b, mut backend, 'proj')!1014 write_fetched(b, fetched)!1015 b.end()!1016 mut paths := []string{}1017 for file in history.files_of(b, save.id)! {1018 paths << file.path1019 }1020 assert 'secrets.env' !in paths1021 assert 'keep.txt' in paths10221023 1024 1025 1026 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1027 put(b.root, 'secrets.env', 'rotated\n')!1028 assert !dirty(b, history.head(b))!1029 put(b.root, 'keep.txt', 'still fine\n')!1030 later := make_save(b, 'edit keep')!1031 offer_now(b, mut backend)!1032 assert backend.list('proj/events')!.len == 11033 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1034}10351036103710381039fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1040 base := scratch('strip_later')!1041 defer {1042 os.rmdir_all(base) or {}1043 }1044 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1045 mut a := open_repo(machine('strip_later_a')!)!1046 defer {1047 a.close()1048 }1049 mut b := open_repo(machine('strip_later_b')!)!1050 defer {1051 b.close()1052 }1053 put(a.root, 'secrets.env', 'api_key=1\n')!1054 old := make_save(a, 'leak the key')!1055 offer_now(a, mut backend)!1056 join(b, mut backend)!1057 strip_local(a, 'secrets.env', '')!1058 send_strip(a, mut backend, 'secrets.env')!1059 1060 put(b.root, 'secrets.env', 'api_key=2\n')!1061 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1062 message: 'rotate the key'1063 created_at: events.strip_events(a)!.last().created_at + 36001064 })!1065 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1066 b.begin()!1067 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1068 b.end()!1069 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1070 offer_now(b, mut backend)!10711072 mut c := open_repo(machine('strip_later_c')!)!1073 defer {1074 c.close()1075 }1076 join(c, mut backend)!1077 for save in [old.id, later.id] {1078 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1079 }1080 assert !os.exists(os.join_path(c.root, 'secrets.env'))1081}1082108310841085fn test_a_held_strip_reaches_saves_arriving_after_it() {1086 base := scratch('strip_held')!1087 defer {1088 os.rmdir_all(base) or {}1089 }1090 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1091 mut a := open_repo(machine('strip_held_a')!)!1092 defer {1093 a.close()1094 }1095 mut b := open_repo(machine('strip_held_b')!)!1096 defer {1097 b.close()1098 }1099 put(a.root, 'secrets.env', 'api_key=1\n')!1100 make_save(a, 'leak the key')!1101 offer_now(a, mut backend)!1102 join(b, mut backend)!1103 strip_local(b, 'secrets.env', '')!1104 put(a.root, 'keep.txt', 'fine\n')!1105 later := make_save(a, 'add keep')!1106 offer_now(a, mut backend)!1107 b.begin()!1108 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1109 b.end()!1110 paths := history.files_of(b, later.id)!.map(it.path)1111 assert 'secrets.env' !in paths1112 assert 'keep.txt' in paths1113}1114111511161117struct StripRace {1118mut:1119 inner remote.Backend1120 other &storage.Store1121 path string1122 done bool1123 1124 fail_after_race bool1125 raced_put bool1126 fail_delete bool1127 fail_match bool1128 1129 on_objects bool1130}11311132fn (mut r StripRace) get(key string) !remote.Object {1133 return r.inner.get(key)!1134}11351136fn (mut r StripRace) list(prefix string) ![]string {1137 return r.inner.list(prefix)!1138}11391140fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1141 if r.fail_after_race && r.raced_put {1142 return error('injected write failure')1143 }1144 if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1145 r.done = true1146 r.raced_put = true1147 strip_local(r.other, r.path, '')!1148 send_strip(r.other, mut r.inner, r.path)!1149 }1150 return r.inner.put_if_absent(key, body)!1151}11521153fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1154 if r.fail_match {1155 return error('injected rewrite failure')1156 }1157 return r.inner.put_if_match(key, body, tag)!1158}11591160fn (mut r StripRace) delete(key string) ! {1161 if r.fail_delete {1162 return error('injected delete failure')1163 }1164 r.inner.delete(key)!1165}11661167116811691170fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1171 base := scratch('strip_race')!1172 defer {1173 os.rmdir_all(base) or {}1174 }1175 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1176 mut a := open_repo(machine('strip_race_a')!)!1177 defer {1178 a.close()1179 }1180 mut b := open_repo(machine('strip_race_b')!)!1181 defer {1182 b.close()1183 }1184 put(a.root, 'secrets.env', 'api_key=1\n')!1185 first := make_save(a, 'leak the key')!1186 offer_now(a, mut backend)!1187 join(b, mut backend)!1188 put(a.root, 'secrets.env', 'api_key=2\n')!1189 later := make_save(a, 'rotate the key')!1190 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1191 mut race := StripRace{1192 inner: backend1193 other: &b1194 path: 'secrets.env'1195 }1196 if _ := offer(a, mut race) {1197 assert false, 'an offer that raced a strip must refuse'1198 } else {1199 assert err.msg().contains('koh steal'), err.msg()1200 }1201 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1202 assert !backend.get(object_key('proj', secret))!.found1203 1204 assert backend.get(save_key('proj', later.id))!.found1205 for key in backend.list('proj/saves')! {1206 record := decode_save(backend.get(key)!.body)!1207 assert 'secrets.env' !in record.files.map(it.path), record.id1208 }1209}1210121112121213fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1214 base := scratch('resend_race')!1215 defer {1216 os.rmdir_all(base) or {}1217 }1218 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1219 mut a := open_repo(machine('resend_race_a')!)!1220 defer {1221 a.close()1222 }1223 mut b := open_repo(machine('resend_race_b')!)!1224 defer {1225 b.close()1226 }1227 put(a.root, 'secrets.env', 'api_key=1\n')!1228 first := make_save(a, 'leak the key')!1229 offer_now(a, mut backend)!1230 join(b, mut backend)!1231 backend.delete(save_key('proj', first.id))!1232 mut race := StripRace{1233 inner: backend1234 other: &b1235 path: 'secrets.env'1236 }1237 if _ := resend(a, mut race, 'proj', [], [first.id]) {1238 assert false, 'a resend that raced a strip must refuse'1239 } else {1240 assert err.msg().contains('koh steal'), err.msg()1241 1242 assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1243 }1244 1245 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1246 assert 'secrets.env' !in record.files.map(it.path)1247 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1248}1249125012511252fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1253 base := scratch('resend_shared')!1254 defer {1255 os.rmdir_all(base) or {}1256 }1257 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1258 mut a := open_repo(machine('resend_shared_a')!)!1259 defer {1260 a.close()1261 }1262 mut b := open_repo(machine('resend_shared_b')!)!1263 defer {1264 b.close()1265 }1266 put(a.root, 'secrets.env', 'same\n')!1267 put(a.root, 'copy.txt', 'same\n')!1268 first := make_save(a, 'two paths, one object')!1269 offer_now(a, mut backend)!1270 join(b, mut backend)!1271 put(a.root, 'later.txt', 'later\n')!1272 second := make_save(a, 'not offered')!1273 shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1274 backend.delete(save_key('proj', first.id))!1275 backend.delete(shared)!1276 mut race := StripRace{1277 inner: backend1278 other: &b1279 path: 'secrets.env'1280 fail_after_race: true1281 }1282 if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1283 assert false, 'the injected failure should have surfaced'1284 } else {1285 assert err.msg().contains('injected'), err.msg()1286 }1287 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1288 paths := record.files.map(it.path)1289 assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1290 assert backend.get(shared)!.found1291}12921293129412951296fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1297 base := scratch('strip_note')!1298 defer {1299 os.rmdir_all(base) or {}1300 }1301 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1302 mut a := open_repo(machine('strip_note_a')!)!1303 defer {1304 a.close()1305 }1306 put(a.root, 'app', 'app\n')!1307 save := make_save(a, 'app')!1308 offer_now(a, mut backend)!1309 events.strip(a, '', 'other')!1310 for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1311 a.set_state(pending_key('app'), note)!1312 if _ := send_strip(a, mut backend, 'app') {1313 assert false, 'an untrusted note must be refused'1314 } else {1315 assert err.msg().contains('kept for recovery'), err.msg()1316 }1317 assert a.state(pending_key('app')) or { '' } == note1318 assert backend.list(events_prefix('proj'))!.len == 01319 assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1320 }1321 1322 events.strip(a, '', 'app')!1323 victim := os.join_path(base, 'remote', 'victim', 'head')1324 os.mkdir_all(os.dir(victim))!1325 os.write_file(victim, 'another repo')!1326 a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1327 if _ := send_strip(a, mut backend, 'app') {1328 assert false, 'a note naming a path outside the objects must be refused'1329 }1330 assert os.read_file(victim)! == 'another repo'1331 1332 a.remove_state(pending_key('app'))!1333 requeue_strip(a, 'app')!1334 backend.delete(save_key('proj', save.id))!1335 backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1336 id: save.id1337 files: [FileRecord{1338 path: 'app'1339 hash: '../../victim/head'1340 }]1341 }).bytes())!1342 send_strip(a, mut backend, 'app')!1343 assert os.read_file(victim)! == 'another repo'1344}13451346134713481349fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1350 base := scratch('settle_record')!1351 defer {1352 os.rmdir_all(base) or {}1353 }1354 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1355 mut a := open_repo(machine('settle_record_a')!)!1356 defer {1357 a.close()1358 }1359 mut b := open_repo(machine('settle_record_b')!)!1360 defer {1361 b.close()1362 }1363 mut c := open_repo(machine('settle_record_c')!)!1364 defer {1365 c.close()1366 }1367 put(a.root, 'readme.txt', 'hello\n')!1368 make_save(a, 'start')!1369 offer_now(a, mut backend)!1370 join(c, mut backend)!1371 put(a.root, 'secrets.env', 'api_key=1\n')!1372 leak := make_save(a, 'leak the key')!1373 offer_now(a, mut backend)!1374 join(b, mut backend)!1375 secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1376 backend.delete(save_key('proj', leak.id))!1377 backend.delete(secret)!1378 mut race := StripRace{1379 inner: backend1380 other: &b1381 path: 'secrets.env'1382 fail_match: true1383 }1384 if _ := resend(a, mut race, 'proj', [], [leak.id]) {1385 assert false, 'the injected rewrite failure should have surfaced'1386 }1387 assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1388 steal_now(c, mut backend)!1389 assert history.head(c) or { history.Save{} }.id == leak.id1390 assert strip_pending(a, 'secrets.env')1391 steal_now(a, mut backend)!1392 send_strip(a, mut backend, 'secrets.env')!1393 cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1394 assert 'secrets.env' !in cleaned.files.map(it.path)1395 assert !backend.get(secret)!.found1396}1397139813991400fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1401 base := scratch('settle_object')!1402 defer {1403 os.rmdir_all(base) or {}1404 }1405 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1406 mut a := open_repo(machine('settle_object_a')!)!1407 defer {1408 a.close()1409 }1410 mut b := open_repo(machine('settle_object_b')!)!1411 defer {1412 b.close()1413 }1414 put(a.root, 'secrets.env', 'api_key=1\n')!1415 make_save(a, 'leak the key')!1416 offer_now(a, mut backend)!1417 join(b, mut backend)!1418 hash := storage.hash_hex('api_key=1\n'.bytes())1419 backend.delete(object_key('proj', hash))!1420 mut race := StripRace{1421 inner: backend1422 other: &b1423 path: 'secrets.env'1424 fail_delete: true1425 on_objects: true1426 }1427 if _ := resend(a, mut race, 'proj', [hash], []) {1428 assert false, 'the injected delete failure should have surfaced'1429 }1430 assert backend.get(object_key('proj', hash))!.found1431 assert strip_pending(a, 'secrets.env')1432 steal_now(a, mut backend)!1433 send_strip(a, mut backend, 'secrets.env')!1434 assert !backend.get(object_key('proj', hash))!.found1435 assert !strip_pending(a, 'secrets.env')1436}1437143814391440fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1441 base := scratch('strip_found')!1442 defer {1443 os.rmdir_all(base) or {}1444 }1445 remote_dir := os.join_path(base, 'remote')1446 mut backend := remote.new_folder(remote_dir)!1447 mut a := open_repo(machine('strip_found_a')!)!1448 defer {1449 a.close()1450 }1451 put(a.root, 'secrets.env', 'api_key=1\n')!1452 make_save(a, 'leak the key')!1453 offer_now(a, mut backend)!1454 strip_local(a, 'secrets.env', '')!1455 1456 a.remove_state(pending_key('secrets.env'))!1457 requeue_strip(a, 'secrets.env')!1458 mut flaky := FaultyBackend{1459 inner: remote.new_folder(remote_dir)!1460 fail_delete: true1461 }1462 if _ := send_strip(a, mut flaky, 'secrets.env') {1463 assert false, 'the injected delete failure should have surfaced'1464 }1465 send_strip(a, mut backend, 'secrets.env')!1466 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1467 assert !strip_pending(a, 'secrets.env')1468}14691470147114721473struct MovingRecord {1474mut:1475 inner remote.Backend1476 other string1477 moved bool1478}14791480fn (mut m MovingRecord) get(key string) !remote.Object {1481 return m.inner.get(key)!1482}14831484fn (mut m MovingRecord) list(prefix string) ![]string {1485 return m.inner.list(prefix)!1486}14871488fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1489 return m.inner.put_if_absent(key, body)!1490}14911492fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1493 if !m.moved {1494 m.moved = true1495 current := m.inner.get(key)!1496 record := decode_save(current.body)!1497 other := record.files.filter(it.path != m.other)1498 m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1499 }1500 return m.inner.put_if_match(key, body, tag)!1501}15021503fn (mut m MovingRecord) delete(key string) ! {1504 m.inner.delete(key)!1505}15061507fn test_a_stripped_record_that_moves_is_read_again() {1508 base := scratch('moving_record')!1509 defer {1510 os.rmdir_all(base) or {}1511 }1512 mut folder := remote.new_folder(os.join_path(base, 'remote'))!1513 key := save_key('proj', 'aaaaaaaaaaaa')1514 folder.put_if_absent(key, encode_save(SaveRecord{1515 id: 'aaaaaaaaaaaa'1516 files: [FileRecord{1517 path: 'secret-a'1518 }, FileRecord{1519 path: 'secret-b'1520 }, FileRecord{1521 path: 'keep'1522 }]1523 }).bytes())!1524 mut moving := MovingRecord{1525 inner: folder1526 other: 'secret-b'1527 }1528 strip_record(mut moving, key, 'aaaaaaaaaaaa', [1529 events.Event{1530 kind: events.kind_strip1531 path: 'secret-a'1532 },1533 ])!1534 assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1535}153615371538fn test_an_invalid_remote_strip_event_is_not_unapplied() {1539 base := scratch('invalid_remote_strip')!1540 defer {1541 os.rmdir_all(base) or {}1542 }1543 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1544 mut a := open_repo(machine('invalid_remote_strip_a')!)!1545 defer {1546 a.close()1547 }1548 bad := events.Event{1549 id: 'badstripevnt'1550 kind: events.kind_strip1551 path: 'app'1552 created_at: -11553 machine: 'other'1554 }1555 backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1556 assert unapplied_strips(a, mut backend, 'proj')!.len == 01557}155815591560fn test_a_failed_resend_reports_what_landed() {1561 base := scratch('resend_partway')!1562 defer {1563 os.rmdir_all(base) or {}1564 }1565 remote_dir := os.join_path(base, 'remote')1566 mut a := open_repo(machine('resend_partway_a')!)!1567 defer {1568 a.close()1569 }1570 put(a.root, 'one.txt', 'one\n')!1571 put(a.root, 'two.txt', 'two\n')!1572 make_save(a, 'two objects')!1573 hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1574 mut flaky := FaultyBackend{1575 inner: remote.new_folder(remote_dir)!1576 fail_write: 21577 }1578 if _ := resend(a, mut flaky, 'proj', hashes, []) {1579 assert false, 'the injected failure should have surfaced'1580 } else {1581 assert err is Partway1582 assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1583 }1584}15851586158715881589fn test_an_interrupted_remote_strip_finishes_when_re_run() {1590 base := scratch('strip_resume')!1591 defer {1592 os.rmdir_all(base) or {}1593 }1594 remote_dir := os.join_path(base, 'remote')1595 mut backend := remote.new_folder(remote_dir)!1596 mut a := open_repo(machine('resume_a')!)!1597 defer {1598 a.close()1599 }1600 mut b := open_repo(machine('resume_b')!)!1601 defer {1602 b.close()1603 }1604 put(a.root, 'secrets.env', 'api_key=1\n')!1605 put(a.root, 'shared.env', 'same bytes on B\n')!1606 save := make_save(a, 'add secrets')!1607 offer_now(a, mut backend)!1608 join(b, mut backend)!1609 put(b.root, 'copy.txt', 'same bytes on B\n')!1610 b_save := make_save(b, 'B keeps a copy')!1611 offer_now(b, mut backend)!1612 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1613 secret_hash := storage.hash_hex('api_key=1\n'.bytes())16141615 strip_local(a, 'secrets.env', '')!1616 1617 1618 mut flaky := FaultyBackend{1619 inner: remote.new_folder(remote_dir)!1620 fail_write: 31621 }1622 if _ := send_strip(a, mut flaky, 'secrets.env') {1623 assert false, 'the injected failure should have surfaced'1624 }1625 assert strip_pending(a, 'secrets.env')1626 assert strip_local(a, 'secrets.env', '')!.files_removed == 01627 report := send_strip(a, mut backend, 'secrets.env')!1628 assert report.records == 11629 assert !strip_pending(a, 'secrets.env')1630 assert !backend.get(object_key('proj', secret_hash))!.found1631 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1632 assert 'secrets.env' !in record.files.map(it.path)1633 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1634 assert 'secrets.env' !in b_record.files.map(it.path)16351636 1637 strip_local(a, 'shared.env', '')!1638 send_strip(a, mut backend, 'shared.env')!1639 assert backend.get(object_key('proj', shared_hash))!.found1640}1641164216431644fn test_offer_waits_for_an_unapplied_strip() {1645 base := scratch('strip_first')!1646 defer {1647 os.rmdir_all(base) or {}1648 }1649 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1650 mut a := open_repo(machine('strip_first_a')!)!1651 defer {1652 a.close()1653 }1654 mut b := open_repo(machine('strip_first_b')!)!1655 defer {1656 b.close()1657 }1658 put(a.root, 'secrets.env', 'api_key=1\n')!1659 make_save(a, 'add secrets')!1660 offer_now(a, mut backend)!1661 join(b, mut backend)!1662 strip_local(a, 'secrets.env', '')!1663 send_strip(a, mut backend, 'secrets.env')!1664 put(b.root, 'more.txt', 'B keeps working\n')!1665 later := make_save(b, 'B saves with the secret still in its tree')!1666 if _ := offer_now(b, mut backend) {1667 assert false, 'B offered records that still list the stripped path'1668 }1669 steal_now(b, mut backend)!1670 offer_now(b, mut backend)!1671 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1672 assert 'secrets.env' !in record.files.map(it.path)1673}1674167516761677fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1678 base := scratch('strip_twice')!1679 defer {1680 os.rmdir_all(base) or {}1681 }1682 remote_dir := os.join_path(base, 'remote')1683 mut backend := remote.new_folder(remote_dir)!1684 mut a := open_repo(machine('strip_twice_a')!)!1685 defer {1686 a.close()1687 }1688 put(a.root, 'secrets.env', 'first key\n')!1689 make_save(a, 'first key')!1690 offer_now(a, mut backend)!1691 strip_local(a, 'secrets.env', '')!1692 mut flaky := FaultyBackend{1693 inner: remote.new_folder(remote_dir)!1694 fail_delete: true1695 }1696 if _ := send_strip(a, mut flaky, 'secrets.env') {1697 assert false, 'the injected delete failure should have surfaced'1698 }1699 put(a.root, 'secrets.env', 'second key\n')!1700 put(a.root, 'other.txt', 'more work\n')!1701 make_save(a, 'more work')!1702 offer_now(a, mut backend)!1703 send_strip(a, mut backend, 'secrets.env')!1704 for key in ['first key\n', 'second key\n'] {1705 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1706 }1707}170817091710fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1711 base := scratch('strip_scopes')!1712 defer {1713 os.rmdir_all(base) or {}1714 }1715 remote_dir := os.join_path(base, 'remote')1716 mut backend := remote.new_folder(remote_dir)!1717 mut a := open_repo(machine('strip_scopes_a')!)!1718 defer {1719 a.close()1720 }1721 put(a.root, 'secrets.env', 'first key\n')!1722 first := make_save(a, 'first key')!1723 put(a.root, 'secrets.env', 'second key\n')!1724 second := make_save(a, 'second key')!1725 offer_now(a, mut backend)!1726 strip_local(a, 'secrets.env', first.id)!1727 1728 mut flaky := FaultyBackend{1729 inner: remote.new_folder(remote_dir)!1730 fail_write: 21731 }1732 if _ := send_strip(a, mut flaky, 'secrets.env') {1733 assert false, 'the injected failure should have surfaced'1734 }1735 strip_local(a, 'secrets.env', second.id)!1736 send_strip(a, mut backend, 'secrets.env')!1737 assert !strip_pending(a, 'secrets.env')1738 for save in [first, second] {1739 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1740 assert 'secrets.env' !in record.files.map(it.path)1741 }1742 for key in ['first key\n', 'second key\n'] {1743 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1744 }1745}1746174717481749fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1750 base := scratch('strip_again')!1751 defer {1752 os.rmdir_all(base) or {}1753 }1754 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1755 mut a := open_repo(machine('strip_again_a')!)!1756 defer {1757 a.close()1758 }1759 mut b := open_repo(machine('strip_again_b')!)!1760 defer {1761 b.close()1762 }1763 put(a.root, 'secrets.env', 'first key\n')!1764 make_save(a, 'first key')!1765 offer_now(a, mut backend)!1766 join(b, mut backend)!1767 strip_local(a, 'secrets.env', '')!1768 send_strip(a, mut backend, 'secrets.env')!1769 steal_now(b, mut backend)!1770 for repo in [a, b] {1771 put(repo.root, 'secrets.env', 'second key\n')!1772 if _ := make_save(repo, 'second key') {1773 assert false, 'a banned path must not make a save'1774 }1775 }1776}1777177817791780fn test_a_stripped_path_is_not_asked_about() {1781 base := scratch('clash_strip')!1782 defer {1783 os.rmdir_all(base) or {}1784 }1785 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1786 mut a := open_repo(machine('clash_strip_a')!)!1787 defer {1788 a.close()1789 }1790 mut b := open_repo(machine('clash_strip_b')!)!1791 defer {1792 b.close()1793 }1794 put(a.root, 'secrets.env', 'key one\n')!1795 make_save(a, 'base')!1796 offer_now(a, mut backend)!1797 join(b, mut backend)!1798 put(a.root, 'secrets.env', 'key two\n')!1799 make_save(a, 'A rotates')!1800 offer_now(a, mut backend)!1801 strip_local(a, 'secrets.env', '')!1802 send_strip(a, mut backend, 'secrets.env')!1803 put(b.root, 'secrets.env', 'key three\n')!1804 mine := make_save(b, 'B rotates')!1805 report := steal(b, mut backend, StealOptions{})!1806 assert report.clashes.len == 01807 for save in history.all_saves(b)! {1808 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1809 }1810 assert history.by_id(b, mine.id) != none1811}1812181318141815fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1816 base := scratch('clash_base_strip')!1817 defer {1818 os.rmdir_all(base) or {}1819 }1820 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1821 mut a := open_repo(machine('base_strip_a')!)!1822 defer {1823 a.close()1824 }1825 mut b := open_repo(machine('base_strip_b')!)!1826 defer {1827 b.close()1828 }1829 put(a.root, 'notes.txt', 'shared\n')!1830 common := make_save(a, 'base')!1831 offer_now(a, mut backend)!1832 join(b, mut backend)!1833 put(a.root, 'other.txt', 'A moves on\n')!1834 make_save(a, 'A moves on')!1835 offer_now(a, mut backend)!1836 strip_local(a, 'notes.txt', common.id)!1837 send_strip(a, mut backend, 'notes.txt')!1838 put(b.root, 'notes.txt', 'B edits\n')!1839 make_save(b, 'B edits notes')!1840 if _ := steal(b, mut backend, StealOptions{}) {1841 assert false, 'with the base stripped, notes.txt differs on both sides'1842 } else {1843 assert err.msg().contains('notes.txt changed on both machines')1844 }1845 report := steal(b, mut backend, StealOptions{1846 mine: true1847 })!1848 assert report.clashes == ['notes.txt']1849 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1850}1851185218531854fn test_steal_into_a_folder_with_unsaved_files_refuses() {1855 base := scratch('steal_unsaved')!1856 defer {1857 os.rmdir_all(base) or {}1858 }1859 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1860 mut a := open_repo(machine('unsaved_a')!)!1861 defer {1862 a.close()1863 }1864 put(a.root, 'notes.txt', 'from A\n')!1865 make_save(a, 'A writes')!1866 offer_now(a, mut backend)!1867 b_root := machine('unsaved_b')!1868 mut b := storage.create(b_root)!1869 defer {1870 b.close()1871 }1872 put(b_root, 'notes.txt', 'never saved\n')!1873 1874 b.set_state('remote_repo', 'proj')!1875 if _ := steal(b, mut backend, StealOptions{}) {1876 assert false, 'steal overwrote a folder with unsaved files'1877 } else {1878 assert err.msg() == no_saves_here1879 }1880 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1881 1882 os.rm(os.join_path(b_root, 'notes.txt'))!1883 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1884 steal(b, mut backend, StealOptions{})!1885 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1886}1887188818891890fn test_steal_reports_incoming_flagged_saves() {1891 base := scratch('flag_warn')!1892 defer {1893 os.rmdir_all(base) or {}1894 }1895 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1896 mut a := open_repo(machine('flag_warn_a')!)!1897 defer {1898 a.close()1899 }1900 mut b := open_repo(machine('flag_warn_b')!)!1901 defer {1902 b.close()1903 }1904 put(a.root, 'a.txt', 'one\n')!1905 make_save(a, 'fine')!1906 offer_now(a, mut backend)!1907 join(b, mut backend)!1908 put(a.root, 'a.txt', 'two\n')!1909 suspect := make_save(a, 'suspect')!1910 put(a.root, 'a.txt', 'three\n')!1911 make_save(a, 'also fine')!1912 events.flag(a, suspect.id, 'broke the build')!1913 offer_now(a, mut backend)!1914 report := steal_now(b, mut backend)!1915 assert report.flagged.map(it.save_id) == [suspect.id]1916 assert report.flagged[0].reason == 'broke the build'1917}191819191920fn head_of(store &storage.Store) history.Save {1921 return history.head(store) or { panic('this repo has no head') }1922}1923192419251926fn test_the_first_offer_records_the_remote_format() {1927 base := scratch('format_write')!1928 defer {1929 os.rmdir_all(base) or {}1930 }1931 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1932 mut a := open_repo(machine('format_write_a')!)!1933 defer {1934 a.close()1935 }1936 put(a.root, 'a.txt', 'one\n')!1937 make_save(a, 'first')!1938 offer_now(a, mut backend)!1939 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1940}1941194219431944fn test_a_remote_without_a_format_still_syncs() {1945 base := scratch('format_old')!1946 defer {1947 os.rmdir_all(base) or {}1948 }1949 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1950 mut a := open_repo(machine('format_old_a')!)!1951 defer {1952 a.close()1953 }1954 put(a.root, 'a.txt', 'one\n')!1955 make_save(a, 'first')!1956 offer_now(a, mut backend)!1957 1958 backend.delete(format_key('proj'))!1959 mut b := open_repo(machine('format_old_b')!)!1960 defer {1961 b.close()1962 }1963 join(b, mut backend)!1964 assert head_of(b).id == head_of(a).id1965 put(a.root, 'a.txt', 'two\n')!1966 make_save(a, 'second')!1967 offer_now(a, mut backend)!1968 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1969 report := steal_now(b, mut backend)!1970 assert report.head == head_of(a).id1971}1972197319741975fn test_an_unknown_remote_format_is_refused() {1976 base := scratch('format_unknown')!1977 defer {1978 os.rmdir_all(base) or {}1979 }1980 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1981 mut a := open_repo(machine('format_bad_a')!)!1982 defer {1983 a.close()1984 }1985 put(a.root, 'a.txt', 'one\n')!1986 make_save(a, 'first')!1987 offer_now(a, mut backend)!1988 backend.delete(format_key('proj'))!1989 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1990 if _ := offer_now(a, mut backend) {1991 assert false, 'offer must refuse a format it does not speak'1992 } else {1993 assert err.msg().contains('koh-remote 2')1994 }1995 if _ := steal_now(a, mut backend) {1996 assert false, 'steal must refuse a format it does not speak'1997 } else {1998 assert err.msg().contains('koh-remote 2')1999 }2000 if _ := fetch_repo(mut backend, 'proj') {2001 assert false, 'a steal must refuse a format it does not speak'2002 } else {2003 assert err.msg().contains('koh-remote 2')2004 }2005}20062007200820092010fn test_a_lost_format_write_is_read_back_and_refused() {2011 base := scratch('format_race')!2012 defer {2013 os.rmdir_all(base) or {}2014 }2015 mut inner := remote.new_folder(os.join_path(base, 'remote'))!2016 mut a := open_repo(machine('format_race_a')!)!2017 defer {2018 a.close()2019 }2020 put(a.root, 'a.txt', 'one\n')!2021 make_save(a, 'first')!2022 mut backend := remote.Backend(FaultyBackend{2023 inner: inner2024 race_format: true2025 })2026 if _ := offer_now(a, mut backend) {2027 assert false, 'offer must refuse the format another machine wrote first'2028 } else {2029 assert err.msg().contains('koh-remote 2')2030 }2031}20322033fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2034 whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2035 for size in [1, 2, 5, whole.len] {2036 mut bundle := Bundle{}2037 for at := 0; at < whole.len; at += size {2038 bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2039 }2040 bundle.finish()!2041 assert bundle.fetched.repo_objects == {2042 'aa': 'abc'.bytes()2043 'bb': []u8{}2044 }2045 }2046 for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2047 'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2048 mut bundle := Bundle{}2049 bundle.feed(cut.bytes()) or { continue }2050 bundle.finish() or { continue }2051 assert false, cut2052 }2053}20542055fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2056 mut store := open_repo(machine('strip_batch')!)!2057 defer { store.close() }2058 put(store.root, 'private/a', 'a')!2059 put(store.root, 'secret', 'secret')!2060 put(store.root, 'keep', 'keep')!2061 save := make_save(store, 'initial')!2062 store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2063 WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2064 if _ := strip_local_batch(store, ['private', 'secret'], '') {2065 assert false, 'batch succeeded despite the second path failing'2066 }2067 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2068 'secret']2069 assert events.strip_events(store)!.len == 02070 assert !strip_pending(store, 'private')2071 store.db.exec('DROP TRIGGER block_secret')!2072 reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2073 assert reports.map(it.files_removed) == [1, 0, 1]2074 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2075 assert events.strip_events(store)!.len == 22076}20772078fn test_strip_batch_limits_every_path_to_selected_save() {2079 mut store := open_repo(machine('strip_batch_scope')!)!2080 defer { store.close() }2081 put(store.root, 'one', 'one')!2082 put(store.root, 'two', 'two')!2083 first := make_save(store, 'initial')!2084 put(store.root, 'keep', 'keep')!2085 second := make_save(store, 'next')!2086 strip_local_batch(store, ['one', 'two'], first.id)!2087 assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2088 assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2089 assert events.strip_events(store)!.all(it.save_id == first.id)2090}20912092fn test_strip_batch_keeps_independent_remote_retry_notes() {2093 mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2094 mut store := open_repo(machine('strip_batch_retry')!)!2095 defer { store.close() }2096 put(store.root, 'one', 'secret')!2097 put(store.root, 'two', 'secret')!2098 save := make_save(store, 'initial')!2099 offer_now(store, mut backend)!2100 strip_local_batch(store, ['one', 'two'], '')!2101 mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2102 if _ := send_strip(store, mut flaky, 'one') {2103 assert false, 'injected remote failure was not reported'2104 }2105 send_strip(store, mut backend, 'two')!2106 assert strip_pending(store, 'one') && !strip_pending(store, 'two')2107 partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2108 assert partial.files.map(it.path) == ['.kohignore', 'one']2109 assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2110 send_strip(store, mut backend, 'one')!2111 assert !strip_pending(store, 'one')2112 finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2113 assert finished.files.map(it.path) == ['.kohignore']2114 assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2115}