12345module exchange67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314fn test_arriving_record_drops_unsafe_paths_and_reports_them() {15 mut record := SaveRecord{16 files: [17 FileRecord{ path: 'safe.txt' },18 FileRecord{ path: '../outside.txt' },19 FileRecord{ path: 'bad\\name.txt' },20 FileRecord{ path: 'Icon\r' },21 ]22 }23 dropped := drop_unsafe_paths(mut record)24 assert record.files.map(it.path) == ['safe.txt']25 assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']26}27282930fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {31 body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'32 mut record := decode_save(body.bytes())!33 dropped := drop_unsafe_paths(mut record)34 assert record.files.map(it.path) == ['ok😀']35 assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']36}37383940fn machine(name string) !string {41 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')42 os.rmdir_all(os.dir(dir)) or {}43 os.mkdir_all(dir, os.MkdirParams{})!44 return dir45}4647fn scratch(name string) !string {48 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')49 os.rmdir_all(dir) or {}50 os.mkdir_all(dir, os.MkdirParams{})!51 return dir52}5354fn open_repo(dir string) !storage.Store {55 mut store := storage.create(dir)!56 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!57 return store58}5960fn put(root string, rel string, text string) ! {61 full := os.join_path(root, rel)62 os.mkdir_all(os.dir(full), os.MkdirParams{})!63 os.write_file(full, text)!64}6566fn make_save(store &storage.Store, message string) !history.Save {67 return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{68 message: message69 })!70}7172fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {73 return offer(store, mut backend)!74}7576fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {77 return steal(store, mut backend, StealOptions{78 theirs: true79 })!80}8182fn test_first_offer_publishes_the_whole_line() {83 base := scratch('first')!84 defer {85 os.rmdir_all(base) or {}86 }87 mut a := open_repo(machine('first_a')!)!88 defer {89 a.close()90 }91 put(a.root, 'a.txt', 'one\n')!92 make_save(a, 'first')!93 put(a.root, 'a.txt', 'two\n')!94 make_save(a, 'second')!95 mut backend := remote.new_folder(os.join_path(base, 'remote'))!9697 report := offer_now(a, mut backend)!98 assert report.repo == 'proj'99 assert report.saves == 2100 assert report.objects >= 2101 assert backend.get(head_key('proj'))!.found102 assert backend.list('proj/saves')!.len == 2103104 105 again := offer_now(a, mut backend)!106 assert again.saves == 0107}108109fn test_the_repo_name_is_remembered_and_head_offered() {110 base := scratch('remember')!111 defer {112 os.rmdir_all(base) or {}113 }114 mut a := open_repo(machine('remember_a')!)!115 defer {116 a.close()117 }118 put(a.root, 'a.txt', 'one\n')!119 make_save(a, 'first')!120 mut backend := remote.new_folder(os.join_path(base, 'remote'))!121 offer_now(a, mut backend)!122 assert a.state('remote_repo') or { '' } == 'proj'123 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id124 125 assert a.state('remote_head') or { '' } == head_of(a).id126127 128 129 put(a.root, 'a.txt', 'two\n')!130 make_save(a, 'second')!131 offer_now(a, mut backend)!132 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id133}134135fn test_a_repo_name_is_lowercased_at_first_offer() {136 base := scratch('lower')!137 defer {138 os.rmdir_all(base) or {}139 }140 dir := os.join_path(base, 'Haven')141 os.mkdir_all(dir, os.MkdirParams{})!142 mut a := open_repo(dir)!143 defer {144 a.close()145 }146 put(a.root, 'a.txt', 'one\n')!147 make_save(a, 'first')!148 mut backend := remote.new_folder(os.join_path(base, 'remote'))!149 assert offer_now(a, mut backend)!.repo == 'haven'150 assert backend.get(head_key('haven'))!.found151}152153fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {154 base := scratch('renamed')!155 defer {156 os.rmdir_all(base) or {}157 }158 mut a := open_repo(machine('renamed_a')!)!159 defer {160 a.close()161 }162 put(a.root, 'a.txt', 'one\n')!163 make_save(a, 'first')!164 remote_dir := os.join_path(base, 'remote')165 mut backend := remote.new_folder(remote_dir)!166 offer_now(a, mut backend)!167 168 os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!169 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!170 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!171172 put(a.root, 'a.txt', 'two\n')!173 make_save(a, 'second')!174 assert offer_now(a, mut backend)!.repo == 'tool'175 assert a.state('remote_repo') or { '' } == 'tool'176 assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id177 assert !backend.get(head_key('proj'))!.found178}179180fn test_a_rename_marker_naming_a_path_is_not_followed() {181 base := scratch('badmarker')!182 defer {183 os.rmdir_all(base) or {}184 }185 mut a := open_repo(machine('badmarker_a')!)!186 defer {187 a.close()188 }189 put(a.root, 'a.txt', 'one\n')!190 make_save(a, 'first')!191 remote_dir := os.join_path(base, 'remote')192 mut backend := remote.new_folder(remote_dir)!193 offer_now(a, mut backend)!194 os.rmdir_all(os.join_path(remote_dir, 'proj'))!195 os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!196 os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!197 assert offer_now(a, mut backend)!.repo == 'proj'198}199200fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {201 base := scratch('forward')!202 defer {203 os.rmdir_all(base) or {}204 }205 mut backend := remote.new_folder(os.join_path(base, 'remote'))!206 mut a := open_repo(machine('fwd_a')!)!207 defer {208 a.close()209 }210 put(a.root, 'a.txt', 'from A\n')!211 make_save(a, 'A writes')!212 offer_now(a, mut backend)!213214 215 mut b := open_repo(machine('fwd_b')!)!216 defer {217 b.close()218 }219 fetched := fetch_repo(mut backend, 'proj')!220 b.begin()!221 write_fetched(b, fetched)!222 history.set_head(b, fetched.head)!223 b.end()!224 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!225 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'226 assert head_of(b).id == fetched.head227}228229230231fn test_steal_while_only_ahead_leaves_the_line_alone() {232 base := scratch('ahead')!233 defer {234 os.rmdir_all(base) or {}235 }236 mut backend := remote.new_folder(os.join_path(base, 'remote'))!237 mut a := open_repo(machine('ahead_a')!)!238 defer {239 a.close()240 }241 put(a.root, 'a.txt', 'one\n')!242 make_save(a, 'offered')!243 offer_now(a, mut backend)!244 put(a.root, 'a.txt', 'two\n')!245 ahead := make_save(a, 'not offered yet')!246 report := steal_now(a, mut backend)!247 assert report.replayed == 0248 assert head_of(a).id == ahead.id249 250 assert !snapshot.restore_pending(a)251 assert history.all_saves(a)!.len == 2252 offer_now(a, mut backend)!253 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id254}255256257258fn test_steal_never_writes_through_a_symlinked_folder() {259 base := scratch('steal_link')!260 defer {261 os.rmdir_all(base) or {}262 }263 mut backend := remote.new_folder(os.join_path(base, 'remote'))!264 mut a := open_repo(machine('steal_link_a')!)!265 defer {266 a.close()267 }268 mut b := open_repo(machine('steal_link_b')!)!269 defer {270 b.close()271 }272 put(a.root, 'a.txt', 'one\n')!273 make_save(a, 'first')!274 offer_now(a, mut backend)!275 join(b, mut backend)!276 outside := os.join_path(base, 'outside')277 put(outside, 'a.md', 'precious\n')!278 os.symlink(outside, os.join_path(b.root, 'docs'))!279 put(a.root, 'docs/a.md', 'from A\n')!280 make_save(a, 'A adds docs')!281 offer_now(a, mut backend)!282 head_before := head_of(b).id283 if _ := steal_now(b, mut backend) {284 assert false, 'a steal writing through a symlinked folder must refuse'285 } else {286 assert err.msg().contains('docs')287 }288 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'289 assert head_of(b).id == head_before290}291292293fn test_steal_refuses_after_a_restore_cut_short() {294 base := scratch('steal_cut')!295 defer {296 os.rmdir_all(base) or {}297 }298 mut backend := remote.new_folder(os.join_path(base, 'remote'))!299 mut a := open_repo(machine('steal_cut_a')!)!300 defer {301 a.close()302 }303 put(a.root, 'a.txt', 'one\n')!304 make_save(a, 'first')!305 offer_now(a, mut backend)!306 a.set_state('restoring', 'yes')!307 if _ := steal_now(a, mut backend) {308 assert false, 'steal must refuse while a restore is unfinished'309 } else {310 assert err.msg().contains('koh revert')311 }312}313314315316fn test_a_steal_imports_parents_before_children() {317 base := scratch('steal_order')!318 defer {319 os.rmdir_all(base) or {}320 }321 mut backend := remote.new_folder(os.join_path(base, 'remote'))!322 mut a := open_repo(machine('order_a')!)!323 defer {324 a.close()325 }326 put(a.root, 'a.txt', 'one\n')!327 parent := make_save(a, 'parent')!328 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{329 message: 'replayed, older than its parent'330 created_at: parent.created_at - 3600331 })!332 offer_now(a, mut backend)!333 mut b := open_repo(machine('order_b')!)!334 defer {335 b.close()336 }337 join(b, mut backend)!338 assert head_of(b).id == child.id339}340341342fn test_steal_refuses_an_unsaved_executable_bit_change() {343 base := scratch('chmod')!344 defer {345 os.rmdir_all(base) or {}346 }347 mut backend := remote.new_folder(os.join_path(base, 'remote'))!348 mut a := open_repo(machine('chmod_a')!)!349 defer {350 a.close()351 }352 put(a.root, 'run.sh', 'echo hi\n')!353 make_save(a, 'script')!354 offer_now(a, mut backend)!355 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!356 if _ := steal_now(a, mut backend) {357 assert false, 'a chmod-only change should make the tree dirty'358 } else {359 assert err.msg() == dirty_tree360 }361}362363fn test_divergence_without_clashes_keeps_both_machines_work() {364 base := scratch('diverge')!365 defer {366 os.rmdir_all(base) or {}367 }368 mut backend := remote.new_folder(os.join_path(base, 'remote'))!369 mut a := open_repo(machine('div_a')!)!370 defer {371 a.close()372 }373 mut b := open_repo(machine('div_b')!)!374 defer {375 b.close()376 }377 put(a.root, 'shared.txt', 'base\n')!378 make_save(a, 'base')!379 offer_now(a, mut backend)!380381 382 join(b, mut backend)!383 put(a.root, 'fromA.txt', 'A\n')!384 make_save(a, 'A adds a file')!385 put(b.root, 'fromB.txt', 'B\n')!386 make_save(b, 'B adds a file')!387 offer_now(a, mut backend)!388389 390 if _ := offer_now(b, mut backend) {391 assert false, 'offering on top of unknown remote saves should be refused'392 } else {393 assert err.msg().contains('run koh steal')394 }395396 report := steal_now(b, mut backend)!397 assert !report.forward398 assert report.replayed == 1399 assert report.clashes.len == 0400 401 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'402 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'403 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'404 405 mut saw_replay := false406 for save in history.all_saves(b)! {407 if save.replayed_from != '' {408 saw_replay = true409 }410 }411 assert saw_replay412 413 offer_now(b, mut backend)!414 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()415 assert line_head == head_of(b).id416}417418419420421422fn test_a_local_change_undone_does_not_block_a_combine() {423 base := scratch('undone')!424 defer {425 os.rmdir_all(base) or {}426 }427 mut backend := remote.new_folder(os.join_path(base, 'remote'))!428 mut a := open_repo(machine('undone_a')!)!429 defer {430 a.close()431 }432 mut b := open_repo(machine('undone_b')!)!433 defer {434 b.close()435 }436 put(a.root, 'f.txt', 'base\n')!437 put(a.root, 'g.txt', 'base\n')!438 make_save(a, 'base')!439 offer_now(a, mut backend)!440 join(b, mut backend)!441442 put(a.root, 'f.txt', 'from A\n')!443 put(a.root, 'g.txt', 'same\n')!444 make_save(a, 'A edits both')!445 offer_now(a, mut backend)!446447 put(b.root, 'f.txt', 'tried on B\n')!448 put(b.root, 'g.txt', 'first try\n')!449 tried := make_save(b, 'B tries')!450 put(b.root, 'f.txt', 'base\n')!451 put(b.root, 'g.txt', 'same\n')!452 put(b.root, 'h.txt', 'B\n')!453 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!454455 report := steal_now(b, mut backend)!456 assert report.clashes.len == 0457 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'458 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'459 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'460 461 462 assert report.replayed == 1463 assert head_of(b).replayed_from == undone.id464 ids := history.all_saves(b)!.map(it.id)465 assert tried.id in ids && undone.id in ids466 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')467}468469470471fn test_clash_questions_are_asked_before_the_write_lock() {472 base := scratch('ask_unlocked')!473 defer {474 os.rmdir_all(base) or {}475 }476 mut backend := remote.new_folder(os.join_path(base, 'remote'))!477 mut a := open_repo(machine('ask_a')!)!478 defer {479 a.close()480 }481 mut b := open_repo(machine('ask_b')!)!482 defer {483 b.close()484 }485 put(a.root, 'same.txt', 'base\n')!486 make_save(a, 'base')!487 offer_now(a, mut backend)!488 join(b, mut backend)!489 put(a.root, 'same.txt', 'A version\n')!490 make_save(a, 'A edits same')!491 offer_now(a, mut backend)!492 put(b.root, 'same.txt', 'B version\n')!493 make_save(b, 'B edits same')!494 root := b.root495 496 if _ := steal(b, mut backend, StealOptions{497 guide: fn [root] (preview StealPreview) !map[string]Side {498 other := storage.open(root) or { panic(err) }499 defer {500 other.close()501 }502 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }503 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{504 message: 'agent saves during the question'505 }) or { panic('steal held the write lock while asking: ${err}') }506 return {507 preview.clashes[0].path: Side.mine508 }509 }510 })511 {512 assert false, 'a steal whose line moved during the question must refuse'513 } else {514 assert err.msg().contains('changed while')515 }516 517 assert head_of(b).message == 'agent saves during the question'518 report := steal(b, mut backend, StealOptions{519 mine: true520 })!521 assert report.kept_mine == 1522 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'523 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'524}525526fn test_clashing_files_are_chosen_one_at_a_time() {527 base := scratch('clash')!528 defer {529 os.rmdir_all(base) or {}530 }531 mut backend := remote.new_folder(os.join_path(base, 'remote'))!532 mut a := open_repo(machine('clash_a')!)!533 defer {534 a.close()535 }536 mut b := open_repo(machine('clash_b')!)!537 defer {538 b.close()539 }540 put(a.root, 'same.txt', 'base\n')!541 put(a.root, 'other.txt', 'quiet\n')!542 make_save(a, 'base')!543 offer_now(a, mut backend)!544 join(b, mut backend)!545546 547 put(a.root, 'same.txt', 'A version\n')!548 make_save(a, 'A edits same')!549 offer_now(a, mut backend)!550 put(b.root, 'same.txt', 'B version\n')!551 make_save(b, 'B edits same')!552553 554 if _ := steal(b, mut backend, StealOptions{}) {555 assert false, 'an undecided clash must be refused'556 } else {557 assert err.msg().contains('--mine') || err.msg().contains('--theirs')558 559 if err is ClashError {560 assert err.paths == ['same.txt']561 } else {562 assert false, 'an undecided clash must be a ClashError, not ${err}'563 }564 }565 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,566 head_of(b).id)!).len == 0567568 569 report := steal_now(b, mut backend)!570 assert report.clashes == ['same.txt']571 assert report.kept_mine == 0572 assert report.kept_versions == ['same.txt:${report.previous_head}']573 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'574 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'575}576577fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {578 base := scratch('guided_cancel')!579 defer { os.rmdir_all(base) or {} }580 mut backend := remote.new_folder(os.join_path(base, 'remote'))!581 mut a := open_repo(machine('guided_cancel_a')!)!582 defer { a.close() }583 mut b := open_repo(machine('guided_cancel_b')!)!584 defer { b.close() }585 put(a.root, 'same.txt', 'base\n')!586 make_save(a, 'base')!587 offer_now(a, mut backend)!588 join(b, mut backend)!589 put(a.root, 'same.txt', 'remote\n')!590 remote_head := make_save(a, 'remote change')!591 offer_now(a, mut backend)!592 put(b.root, 'same.txt', 'mine\n')!593 local_head := make_save(b, 'local change')!594595 if _ := steal(b, mut backend, StealOptions{596 guide: fn (preview StealPreview) !map[string]Side {597 assert preview.clashes.len == 1598 return error('cancelled')599 }600 }) {601 assert false, 'the guide cancelled'602 } else {603 assert err.msg() == 'cancelled'604 }605 assert head_of(b).id == local_head.id606 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'607 assert history.by_id(b, remote_head.id) != none608 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1609}610611fn test_choosing_mine_keeps_the_local_version() {612 base := scratch('mine')!613 defer {614 os.rmdir_all(base) or {}615 }616 mut backend := remote.new_folder(os.join_path(base, 'remote'))!617 mut a := open_repo(machine('mine_a')!)!618 defer {619 a.close()620 }621 mut b := open_repo(machine('mine_b')!)!622 defer {623 b.close()624 }625 put(a.root, 'same.txt', 'base\n')!626 make_save(a, 'base')!627 offer_now(a, mut backend)!628 join(b, mut backend)!629 put(a.root, 'same.txt', 'A version\n')!630 make_save(a, 'A edits')!631 offer_now(a, mut backend)!632 put(b.root, 'same.txt', 'B version\n')!633 make_save(b, 'B edits')!634635 report := steal(b, mut backend, StealOptions{636 mine: true637 })!638 assert report.kept_mine == 1639 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'640 641 assert head_of(b).message.contains('combine: kept mine for 1 file')642 643 assert head_of(b).restores == ''644 assert history.all_saves(b)!.len >= 4645}646647fn test_flags_travel_with_the_saves() {648 base := scratch('flags')!649 defer {650 os.rmdir_all(base) or {}651 }652 mut backend := remote.new_folder(os.join_path(base, 'remote'))!653 mut a := open_repo(machine('flag_a')!)!654 defer {655 a.close()656 }657 mut b := open_repo(machine('flag_b')!)!658 defer {659 b.close()660 }661 put(a.root, 'a.txt', 'one\n')!662 save := make_save(a, 'suspect save')!663 events.flag(a, save.id, 'look at this')!664 offer_now(a, mut backend)!665666 667 fetched := fetch_back_to_known(b, mut backend, 'proj')!668 assert fetched.events_out.len == 1669 assert fetched.events_out[0].kind == events.kind_flag670 b.begin()!671 write_fetched(b, fetched)!672 history.set_head(b, fetched.head)!673 b.end()!674 state := events.flag_state(b, save.id)!675 assert state.flagged676 assert state.reason == 'look at this'677}678679fn test_a_second_offer_racing_head_is_refused_not_forced() {680 base := scratch('race')!681 defer {682 os.rmdir_all(base) or {}683 }684 mut backend := remote.new_folder(os.join_path(base, 'remote'))!685 mut a := open_repo(machine('race_a')!)!686 defer {687 a.close()688 }689 put(a.root, 'a.txt', 'one\n')!690 make_save(a, 'base')!691 offer_now(a, mut backend)!692693 694 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),695 backend.get(head_key('proj'))!.tag)!696697 put(a.root, 'a.txt', 'two\n')!698 make_save(a, 'second')!699 if _ := offer_now(a, mut backend) {700 assert false, 'offering while the remote head moved must be refused'701 } else {702 assert err.msg().contains('run koh steal')703 }704}705706707fn join(store &storage.Store, mut backend remote.Backend) ! {708 fetched := fetch_repo(mut backend, 'proj')!709 store.begin()!710 write_fetched(store, fetched)!711 history.set_head(store, fetched.head)!712 store.end()!713 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,714 fetched.head)!, [])!715 store.set_state('remote_repo', 'proj')!716}717718struct FaultyBackend {719mut:720 inner remote.Backend721 writes int722 reads int723 fail_write int 724 fail_read int725 726 fail_delete bool727 728 729 race_format bool730 raced bool731}732733fn (mut f FaultyBackend) get(key string) !remote.Object {734 f.reads++735 if f.fail_read > 0 && f.reads >= f.fail_read {736 return error('injected read failure')737 }738 return f.inner.get(key)!739}740741fn (mut f FaultyBackend) list(prefix string) ![]string {742 return f.inner.list(prefix)!743}744745fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {746 f.writes++747 if f.fail_write > 0 && f.writes >= f.fail_write {748 return error('injected write failure')749 }750 if f.race_format && !f.raced && key.ends_with('/format') {751 f.raced = true752 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!753 return false754 }755 return f.inner.put_if_absent(key, body)!756}757758fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {759 f.writes++760 if f.fail_write > 0 && f.writes >= f.fail_write {761 return error('injected write failure')762 }763 return f.inner.put_if_match(key, body, tag)!764}765766fn (mut f FaultyBackend) delete(key string) ! {767 if f.fail_delete {768 return error('injected delete failure')769 }770 f.inner.delete(key)!771}772773774775776fn test_an_offer_sends_only_the_objects_a_save_adds() {777 base := scratch('offer_diff')!778 defer {779 os.rmdir_all(base) or {}780 }781 mut a := open_repo(machine('offer_diff_a')!)!782 defer {783 a.close()784 }785 for i in 0 .. 20 {786 put(a.root, 'f${i}.txt', 'file ${i}\n')!787 }788 make_save(a, 'twenty files')!789 mut backend := FaultyBackend{790 inner: remote.new_folder(os.join_path(base, 'remote'))!791 }792 offer(a, mut backend)!793 put(a.root, 'f3.txt', 'changed\n')!794 make_save(a, 'one change')!795 before := backend.writes796 report := offer(a, mut backend)!797 assert report.objects == 1798 799 assert backend.writes - before == 3800}801802fn test_a_failed_offer_is_safe_to_re_run() {803 base := scratch('fault_offer')!804 defer {805 os.rmdir_all(base) or {}806 }807 remote_dir := os.join_path(base, 'remote')808 mut a := open_repo(machine('fault_a')!)!809 defer {810 a.close()811 }812 put(a.root, 'a.txt', 'one\n')!813 make_save(a, 'first')!814 put(a.root, 'a.txt', 'two\n')!815 make_save(a, 'second')!816817 818 mut flaky := FaultyBackend{819 inner: remote.new_folder(remote_dir)!820 fail_write: 2821 }822 if _ := offer(a, mut flaky) {823 assert false, 'the injected failure should have surfaced'824 }825826 827 828 mut healthy := remote.new_folder(remote_dir)!829 report := offer(a, mut healthy)!830 assert report.saves == 2831 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id832 assert healthy.list('proj/saves')!.len == 2833}834835836837fn test_a_strip_with_an_illegal_path_is_ignored() {838 base := scratch('illegal_strip')!839 defer {840 os.rmdir_all(base) or {}841 }842 mut backend := remote.new_folder(os.join_path(base, 'remote'))!843 mut a := open_repo(machine('illegal_strip_a')!)!844 defer {845 a.close()846 }847 mut b := open_repo(machine('illegal_strip_b')!)!848 defer {849 b.close()850 }851 put(a.root, 'a/x.txt', 'kept\n')!852 make_save(a, 'add a folder')!853 offer_now(a, mut backend)!854 for id, path in {855 'aaaaaaaaaaaa': 'a//'856 'bbbbbbbbbbbb': 'a\\ud800'857 } {858 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!859 }860 join(b, mut backend)!861 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)862 put(a.root, 'more.txt', 'more\n')!863 make_save(a, 'more')!864 offer_now(a, mut backend)!865}866867fn test_a_failed_steal_leaves_nothing_behind() {868 base := scratch('fault_steal')!869 defer {870 os.rmdir_all(base) or {}871 }872 remote_dir := os.join_path(base, 'remote')873 mut a := open_repo(machine('fault_s_a')!)!874 defer {875 a.close()876 }877 mut b := open_repo(machine('fault_s_b')!)!878 defer {879 b.close()880 }881 put(a.root, 'a.txt', 'from A\n')!882 make_save(a, 'A writes')!883 mut healthy := remote.new_folder(remote_dir)!884 offer(a, mut healthy)!885886 887 mut flaky := FaultyBackend{888 inner: remote.new_folder(remote_dir)!889 fail_read: 2890 }891 if _ := fetch_back_to_known(b, mut flaky, 'proj') {892 assert false, 'the injected read failure should have surfaced'893 }894 895 assert history.head(b) == none896 assert b.object_count()! == 0897898 899 fetched := fetch_back_to_known(b, mut healthy, 'proj')!900 b.begin()!901 write_fetched(b, fetched)!902 history.set_head(b, fetched.head)!903 b.end()!904 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!905 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'906}907908fn test_an_interrupted_strip_is_idempotent() {909 910 911 912 base := scratch('fault_strip')!913 defer {914 os.rmdir_all(base) or {}915 }916 mut a := open_repo(machine('fault_strip_a')!)!917 defer {918 a.close()919 }920 put(a.root, 'secret.txt', 'shh\n')!921 put(a.root, 'keep.txt', 'fine\n')!922 save := make_save(a, 'add both')!923924 for _ in 0 .. 2 {925 strip_local(a, 'secret.txt', '')!926 }927 files := history.files_of(a, save.id)!928 929 930 assert files.map(it.path) == ['.kohignore', 'keep.txt']931 assert events.strip_events(a)!.len == 1932}933934935936937938fn test_a_strip_matches_the_path_exactly() {939 mut a := open_repo(machine('strip_exact')!)!940 defer {941 a.close()942 }943 hash := a.put_object('x\n'.bytes())!944 mut files := []snapshot.FileEntry{}945 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {946 files << snapshot.FileEntry{947 path: path948 hash: hash949 }950 }951 save := history.save_tree(a, '', files, history.SaveInput{952 message: 'add all'953 })!954 assert strip_scope(a, 'docs', '')!.files == 1955 strip_local(a, 'docs', '')!956 strip_local(a, 'a_b', '')!957 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']958}959960fn test_a_strip_reaches_the_remote_and_other_machines() {961 base := scratch('strip_sync')!962 defer {963 os.rmdir_all(base) or {}964 }965 mut a := open_repo(machine('strip_a')!)!966 defer {967 a.close()968 }969 mut b := open_repo(machine('strip_b')!)!970 defer {971 b.close()972 }973 put(a.root, 'secrets.env', 'api_key=1\n')!974 put(a.root, 'keep.txt', 'fine\n')!975 save := make_save(a, 'add both')!976 mut backend := remote.new_folder(os.join_path(base, 'remote'))!977 offer_now(a, mut backend)!978 join(b, mut backend)!979 assert b.object_count()! > 0980981 982 983 mut secret_hash := ''984 for file in history.files_of(a, save.id)! {985 if file.path == 'secrets.env' {986 secret_hash = file.hash987 }988 }989 assert secret_hash != ''990 strip_local(a, 'secrets.env', '')!991 report := send_strip(a, mut backend, 'secrets.env')!992 assert report.event_sent993 assert report.records == 1994995 996 997 record := decode_save(backend.get(save_key('proj', save.id))!.body)!998 for file in record.files {999 assert file.path != 'secrets.env'1000 }1001 1002 1003 assert !backend.get(object_key('proj', secret_hash))!.found1004 assert backend.list('proj/objects')!.len == 210051006 1007 b.begin()!1008 fetched := fetch_back_to_known(b, mut backend, 'proj')!1009 write_fetched(b, fetched)!1010 b.end()!1011 mut paths := []string{}1012 for file in history.files_of(b, save.id)! {1013 paths << file.path1014 }1015 assert 'secrets.env' !in paths1016 assert 'keep.txt' in paths10171018 1019 1020 1021 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1022 put(b.root, 'secrets.env', 'rotated\n')!1023 assert !dirty(b, history.head(b))!1024 put(b.root, 'keep.txt', 'still fine\n')!1025 later := make_save(b, 'edit keep')!1026 offer_now(b, mut backend)!1027 assert backend.list('proj/events')!.len == 11028 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1029}10301031103210331034fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1035 base := scratch('strip_later')!1036 defer {1037 os.rmdir_all(base) or {}1038 }1039 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1040 mut a := open_repo(machine('strip_later_a')!)!1041 defer {1042 a.close()1043 }1044 mut b := open_repo(machine('strip_later_b')!)!1045 defer {1046 b.close()1047 }1048 put(a.root, 'secrets.env', 'api_key=1\n')!1049 old := make_save(a, 'leak the key')!1050 offer_now(a, mut backend)!1051 join(b, mut backend)!1052 strip_local(a, 'secrets.env', '')!1053 send_strip(a, mut backend, 'secrets.env')!1054 1055 put(b.root, 'secrets.env', 'api_key=2\n')!1056 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1057 message: 'rotate the key'1058 created_at: events.strip_events(a)!.last().created_at + 36001059 })!1060 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1061 b.begin()!1062 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1063 b.end()!1064 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1065 offer_now(b, mut backend)!10661067 mut c := open_repo(machine('strip_later_c')!)!1068 defer {1069 c.close()1070 }1071 join(c, mut backend)!1072 for save in [old.id, later.id] {1073 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1074 }1075 assert !os.exists(os.join_path(c.root, 'secrets.env'))1076}1077107810791080fn test_a_held_strip_reaches_saves_arriving_after_it() {1081 base := scratch('strip_held')!1082 defer {1083 os.rmdir_all(base) or {}1084 }1085 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1086 mut a := open_repo(machine('strip_held_a')!)!1087 defer {1088 a.close()1089 }1090 mut b := open_repo(machine('strip_held_b')!)!1091 defer {1092 b.close()1093 }1094 put(a.root, 'secrets.env', 'api_key=1\n')!1095 make_save(a, 'leak the key')!1096 offer_now(a, mut backend)!1097 join(b, mut backend)!1098 strip_local(b, 'secrets.env', '')!1099 put(a.root, 'keep.txt', 'fine\n')!1100 later := make_save(a, 'add keep')!1101 offer_now(a, mut backend)!1102 b.begin()!1103 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1104 b.end()!1105 paths := history.files_of(b, later.id)!.map(it.path)1106 assert 'secrets.env' !in paths1107 assert 'keep.txt' in paths1108}1109111011111112struct StripRace {1113mut:1114 inner remote.Backend1115 other &storage.Store1116 path string1117 done bool1118}11191120fn (mut r StripRace) get(key string) !remote.Object {1121 return r.inner.get(key)!1122}11231124fn (mut r StripRace) list(prefix string) ![]string {1125 return r.inner.list(prefix)!1126}11271128fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1129 if !r.done && key.contains('/saves/') {1130 r.done = true1131 strip_local(r.other, r.path, '')!1132 send_strip(r.other, mut r.inner, r.path)!1133 }1134 return r.inner.put_if_absent(key, body)!1135}11361137fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1138 return r.inner.put_if_match(key, body, tag)!1139}11401141fn (mut r StripRace) delete(key string) ! {1142 r.inner.delete(key)!1143}11441145114611471148fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1149 base := scratch('strip_race')!1150 defer {1151 os.rmdir_all(base) or {}1152 }1153 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1154 mut a := open_repo(machine('strip_race_a')!)!1155 defer {1156 a.close()1157 }1158 mut b := open_repo(machine('strip_race_b')!)!1159 defer {1160 b.close()1161 }1162 put(a.root, 'secrets.env', 'api_key=1\n')!1163 first := make_save(a, 'leak the key')!1164 offer_now(a, mut backend)!1165 join(b, mut backend)!1166 put(a.root, 'secrets.env', 'api_key=2\n')!1167 later := make_save(a, 'rotate the key')!1168 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1169 mut race := StripRace{1170 inner: backend1171 other: &b1172 path: 'secrets.env'1173 }1174 if _ := offer(a, mut race) {1175 assert false, 'an offer that raced a strip must refuse'1176 } else {1177 assert err.msg().contains('koh steal'), err.msg()1178 }1179 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1180 assert !backend.get(object_key('proj', secret))!.found1181 for key in backend.list('proj/saves')! {1182 record := decode_save(backend.get(key)!.body)!1183 assert 'secrets.env' !in record.files.map(it.path), record.id1184 }1185}11861187118811891190fn test_an_interrupted_remote_strip_finishes_when_re_run() {1191 base := scratch('strip_resume')!1192 defer {1193 os.rmdir_all(base) or {}1194 }1195 remote_dir := os.join_path(base, 'remote')1196 mut backend := remote.new_folder(remote_dir)!1197 mut a := open_repo(machine('resume_a')!)!1198 defer {1199 a.close()1200 }1201 mut b := open_repo(machine('resume_b')!)!1202 defer {1203 b.close()1204 }1205 put(a.root, 'secrets.env', 'api_key=1\n')!1206 put(a.root, 'shared.env', 'same bytes on B\n')!1207 save := make_save(a, 'add secrets')!1208 offer_now(a, mut backend)!1209 join(b, mut backend)!1210 put(b.root, 'copy.txt', 'same bytes on B\n')!1211 b_save := make_save(b, 'B keeps a copy')!1212 offer_now(b, mut backend)!1213 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1214 secret_hash := storage.hash_hex('api_key=1\n'.bytes())12151216 strip_local(a, 'secrets.env', '')!1217 1218 1219 mut flaky := FaultyBackend{1220 inner: remote.new_folder(remote_dir)!1221 fail_write: 31222 }1223 if _ := send_strip(a, mut flaky, 'secrets.env') {1224 assert false, 'the injected failure should have surfaced'1225 }1226 assert strip_pending(a, 'secrets.env')1227 assert strip_local(a, 'secrets.env', '')!.files_removed == 01228 report := send_strip(a, mut backend, 'secrets.env')!1229 assert report.records == 11230 assert !strip_pending(a, 'secrets.env')1231 assert !backend.get(object_key('proj', secret_hash))!.found1232 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1233 assert 'secrets.env' !in record.files.map(it.path)1234 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1235 assert 'secrets.env' !in b_record.files.map(it.path)12361237 1238 strip_local(a, 'shared.env', '')!1239 send_strip(a, mut backend, 'shared.env')!1240 assert backend.get(object_key('proj', shared_hash))!.found1241}1242124312441245fn test_offer_waits_for_an_unapplied_strip() {1246 base := scratch('strip_first')!1247 defer {1248 os.rmdir_all(base) or {}1249 }1250 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1251 mut a := open_repo(machine('strip_first_a')!)!1252 defer {1253 a.close()1254 }1255 mut b := open_repo(machine('strip_first_b')!)!1256 defer {1257 b.close()1258 }1259 put(a.root, 'secrets.env', 'api_key=1\n')!1260 make_save(a, 'add secrets')!1261 offer_now(a, mut backend)!1262 join(b, mut backend)!1263 strip_local(a, 'secrets.env', '')!1264 send_strip(a, mut backend, 'secrets.env')!1265 put(b.root, 'more.txt', 'B keeps working\n')!1266 later := make_save(b, 'B saves with the secret still in its tree')!1267 if _ := offer_now(b, mut backend) {1268 assert false, 'B offered records that still list the stripped path'1269 }1270 steal_now(b, mut backend)!1271 offer_now(b, mut backend)!1272 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1273 assert 'secrets.env' !in record.files.map(it.path)1274}1275127612771278fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1279 base := scratch('strip_twice')!1280 defer {1281 os.rmdir_all(base) or {}1282 }1283 remote_dir := os.join_path(base, 'remote')1284 mut backend := remote.new_folder(remote_dir)!1285 mut a := open_repo(machine('strip_twice_a')!)!1286 defer {1287 a.close()1288 }1289 put(a.root, 'secrets.env', 'first key\n')!1290 make_save(a, 'first key')!1291 offer_now(a, mut backend)!1292 strip_local(a, 'secrets.env', '')!1293 mut flaky := FaultyBackend{1294 inner: remote.new_folder(remote_dir)!1295 fail_delete: true1296 }1297 if _ := send_strip(a, mut flaky, 'secrets.env') {1298 assert false, 'the injected delete failure should have surfaced'1299 }1300 put(a.root, 'secrets.env', 'second key\n')!1301 put(a.root, 'other.txt', 'more work\n')!1302 make_save(a, 'more work')!1303 offer_now(a, mut backend)!1304 send_strip(a, mut backend, 'secrets.env')!1305 for key in ['first key\n', 'second key\n'] {1306 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1307 }1308}130913101311fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1312 base := scratch('strip_scopes')!1313 defer {1314 os.rmdir_all(base) or {}1315 }1316 remote_dir := os.join_path(base, 'remote')1317 mut backend := remote.new_folder(remote_dir)!1318 mut a := open_repo(machine('strip_scopes_a')!)!1319 defer {1320 a.close()1321 }1322 put(a.root, 'secrets.env', 'first key\n')!1323 first := make_save(a, 'first key')!1324 put(a.root, 'secrets.env', 'second key\n')!1325 second := make_save(a, 'second key')!1326 offer_now(a, mut backend)!1327 strip_local(a, 'secrets.env', first.id)!1328 1329 mut flaky := FaultyBackend{1330 inner: remote.new_folder(remote_dir)!1331 fail_write: 21332 }1333 if _ := send_strip(a, mut flaky, 'secrets.env') {1334 assert false, 'the injected failure should have surfaced'1335 }1336 strip_local(a, 'secrets.env', second.id)!1337 send_strip(a, mut backend, 'secrets.env')!1338 assert !strip_pending(a, 'secrets.env')1339 for save in [first, second] {1340 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1341 assert 'secrets.env' !in record.files.map(it.path)1342 }1343 for key in ['first key\n', 'second key\n'] {1344 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1345 }1346}1347134813491350fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1351 base := scratch('strip_again')!1352 defer {1353 os.rmdir_all(base) or {}1354 }1355 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1356 mut a := open_repo(machine('strip_again_a')!)!1357 defer {1358 a.close()1359 }1360 mut b := open_repo(machine('strip_again_b')!)!1361 defer {1362 b.close()1363 }1364 put(a.root, 'secrets.env', 'first key\n')!1365 make_save(a, 'first key')!1366 offer_now(a, mut backend)!1367 join(b, mut backend)!1368 strip_local(a, 'secrets.env', '')!1369 send_strip(a, mut backend, 'secrets.env')!1370 steal_now(b, mut backend)!1371 for repo in [a, b] {1372 put(repo.root, 'secrets.env', 'second key\n')!1373 if _ := make_save(repo, 'second key') {1374 assert false, 'a banned path must not make a save'1375 }1376 }1377}1378137913801381fn test_a_stripped_path_is_not_asked_about() {1382 base := scratch('clash_strip')!1383 defer {1384 os.rmdir_all(base) or {}1385 }1386 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1387 mut a := open_repo(machine('clash_strip_a')!)!1388 defer {1389 a.close()1390 }1391 mut b := open_repo(machine('clash_strip_b')!)!1392 defer {1393 b.close()1394 }1395 put(a.root, 'secrets.env', 'key one\n')!1396 make_save(a, 'base')!1397 offer_now(a, mut backend)!1398 join(b, mut backend)!1399 put(a.root, 'secrets.env', 'key two\n')!1400 make_save(a, 'A rotates')!1401 offer_now(a, mut backend)!1402 strip_local(a, 'secrets.env', '')!1403 send_strip(a, mut backend, 'secrets.env')!1404 put(b.root, 'secrets.env', 'key three\n')!1405 mine := make_save(b, 'B rotates')!1406 report := steal(b, mut backend, StealOptions{})!1407 assert report.clashes.len == 01408 for save in history.all_saves(b)! {1409 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1410 }1411 assert history.by_id(b, mine.id) != none1412}1413141414151416fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1417 base := scratch('clash_base_strip')!1418 defer {1419 os.rmdir_all(base) or {}1420 }1421 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1422 mut a := open_repo(machine('base_strip_a')!)!1423 defer {1424 a.close()1425 }1426 mut b := open_repo(machine('base_strip_b')!)!1427 defer {1428 b.close()1429 }1430 put(a.root, 'notes.txt', 'shared\n')!1431 common := make_save(a, 'base')!1432 offer_now(a, mut backend)!1433 join(b, mut backend)!1434 put(a.root, 'other.txt', 'A moves on\n')!1435 make_save(a, 'A moves on')!1436 offer_now(a, mut backend)!1437 strip_local(a, 'notes.txt', common.id)!1438 send_strip(a, mut backend, 'notes.txt')!1439 put(b.root, 'notes.txt', 'B edits\n')!1440 make_save(b, 'B edits notes')!1441 if _ := steal(b, mut backend, StealOptions{}) {1442 assert false, 'with the base stripped, notes.txt differs on both sides'1443 } else {1444 assert err.msg().contains('notes.txt changed on both machines')1445 }1446 report := steal(b, mut backend, StealOptions{1447 mine: true1448 })!1449 assert report.clashes == ['notes.txt']1450 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1451}1452145314541455fn test_steal_into_a_folder_with_unsaved_files_refuses() {1456 base := scratch('steal_unsaved')!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('unsaved_a')!)!1462 defer {1463 a.close()1464 }1465 put(a.root, 'notes.txt', 'from A\n')!1466 make_save(a, 'A writes')!1467 offer_now(a, mut backend)!1468 b_root := machine('unsaved_b')!1469 mut b := storage.create(b_root)!1470 defer {1471 b.close()1472 }1473 put(b_root, 'notes.txt', 'never saved\n')!1474 1475 b.set_state('remote_repo', 'proj')!1476 if _ := steal(b, mut backend, StealOptions{}) {1477 assert false, 'steal overwrote a folder with unsaved files'1478 } else {1479 assert err.msg() == no_saves_here1480 }1481 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1482 1483 os.rm(os.join_path(b_root, 'notes.txt'))!1484 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1485 steal(b, mut backend, StealOptions{})!1486 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1487}1488148914901491fn test_steal_reports_incoming_flagged_saves() {1492 base := scratch('flag_warn')!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('flag_warn_a')!)!1498 defer {1499 a.close()1500 }1501 mut b := open_repo(machine('flag_warn_b')!)!1502 defer {1503 b.close()1504 }1505 put(a.root, 'a.txt', 'one\n')!1506 make_save(a, 'fine')!1507 offer_now(a, mut backend)!1508 join(b, mut backend)!1509 put(a.root, 'a.txt', 'two\n')!1510 suspect := make_save(a, 'suspect')!1511 put(a.root, 'a.txt', 'three\n')!1512 make_save(a, 'also fine')!1513 events.flag(a, suspect.id, 'broke the build')!1514 offer_now(a, mut backend)!1515 report := steal_now(b, mut backend)!1516 assert report.flagged.map(it.save_id) == [suspect.id]1517 assert report.flagged[0].reason == 'broke the build'1518}151915201521fn head_of(store &storage.Store) history.Save {1522 return history.head(store) or { panic('this repo has no head') }1523}1524152515261527fn test_the_first_offer_records_the_remote_format() {1528 base := scratch('format_write')!1529 defer {1530 os.rmdir_all(base) or {}1531 }1532 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1533 mut a := open_repo(machine('format_write_a')!)!1534 defer {1535 a.close()1536 }1537 put(a.root, 'a.txt', 'one\n')!1538 make_save(a, 'first')!1539 offer_now(a, mut backend)!1540 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1541}1542154315441545fn test_a_remote_without_a_format_still_syncs() {1546 base := scratch('format_old')!1547 defer {1548 os.rmdir_all(base) or {}1549 }1550 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1551 mut a := open_repo(machine('format_old_a')!)!1552 defer {1553 a.close()1554 }1555 put(a.root, 'a.txt', 'one\n')!1556 make_save(a, 'first')!1557 offer_now(a, mut backend)!1558 1559 backend.delete(format_key('proj'))!1560 mut b := open_repo(machine('format_old_b')!)!1561 defer {1562 b.close()1563 }1564 join(b, mut backend)!1565 assert head_of(b).id == head_of(a).id1566 put(a.root, 'a.txt', 'two\n')!1567 make_save(a, 'second')!1568 offer_now(a, mut backend)!1569 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1570 report := steal_now(b, mut backend)!1571 assert report.head == head_of(a).id1572}1573157415751576fn test_an_unknown_remote_format_is_refused() {1577 base := scratch('format_unknown')!1578 defer {1579 os.rmdir_all(base) or {}1580 }1581 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1582 mut a := open_repo(machine('format_bad_a')!)!1583 defer {1584 a.close()1585 }1586 put(a.root, 'a.txt', 'one\n')!1587 make_save(a, 'first')!1588 offer_now(a, mut backend)!1589 backend.delete(format_key('proj'))!1590 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1591 if _ := offer_now(a, mut backend) {1592 assert false, 'offer must refuse a format it does not speak'1593 } else {1594 assert err.msg().contains('koh-remote 2')1595 }1596 if _ := steal_now(a, mut backend) {1597 assert false, 'steal must refuse a format it does not speak'1598 } else {1599 assert err.msg().contains('koh-remote 2')1600 }1601 if _ := fetch_repo(mut backend, 'proj') {1602 assert false, 'a steal must refuse a format it does not speak'1603 } else {1604 assert err.msg().contains('koh-remote 2')1605 }1606}16071608160916101611fn test_a_lost_format_write_is_read_back_and_refused() {1612 base := scratch('format_race')!1613 defer {1614 os.rmdir_all(base) or {}1615 }1616 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1617 mut a := open_repo(machine('format_race_a')!)!1618 defer {1619 a.close()1620 }1621 put(a.root, 'a.txt', 'one\n')!1622 make_save(a, 'first')!1623 mut backend := remote.Backend(FaultyBackend{1624 inner: inner1625 race_format: true1626 })1627 if _ := offer_now(a, mut backend) {1628 assert false, 'offer must refuse the format another machine wrote first'1629 } else {1630 assert err.msg().contains('koh-remote 2')1631 }1632}16331634fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {1635 whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'1636 for size in [1, 2, 5, whole.len] {1637 mut bundle := Bundle{}1638 for at := 0; at < whole.len; at += size {1639 bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!1640 }1641 bundle.finish()!1642 assert bundle.fetched.repo_objects == {1643 'aa': 'abc'.bytes()1644 'bb': []u8{}1645 }1646 }1647 for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',1648 'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {1649 mut bundle := Bundle{}1650 bundle.feed(cut.bytes()) or { continue }1651 bundle.finish() or { continue }1652 assert false, cut1653 }1654}