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_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {136 base := scratch('forward')!137 defer {138 os.rmdir_all(base) or {}139 }140 mut backend := remote.new_folder(os.join_path(base, 'remote'))!141 mut a := open_repo(machine('fwd_a')!)!142 defer {143 a.close()144 }145 put(a.root, 'a.txt', 'from A\n')!146 make_save(a, 'A writes')!147 offer_now(a, mut backend)!148149 150 mut b := open_repo(machine('fwd_b')!)!151 defer {152 b.close()153 }154 fetched := fetch_repo(mut backend, 'proj')!155 b.begin()!156 write_fetched(b, fetched)!157 history.set_head(b, fetched.head)!158 b.end()!159 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!160 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'161 assert head_of(b).id == fetched.head162}163164165166fn test_steal_while_only_ahead_leaves_the_line_alone() {167 base := scratch('ahead')!168 defer {169 os.rmdir_all(base) or {}170 }171 mut backend := remote.new_folder(os.join_path(base, 'remote'))!172 mut a := open_repo(machine('ahead_a')!)!173 defer {174 a.close()175 }176 put(a.root, 'a.txt', 'one\n')!177 make_save(a, 'offered')!178 offer_now(a, mut backend)!179 put(a.root, 'a.txt', 'two\n')!180 ahead := make_save(a, 'not offered yet')!181 report := steal_now(a, mut backend)!182 assert report.replayed == 0183 assert head_of(a).id == ahead.id184 185 assert !snapshot.restore_pending(a)186 assert history.all_saves(a)!.len == 2187 offer_now(a, mut backend)!188 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id189}190191192193fn test_steal_never_writes_through_a_symlinked_folder() {194 base := scratch('steal_link')!195 defer {196 os.rmdir_all(base) or {}197 }198 mut backend := remote.new_folder(os.join_path(base, 'remote'))!199 mut a := open_repo(machine('steal_link_a')!)!200 defer {201 a.close()202 }203 mut b := open_repo(machine('steal_link_b')!)!204 defer {205 b.close()206 }207 put(a.root, 'a.txt', 'one\n')!208 make_save(a, 'first')!209 offer_now(a, mut backend)!210 join(b, mut backend)!211 outside := os.join_path(base, 'outside')212 put(outside, 'a.md', 'precious\n')!213 os.symlink(outside, os.join_path(b.root, 'docs'))!214 put(a.root, 'docs/a.md', 'from A\n')!215 make_save(a, 'A adds docs')!216 offer_now(a, mut backend)!217 head_before := head_of(b).id218 if _ := steal_now(b, mut backend) {219 assert false, 'a steal writing through a symlinked folder must refuse'220 } else {221 assert err.msg().contains('docs')222 }223 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'224 assert head_of(b).id == head_before225}226227228fn test_steal_refuses_after_a_restore_cut_short() {229 base := scratch('steal_cut')!230 defer {231 os.rmdir_all(base) or {}232 }233 mut backend := remote.new_folder(os.join_path(base, 'remote'))!234 mut a := open_repo(machine('steal_cut_a')!)!235 defer {236 a.close()237 }238 put(a.root, 'a.txt', 'one\n')!239 make_save(a, 'first')!240 offer_now(a, mut backend)!241 a.set_state('restoring', 'yes')!242 if _ := steal_now(a, mut backend) {243 assert false, 'steal must refuse while a restore is unfinished'244 } else {245 assert err.msg().contains('koh revert')246 }247}248249250251fn test_a_steal_imports_parents_before_children() {252 base := scratch('steal_order')!253 defer {254 os.rmdir_all(base) or {}255 }256 mut backend := remote.new_folder(os.join_path(base, 'remote'))!257 mut a := open_repo(machine('order_a')!)!258 defer {259 a.close()260 }261 put(a.root, 'a.txt', 'one\n')!262 parent := make_save(a, 'parent')!263 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{264 message: 'replayed, older than its parent'265 created_at: parent.created_at - 3600266 })!267 offer_now(a, mut backend)!268 mut b := open_repo(machine('order_b')!)!269 defer {270 b.close()271 }272 join(b, mut backend)!273 assert head_of(b).id == child.id274}275276277fn test_steal_refuses_an_unsaved_executable_bit_change() {278 base := scratch('chmod')!279 defer {280 os.rmdir_all(base) or {}281 }282 mut backend := remote.new_folder(os.join_path(base, 'remote'))!283 mut a := open_repo(machine('chmod_a')!)!284 defer {285 a.close()286 }287 put(a.root, 'run.sh', 'echo hi\n')!288 make_save(a, 'script')!289 offer_now(a, mut backend)!290 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!291 if _ := steal_now(a, mut backend) {292 assert false, 'a chmod-only change should make the tree dirty'293 } else {294 assert err.msg() == dirty_tree295 }296}297298fn test_divergence_without_clashes_keeps_both_machines_work() {299 base := scratch('diverge')!300 defer {301 os.rmdir_all(base) or {}302 }303 mut backend := remote.new_folder(os.join_path(base, 'remote'))!304 mut a := open_repo(machine('div_a')!)!305 defer {306 a.close()307 }308 mut b := open_repo(machine('div_b')!)!309 defer {310 b.close()311 }312 put(a.root, 'shared.txt', 'base\n')!313 make_save(a, 'base')!314 offer_now(a, mut backend)!315316 317 join(b, mut backend)!318 put(a.root, 'fromA.txt', 'A\n')!319 make_save(a, 'A adds a file')!320 put(b.root, 'fromB.txt', 'B\n')!321 make_save(b, 'B adds a file')!322 offer_now(a, mut backend)!323324 325 if _ := offer_now(b, mut backend) {326 assert false, 'offering on top of unknown remote saves should be refused'327 } else {328 assert err.msg().contains('run koh steal')329 }330331 report := steal_now(b, mut backend)!332 assert !report.forward333 assert report.replayed == 1334 assert report.clashes.len == 0335 336 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'337 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'338 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'339 340 mut saw_replay := false341 for save in history.all_saves(b)! {342 if save.replayed_from != '' {343 saw_replay = true344 }345 }346 assert saw_replay347 348 offer_now(b, mut backend)!349 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()350 assert line_head == head_of(b).id351}352353354355356357fn test_a_local_change_undone_does_not_block_a_combine() {358 base := scratch('undone')!359 defer {360 os.rmdir_all(base) or {}361 }362 mut backend := remote.new_folder(os.join_path(base, 'remote'))!363 mut a := open_repo(machine('undone_a')!)!364 defer {365 a.close()366 }367 mut b := open_repo(machine('undone_b')!)!368 defer {369 b.close()370 }371 put(a.root, 'f.txt', 'base\n')!372 put(a.root, 'g.txt', 'base\n')!373 make_save(a, 'base')!374 offer_now(a, mut backend)!375 join(b, mut backend)!376377 put(a.root, 'f.txt', 'from A\n')!378 put(a.root, 'g.txt', 'same\n')!379 make_save(a, 'A edits both')!380 offer_now(a, mut backend)!381382 put(b.root, 'f.txt', 'tried on B\n')!383 put(b.root, 'g.txt', 'first try\n')!384 tried := make_save(b, 'B tries')!385 put(b.root, 'f.txt', 'base\n')!386 put(b.root, 'g.txt', 'same\n')!387 put(b.root, 'h.txt', 'B\n')!388 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!389390 report := steal_now(b, mut backend)!391 assert report.clashes.len == 0392 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'393 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'394 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'395 396 397 assert report.replayed == 1398 assert head_of(b).replayed_from == undone.id399 ids := history.all_saves(b)!.map(it.id)400 assert tried.id in ids && undone.id in ids401 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')402}403404405406fn test_clash_questions_are_asked_before_the_write_lock() {407 base := scratch('ask_unlocked')!408 defer {409 os.rmdir_all(base) or {}410 }411 mut backend := remote.new_folder(os.join_path(base, 'remote'))!412 mut a := open_repo(machine('ask_a')!)!413 defer {414 a.close()415 }416 mut b := open_repo(machine('ask_b')!)!417 defer {418 b.close()419 }420 put(a.root, 'same.txt', 'base\n')!421 make_save(a, 'base')!422 offer_now(a, mut backend)!423 join(b, mut backend)!424 put(a.root, 'same.txt', 'A version\n')!425 make_save(a, 'A edits same')!426 offer_now(a, mut backend)!427 put(b.root, 'same.txt', 'B version\n')!428 make_save(b, 'B edits same')!429 root := b.root430 431 if _ := steal(b, mut backend, StealOptions{432 guide: fn [root] (preview StealPreview) !map[string]Side {433 other := storage.open(root) or { panic(err) }434 defer {435 other.close()436 }437 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }438 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{439 message: 'agent saves during the question'440 }) or { panic('steal held the write lock while asking: ${err}') }441 return {442 preview.clashes[0].path: Side.mine443 }444 }445 })446 {447 assert false, 'a steal whose line moved during the question must refuse'448 } else {449 assert err.msg().contains('changed while')450 }451 452 assert head_of(b).message == 'agent saves during the question'453 report := steal(b, mut backend, StealOptions{454 mine: true455 })!456 assert report.kept_mine == 1457 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'458 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'459}460461fn test_clashing_files_are_chosen_one_at_a_time() {462 base := scratch('clash')!463 defer {464 os.rmdir_all(base) or {}465 }466 mut backend := remote.new_folder(os.join_path(base, 'remote'))!467 mut a := open_repo(machine('clash_a')!)!468 defer {469 a.close()470 }471 mut b := open_repo(machine('clash_b')!)!472 defer {473 b.close()474 }475 put(a.root, 'same.txt', 'base\n')!476 put(a.root, 'other.txt', 'quiet\n')!477 make_save(a, 'base')!478 offer_now(a, mut backend)!479 join(b, mut backend)!480481 482 put(a.root, 'same.txt', 'A version\n')!483 make_save(a, 'A edits same')!484 offer_now(a, mut backend)!485 put(b.root, 'same.txt', 'B version\n')!486 make_save(b, 'B edits same')!487488 489 if _ := steal(b, mut backend, StealOptions{}) {490 assert false, 'an undecided clash must be refused'491 } else {492 assert err.msg().contains('--mine') || err.msg().contains('--theirs')493 494 if err is ClashError {495 assert err.paths == ['same.txt']496 } else {497 assert false, 'an undecided clash must be a ClashError, not ${err}'498 }499 }500 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,501 head_of(b).id)!).len == 0502503 504 report := steal_now(b, mut backend)!505 assert report.clashes == ['same.txt']506 assert report.kept_mine == 0507 assert report.kept_versions == ['same.txt:${report.previous_head}']508 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'509 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'510}511512fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {513 base := scratch('guided_cancel')!514 defer { os.rmdir_all(base) or {} }515 mut backend := remote.new_folder(os.join_path(base, 'remote'))!516 mut a := open_repo(machine('guided_cancel_a')!)!517 defer { a.close() }518 mut b := open_repo(machine('guided_cancel_b')!)!519 defer { b.close() }520 put(a.root, 'same.txt', 'base\n')!521 make_save(a, 'base')!522 offer_now(a, mut backend)!523 join(b, mut backend)!524 put(a.root, 'same.txt', 'remote\n')!525 remote_head := make_save(a, 'remote change')!526 offer_now(a, mut backend)!527 put(b.root, 'same.txt', 'mine\n')!528 local_head := make_save(b, 'local change')!529530 if _ := steal(b, mut backend, StealOptions{531 guide: fn (preview StealPreview) !map[string]Side {532 assert preview.clashes.len == 1533 return error('cancelled')534 }535 }) {536 assert false, 'the guide cancelled'537 } else {538 assert err.msg() == 'cancelled'539 }540 assert head_of(b).id == local_head.id541 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'542 assert history.by_id(b, remote_head.id) != none543 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1544}545546fn test_choosing_mine_keeps_the_local_version() {547 base := scratch('mine')!548 defer {549 os.rmdir_all(base) or {}550 }551 mut backend := remote.new_folder(os.join_path(base, 'remote'))!552 mut a := open_repo(machine('mine_a')!)!553 defer {554 a.close()555 }556 mut b := open_repo(machine('mine_b')!)!557 defer {558 b.close()559 }560 put(a.root, 'same.txt', 'base\n')!561 make_save(a, 'base')!562 offer_now(a, mut backend)!563 join(b, mut backend)!564 put(a.root, 'same.txt', 'A version\n')!565 make_save(a, 'A edits')!566 offer_now(a, mut backend)!567 put(b.root, 'same.txt', 'B version\n')!568 make_save(b, 'B edits')!569570 report := steal(b, mut backend, StealOptions{571 mine: true572 })!573 assert report.kept_mine == 1574 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'575 576 assert head_of(b).message.contains('combine: kept mine for 1 file')577 578 assert head_of(b).restores == ''579 assert history.all_saves(b)!.len >= 4580}581582fn test_flags_travel_with_the_saves() {583 base := scratch('flags')!584 defer {585 os.rmdir_all(base) or {}586 }587 mut backend := remote.new_folder(os.join_path(base, 'remote'))!588 mut a := open_repo(machine('flag_a')!)!589 defer {590 a.close()591 }592 mut b := open_repo(machine('flag_b')!)!593 defer {594 b.close()595 }596 put(a.root, 'a.txt', 'one\n')!597 save := make_save(a, 'suspect save')!598 events.flag(a, save.id, 'look at this')!599 offer_now(a, mut backend)!600601 602 fetched := fetch_back_to_known(b, mut backend, 'proj')!603 assert fetched.events_out.len == 1604 assert fetched.events_out[0].kind == events.kind_flag605 b.begin()!606 write_fetched(b, fetched)!607 history.set_head(b, fetched.head)!608 b.end()!609 state := events.flag_state(b, save.id)!610 assert state.flagged611 assert state.reason == 'look at this'612}613614fn test_a_second_offer_racing_head_is_refused_not_forced() {615 base := scratch('race')!616 defer {617 os.rmdir_all(base) or {}618 }619 mut backend := remote.new_folder(os.join_path(base, 'remote'))!620 mut a := open_repo(machine('race_a')!)!621 defer {622 a.close()623 }624 put(a.root, 'a.txt', 'one\n')!625 make_save(a, 'base')!626 offer_now(a, mut backend)!627628 629 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),630 backend.get(head_key('proj'))!.tag)!631632 put(a.root, 'a.txt', 'two\n')!633 make_save(a, 'second')!634 if _ := offer_now(a, mut backend) {635 assert false, 'offering while the remote head moved must be refused'636 } else {637 assert err.msg().contains('run koh steal')638 }639}640641642fn join(store &storage.Store, mut backend remote.Backend) ! {643 fetched := fetch_repo(mut backend, 'proj')!644 store.begin()!645 write_fetched(store, fetched)!646 history.set_head(store, fetched.head)!647 store.end()!648 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,649 fetched.head)!, [])!650 store.set_state('remote_repo', 'proj')!651}652653struct FaultyBackend {654mut:655 inner remote.Backend656 writes int657 reads int658 fail_write int 659 fail_read int660 661 fail_delete bool662 663 664 race_format bool665 raced bool666}667668fn (mut f FaultyBackend) get(key string) !remote.Object {669 f.reads++670 if f.fail_read > 0 && f.reads >= f.fail_read {671 return error('injected read failure')672 }673 return f.inner.get(key)!674}675676fn (mut f FaultyBackend) list(prefix string) ![]string {677 return f.inner.list(prefix)!678}679680fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {681 f.writes++682 if f.fail_write > 0 && f.writes >= f.fail_write {683 return error('injected write failure')684 }685 if f.race_format && !f.raced && key.ends_with('/format') {686 f.raced = true687 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!688 return false689 }690 return f.inner.put_if_absent(key, body)!691}692693fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {694 f.writes++695 if f.fail_write > 0 && f.writes >= f.fail_write {696 return error('injected write failure')697 }698 return f.inner.put_if_match(key, body, tag)!699}700701fn (mut f FaultyBackend) delete(key string) ! {702 if f.fail_delete {703 return error('injected delete failure')704 }705 f.inner.delete(key)!706}707708709710711fn test_an_offer_sends_only_the_objects_a_save_adds() {712 base := scratch('offer_diff')!713 defer {714 os.rmdir_all(base) or {}715 }716 mut a := open_repo(machine('offer_diff_a')!)!717 defer {718 a.close()719 }720 for i in 0 .. 20 {721 put(a.root, 'f${i}.txt', 'file ${i}\n')!722 }723 make_save(a, 'twenty files')!724 mut backend := FaultyBackend{725 inner: remote.new_folder(os.join_path(base, 'remote'))!726 }727 offer(a, mut backend)!728 put(a.root, 'f3.txt', 'changed\n')!729 make_save(a, 'one change')!730 before := backend.writes731 report := offer(a, mut backend)!732 assert report.objects == 1733 734 assert backend.writes - before == 3735}736737fn test_a_failed_offer_is_safe_to_re_run() {738 base := scratch('fault_offer')!739 defer {740 os.rmdir_all(base) or {}741 }742 remote_dir := os.join_path(base, 'remote')743 mut a := open_repo(machine('fault_a')!)!744 defer {745 a.close()746 }747 put(a.root, 'a.txt', 'one\n')!748 make_save(a, 'first')!749 put(a.root, 'a.txt', 'two\n')!750 make_save(a, 'second')!751752 753 mut flaky := FaultyBackend{754 inner: remote.new_folder(remote_dir)!755 fail_write: 2756 }757 if _ := offer(a, mut flaky) {758 assert false, 'the injected failure should have surfaced'759 }760761 762 763 mut healthy := remote.new_folder(remote_dir)!764 report := offer(a, mut healthy)!765 assert report.saves == 2766 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id767 assert healthy.list('proj/saves')!.len == 2768}769770771772fn test_a_strip_with_an_illegal_path_is_ignored() {773 base := scratch('illegal_strip')!774 defer {775 os.rmdir_all(base) or {}776 }777 mut backend := remote.new_folder(os.join_path(base, 'remote'))!778 mut a := open_repo(machine('illegal_strip_a')!)!779 defer {780 a.close()781 }782 mut b := open_repo(machine('illegal_strip_b')!)!783 defer {784 b.close()785 }786 put(a.root, 'a/x.txt', 'kept\n')!787 make_save(a, 'add a folder')!788 offer_now(a, mut backend)!789 for id, path in {790 'aaaaaaaaaaaa': 'a//'791 'bbbbbbbbbbbb': 'a\\ud800'792 } {793 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!794 }795 join(b, mut backend)!796 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)797 put(a.root, 'more.txt', 'more\n')!798 make_save(a, 'more')!799 offer_now(a, mut backend)!800}801802fn test_a_failed_steal_leaves_nothing_behind() {803 base := scratch('fault_steal')!804 defer {805 os.rmdir_all(base) or {}806 }807 remote_dir := os.join_path(base, 'remote')808 mut a := open_repo(machine('fault_s_a')!)!809 defer {810 a.close()811 }812 mut b := open_repo(machine('fault_s_b')!)!813 defer {814 b.close()815 }816 put(a.root, 'a.txt', 'from A\n')!817 make_save(a, 'A writes')!818 mut healthy := remote.new_folder(remote_dir)!819 offer(a, mut healthy)!820821 822 mut flaky := FaultyBackend{823 inner: remote.new_folder(remote_dir)!824 fail_read: 2825 }826 if _ := fetch_back_to_known(b, mut flaky, 'proj') {827 assert false, 'the injected read failure should have surfaced'828 }829 830 assert history.head(b) == none831 assert b.object_count()! == 0832833 834 fetched := fetch_back_to_known(b, mut healthy, 'proj')!835 b.begin()!836 write_fetched(b, fetched)!837 history.set_head(b, fetched.head)!838 b.end()!839 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!840 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'841}842843fn test_an_interrupted_strip_is_idempotent() {844 845 846 847 base := scratch('fault_strip')!848 defer {849 os.rmdir_all(base) or {}850 }851 mut a := open_repo(machine('fault_strip_a')!)!852 defer {853 a.close()854 }855 put(a.root, 'secret.txt', 'shh\n')!856 put(a.root, 'keep.txt', 'fine\n')!857 save := make_save(a, 'add both')!858859 for _ in 0 .. 2 {860 strip_local(a, 'secret.txt', '')!861 }862 files := history.files_of(a, save.id)!863 864 865 assert files.map(it.path) == ['.kohignore', 'keep.txt']866 assert events.strip_events(a)!.len == 1867}868869870871872873fn test_a_strip_matches_the_path_exactly() {874 mut a := open_repo(machine('strip_exact')!)!875 defer {876 a.close()877 }878 hash := a.put_object('x\n'.bytes())!879 mut files := []snapshot.FileEntry{}880 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {881 files << snapshot.FileEntry{882 path: path883 hash: hash884 }885 }886 save := history.save_tree(a, '', files, history.SaveInput{887 message: 'add all'888 })!889 assert strip_scope(a, 'docs', '')!.files == 1890 strip_local(a, 'docs', '')!891 strip_local(a, 'a_b', '')!892 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']893}894895fn test_a_strip_reaches_the_remote_and_other_machines() {896 base := scratch('strip_sync')!897 defer {898 os.rmdir_all(base) or {}899 }900 mut a := open_repo(machine('strip_a')!)!901 defer {902 a.close()903 }904 mut b := open_repo(machine('strip_b')!)!905 defer {906 b.close()907 }908 put(a.root, 'secrets.env', 'api_key=1\n')!909 put(a.root, 'keep.txt', 'fine\n')!910 save := make_save(a, 'add both')!911 mut backend := remote.new_folder(os.join_path(base, 'remote'))!912 offer_now(a, mut backend)!913 join(b, mut backend)!914 assert b.object_count()! > 0915916 917 918 mut secret_hash := ''919 for file in history.files_of(a, save.id)! {920 if file.path == 'secrets.env' {921 secret_hash = file.hash922 }923 }924 assert secret_hash != ''925 strip_local(a, 'secrets.env', '')!926 report := send_strip(a, mut backend, 'secrets.env')!927 assert report.event_sent928 assert report.records == 1929930 931 932 record := decode_save(backend.get(save_key('proj', save.id))!.body)!933 for file in record.files {934 assert file.path != 'secrets.env'935 }936 937 938 assert !backend.get(object_key('proj', secret_hash))!.found939 assert backend.list('proj/objects')!.len == 2940941 942 b.begin()!943 fetched := fetch_back_to_known(b, mut backend, 'proj')!944 write_fetched(b, fetched)!945 b.end()!946 mut paths := []string{}947 for file in history.files_of(b, save.id)! {948 paths << file.path949 }950 assert 'secrets.env' !in paths951 assert 'keep.txt' in paths952953 954 955 956 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)957 put(b.root, 'secrets.env', 'rotated\n')!958 assert !dirty(b, history.head(b))!959 put(b.root, 'keep.txt', 'still fine\n')!960 later := make_save(b, 'edit keep')!961 offer_now(b, mut backend)!962 assert backend.list('proj/events')!.len == 1963 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)964}965966967968969fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {970 base := scratch('strip_later')!971 defer {972 os.rmdir_all(base) or {}973 }974 mut backend := remote.new_folder(os.join_path(base, 'remote'))!975 mut a := open_repo(machine('strip_later_a')!)!976 defer {977 a.close()978 }979 mut b := open_repo(machine('strip_later_b')!)!980 defer {981 b.close()982 }983 put(a.root, 'secrets.env', 'api_key=1\n')!984 old := make_save(a, 'leak the key')!985 offer_now(a, mut backend)!986 join(b, mut backend)!987 strip_local(a, 'secrets.env', '')!988 send_strip(a, mut backend, 'secrets.env')!989 990 put(b.root, 'secrets.env', 'api_key=2\n')!991 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{992 message: 'rotate the key'993 created_at: events.strip_events(a)!.last().created_at + 3600994 })!995 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)996 b.begin()!997 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!998 b.end()!999 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1000 offer_now(b, mut backend)!10011002 mut c := open_repo(machine('strip_later_c')!)!1003 defer {1004 c.close()1005 }1006 join(c, mut backend)!1007 for save in [old.id, later.id] {1008 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1009 }1010 assert !os.exists(os.join_path(c.root, 'secrets.env'))1011}1012101310141015fn test_a_held_strip_reaches_saves_arriving_after_it() {1016 base := scratch('strip_held')!1017 defer {1018 os.rmdir_all(base) or {}1019 }1020 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1021 mut a := open_repo(machine('strip_held_a')!)!1022 defer {1023 a.close()1024 }1025 mut b := open_repo(machine('strip_held_b')!)!1026 defer {1027 b.close()1028 }1029 put(a.root, 'secrets.env', 'api_key=1\n')!1030 make_save(a, 'leak the key')!1031 offer_now(a, mut backend)!1032 join(b, mut backend)!1033 strip_local(b, 'secrets.env', '')!1034 put(a.root, 'keep.txt', 'fine\n')!1035 later := make_save(a, 'add keep')!1036 offer_now(a, mut backend)!1037 b.begin()!1038 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1039 b.end()!1040 paths := history.files_of(b, later.id)!.map(it.path)1041 assert 'secrets.env' !in paths1042 assert 'keep.txt' in paths1043}1044104510461047struct StripRace {1048mut:1049 inner remote.Backend1050 other &storage.Store1051 path string1052 done bool1053}10541055fn (mut r StripRace) get(key string) !remote.Object {1056 return r.inner.get(key)!1057}10581059fn (mut r StripRace) list(prefix string) ![]string {1060 return r.inner.list(prefix)!1061}10621063fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1064 if !r.done && key.contains('/saves/') {1065 r.done = true1066 strip_local(r.other, r.path, '')!1067 send_strip(r.other, mut r.inner, r.path)!1068 }1069 return r.inner.put_if_absent(key, body)!1070}10711072fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1073 return r.inner.put_if_match(key, body, tag)!1074}10751076fn (mut r StripRace) delete(key string) ! {1077 r.inner.delete(key)!1078}10791080108110821083fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1084 base := scratch('strip_race')!1085 defer {1086 os.rmdir_all(base) or {}1087 }1088 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1089 mut a := open_repo(machine('strip_race_a')!)!1090 defer {1091 a.close()1092 }1093 mut b := open_repo(machine('strip_race_b')!)!1094 defer {1095 b.close()1096 }1097 put(a.root, 'secrets.env', 'api_key=1\n')!1098 first := make_save(a, 'leak the key')!1099 offer_now(a, mut backend)!1100 join(b, mut backend)!1101 put(a.root, 'secrets.env', 'api_key=2\n')!1102 later := make_save(a, 'rotate the key')!1103 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1104 mut race := StripRace{1105 inner: backend1106 other: &b1107 path: 'secrets.env'1108 }1109 if _ := offer(a, mut race) {1110 assert false, 'an offer that raced a strip must refuse'1111 } else {1112 assert err.msg().contains('koh steal'), err.msg()1113 }1114 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1115 assert !backend.get(object_key('proj', secret))!.found1116 for key in backend.list('proj/saves')! {1117 record := decode_save(backend.get(key)!.body)!1118 assert 'secrets.env' !in record.files.map(it.path), record.id1119 }1120}11211122112311241125fn test_an_interrupted_remote_strip_finishes_when_re_run() {1126 base := scratch('strip_resume')!1127 defer {1128 os.rmdir_all(base) or {}1129 }1130 remote_dir := os.join_path(base, 'remote')1131 mut backend := remote.new_folder(remote_dir)!1132 mut a := open_repo(machine('resume_a')!)!1133 defer {1134 a.close()1135 }1136 mut b := open_repo(machine('resume_b')!)!1137 defer {1138 b.close()1139 }1140 put(a.root, 'secrets.env', 'api_key=1\n')!1141 put(a.root, 'shared.env', 'same bytes on B\n')!1142 save := make_save(a, 'add secrets')!1143 offer_now(a, mut backend)!1144 join(b, mut backend)!1145 put(b.root, 'copy.txt', 'same bytes on B\n')!1146 b_save := make_save(b, 'B keeps a copy')!1147 offer_now(b, mut backend)!1148 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1149 secret_hash := storage.hash_hex('api_key=1\n'.bytes())11501151 strip_local(a, 'secrets.env', '')!1152 1153 1154 mut flaky := FaultyBackend{1155 inner: remote.new_folder(remote_dir)!1156 fail_write: 31157 }1158 if _ := send_strip(a, mut flaky, 'secrets.env') {1159 assert false, 'the injected failure should have surfaced'1160 }1161 assert strip_pending(a, 'secrets.env')1162 assert strip_local(a, 'secrets.env', '')!.files_removed == 01163 report := send_strip(a, mut backend, 'secrets.env')!1164 assert report.records == 11165 assert !strip_pending(a, 'secrets.env')1166 assert !backend.get(object_key('proj', secret_hash))!.found1167 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1168 assert 'secrets.env' !in record.files.map(it.path)1169 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1170 assert 'secrets.env' !in b_record.files.map(it.path)11711172 1173 strip_local(a, 'shared.env', '')!1174 send_strip(a, mut backend, 'shared.env')!1175 assert backend.get(object_key('proj', shared_hash))!.found1176}1177117811791180fn test_offer_waits_for_an_unapplied_strip() {1181 base := scratch('strip_first')!1182 defer {1183 os.rmdir_all(base) or {}1184 }1185 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1186 mut a := open_repo(machine('strip_first_a')!)!1187 defer {1188 a.close()1189 }1190 mut b := open_repo(machine('strip_first_b')!)!1191 defer {1192 b.close()1193 }1194 put(a.root, 'secrets.env', 'api_key=1\n')!1195 make_save(a, 'add secrets')!1196 offer_now(a, mut backend)!1197 join(b, mut backend)!1198 strip_local(a, 'secrets.env', '')!1199 send_strip(a, mut backend, 'secrets.env')!1200 put(b.root, 'more.txt', 'B keeps working\n')!1201 later := make_save(b, 'B saves with the secret still in its tree')!1202 if _ := offer_now(b, mut backend) {1203 assert false, 'B offered records that still list the stripped path'1204 }1205 steal_now(b, mut backend)!1206 offer_now(b, mut backend)!1207 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1208 assert 'secrets.env' !in record.files.map(it.path)1209}1210121112121213fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1214 base := scratch('strip_twice')!1215 defer {1216 os.rmdir_all(base) or {}1217 }1218 remote_dir := os.join_path(base, 'remote')1219 mut backend := remote.new_folder(remote_dir)!1220 mut a := open_repo(machine('strip_twice_a')!)!1221 defer {1222 a.close()1223 }1224 put(a.root, 'secrets.env', 'first key\n')!1225 make_save(a, 'first key')!1226 offer_now(a, mut backend)!1227 strip_local(a, 'secrets.env', '')!1228 mut flaky := FaultyBackend{1229 inner: remote.new_folder(remote_dir)!1230 fail_delete: true1231 }1232 if _ := send_strip(a, mut flaky, 'secrets.env') {1233 assert false, 'the injected delete failure should have surfaced'1234 }1235 put(a.root, 'secrets.env', 'second key\n')!1236 put(a.root, 'other.txt', 'more work\n')!1237 make_save(a, 'more work')!1238 offer_now(a, mut backend)!1239 send_strip(a, mut backend, 'secrets.env')!1240 for key in ['first key\n', 'second key\n'] {1241 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1242 }1243}124412451246fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1247 base := scratch('strip_scopes')!1248 defer {1249 os.rmdir_all(base) or {}1250 }1251 remote_dir := os.join_path(base, 'remote')1252 mut backend := remote.new_folder(remote_dir)!1253 mut a := open_repo(machine('strip_scopes_a')!)!1254 defer {1255 a.close()1256 }1257 put(a.root, 'secrets.env', 'first key\n')!1258 first := make_save(a, 'first key')!1259 put(a.root, 'secrets.env', 'second key\n')!1260 second := make_save(a, 'second key')!1261 offer_now(a, mut backend)!1262 strip_local(a, 'secrets.env', first.id)!1263 1264 mut flaky := FaultyBackend{1265 inner: remote.new_folder(remote_dir)!1266 fail_write: 21267 }1268 if _ := send_strip(a, mut flaky, 'secrets.env') {1269 assert false, 'the injected failure should have surfaced'1270 }1271 strip_local(a, 'secrets.env', second.id)!1272 send_strip(a, mut backend, 'secrets.env')!1273 assert !strip_pending(a, 'secrets.env')1274 for save in [first, second] {1275 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1276 assert 'secrets.env' !in record.files.map(it.path)1277 }1278 for key in ['first key\n', 'second key\n'] {1279 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1280 }1281}1282128312841285fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1286 base := scratch('strip_again')!1287 defer {1288 os.rmdir_all(base) or {}1289 }1290 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1291 mut a := open_repo(machine('strip_again_a')!)!1292 defer {1293 a.close()1294 }1295 mut b := open_repo(machine('strip_again_b')!)!1296 defer {1297 b.close()1298 }1299 put(a.root, 'secrets.env', 'first key\n')!1300 make_save(a, 'first key')!1301 offer_now(a, mut backend)!1302 join(b, mut backend)!1303 strip_local(a, 'secrets.env', '')!1304 send_strip(a, mut backend, 'secrets.env')!1305 steal_now(b, mut backend)!1306 for repo in [a, b] {1307 put(repo.root, 'secrets.env', 'second key\n')!1308 if _ := make_save(repo, 'second key') {1309 assert false, 'a banned path must not make a save'1310 }1311 }1312}1313131413151316fn test_a_stripped_path_is_not_asked_about() {1317 base := scratch('clash_strip')!1318 defer {1319 os.rmdir_all(base) or {}1320 }1321 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1322 mut a := open_repo(machine('clash_strip_a')!)!1323 defer {1324 a.close()1325 }1326 mut b := open_repo(machine('clash_strip_b')!)!1327 defer {1328 b.close()1329 }1330 put(a.root, 'secrets.env', 'key one\n')!1331 make_save(a, 'base')!1332 offer_now(a, mut backend)!1333 join(b, mut backend)!1334 put(a.root, 'secrets.env', 'key two\n')!1335 make_save(a, 'A rotates')!1336 offer_now(a, mut backend)!1337 strip_local(a, 'secrets.env', '')!1338 send_strip(a, mut backend, 'secrets.env')!1339 put(b.root, 'secrets.env', 'key three\n')!1340 mine := make_save(b, 'B rotates')!1341 report := steal(b, mut backend, StealOptions{})!1342 assert report.clashes.len == 01343 for save in history.all_saves(b)! {1344 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1345 }1346 assert history.by_id(b, mine.id) != none1347}1348134913501351fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1352 base := scratch('clash_base_strip')!1353 defer {1354 os.rmdir_all(base) or {}1355 }1356 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1357 mut a := open_repo(machine('base_strip_a')!)!1358 defer {1359 a.close()1360 }1361 mut b := open_repo(machine('base_strip_b')!)!1362 defer {1363 b.close()1364 }1365 put(a.root, 'notes.txt', 'shared\n')!1366 common := make_save(a, 'base')!1367 offer_now(a, mut backend)!1368 join(b, mut backend)!1369 put(a.root, 'other.txt', 'A moves on\n')!1370 make_save(a, 'A moves on')!1371 offer_now(a, mut backend)!1372 strip_local(a, 'notes.txt', common.id)!1373 send_strip(a, mut backend, 'notes.txt')!1374 put(b.root, 'notes.txt', 'B edits\n')!1375 make_save(b, 'B edits notes')!1376 if _ := steal(b, mut backend, StealOptions{}) {1377 assert false, 'with the base stripped, notes.txt differs on both sides'1378 } else {1379 assert err.msg().contains('notes.txt changed on both machines')1380 }1381 report := steal(b, mut backend, StealOptions{1382 mine: true1383 })!1384 assert report.clashes == ['notes.txt']1385 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1386}1387138813891390fn test_steal_into_a_folder_with_unsaved_files_refuses() {1391 base := scratch('steal_unsaved')!1392 defer {1393 os.rmdir_all(base) or {}1394 }1395 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1396 mut a := open_repo(machine('unsaved_a')!)!1397 defer {1398 a.close()1399 }1400 put(a.root, 'notes.txt', 'from A\n')!1401 make_save(a, 'A writes')!1402 offer_now(a, mut backend)!1403 b_root := machine('unsaved_b')!1404 mut b := storage.create(b_root)!1405 defer {1406 b.close()1407 }1408 put(b_root, 'notes.txt', 'never saved\n')!1409 1410 b.set_state('remote_repo', 'proj')!1411 if _ := steal(b, mut backend, StealOptions{}) {1412 assert false, 'steal overwrote a folder with unsaved files'1413 } else {1414 assert err.msg() == no_saves_here1415 }1416 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1417 1418 os.rm(os.join_path(b_root, 'notes.txt'))!1419 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1420 steal(b, mut backend, StealOptions{})!1421 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1422}1423142414251426fn test_steal_reports_incoming_flagged_saves() {1427 base := scratch('flag_warn')!1428 defer {1429 os.rmdir_all(base) or {}1430 }1431 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1432 mut a := open_repo(machine('flag_warn_a')!)!1433 defer {1434 a.close()1435 }1436 mut b := open_repo(machine('flag_warn_b')!)!1437 defer {1438 b.close()1439 }1440 put(a.root, 'a.txt', 'one\n')!1441 make_save(a, 'fine')!1442 offer_now(a, mut backend)!1443 join(b, mut backend)!1444 put(a.root, 'a.txt', 'two\n')!1445 suspect := make_save(a, 'suspect')!1446 put(a.root, 'a.txt', 'three\n')!1447 make_save(a, 'also fine')!1448 events.flag(a, suspect.id, 'broke the build')!1449 offer_now(a, mut backend)!1450 report := steal_now(b, mut backend)!1451 assert report.flagged.map(it.save_id) == [suspect.id]1452 assert report.flagged[0].reason == 'broke the build'1453}145414551456fn head_of(store &storage.Store) history.Save {1457 return history.head(store) or { panic('this repo has no head') }1458}1459146014611462fn test_the_first_offer_records_the_remote_format() {1463 base := scratch('format_write')!1464 defer {1465 os.rmdir_all(base) or {}1466 }1467 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1468 mut a := open_repo(machine('format_write_a')!)!1469 defer {1470 a.close()1471 }1472 put(a.root, 'a.txt', 'one\n')!1473 make_save(a, 'first')!1474 offer_now(a, mut backend)!1475 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1476}1477147814791480fn test_a_remote_without_a_format_still_syncs() {1481 base := scratch('format_old')!1482 defer {1483 os.rmdir_all(base) or {}1484 }1485 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1486 mut a := open_repo(machine('format_old_a')!)!1487 defer {1488 a.close()1489 }1490 put(a.root, 'a.txt', 'one\n')!1491 make_save(a, 'first')!1492 offer_now(a, mut backend)!1493 1494 backend.delete(format_key('proj'))!1495 mut b := open_repo(machine('format_old_b')!)!1496 defer {1497 b.close()1498 }1499 join(b, mut backend)!1500 assert head_of(b).id == head_of(a).id1501 put(a.root, 'a.txt', 'two\n')!1502 make_save(a, 'second')!1503 offer_now(a, mut backend)!1504 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1505 report := steal_now(b, mut backend)!1506 assert report.head == head_of(a).id1507}1508150915101511fn test_an_unknown_remote_format_is_refused() {1512 base := scratch('format_unknown')!1513 defer {1514 os.rmdir_all(base) or {}1515 }1516 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1517 mut a := open_repo(machine('format_bad_a')!)!1518 defer {1519 a.close()1520 }1521 put(a.root, 'a.txt', 'one\n')!1522 make_save(a, 'first')!1523 offer_now(a, mut backend)!1524 backend.delete(format_key('proj'))!1525 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1526 if _ := offer_now(a, mut backend) {1527 assert false, 'offer must refuse a format it does not speak'1528 } else {1529 assert err.msg().contains('koh-remote 2')1530 }1531 if _ := steal_now(a, mut backend) {1532 assert false, 'steal must refuse a format it does not speak'1533 } else {1534 assert err.msg().contains('koh-remote 2')1535 }1536 if _ := fetch_repo(mut backend, 'proj') {1537 assert false, 'a steal must refuse a format it does not speak'1538 } else {1539 assert err.msg().contains('koh-remote 2')1540 }1541}15421543154415451546fn test_a_lost_format_write_is_read_back_and_refused() {1547 base := scratch('format_race')!1548 defer {1549 os.rmdir_all(base) or {}1550 }1551 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1552 mut a := open_repo(machine('format_race_a')!)!1553 defer {1554 a.close()1555 }1556 put(a.root, 'a.txt', 'one\n')!1557 make_save(a, 'first')!1558 mut backend := remote.Backend(FaultyBackend{1559 inner: inner1560 race_format: true1561 })1562 if _ := offer_now(a, mut backend) {1563 assert false, 'offer must refuse the format another machine wrote first'1564 } else {1565 assert err.msg().contains('koh-remote 2')1566 }1567}