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_steal_refuses_invalid_remote_save_metadata() {924 base := scratch('invalid_steal_metadata')!925 defer { os.rmdir_all(base) or {} }926 mut backend := remote.new_folder(os.join_path(base, 'remote'))!927 mut receiver := open_repo(machine('invalid_steal_metadata_receiver')!)!928 defer { receiver.close() }929 for repo, record in {930 'negative-time': SaveRecord{ id: 'aaaaaaaaaaaa', created_at: -1 }931 'negative-size': SaveRecord{ id: 'bbbbbbbbbbbb', size_bytes: -1 }932 } {933 backend.put_if_absent(head_key(repo), record.id.bytes())!934 backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!935 if _ := fetch_back_to_known(receiver, mut backend, repo) {936 assert false, 'invalid remote save metadata must be refused'937 } else {938 assert err.msg().contains('invalid metadata'), err.msg()939 }940 }941}942943fn test_steal_binds_remote_records_to_their_keys_and_head() {944 base := scratch('steal_record_identity')!945 defer { os.rmdir_all(base) or {} }946 mut backend := remote.new_folder(os.join_path(base, 'remote'))!947 mut receiver := open_repo(machine('steal_record_identity_receiver')!)!948 defer { receiver.close() }949 backend.put_if_absent(head_key('mismatch'), 'aaaaaaaaaaaa'.bytes())!950 backend.put_if_absent(save_key('mismatch', 'aaaaaaaaaaaa'), encode_save(SaveRecord{951 id: 'bbbbbbbbbbbb'952 }).bytes())!953 if _ := fetch_back_to_known(receiver, mut backend, 'mismatch') {954 assert false, 'a save record under another ID must be refused'955 } else {956 assert err.msg().contains('disagrees with'), err.msg()957 }958 backend.put_if_absent(head_key('missing-head'), 'cccccccccccc'.bytes())!959 backend.put_if_absent(save_key('missing-head', 'dddddddddddd'), encode_save(SaveRecord{960 id: 'dddddddddddd'961 }).bytes())!962 if _ := fetch_repo(mut backend, 'missing-head') {963 assert false, 'a whole-repo steal without its declared head must be refused'964 } else {965 assert err.msg().contains('head'), err.msg()966 }967}968969fn test_steal_refuses_invalid_object_hashes_before_reading_their_keys() {970 base := scratch('steal_bad_hash')!971 defer { os.rmdir_all(base) or {} }972 remote_dir := os.join_path(base, 'remote')973 mut folder := remote.new_folder(remote_dir)!974 mut receiver := open_repo(machine('steal_bad_hash_receiver')!)!975 defer { receiver.close() }976 folder.put_if_absent(head_key('bad-hash'), 'aaaaaaaaaaaa'.bytes())!977 folder.put_if_absent(save_key('bad-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{978 id: 'aaaaaaaaaaaa'979 files: [FileRecord{ path: 'file', hash: '../../outside' }]980 }).bytes())!981 mut guarded := FaultyBackend{982 inner: remote.new_folder(remote_dir)!983 fail_read: 4984 }985 if _ := fetch_back_to_known(receiver, mut guarded, 'bad-hash') {986 assert false, 'an invalid object hash must be refused'987 } else {988 assert err.msg().contains('invalid object hash'), err.msg()989 }990}991992fn test_steal_drops_an_unsafe_path_before_reading_its_hash() {993 base := scratch('steal_unsafe_hash')!994 defer { os.rmdir_all(base) or {} }995 remote_dir := os.join_path(base, 'remote')996 mut folder := remote.new_folder(remote_dir)!997 mut receiver := open_repo(machine('steal_unsafe_hash_receiver')!)!998 defer { receiver.close() }999 folder.put_if_absent(head_key('unsafe-hash'), 'aaaaaaaaaaaa'.bytes())!1000 folder.put_if_absent(save_key('unsafe-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1001 id: 'aaaaaaaaaaaa'1002 files: [FileRecord{ path: '../outside', hash: '../../outside' }]1003 }).bytes())!1004 mut guarded := FaultyBackend{1005 inner: remote.new_folder(remote_dir)!1006 fail_read: 41007 }1008 fetched := fetch_back_to_known(receiver, mut guarded, 'unsafe-hash')!1009 assert fetched.saves[0].files.len == 01010 assert fetched.unsafe_paths == ['../outside']1011}10121013fn test_steal_refuses_invalid_save_references() {1014 base := scratch('steal_bad_references')!1015 defer { os.rmdir_all(base) or {} }1016 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1017 mut receiver := open_repo(machine('steal_bad_references_receiver')!)!1018 defer { receiver.close() }1019 for repo, record in {1020 'bad-parent': SaveRecord{ id: 'aaaaaaaaaaaa', parent: '../parent' }1021 'bad-restore': SaveRecord{ id: 'bbbbbbbbbbbb', restores: '../restore' }1022 'bad-replay': SaveRecord{ id: 'cccccccccccc', replayed_from: '../replay' }1023 } {1024 backend.put_if_absent(head_key(repo), record.id.bytes())!1025 backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!1026 if _ := fetch_back_to_known(receiver, mut backend, repo) {1027 assert false, 'invalid remote save references must be refused'1028 } else {1029 assert err.msg().contains('invalid save ID'), err.msg()1030 }1031 }1032}10331034fn test_steal_accepts_migrated_six_character_save_ids() {1035 base := scratch('steal_six_ids')!1036 defer { os.rmdir_all(base) or {} }1037 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1038 backend.put_if_absent(head_key('six'), 'bbbbbb'.bytes())!1039 backend.put_if_absent(save_key('six', 'aaaaaa'), encode_save(SaveRecord{1040 id: 'aaaaaa'1041 }).bytes())!1042 backend.put_if_absent(save_key('six', 'bbbbbb'), encode_save(SaveRecord{1043 id: 'bbbbbb'1044 parent: 'aaaaaa'1045 }).bytes())!1046 whole := fetch_repo(mut backend, 'six')!1047 assert whole.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1048 mut receiver := open_repo(machine('steal_six_ids_receiver')!)!1049 defer { receiver.close() }1050 incremental := fetch_back_to_known(receiver, mut backend, 'six')!1051 assert incremental.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1052}10531054fn test_steal_refuses_a_remote_parent_cycle() {1055 base := scratch('steal_parent_cycle')!1056 defer { os.rmdir_all(base) or {} }1057 remote_dir := os.join_path(base, 'remote')1058 mut folder := remote.new_folder(remote_dir)!1059 mut receiver := open_repo(machine('steal_parent_cycle_receiver')!)!1060 defer { receiver.close() }1061 folder.put_if_absent(head_key('cycle'), 'aaaaaaaaaaaa'.bytes())!1062 folder.put_if_absent(save_key('cycle', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1063 id: 'aaaaaaaaaaaa'1064 parent: 'bbbbbbbbbbbb'1065 }).bytes())!1066 folder.put_if_absent(save_key('cycle', 'bbbbbbbbbbbb'), encode_save(SaveRecord{1067 id: 'bbbbbbbbbbbb'1068 parent: 'aaaaaaaaaaaa'1069 }).bytes())!1070 mut guarded := FaultyBackend{1071 inner: remote.new_folder(remote_dir)!1072 fail_read: 51073 }1074 if _ := fetch_back_to_known(receiver, mut guarded, 'cycle') {1075 assert false, 'a remote parent cycle must be refused'1076 } else {1077 assert err.msg().contains('parent cycle'), err.msg()1078 }1079}10801081fn test_steal_binds_remote_events_to_their_keys() {1082 base := scratch('steal_event_identity')!1083 defer { os.rmdir_all(base) or {} }1084 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1085 mut receiver := open_repo(machine('steal_event_identity_receiver')!)!1086 defer { receiver.close() }1087 backend.put_if_absent(event_key('repo', 'aaaaaaaaaaaa'), encode_event(events.Event{1088 id: 'bbbbbbbbbbbb'1089 kind: events.kind_flag1090 save_id: 'cccccccccccc'1091 created_at: 11092 machine: 'other'1093 }).bytes())!1094 if _ := fetch_events(receiver, mut backend, 'repo') {1095 assert false, 'an event record under another ID must be refused'1096 } else {1097 assert err.msg().contains('disagrees with'), err.msg()1098 }1099}11001101fn test_an_incoming_strip_drops_only_objects_it_makes_unused() {1102 mut receiver := open_repo(machine('incoming_strip_objects')!)!1103 defer { receiver.close() }1104 recovery := receiver.put_object('recovery'.bytes())!1105 secret := receiver.put_object('secret'.bytes())!1106 blob := receiver.object_blob(secret)!1107 write_fetched(receiver, Fetched{1108 repo_objects: {1109 secret: blob1110 }1111 saves: [SaveRecord{1112 id: 'aaaaaaaaaaaa'1113 files: [FileRecord{ path: 'secret', hash: secret }]1114 }]1115 events_out: [events.Event{1116 id: 'bbbbbbbbbbbb'1117 kind: events.kind_strip1118 path: 'secret'1119 created_at: 11120 machine: 'other'1121 }]1122 })!1123 assert !receiver.has_object(secret)!1124 assert receiver.has_object(recovery)!1125}11261127fn test_an_incoming_strip_keeps_a_matching_preexisting_orphan() {1128 mut receiver := open_repo(machine('incoming_strip_matching_orphan')!)!1129 defer { receiver.close() }1130 orphan := receiver.put_object('same bytes'.bytes())!1131 write_fetched(receiver, Fetched{1132 saves: [SaveRecord{1133 id: 'aaaaaaaaaaaa'1134 files: [FileRecord{ path: 'secret', hash: orphan }]1135 }]1136 events_out: [events.Event{1137 id: 'bbbbbbbbbbbb'1138 kind: events.kind_strip1139 path: 'secret'1140 created_at: 11141 machine: 'other'1142 }]1143 })!1144 assert receiver.has_object(orphan)!1145}11461147fn test_an_interrupted_strip_is_idempotent() {1148 1149 1150 1151 base := scratch('fault_strip')!1152 defer {1153 os.rmdir_all(base) or {}1154 }1155 mut a := open_repo(machine('fault_strip_a')!)!1156 defer {1157 a.close()1158 }1159 put(a.root, 'secret.txt', 'shh\n')!1160 put(a.root, 'keep.txt', 'fine\n')!1161 save := make_save(a, 'add both')!11621163 for _ in 0 .. 2 {1164 strip_local(a, 'secret.txt', '')!1165 }1166 files := history.files_of(a, save.id)!1167 1168 1169 assert files.map(it.path) == ['.kohignore', 'keep.txt']1170 assert events.strip_events(a)!.len == 11171}117211731174117511761177fn test_a_strip_matches_the_path_exactly() {1178 mut a := open_repo(machine('strip_exact')!)!1179 defer {1180 a.close()1181 }1182 hash := a.put_object('x\n'.bytes())!1183 mut files := []snapshot.FileEntry{}1184 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {1185 files << snapshot.FileEntry{1186 path: path1187 hash: hash1188 }1189 }1190 save := history.save_tree(a, '', files, history.SaveInput{1191 message: 'add all'1192 })!1193 assert strip_scope(a, 'docs', '')!.files == 11194 strip_local(a, 'docs', '')!1195 strip_local(a, 'a_b', '')!1196 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']1197}11981199fn test_a_strip_reaches_the_remote_and_other_machines() {1200 base := scratch('strip_sync')!1201 defer {1202 os.rmdir_all(base) or {}1203 }1204 mut a := open_repo(machine('strip_a')!)!1205 defer {1206 a.close()1207 }1208 mut b := open_repo(machine('strip_b')!)!1209 defer {1210 b.close()1211 }1212 put(a.root, 'secrets.env', 'api_key=1\n')!1213 put(a.root, 'keep.txt', 'fine\n')!1214 save := make_save(a, 'add both')!1215 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1216 offer_now(a, mut backend)!1217 join(b, mut backend)!1218 assert b.object_count()! > 012191220 1221 1222 mut secret_hash := ''1223 for file in history.files_of(a, save.id)! {1224 if file.path == 'secrets.env' {1225 secret_hash = file.hash1226 }1227 }1228 assert secret_hash != ''1229 strip_local(a, 'secrets.env', '')!1230 report := send_strip(a, mut backend, 'secrets.env')!1231 assert report.event_sent1232 assert report.records == 112331234 1235 1236 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1237 for file in record.files {1238 assert file.path != 'secrets.env'1239 }1240 1241 1242 assert !backend.get(object_key('proj', secret_hash))!.found1243 assert backend.list('proj/objects')!.len == 212441245 1246 b.begin()!1247 fetched := fetch_back_to_known(b, mut backend, 'proj')!1248 write_fetched(b, fetched)!1249 b.end()!1250 mut paths := []string{}1251 for file in history.files_of(b, save.id)! {1252 paths << file.path1253 }1254 assert 'secrets.env' !in paths1255 assert 'keep.txt' in paths12561257 1258 1259 1260 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1261 put(b.root, 'secrets.env', 'rotated\n')!1262 assert !dirty(b, history.head(b))!1263 put(b.root, 'keep.txt', 'still fine\n')!1264 later := make_save(b, 'edit keep')!1265 offer_now(b, mut backend)!1266 assert backend.list('proj/events')!.len == 11267 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1268}12691270127112721273fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1274 base := scratch('strip_later')!1275 defer {1276 os.rmdir_all(base) or {}1277 }1278 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1279 mut a := open_repo(machine('strip_later_a')!)!1280 defer {1281 a.close()1282 }1283 mut b := open_repo(machine('strip_later_b')!)!1284 defer {1285 b.close()1286 }1287 put(a.root, 'secrets.env', 'api_key=1\n')!1288 old := make_save(a, 'leak the key')!1289 offer_now(a, mut backend)!1290 join(b, mut backend)!1291 strip_local(a, 'secrets.env', '')!1292 send_strip(a, mut backend, 'secrets.env')!1293 1294 put(b.root, 'secrets.env', 'api_key=2\n')!1295 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1296 message: 'rotate the key'1297 created_at: events.strip_events(a)!.last().created_at + 36001298 })!1299 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1300 b.begin()!1301 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1302 b.end()!1303 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1304 offer_now(b, mut backend)!13051306 mut c := open_repo(machine('strip_later_c')!)!1307 defer {1308 c.close()1309 }1310 join(c, mut backend)!1311 for save in [old.id, later.id] {1312 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1313 }1314 assert !os.exists(os.join_path(c.root, 'secrets.env'))1315}1316131713181319fn test_a_held_strip_reaches_saves_arriving_after_it() {1320 base := scratch('strip_held')!1321 defer {1322 os.rmdir_all(base) or {}1323 }1324 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1325 mut a := open_repo(machine('strip_held_a')!)!1326 defer {1327 a.close()1328 }1329 mut b := open_repo(machine('strip_held_b')!)!1330 defer {1331 b.close()1332 }1333 put(a.root, 'secrets.env', 'api_key=1\n')!1334 make_save(a, 'leak the key')!1335 offer_now(a, mut backend)!1336 join(b, mut backend)!1337 strip_local(b, 'secrets.env', '')!1338 put(a.root, 'keep.txt', 'fine\n')!1339 later := make_save(a, 'add keep')!1340 offer_now(a, mut backend)!1341 b.begin()!1342 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1343 b.end()!1344 paths := history.files_of(b, later.id)!.map(it.path)1345 assert 'secrets.env' !in paths1346 assert 'keep.txt' in paths1347}1348134913501351struct StripRace {1352mut:1353 inner remote.Backend1354 other &storage.Store1355 path string1356 done bool1357 1358 fail_after_race bool1359 raced_put bool1360 fail_delete bool1361 fail_match bool1362 1363 on_objects bool1364 1365 fail_events_after_race bool1366}13671368fn (mut r StripRace) get(key string) !remote.Object {1369 return r.inner.get(key)!1370}13711372fn (mut r StripRace) list(prefix string) ![]string {1373 if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1374 return error('injected list failure')1375 }1376 return r.inner.list(prefix)!1377}13781379fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1380 if r.fail_after_race && r.raced_put {1381 return error('injected write failure')1382 }1383 if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1384 r.done = true1385 r.raced_put = true1386 strip_local(r.other, r.path, '')!1387 send_strip(r.other, mut r.inner, r.path)!1388 }1389 return r.inner.put_if_absent(key, body)!1390}13911392fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1393 if r.fail_match {1394 return error('injected rewrite failure')1395 }1396 return r.inner.put_if_match(key, body, tag)!1397}13981399fn (mut r StripRace) delete(key string) ! {1400 if r.fail_delete {1401 return error('injected delete failure')1402 }1403 r.inner.delete(key)!1404}14051406140714081409fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1410 base := scratch('strip_race')!1411 defer {1412 os.rmdir_all(base) or {}1413 }1414 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1415 mut a := open_repo(machine('strip_race_a')!)!1416 defer {1417 a.close()1418 }1419 mut b := open_repo(machine('strip_race_b')!)!1420 defer {1421 b.close()1422 }1423 put(a.root, 'secrets.env', 'api_key=1\n')!1424 first := make_save(a, 'leak the key')!1425 offer_now(a, mut backend)!1426 join(b, mut backend)!1427 put(a.root, 'secrets.env', 'api_key=2\n')!1428 later := make_save(a, 'rotate the key')!1429 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1430 mut race := StripRace{1431 inner: backend1432 other: &b1433 path: 'secrets.env'1434 }1435 if _ := offer(a, mut race) {1436 assert false, 'an offer that raced a strip must refuse'1437 } else {1438 assert err.msg().contains('koh steal'), err.msg()1439 }1440 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1441 assert !backend.get(object_key('proj', secret))!.found1442 1443 assert backend.get(save_key('proj', later.id))!.found1444 for key in backend.list('proj/saves')! {1445 record := decode_save(backend.get(key)!.body)!1446 assert 'secrets.env' !in record.files.map(it.path), record.id1447 }1448}1449145014511452fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1453 base := scratch('resend_race')!1454 defer {1455 os.rmdir_all(base) or {}1456 }1457 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1458 mut a := open_repo(machine('resend_race_a')!)!1459 defer {1460 a.close()1461 }1462 mut b := open_repo(machine('resend_race_b')!)!1463 defer {1464 b.close()1465 }1466 put(a.root, 'secrets.env', 'api_key=1\n')!1467 first := make_save(a, 'leak the key')!1468 offer_now(a, mut backend)!1469 join(b, mut backend)!1470 backend.delete(save_key('proj', first.id))!1471 mut race := StripRace{1472 inner: backend1473 other: &b1474 path: 'secrets.env'1475 }1476 if _ := resend(a, mut race, 'proj', [], [first.id]) {1477 assert false, 'a resend that raced a strip must refuse'1478 } else {1479 assert err.msg().contains('koh steal'), err.msg()1480 1481 assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1482 }1483 1484 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1485 assert 'secrets.env' !in record.files.map(it.path)1486 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1487}1488148914901491fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1492 base := scratch('resend_shared')!1493 defer {1494 os.rmdir_all(base) or {}1495 }1496 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1497 mut a := open_repo(machine('resend_shared_a')!)!1498 defer {1499 a.close()1500 }1501 mut b := open_repo(machine('resend_shared_b')!)!1502 defer {1503 b.close()1504 }1505 put(a.root, 'secrets.env', 'same\n')!1506 put(a.root, 'copy.txt', 'same\n')!1507 first := make_save(a, 'two paths, one object')!1508 offer_now(a, mut backend)!1509 join(b, mut backend)!1510 put(a.root, 'later.txt', 'later\n')!1511 second := make_save(a, 'not offered')!1512 shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1513 backend.delete(save_key('proj', first.id))!1514 backend.delete(shared)!1515 mut race := StripRace{1516 inner: backend1517 other: &b1518 path: 'secrets.env'1519 fail_after_race: true1520 }1521 if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1522 assert false, 'the injected failure should have surfaced'1523 } else {1524 assert err.msg().contains('injected'), err.msg()1525 }1526 record := decode_save(backend.get(save_key('proj', first.id))!.body)!1527 paths := record.files.map(it.path)1528 assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1529 assert backend.get(shared)!.found1530}15311532153315341535fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1536 base := scratch('strip_note')!1537 defer {1538 os.rmdir_all(base) or {}1539 }1540 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1541 mut a := open_repo(machine('strip_note_a')!)!1542 defer {1543 a.close()1544 }1545 put(a.root, 'app', 'app\n')!1546 save := make_save(a, 'app')!1547 offer_now(a, mut backend)!1548 events.strip(a, '', 'other')!1549 for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1550 a.set_state(pending_key('app'), note)!1551 if _ := send_strip(a, mut backend, 'app') {1552 assert false, 'an untrusted note must be refused'1553 } else {1554 assert err.msg().contains('kept for recovery'), err.msg()1555 }1556 assert a.state(pending_key('app')) or { '' } == note1557 assert backend.list(events_prefix('proj'))!.len == 01558 assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1559 }1560 1561 events.strip(a, '', 'app')!1562 victim := os.join_path(base, 'remote', 'victim', 'head')1563 os.mkdir_all(os.dir(victim))!1564 os.write_file(victim, 'another repo')!1565 a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1566 if _ := send_strip(a, mut backend, 'app') {1567 assert false, 'a note naming a path outside the objects must be refused'1568 }1569 assert os.read_file(victim)! == 'another repo'1570 1571 a.remove_state(pending_key('app'))!1572 requeue_strip(a, 'app')!1573 backend.delete(save_key('proj', save.id))!1574 backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1575 id: save.id1576 files: [FileRecord{1577 path: 'app'1578 hash: '../../victim/head'1579 }]1580 }).bytes())!1581 send_strip(a, mut backend, 'app')!1582 assert os.read_file(victim)! == 'another repo'1583}15841585158615871588fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1589 base := scratch('settle_record')!1590 defer {1591 os.rmdir_all(base) or {}1592 }1593 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1594 mut a := open_repo(machine('settle_record_a')!)!1595 defer {1596 a.close()1597 }1598 mut b := open_repo(machine('settle_record_b')!)!1599 defer {1600 b.close()1601 }1602 mut c := open_repo(machine('settle_record_c')!)!1603 defer {1604 c.close()1605 }1606 put(a.root, 'readme.txt', 'hello\n')!1607 make_save(a, 'start')!1608 offer_now(a, mut backend)!1609 join(c, mut backend)!1610 put(a.root, 'secrets.env', 'api_key=1\n')!1611 leak := make_save(a, 'leak the key')!1612 offer_now(a, mut backend)!1613 join(b, mut backend)!1614 secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1615 backend.delete(save_key('proj', leak.id))!1616 backend.delete(secret)!1617 mut race := StripRace{1618 inner: backend1619 other: &b1620 path: 'secrets.env'1621 fail_match: true1622 }1623 if _ := resend(a, mut race, 'proj', [], [leak.id]) {1624 assert false, 'the injected rewrite failure should have surfaced'1625 }1626 assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1627 steal_now(c, mut backend)!1628 assert history.head(c) or { history.Save{} }.id == leak.id1629 assert strip_pending(a, 'secrets.env')1630 steal_now(a, mut backend)!1631 send_strip(a, mut backend, 'secrets.env')!1632 cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1633 assert 'secrets.env' !in cleaned.files.map(it.path)1634 assert !backend.get(secret)!.found1635}1636163716381639fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1640 base := scratch('settle_object')!1641 defer {1642 os.rmdir_all(base) or {}1643 }1644 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1645 mut a := open_repo(machine('settle_object_a')!)!1646 defer {1647 a.close()1648 }1649 mut b := open_repo(machine('settle_object_b')!)!1650 defer {1651 b.close()1652 }1653 put(a.root, 'secrets.env', 'api_key=1\n')!1654 make_save(a, 'leak the key')!1655 offer_now(a, mut backend)!1656 join(b, mut backend)!1657 hash := storage.hash_hex('api_key=1\n'.bytes())1658 backend.delete(object_key('proj', hash))!1659 mut race := StripRace{1660 inner: backend1661 other: &b1662 path: 'secrets.env'1663 fail_delete: true1664 on_objects: true1665 }1666 if _ := resend(a, mut race, 'proj', [hash], []) {1667 assert false, 'the injected delete failure should have surfaced'1668 }1669 assert backend.get(object_key('proj', hash))!.found1670 assert strip_pending(a, 'secrets.env')1671 steal_now(a, mut backend)!1672 send_strip(a, mut backend, 'secrets.env')!1673 assert !backend.get(object_key('proj', hash))!.found1674 assert !strip_pending(a, 'secrets.env')1675}1676167716781679fn test_an_unsettled_upload_is_finished_after_steal() {1680 base := scratch('unsettled')!1681 defer {1682 os.rmdir_all(base) or {}1683 }1684 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1685 mut a := open_repo(machine('unsettled_a')!)!1686 defer {1687 a.close()1688 }1689 mut b := open_repo(machine('unsettled_b')!)!1690 defer {1691 b.close()1692 }1693 put(a.root, 'secrets.env', 'api_key=1\n')!1694 make_save(a, 'leak the key')!1695 offer_now(a, mut backend)!1696 join(b, mut backend)!1697 hash := storage.hash_hex('api_key=1\n'.bytes())1698 backend.delete(object_key('proj', hash))!1699 mut race := StripRace{1700 inner: backend1701 other: &b1702 path: 'secrets.env'1703 on_objects: true1704 fail_events_after_race: true1705 }1706 if _ := resend(a, mut race, 'proj', [hash], []) {1707 assert false, 'the injected list failure should have surfaced'1708 }1709 assert unsettled(a)! == [hash]1710 if _ := settle_unsettled(a, mut backend, 'proj') {1711 assert false, 'settling before the strip is here would keep its bytes'1712 }1713 steal_now(a, mut backend)!1714 settle_unsettled(a, mut backend, 'proj')!1715 assert !backend.get(object_key('proj', hash))!.found1716 assert unsettled(a)!.len == 01717}1718171917201721fn test_settling_an_upload_keeps_named_objects_and_reports_deletions() {1722 base := scratch('settle_named')!1723 defer {1724 os.rmdir_all(base) or {}1725 }1726 remote_dir := os.join_path(base, 'remote')1727 mut backend := remote.new_folder(remote_dir)!1728 mut a := open_repo(machine('settle_named_a')!)!1729 defer {1730 a.close()1731 }1732 put(a.root, 'app', 'app\n')!1733 save := make_save(a, 'app')!1734 offer_now(a, mut backend)!1735 app := storage.hash_hex('app\n'.bytes())1736 gone := ['gone one', 'gone two'].map(storage.hash_hex(it.bytes()))1737 for hash in gone {1738 backend.put_if_absent(object_key('proj', hash), 'stripped'.bytes())!1739 }1740 backend.delete(save_key('proj', save.id))!1741 a.db.exec('PRAGMA foreign_keys=OFF')!1742 a.db.exec("DELETE FROM objects WHERE hash = '${app}'")!1743 a.set_state(unsettled_key, json2.encode([app, gone[0], gone[1]]))!1744 mut flaky := FaultyBackend{1745 inner: remote.new_folder(remote_dir)!1746 fail_delete_after: 11747 }1748 if _ := settle_unsettled(a, mut flaky, 'proj') {1749 assert false, 'the injected delete failure should have surfaced'1750 } else {1751 assert (err as Partway).done == 'deleted 1 remote object', (err as Partway).done1752 }1753 assert backend.get(object_key('proj', app))!.found1754 assert unsettled(a)!.len == 31755}175617571758fn test_a_resend_reports_a_damaged_copy_it_deleted() {1759 base := scratch('resend_deleted')!1760 defer {1761 os.rmdir_all(base) or {}1762 }1763 remote_dir := os.join_path(base, 'remote')1764 mut backend := remote.new_folder(remote_dir)!1765 mut a := open_repo(machine('resend_deleted_a')!)!1766 defer {1767 a.close()1768 }1769 put(a.root, 'app', 'app\n')!1770 make_save(a, 'app')!1771 hash := storage.hash_hex('app\n'.bytes())1772 backend.put_if_absent(object_key('proj', hash), 'damaged'.bytes())!1773 mut flaky := FaultyBackend{1774 inner: remote.new_folder(remote_dir)!1775 fail_write: 11776 }1777 if _ := resend(a, mut flaky, 'proj', [hash], []) {1778 assert false, 'the injected write failure should have surfaced'1779 } else {1780 assert (err as Partway).done == 'deleted 1 damaged remote object', (err as Partway).done1781 }1782}178317841785fn test_a_strip_that_fails_after_its_event_reports_it() {1786 base := scratch('strip_event_partway')!1787 defer {1788 os.rmdir_all(base) or {}1789 }1790 remote_dir := os.join_path(base, 'remote')1791 mut backend := remote.new_folder(remote_dir)!1792 mut a := open_repo(machine('strip_event_partway_a')!)!1793 defer {1794 a.close()1795 }1796 put(a.root, 'secrets.env', 'api_key=1\n')!1797 make_save(a, 'leak the key')!1798 offer_now(a, mut backend)!1799 strip_local(a, 'secrets.env', '')!1800 mut flaky := FaultyBackend{1801 inner: remote.new_folder(remote_dir)!1802 fail_list: '/saves'1803 }1804 if _ := send_strip(a, mut flaky, 'secrets.env') {1805 assert false, 'the injected list failure should have surfaced'1806 } else {1807 assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1808 }1809}1810181118121813fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1814 base := scratch('strip_found')!1815 defer {1816 os.rmdir_all(base) or {}1817 }1818 remote_dir := os.join_path(base, 'remote')1819 mut backend := remote.new_folder(remote_dir)!1820 mut a := open_repo(machine('strip_found_a')!)!1821 defer {1822 a.close()1823 }1824 put(a.root, 'secrets.env', 'api_key=1\n')!1825 make_save(a, 'leak the key')!1826 offer_now(a, mut backend)!1827 strip_local(a, 'secrets.env', '')!1828 1829 a.remove_state(pending_key('secrets.env'))!1830 requeue_strip(a, 'secrets.env')!1831 mut flaky := FaultyBackend{1832 inner: remote.new_folder(remote_dir)!1833 fail_delete: true1834 }1835 if _ := send_strip(a, mut flaky, 'secrets.env') {1836 assert false, 'the injected delete failure should have surfaced'1837 }1838 send_strip(a, mut backend, 'secrets.env')!1839 assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1840 assert !strip_pending(a, 'secrets.env')1841}18421843184418451846struct MovingRecord {1847mut:1848 inner remote.Backend1849 other string1850 moved bool1851}18521853fn (mut m MovingRecord) get(key string) !remote.Object {1854 return m.inner.get(key)!1855}18561857fn (mut m MovingRecord) list(prefix string) ![]string {1858 return m.inner.list(prefix)!1859}18601861fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1862 return m.inner.put_if_absent(key, body)!1863}18641865fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1866 if !m.moved {1867 m.moved = true1868 current := m.inner.get(key)!1869 record := decode_save(current.body)!1870 other := record.files.filter(it.path != m.other)1871 m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1872 }1873 return m.inner.put_if_match(key, body, tag)!1874}18751876fn (mut m MovingRecord) delete(key string) ! {1877 m.inner.delete(key)!1878}18791880fn test_a_stripped_record_that_moves_is_read_again() {1881 base := scratch('moving_record')!1882 defer {1883 os.rmdir_all(base) or {}1884 }1885 mut folder := remote.new_folder(os.join_path(base, 'remote'))!1886 key := save_key('proj', 'aaaaaaaaaaaa')1887 folder.put_if_absent(key, encode_save(SaveRecord{1888 id: 'aaaaaaaaaaaa'1889 files: [FileRecord{1890 path: 'secret-a'1891 }, FileRecord{1892 path: 'secret-b'1893 }, FileRecord{1894 path: 'keep'1895 }]1896 }).bytes())!1897 mut moving := MovingRecord{1898 inner: folder1899 other: 'secret-b'1900 }1901 strip_record(mut moving, key, 'aaaaaaaaaaaa', [1902 events.Event{1903 kind: events.kind_strip1904 path: 'secret-a'1905 },1906 ])!1907 assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1908}190919101911fn test_an_invalid_remote_strip_event_is_not_unapplied() {1912 base := scratch('invalid_remote_strip')!1913 defer {1914 os.rmdir_all(base) or {}1915 }1916 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1917 mut a := open_repo(machine('invalid_remote_strip_a')!)!1918 defer {1919 a.close()1920 }1921 bad := events.Event{1922 id: 'badstripevnt'1923 kind: events.kind_strip1924 path: 'app'1925 created_at: -11926 machine: 'other'1927 }1928 backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1929 assert unapplied_strips(a, mut backend, 'proj')!.len == 01930}193119321933fn test_a_failed_resend_reports_what_landed() {1934 base := scratch('resend_partway')!1935 defer {1936 os.rmdir_all(base) or {}1937 }1938 remote_dir := os.join_path(base, 'remote')1939 mut a := open_repo(machine('resend_partway_a')!)!1940 defer {1941 a.close()1942 }1943 put(a.root, 'one.txt', 'one\n')!1944 put(a.root, 'two.txt', 'two\n')!1945 make_save(a, 'two objects')!1946 hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1947 mut flaky := FaultyBackend{1948 inner: remote.new_folder(remote_dir)!1949 fail_write: 21950 }1951 if _ := resend(a, mut flaky, 'proj', hashes, []) {1952 assert false, 'the injected failure should have surfaced'1953 } else {1954 assert err is Partway1955 assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1956 }1957}19581959196019611962fn test_an_interrupted_remote_strip_finishes_when_re_run() {1963 base := scratch('strip_resume')!1964 defer {1965 os.rmdir_all(base) or {}1966 }1967 remote_dir := os.join_path(base, 'remote')1968 mut backend := remote.new_folder(remote_dir)!1969 mut a := open_repo(machine('resume_a')!)!1970 defer {1971 a.close()1972 }1973 mut b := open_repo(machine('resume_b')!)!1974 defer {1975 b.close()1976 }1977 put(a.root, 'secrets.env', 'api_key=1\n')!1978 put(a.root, 'shared.env', 'same bytes on B\n')!1979 save := make_save(a, 'add secrets')!1980 offer_now(a, mut backend)!1981 join(b, mut backend)!1982 put(b.root, 'copy.txt', 'same bytes on B\n')!1983 b_save := make_save(b, 'B keeps a copy')!1984 offer_now(b, mut backend)!1985 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1986 secret_hash := storage.hash_hex('api_key=1\n'.bytes())19871988 strip_local(a, 'secrets.env', '')!1989 1990 1991 mut flaky := FaultyBackend{1992 inner: remote.new_folder(remote_dir)!1993 fail_write: 31994 }1995 if _ := send_strip(a, mut flaky, 'secrets.env') {1996 assert false, 'the injected failure should have surfaced'1997 }1998 assert strip_pending(a, 'secrets.env')1999 assert strip_local(a, 'secrets.env', '')!.files_removed == 02000 report := send_strip(a, mut backend, 'secrets.env')!2001 assert report.records == 12002 assert !strip_pending(a, 'secrets.env')2003 assert !backend.get(object_key('proj', secret_hash))!.found2004 record := decode_save(backend.get(save_key('proj', save.id))!.body)!2005 assert 'secrets.env' !in record.files.map(it.path)2006 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!2007 assert 'secrets.env' !in b_record.files.map(it.path)20082009 2010 strip_local(a, 'shared.env', '')!2011 send_strip(a, mut backend, 'shared.env')!2012 assert backend.get(object_key('proj', shared_hash))!.found2013}2014201520162017fn test_offer_waits_for_an_unapplied_strip() {2018 base := scratch('strip_first')!2019 defer {2020 os.rmdir_all(base) or {}2021 }2022 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2023 mut a := open_repo(machine('strip_first_a')!)!2024 defer {2025 a.close()2026 }2027 mut b := open_repo(machine('strip_first_b')!)!2028 defer {2029 b.close()2030 }2031 put(a.root, 'secrets.env', 'api_key=1\n')!2032 make_save(a, 'add secrets')!2033 offer_now(a, mut backend)!2034 join(b, mut backend)!2035 strip_local(a, 'secrets.env', '')!2036 send_strip(a, mut backend, 'secrets.env')!2037 put(b.root, 'more.txt', 'B keeps working\n')!2038 later := make_save(b, 'B saves with the secret still in its tree')!2039 if _ := offer_now(b, mut backend) {2040 assert false, 'B offered records that still list the stripped path'2041 }2042 steal_now(b, mut backend)!2043 offer_now(b, mut backend)!2044 record := decode_save(backend.get(save_key('proj', later.id))!.body)!2045 assert 'secrets.env' !in record.files.map(it.path)2046}2047204820492050fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {2051 base := scratch('strip_twice')!2052 defer {2053 os.rmdir_all(base) or {}2054 }2055 remote_dir := os.join_path(base, 'remote')2056 mut backend := remote.new_folder(remote_dir)!2057 mut a := open_repo(machine('strip_twice_a')!)!2058 defer {2059 a.close()2060 }2061 put(a.root, 'secrets.env', 'first key\n')!2062 make_save(a, 'first key')!2063 offer_now(a, mut backend)!2064 strip_local(a, 'secrets.env', '')!2065 mut flaky := FaultyBackend{2066 inner: remote.new_folder(remote_dir)!2067 fail_delete: true2068 }2069 if _ := send_strip(a, mut flaky, 'secrets.env') {2070 assert false, 'the injected delete failure should have surfaced'2071 }2072 put(a.root, 'secrets.env', 'second key\n')!2073 put(a.root, 'other.txt', 'more work\n')!2074 make_save(a, 'more work')!2075 offer_now(a, mut backend)!2076 send_strip(a, mut backend, 'secrets.env')!2077 for key in ['first key\n', 'second key\n'] {2078 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found2079 }2080}208120822083fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {2084 base := scratch('strip_scopes')!2085 defer {2086 os.rmdir_all(base) or {}2087 }2088 remote_dir := os.join_path(base, 'remote')2089 mut backend := remote.new_folder(remote_dir)!2090 mut a := open_repo(machine('strip_scopes_a')!)!2091 defer {2092 a.close()2093 }2094 put(a.root, 'secrets.env', 'first key\n')!2095 first := make_save(a, 'first key')!2096 put(a.root, 'secrets.env', 'second key\n')!2097 second := make_save(a, 'second key')!2098 offer_now(a, mut backend)!2099 strip_local(a, 'secrets.env', first.id)!2100 2101 mut flaky := FaultyBackend{2102 inner: remote.new_folder(remote_dir)!2103 fail_write: 22104 }2105 if _ := send_strip(a, mut flaky, 'secrets.env') {2106 assert false, 'the injected failure should have surfaced'2107 }2108 strip_local(a, 'secrets.env', second.id)!2109 send_strip(a, mut backend, 'secrets.env')!2110 assert !strip_pending(a, 'secrets.env')2111 for save in [first, second] {2112 record := decode_save(backend.get(save_key('proj', save.id))!.body)!2113 assert 'secrets.env' !in record.files.map(it.path)2114 }2115 for key in ['first key\n', 'second key\n'] {2116 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found2117 }2118}2119212021212122fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {2123 base := scratch('strip_again')!2124 defer {2125 os.rmdir_all(base) or {}2126 }2127 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2128 mut a := open_repo(machine('strip_again_a')!)!2129 defer {2130 a.close()2131 }2132 mut b := open_repo(machine('strip_again_b')!)!2133 defer {2134 b.close()2135 }2136 put(a.root, 'secrets.env', 'first key\n')!2137 make_save(a, 'first key')!2138 offer_now(a, mut backend)!2139 join(b, mut backend)!2140 strip_local(a, 'secrets.env', '')!2141 send_strip(a, mut backend, 'secrets.env')!2142 steal_now(b, mut backend)!2143 for repo in [a, b] {2144 put(repo.root, 'secrets.env', 'second key\n')!2145 if _ := make_save(repo, 'second key') {2146 assert false, 'a banned path must not make a save'2147 }2148 }2149}2150215121522153fn test_a_stripped_path_is_not_asked_about() {2154 base := scratch('clash_strip')!2155 defer {2156 os.rmdir_all(base) or {}2157 }2158 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2159 mut a := open_repo(machine('clash_strip_a')!)!2160 defer {2161 a.close()2162 }2163 mut b := open_repo(machine('clash_strip_b')!)!2164 defer {2165 b.close()2166 }2167 put(a.root, 'secrets.env', 'key one\n')!2168 make_save(a, 'base')!2169 offer_now(a, mut backend)!2170 join(b, mut backend)!2171 put(a.root, 'secrets.env', 'key two\n')!2172 make_save(a, 'A rotates')!2173 offer_now(a, mut backend)!2174 strip_local(a, 'secrets.env', '')!2175 send_strip(a, mut backend, 'secrets.env')!2176 put(b.root, 'secrets.env', 'key three\n')!2177 mine := make_save(b, 'B rotates')!2178 report := steal(b, mut backend, StealOptions{})!2179 assert report.clashes.len == 02180 for save in history.all_saves(b)! {2181 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)2182 }2183 assert history.by_id(b, mine.id) != none2184}2185218621872188fn test_a_strip_of_the_shared_base_is_seen_before_asking() {2189 base := scratch('clash_base_strip')!2190 defer {2191 os.rmdir_all(base) or {}2192 }2193 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2194 mut a := open_repo(machine('base_strip_a')!)!2195 defer {2196 a.close()2197 }2198 mut b := open_repo(machine('base_strip_b')!)!2199 defer {2200 b.close()2201 }2202 put(a.root, 'notes.txt', 'shared\n')!2203 common := make_save(a, 'base')!2204 offer_now(a, mut backend)!2205 join(b, mut backend)!2206 put(a.root, 'other.txt', 'A moves on\n')!2207 make_save(a, 'A moves on')!2208 offer_now(a, mut backend)!2209 strip_local(a, 'notes.txt', common.id)!2210 send_strip(a, mut backend, 'notes.txt')!2211 put(b.root, 'notes.txt', 'B edits\n')!2212 make_save(b, 'B edits notes')!2213 if _ := steal(b, mut backend, StealOptions{}) {2214 assert false, 'with the base stripped, notes.txt differs on both sides'2215 } else {2216 assert err.msg().contains('notes.txt changed on both machines')2217 }2218 report := steal(b, mut backend, StealOptions{2219 mine: true2220 })!2221 assert report.clashes == ['notes.txt']2222 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'2223}2224222522262227fn test_steal_into_a_folder_with_unsaved_files_refuses() {2228 base := scratch('steal_unsaved')!2229 defer {2230 os.rmdir_all(base) or {}2231 }2232 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2233 mut a := open_repo(machine('unsaved_a')!)!2234 defer {2235 a.close()2236 }2237 put(a.root, 'notes.txt', 'from A\n')!2238 make_save(a, 'A writes')!2239 offer_now(a, mut backend)!2240 b_root := machine('unsaved_b')!2241 mut b := storage.create(b_root)!2242 defer {2243 b.close()2244 }2245 put(b_root, 'notes.txt', 'never saved\n')!2246 2247 b.set_state('remote_repo', 'proj')!2248 if _ := steal(b, mut backend, StealOptions{}) {2249 assert false, 'steal overwrote a folder with unsaved files'2250 } else {2251 assert err.msg() == no_saves_here2252 }2253 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'2254 2255 os.rm(os.join_path(b_root, 'notes.txt'))!2256 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!2257 steal(b, mut backend, StealOptions{})!2258 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'2259}2260226122622263fn test_steal_reports_incoming_flagged_saves() {2264 base := scratch('flag_warn')!2265 defer {2266 os.rmdir_all(base) or {}2267 }2268 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2269 mut a := open_repo(machine('flag_warn_a')!)!2270 defer {2271 a.close()2272 }2273 mut b := open_repo(machine('flag_warn_b')!)!2274 defer {2275 b.close()2276 }2277 put(a.root, 'a.txt', 'one\n')!2278 make_save(a, 'fine')!2279 offer_now(a, mut backend)!2280 join(b, mut backend)!2281 put(a.root, 'a.txt', 'two\n')!2282 suspect := make_save(a, 'suspect')!2283 put(a.root, 'a.txt', 'three\n')!2284 make_save(a, 'also fine')!2285 events.flag(a, suspect.id, 'broke the build')!2286 offer_now(a, mut backend)!2287 report := steal_now(b, mut backend)!2288 assert report.flagged.map(it.save_id) == [suspect.id]2289 assert report.flagged[0].reason == 'broke the build'2290}229122922293fn head_of(store &storage.Store) history.Save {2294 return history.head(store) or { panic('this repo has no head') }2295}2296229722982299fn test_the_first_offer_records_the_remote_format() {2300 base := scratch('format_write')!2301 defer {2302 os.rmdir_all(base) or {}2303 }2304 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2305 mut a := open_repo(machine('format_write_a')!)!2306 defer {2307 a.close()2308 }2309 put(a.root, 'a.txt', 'one\n')!2310 make_save(a, 'first')!2311 offer_now(a, mut backend)!2312 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2313}2314231523162317fn test_a_remote_without_a_format_still_syncs() {2318 base := scratch('format_old')!2319 defer {2320 os.rmdir_all(base) or {}2321 }2322 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2323 mut a := open_repo(machine('format_old_a')!)!2324 defer {2325 a.close()2326 }2327 put(a.root, 'a.txt', 'one\n')!2328 make_save(a, 'first')!2329 offer_now(a, mut backend)!2330 2331 backend.delete(format_key('proj'))!2332 mut b := open_repo(machine('format_old_b')!)!2333 defer {2334 b.close()2335 }2336 join(b, mut backend)!2337 assert head_of(b).id == head_of(a).id2338 put(a.root, 'a.txt', 'two\n')!2339 make_save(a, 'second')!2340 offer_now(a, mut backend)!2341 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2342 report := steal_now(b, mut backend)!2343 assert report.head == head_of(a).id2344}2345234623472348fn test_an_unknown_remote_format_is_refused() {2349 base := scratch('format_unknown')!2350 defer {2351 os.rmdir_all(base) or {}2352 }2353 mut backend := remote.new_folder(os.join_path(base, 'remote'))!2354 mut a := open_repo(machine('format_bad_a')!)!2355 defer {2356 a.close()2357 }2358 put(a.root, 'a.txt', 'one\n')!2359 make_save(a, 'first')!2360 offer_now(a, mut backend)!2361 backend.delete(format_key('proj'))!2362 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2363 if _ := offer_now(a, mut backend) {2364 assert false, 'offer must refuse a format it does not speak'2365 } else {2366 assert err.msg().contains('koh-remote 2')2367 }2368 if _ := steal_now(a, mut backend) {2369 assert false, 'steal must refuse a format it does not speak'2370 } else {2371 assert err.msg().contains('koh-remote 2')2372 }2373 if _ := fetch_repo(mut backend, 'proj') {2374 assert false, 'a steal must refuse a format it does not speak'2375 } else {2376 assert err.msg().contains('koh-remote 2')2377 }2378}23792380238123822383fn test_a_lost_format_write_is_read_back_and_refused() {2384 base := scratch('format_race')!2385 defer {2386 os.rmdir_all(base) or {}2387 }2388 mut inner := remote.new_folder(os.join_path(base, 'remote'))!2389 mut a := open_repo(machine('format_race_a')!)!2390 defer {2391 a.close()2392 }2393 put(a.root, 'a.txt', 'one\n')!2394 make_save(a, 'first')!2395 mut backend := remote.Backend(FaultyBackend{2396 inner: inner2397 race_format: true2398 })2399 if _ := offer_now(a, mut backend) {2400 assert false, 'offer must refuse the format another machine wrote first'2401 } else {2402 assert err.msg().contains('koh-remote 2')2403 }2404}24052406fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2407 hash_a := 'a'.repeat(64)2408 hash_b := 'b'.repeat(64)2409 whole := 'objects/${hash_a} 3\nabcobjects/${hash_b} 0\nend 0\n'2410 for size in [1, 2, 5, whole.len] {2411 mut bundle := Bundle{}2412 for at := 0; at < whole.len; at += size {2413 bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2414 }2415 bundle.finish()!2416 assert bundle.fetched.repo_objects == {2417 hash_a: 'abc'.bytes()2418 hash_b: []u8{}2419 }2420 }2421 for cut in ['objects/${hash_a} 3\nabc', 'objects/${hash_a} 4\nabc', 'objects/${hash_a} 3',2422 'objects/${hash_a} x\nabc', 'releases/v1 1\nx', 'end 0\nobjects/${hash_a} 3\nabc'] {2423 mut bundle := Bundle{}2424 bundle.feed(cut.bytes()) or { continue }2425 bundle.finish() or { continue }2426 assert false, cut2427 }2428}24292430fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2431 mut store := open_repo(machine('strip_batch')!)!2432 defer { store.close() }2433 put(store.root, 'private/a', 'a')!2434 put(store.root, 'secret', 'secret')!2435 put(store.root, 'keep', 'keep')!2436 save := make_save(store, 'initial')!2437 store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2438 WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2439 if _ := strip_local_batch(store, ['private', 'secret'], '') {2440 assert false, 'batch succeeded despite the second path failing'2441 }2442 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2443 'secret']2444 assert events.strip_events(store)!.len == 02445 assert !strip_pending(store, 'private')2446 store.db.exec('DROP TRIGGER block_secret')!2447 reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2448 assert reports.map(it.files_removed) == [1, 0, 1]2449 assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2450 assert events.strip_events(store)!.len == 22451}24522453fn test_strip_batch_limits_every_path_to_selected_save() {2454 mut store := open_repo(machine('strip_batch_scope')!)!2455 defer { store.close() }2456 put(store.root, 'one', 'one')!2457 put(store.root, 'two', 'two')!2458 first := make_save(store, 'initial')!2459 put(store.root, 'keep', 'keep')!2460 second := make_save(store, 'next')!2461 strip_local_batch(store, ['one', 'two'], first.id)!2462 assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2463 assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2464 assert events.strip_events(store)!.all(it.save_id == first.id)2465}24662467fn test_strip_batch_keeps_independent_remote_retry_notes() {2468 mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2469 mut store := open_repo(machine('strip_batch_retry')!)!2470 defer { store.close() }2471 put(store.root, 'one', 'secret')!2472 put(store.root, 'two', 'secret')!2473 save := make_save(store, 'initial')!2474 offer_now(store, mut backend)!2475 strip_local_batch(store, ['one', 'two'], '')!2476 mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2477 if _ := send_strip(store, mut flaky, 'one') {2478 assert false, 'injected remote failure was not reported'2479 }2480 send_strip(store, mut backend, 'two')!2481 assert strip_pending(store, 'one') && !strip_pending(store, 'two')2482 partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2483 assert partial.files.map(it.path) == ['.kohignore', 'one']2484 assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2485 send_strip(store, mut backend, 'one')!2486 assert !strip_pending(store, 'one')2487 finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2488 assert finished.files.map(it.path) == ['.kohignore']2489 assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2490}