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