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 machine(name string) !string {31 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')32 os.rmdir_all(os.dir(dir)) or {}33 os.mkdir_all(dir, os.MkdirParams{})!34 return dir35}3637fn scratch(name string) !string {38 dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')39 os.rmdir_all(dir) or {}40 os.mkdir_all(dir, os.MkdirParams{})!41 return dir42}4344fn open_repo(dir string) !storage.Store {45 mut store := storage.create(dir)!46 os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!47 return store48}4950fn put(root string, rel string, text string) ! {51 full := os.join_path(root, rel)52 os.mkdir_all(os.dir(full), os.MkdirParams{})!53 os.write_file(full, text)!54}5556fn make_save(store &storage.Store, message string) !history.Save {57 return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{58 message: message59 })!60}6162fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {63 return offer(store, mut backend)!64}6566fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {67 return steal(store, mut backend, StealOptions{68 theirs: true69 })!70}7172fn test_first_offer_publishes_the_whole_line() {73 base := scratch('first')!74 defer {75 os.rmdir_all(base) or {}76 }77 mut a := open_repo(machine('first_a')!)!78 defer {79 a.close()80 }81 put(a.root, 'a.txt', 'one\n')!82 make_save(a, 'first')!83 put(a.root, 'a.txt', 'two\n')!84 make_save(a, 'second')!85 mut backend := remote.new_folder(os.join_path(base, 'remote'))!8687 report := offer_now(a, mut backend)!88 assert report.repo == 'proj'89 assert report.saves == 290 assert report.objects >= 291 assert backend.get(head_key('proj'))!.found92 assert backend.list('proj/saves')!.len == 29394 95 again := offer_now(a, mut backend)!96 assert again.saves == 097}9899fn test_the_repo_name_is_remembered_and_head_offered() {100 base := scratch('remember')!101 defer {102 os.rmdir_all(base) or {}103 }104 mut a := open_repo(machine('remember_a')!)!105 defer {106 a.close()107 }108 put(a.root, 'a.txt', 'one\n')!109 make_save(a, 'first')!110 mut backend := remote.new_folder(os.join_path(base, 'remote'))!111 offer_now(a, mut backend)!112 assert a.state('remote_repo') or { '' } == 'proj'113 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id114 115 assert a.state('remote_head') or { '' } == head_of(a).id116117 118 119 put(a.root, 'a.txt', 'two\n')!120 make_save(a, 'second')!121 offer_now(a, mut backend)!122 assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id123}124125fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {126 base := scratch('forward')!127 defer {128 os.rmdir_all(base) or {}129 }130 mut backend := remote.new_folder(os.join_path(base, 'remote'))!131 mut a := open_repo(machine('fwd_a')!)!132 defer {133 a.close()134 }135 put(a.root, 'a.txt', 'from A\n')!136 make_save(a, 'A writes')!137 offer_now(a, mut backend)!138139 140 mut b := open_repo(machine('fwd_b')!)!141 defer {142 b.close()143 }144 fetched := fetch_repo(mut backend, 'proj')!145 b.begin()!146 write_fetched(b, fetched)!147 history.set_head(b, fetched.head)!148 b.end()!149 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!150 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'151 assert head_of(b).id == fetched.head152}153154155156fn test_steal_while_only_ahead_leaves_the_line_alone() {157 base := scratch('ahead')!158 defer {159 os.rmdir_all(base) or {}160 }161 mut backend := remote.new_folder(os.join_path(base, 'remote'))!162 mut a := open_repo(machine('ahead_a')!)!163 defer {164 a.close()165 }166 put(a.root, 'a.txt', 'one\n')!167 make_save(a, 'offered')!168 offer_now(a, mut backend)!169 put(a.root, 'a.txt', 'two\n')!170 ahead := make_save(a, 'not offered yet')!171 report := steal_now(a, mut backend)!172 assert report.replayed == 0173 assert head_of(a).id == ahead.id174 175 assert !snapshot.restore_pending(a)176 assert history.all_saves(a)!.len == 2177 offer_now(a, mut backend)!178 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id179}180181182183fn test_steal_never_writes_through_a_symlinked_folder() {184 base := scratch('steal_link')!185 defer {186 os.rmdir_all(base) or {}187 }188 mut backend := remote.new_folder(os.join_path(base, 'remote'))!189 mut a := open_repo(machine('steal_link_a')!)!190 defer {191 a.close()192 }193 mut b := open_repo(machine('steal_link_b')!)!194 defer {195 b.close()196 }197 put(a.root, 'a.txt', 'one\n')!198 make_save(a, 'first')!199 offer_now(a, mut backend)!200 join(b, mut backend)!201 outside := os.join_path(base, 'outside')202 put(outside, 'a.md', 'precious\n')!203 os.symlink(outside, os.join_path(b.root, 'docs'))!204 put(a.root, 'docs/a.md', 'from A\n')!205 make_save(a, 'A adds docs')!206 offer_now(a, mut backend)!207 head_before := head_of(b).id208 if _ := steal_now(b, mut backend) {209 assert false, 'a steal writing through a symlinked folder must refuse'210 } else {211 assert err.msg().contains('docs')212 }213 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'214 assert head_of(b).id == head_before215}216217218fn test_steal_refuses_after_a_restore_cut_short() {219 base := scratch('steal_cut')!220 defer {221 os.rmdir_all(base) or {}222 }223 mut backend := remote.new_folder(os.join_path(base, 'remote'))!224 mut a := open_repo(machine('steal_cut_a')!)!225 defer {226 a.close()227 }228 put(a.root, 'a.txt', 'one\n')!229 make_save(a, 'first')!230 offer_now(a, mut backend)!231 a.set_state('restoring', 'yes')!232 if _ := steal_now(a, mut backend) {233 assert false, 'steal must refuse while a restore is unfinished'234 } else {235 assert err.msg().contains('koh revert')236 }237}238239240241fn test_a_steal_imports_parents_before_children() {242 base := scratch('steal_order')!243 defer {244 os.rmdir_all(base) or {}245 }246 mut backend := remote.new_folder(os.join_path(base, 'remote'))!247 mut a := open_repo(machine('order_a')!)!248 defer {249 a.close()250 }251 put(a.root, 'a.txt', 'one\n')!252 parent := make_save(a, 'parent')!253 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{254 message: 'replayed, older than its parent'255 created_at: parent.created_at - 3600256 })!257 offer_now(a, mut backend)!258 mut b := open_repo(machine('order_b')!)!259 defer {260 b.close()261 }262 join(b, mut backend)!263 assert head_of(b).id == child.id264}265266267fn test_steal_refuses_an_unsaved_executable_bit_change() {268 base := scratch('chmod')!269 defer {270 os.rmdir_all(base) or {}271 }272 mut backend := remote.new_folder(os.join_path(base, 'remote'))!273 mut a := open_repo(machine('chmod_a')!)!274 defer {275 a.close()276 }277 put(a.root, 'run.sh', 'echo hi\n')!278 make_save(a, 'script')!279 offer_now(a, mut backend)!280 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!281 if _ := steal_now(a, mut backend) {282 assert false, 'a chmod-only change should make the tree dirty'283 } else {284 assert err.msg() == dirty_tree285 }286}287288fn test_divergence_without_clashes_keeps_both_machines_work() {289 base := scratch('diverge')!290 defer {291 os.rmdir_all(base) or {}292 }293 mut backend := remote.new_folder(os.join_path(base, 'remote'))!294 mut a := open_repo(machine('div_a')!)!295 defer {296 a.close()297 }298 mut b := open_repo(machine('div_b')!)!299 defer {300 b.close()301 }302 put(a.root, 'shared.txt', 'base\n')!303 make_save(a, 'base')!304 offer_now(a, mut backend)!305306 307 join(b, mut backend)!308 put(a.root, 'fromA.txt', 'A\n')!309 make_save(a, 'A adds a file')!310 put(b.root, 'fromB.txt', 'B\n')!311 make_save(b, 'B adds a file')!312 offer_now(a, mut backend)!313314 315 if _ := offer_now(b, mut backend) {316 assert false, 'offering on top of unknown remote saves should be refused'317 } else {318 assert err.msg().contains('run koh steal')319 }320321 report := steal_now(b, mut backend)!322 assert !report.forward323 assert report.replayed == 1324 assert report.clashes.len == 0325 326 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'327 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'328 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'329 330 mut saw_replay := false331 for save in history.all_saves(b)! {332 if save.replayed_from != '' {333 saw_replay = true334 }335 }336 assert saw_replay337 338 offer_now(b, mut backend)!339 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()340 assert line_head == head_of(b).id341}342343344345346347fn test_a_local_change_undone_does_not_block_a_combine() {348 base := scratch('undone')!349 defer {350 os.rmdir_all(base) or {}351 }352 mut backend := remote.new_folder(os.join_path(base, 'remote'))!353 mut a := open_repo(machine('undone_a')!)!354 defer {355 a.close()356 }357 mut b := open_repo(machine('undone_b')!)!358 defer {359 b.close()360 }361 put(a.root, 'f.txt', 'base\n')!362 put(a.root, 'g.txt', 'base\n')!363 make_save(a, 'base')!364 offer_now(a, mut backend)!365 join(b, mut backend)!366367 put(a.root, 'f.txt', 'from A\n')!368 put(a.root, 'g.txt', 'same\n')!369 make_save(a, 'A edits both')!370 offer_now(a, mut backend)!371372 put(b.root, 'f.txt', 'tried on B\n')!373 put(b.root, 'g.txt', 'first try\n')!374 tried := make_save(b, 'B tries')!375 put(b.root, 'f.txt', 'base\n')!376 put(b.root, 'g.txt', 'same\n')!377 put(b.root, 'h.txt', 'B\n')!378 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!379380 report := steal_now(b, mut backend)!381 assert report.clashes.len == 0382 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'383 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'384 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'385 386 387 assert report.replayed == 1388 assert head_of(b).replayed_from == undone.id389 ids := history.all_saves(b)!.map(it.id)390 assert tried.id in ids && undone.id in ids391 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')392}393394395396fn test_clash_questions_are_asked_before_the_write_lock() {397 base := scratch('ask_unlocked')!398 defer {399 os.rmdir_all(base) or {}400 }401 mut backend := remote.new_folder(os.join_path(base, 'remote'))!402 mut a := open_repo(machine('ask_a')!)!403 defer {404 a.close()405 }406 mut b := open_repo(machine('ask_b')!)!407 defer {408 b.close()409 }410 put(a.root, 'same.txt', 'base\n')!411 make_save(a, 'base')!412 offer_now(a, mut backend)!413 join(b, mut backend)!414 put(a.root, 'same.txt', 'A version\n')!415 make_save(a, 'A edits same')!416 offer_now(a, mut backend)!417 put(b.root, 'same.txt', 'B version\n')!418 make_save(b, 'B edits same')!419 root := b.root420 421 if _ := steal(b, mut backend, StealOptions{422 guide: fn [root] (preview StealPreview) !map[string]Side {423 other := storage.open(root) or { panic(err) }424 defer {425 other.close()426 }427 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }428 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{429 message: 'agent saves during the question'430 }) or { panic('steal held the write lock while asking: ${err}') }431 return {432 preview.clashes[0].path: Side.mine433 }434 }435 })436 {437 assert false, 'a steal whose line moved during the question must refuse'438 } else {439 assert err.msg().contains('changed while')440 }441 442 assert head_of(b).message == 'agent saves during the question'443 report := steal(b, mut backend, StealOptions{444 mine: true445 })!446 assert report.kept_mine == 1447 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'448 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'449}450451fn test_clashing_files_are_chosen_one_at_a_time() {452 base := scratch('clash')!453 defer {454 os.rmdir_all(base) or {}455 }456 mut backend := remote.new_folder(os.join_path(base, 'remote'))!457 mut a := open_repo(machine('clash_a')!)!458 defer {459 a.close()460 }461 mut b := open_repo(machine('clash_b')!)!462 defer {463 b.close()464 }465 put(a.root, 'same.txt', 'base\n')!466 put(a.root, 'other.txt', 'quiet\n')!467 make_save(a, 'base')!468 offer_now(a, mut backend)!469 join(b, mut backend)!470471 472 put(a.root, 'same.txt', 'A version\n')!473 make_save(a, 'A edits same')!474 offer_now(a, mut backend)!475 put(b.root, 'same.txt', 'B version\n')!476 make_save(b, 'B edits same')!477478 479 if _ := steal(b, mut backend, StealOptions{}) {480 assert false, 'an undecided clash must be refused'481 } else {482 assert err.msg().contains('--mine') || err.msg().contains('--theirs')483 484 if err is ClashError {485 assert err.paths == ['same.txt']486 } else {487 assert false, 'an undecided clash must be a ClashError, not ${err}'488 }489 }490 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,491 head_of(b).id)!).len == 0492493 494 report := steal_now(b, mut backend)!495 assert report.clashes == ['same.txt']496 assert report.kept_mine == 0497 assert report.kept_versions == ['same.txt:${report.previous_head}']498 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'499 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'500}501502fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {503 base := scratch('guided_cancel')!504 defer { os.rmdir_all(base) or {} }505 mut backend := remote.new_folder(os.join_path(base, 'remote'))!506 mut a := open_repo(machine('guided_cancel_a')!)!507 defer { a.close() }508 mut b := open_repo(machine('guided_cancel_b')!)!509 defer { b.close() }510 put(a.root, 'same.txt', 'base\n')!511 make_save(a, 'base')!512 offer_now(a, mut backend)!513 join(b, mut backend)!514 put(a.root, 'same.txt', 'remote\n')!515 remote_head := make_save(a, 'remote change')!516 offer_now(a, mut backend)!517 put(b.root, 'same.txt', 'mine\n')!518 local_head := make_save(b, 'local change')!519520 if _ := steal(b, mut backend, StealOptions{521 guide: fn (preview StealPreview) !map[string]Side {522 assert preview.clashes.len == 1523 return error('cancelled')524 }525 }) {526 assert false, 'the guide cancelled'527 } else {528 assert err.msg() == 'cancelled'529 }530 assert head_of(b).id == local_head.id531 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'532 assert history.by_id(b, remote_head.id) != none533 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1534}535536fn test_choosing_mine_keeps_the_local_version() {537 base := scratch('mine')!538 defer {539 os.rmdir_all(base) or {}540 }541 mut backend := remote.new_folder(os.join_path(base, 'remote'))!542 mut a := open_repo(machine('mine_a')!)!543 defer {544 a.close()545 }546 mut b := open_repo(machine('mine_b')!)!547 defer {548 b.close()549 }550 put(a.root, 'same.txt', 'base\n')!551 make_save(a, 'base')!552 offer_now(a, mut backend)!553 join(b, mut backend)!554 put(a.root, 'same.txt', 'A version\n')!555 make_save(a, 'A edits')!556 offer_now(a, mut backend)!557 put(b.root, 'same.txt', 'B version\n')!558 make_save(b, 'B edits')!559560 report := steal(b, mut backend, StealOptions{561 mine: true562 })!563 assert report.kept_mine == 1564 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'565 566 assert head_of(b).message.contains('combine: kept mine for 1 file')567 568 assert head_of(b).restores == ''569 assert history.all_saves(b)!.len >= 4570}571572fn test_flags_travel_with_the_saves() {573 base := scratch('flags')!574 defer {575 os.rmdir_all(base) or {}576 }577 mut backend := remote.new_folder(os.join_path(base, 'remote'))!578 mut a := open_repo(machine('flag_a')!)!579 defer {580 a.close()581 }582 mut b := open_repo(machine('flag_b')!)!583 defer {584 b.close()585 }586 put(a.root, 'a.txt', 'one\n')!587 save := make_save(a, 'suspect save')!588 events.flag(a, save.id, 'look at this')!589 offer_now(a, mut backend)!590591 592 fetched := fetch_back_to_known(b, mut backend, 'proj')!593 assert fetched.events_out.len == 1594 assert fetched.events_out[0].kind == events.kind_flag595 b.begin()!596 write_fetched(b, fetched)!597 history.set_head(b, fetched.head)!598 b.end()!599 state := events.flag_state(b, save.id)!600 assert state.flagged601 assert state.reason == 'look at this'602}603604fn test_a_second_offer_racing_head_is_refused_not_forced() {605 base := scratch('race')!606 defer {607 os.rmdir_all(base) or {}608 }609 mut backend := remote.new_folder(os.join_path(base, 'remote'))!610 mut a := open_repo(machine('race_a')!)!611 defer {612 a.close()613 }614 put(a.root, 'a.txt', 'one\n')!615 make_save(a, 'base')!616 offer_now(a, mut backend)!617618 619 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),620 backend.get(head_key('proj'))!.tag)!621622 put(a.root, 'a.txt', 'two\n')!623 make_save(a, 'second')!624 if _ := offer_now(a, mut backend) {625 assert false, 'offering while the remote head moved must be refused'626 } else {627 assert err.msg().contains('run koh steal')628 }629}630631632fn join(store &storage.Store, mut backend remote.Backend) ! {633 fetched := fetch_repo(mut backend, 'proj')!634 store.begin()!635 write_fetched(store, fetched)!636 history.set_head(store, fetched.head)!637 store.end()!638 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,639 fetched.head)!, [])!640 store.set_state('remote_repo', 'proj')!641}642643struct FaultyBackend {644mut:645 inner remote.Backend646 writes int647 reads int648 fail_write int 649 fail_read int650 651 fail_delete bool652 653 654 race_format bool655 raced bool656}657658fn (mut f FaultyBackend) get(key string) !remote.Object {659 f.reads++660 if f.fail_read > 0 && f.reads >= f.fail_read {661 return error('injected read failure')662 }663 return f.inner.get(key)!664}665666fn (mut f FaultyBackend) list(prefix string) ![]string {667 return f.inner.list(prefix)!668}669670fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {671 f.writes++672 if f.fail_write > 0 && f.writes >= f.fail_write {673 return error('injected write failure')674 }675 if f.race_format && !f.raced && key.ends_with('/format') {676 f.raced = true677 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!678 return false679 }680 return f.inner.put_if_absent(key, body)!681}682683fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {684 f.writes++685 if f.fail_write > 0 && f.writes >= f.fail_write {686 return error('injected write failure')687 }688 return f.inner.put_if_match(key, body, tag)!689}690691fn (mut f FaultyBackend) delete(key string) ! {692 if f.fail_delete {693 return error('injected delete failure')694 }695 f.inner.delete(key)!696}697698699700701fn test_an_offer_sends_only_the_objects_a_save_adds() {702 base := scratch('offer_diff')!703 defer {704 os.rmdir_all(base) or {}705 }706 mut a := open_repo(machine('offer_diff_a')!)!707 defer {708 a.close()709 }710 for i in 0 .. 20 {711 put(a.root, 'f${i}.txt', 'file ${i}\n')!712 }713 make_save(a, 'twenty files')!714 mut backend := FaultyBackend{715 inner: remote.new_folder(os.join_path(base, 'remote'))!716 }717 offer(a, mut backend)!718 put(a.root, 'f3.txt', 'changed\n')!719 make_save(a, 'one change')!720 before := backend.writes721 report := offer(a, mut backend)!722 assert report.objects == 1723 724 assert backend.writes - before == 3725}726727fn test_a_failed_offer_is_safe_to_re_run() {728 base := scratch('fault_offer')!729 defer {730 os.rmdir_all(base) or {}731 }732 remote_dir := os.join_path(base, 'remote')733 mut a := open_repo(machine('fault_a')!)!734 defer {735 a.close()736 }737 put(a.root, 'a.txt', 'one\n')!738 make_save(a, 'first')!739 put(a.root, 'a.txt', 'two\n')!740 make_save(a, 'second')!741742 743 mut flaky := FaultyBackend{744 inner: remote.new_folder(remote_dir)!745 fail_write: 2746 }747 if _ := offer(a, mut flaky) {748 assert false, 'the injected failure should have surfaced'749 }750751 752 753 mut healthy := remote.new_folder(remote_dir)!754 report := offer(a, mut healthy)!755 assert report.saves == 2756 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id757 assert healthy.list('proj/saves')!.len == 2758}759760fn test_a_failed_steal_leaves_nothing_behind() {761 base := scratch('fault_steal')!762 defer {763 os.rmdir_all(base) or {}764 }765 remote_dir := os.join_path(base, 'remote')766 mut a := open_repo(machine('fault_s_a')!)!767 defer {768 a.close()769 }770 mut b := open_repo(machine('fault_s_b')!)!771 defer {772 b.close()773 }774 put(a.root, 'a.txt', 'from A\n')!775 make_save(a, 'A writes')!776 mut healthy := remote.new_folder(remote_dir)!777 offer(a, mut healthy)!778779 780 mut flaky := FaultyBackend{781 inner: remote.new_folder(remote_dir)!782 fail_read: 2783 }784 if _ := fetch_back_to_known(b, mut flaky, 'proj') {785 assert false, 'the injected read failure should have surfaced'786 }787 788 assert history.head(b) == none789 assert b.object_count()! == 0790791 792 fetched := fetch_back_to_known(b, mut healthy, 'proj')!793 b.begin()!794 write_fetched(b, fetched)!795 history.set_head(b, fetched.head)!796 b.end()!797 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!798 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'799}800801fn test_an_interrupted_strip_is_idempotent() {802 803 804 805 base := scratch('fault_strip')!806 defer {807 os.rmdir_all(base) or {}808 }809 mut a := open_repo(machine('fault_strip_a')!)!810 defer {811 a.close()812 }813 put(a.root, 'secret.txt', 'shh\n')!814 put(a.root, 'keep.txt', 'fine\n')!815 save := make_save(a, 'add both')!816817 for _ in 0 .. 2 {818 strip_local(a, 'secret.txt', '')!819 }820 files := history.files_of(a, save.id)!821 822 823 assert files.map(it.path) == ['.kohignore', 'keep.txt']824 assert events.strip_events(a)!.len == 1825}826827828829830831fn test_a_strip_matches_the_path_exactly() {832 mut a := open_repo(machine('strip_exact')!)!833 defer {834 a.close()835 }836 hash := a.put_object('x\n'.bytes())!837 mut files := []snapshot.FileEntry{}838 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {839 files << snapshot.FileEntry{840 path: path841 hash: hash842 }843 }844 save := history.save_tree(a, '', files, history.SaveInput{845 message: 'add all'846 })!847 assert strip_scope(a, 'docs', '')!.files == 1848 strip_local(a, 'docs', '')!849 strip_local(a, 'a_b', '')!850 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']851}852853fn test_a_strip_reaches_the_remote_and_other_machines() {854 base := scratch('strip_sync')!855 defer {856 os.rmdir_all(base) or {}857 }858 mut a := open_repo(machine('strip_a')!)!859 defer {860 a.close()861 }862 mut b := open_repo(machine('strip_b')!)!863 defer {864 b.close()865 }866 put(a.root, 'secrets.env', 'api_key=1\n')!867 put(a.root, 'keep.txt', 'fine\n')!868 save := make_save(a, 'add both')!869 mut backend := remote.new_folder(os.join_path(base, 'remote'))!870 offer_now(a, mut backend)!871 join(b, mut backend)!872 assert b.object_count()! > 0873874 875 876 mut secret_hash := ''877 for file in history.files_of(a, save.id)! {878 if file.path == 'secrets.env' {879 secret_hash = file.hash880 }881 }882 assert secret_hash != ''883 strip_local(a, 'secrets.env', '')!884 report := send_strip(a, mut backend, 'secrets.env')!885 assert report.event_sent886 assert report.records == 1887888 889 890 record := decode_save(backend.get(save_key('proj', save.id))!.body)!891 for file in record.files {892 assert file.path != 'secrets.env'893 }894 895 896 assert !backend.get(object_key('proj', secret_hash))!.found897 assert backend.list('proj/objects')!.len == 2898899 900 b.begin()!901 fetched := fetch_back_to_known(b, mut backend, 'proj')!902 write_fetched(b, fetched)!903 b.end()!904 mut paths := []string{}905 for file in history.files_of(b, save.id)! {906 paths << file.path907 }908 assert 'secrets.env' !in paths909 assert 'keep.txt' in paths910911 912 913 914 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)915 put(b.root, 'secrets.env', 'rotated\n')!916 assert !dirty(b, history.head(b))!917 put(b.root, 'keep.txt', 'still fine\n')!918 later := make_save(b, 'edit keep')!919 offer_now(b, mut backend)!920 assert backend.list('proj/events')!.len == 1921 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)922}923924925926927fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {928 base := scratch('strip_later')!929 defer {930 os.rmdir_all(base) or {}931 }932 mut backend := remote.new_folder(os.join_path(base, 'remote'))!933 mut a := open_repo(machine('strip_later_a')!)!934 defer {935 a.close()936 }937 mut b := open_repo(machine('strip_later_b')!)!938 defer {939 b.close()940 }941 put(a.root, 'secrets.env', 'api_key=1\n')!942 old := make_save(a, 'leak the key')!943 offer_now(a, mut backend)!944 join(b, mut backend)!945 strip_local(a, 'secrets.env', '')!946 send_strip(a, mut backend, 'secrets.env')!947 948 put(b.root, 'secrets.env', 'api_key=2\n')!949 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{950 message: 'rotate the key'951 created_at: events.strip_events(a)!.last().created_at + 3600952 })!953 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)954 b.begin()!955 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!956 b.end()!957 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)958 offer_now(b, mut backend)!959960 mut c := open_repo(machine('strip_later_c')!)!961 defer {962 c.close()963 }964 join(c, mut backend)!965 for save in [old.id, later.id] {966 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)967 }968 assert !os.exists(os.join_path(c.root, 'secrets.env'))969}970971972973fn test_a_held_strip_reaches_saves_arriving_after_it() {974 base := scratch('strip_held')!975 defer {976 os.rmdir_all(base) or {}977 }978 mut backend := remote.new_folder(os.join_path(base, 'remote'))!979 mut a := open_repo(machine('strip_held_a')!)!980 defer {981 a.close()982 }983 mut b := open_repo(machine('strip_held_b')!)!984 defer {985 b.close()986 }987 put(a.root, 'secrets.env', 'api_key=1\n')!988 make_save(a, 'leak the key')!989 offer_now(a, mut backend)!990 join(b, mut backend)!991 strip_local(b, 'secrets.env', '')!992 put(a.root, 'keep.txt', 'fine\n')!993 later := make_save(a, 'add keep')!994 offer_now(a, mut backend)!995 b.begin()!996 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!997 b.end()!998 paths := history.files_of(b, later.id)!.map(it.path)999 assert 'secrets.env' !in paths1000 assert 'keep.txt' in paths1001}1002100310041005struct StripRace {1006mut:1007 inner remote.Backend1008 other &storage.Store1009 path string1010 done bool1011}10121013fn (mut r StripRace) get(key string) !remote.Object {1014 return r.inner.get(key)!1015}10161017fn (mut r StripRace) list(prefix string) ![]string {1018 return r.inner.list(prefix)!1019}10201021fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1022 if !r.done && key.contains('/saves/') {1023 r.done = true1024 strip_local(r.other, r.path, '')!1025 send_strip(r.other, mut r.inner, r.path)!1026 }1027 return r.inner.put_if_absent(key, body)!1028}10291030fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1031 return r.inner.put_if_match(key, body, tag)!1032}10331034fn (mut r StripRace) delete(key string) ! {1035 r.inner.delete(key)!1036}10371038103910401041fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1042 base := scratch('strip_race')!1043 defer {1044 os.rmdir_all(base) or {}1045 }1046 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1047 mut a := open_repo(machine('strip_race_a')!)!1048 defer {1049 a.close()1050 }1051 mut b := open_repo(machine('strip_race_b')!)!1052 defer {1053 b.close()1054 }1055 put(a.root, 'secrets.env', 'api_key=1\n')!1056 first := make_save(a, 'leak the key')!1057 offer_now(a, mut backend)!1058 join(b, mut backend)!1059 put(a.root, 'secrets.env', 'api_key=2\n')!1060 later := make_save(a, 'rotate the key')!1061 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1062 mut race := StripRace{1063 inner: backend1064 other: &b1065 path: 'secrets.env'1066 }1067 if _ := offer(a, mut race) {1068 assert false, 'an offer that raced a strip must refuse'1069 } else {1070 assert err.msg().contains('koh steal'), err.msg()1071 }1072 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1073 assert !backend.get(object_key('proj', secret))!.found1074 for key in backend.list('proj/saves')! {1075 record := decode_save(backend.get(key)!.body)!1076 assert 'secrets.env' !in record.files.map(it.path), record.id1077 }1078}10791080108110821083fn test_an_interrupted_remote_strip_finishes_when_re_run() {1084 base := scratch('strip_resume')!1085 defer {1086 os.rmdir_all(base) or {}1087 }1088 remote_dir := os.join_path(base, 'remote')1089 mut backend := remote.new_folder(remote_dir)!1090 mut a := open_repo(machine('resume_a')!)!1091 defer {1092 a.close()1093 }1094 mut b := open_repo(machine('resume_b')!)!1095 defer {1096 b.close()1097 }1098 put(a.root, 'secrets.env', 'api_key=1\n')!1099 put(a.root, 'shared.env', 'same bytes on B\n')!1100 save := make_save(a, 'add secrets')!1101 offer_now(a, mut backend)!1102 join(b, mut backend)!1103 put(b.root, 'copy.txt', 'same bytes on B\n')!1104 b_save := make_save(b, 'B keeps a copy')!1105 offer_now(b, mut backend)!1106 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1107 secret_hash := storage.hash_hex('api_key=1\n'.bytes())11081109 strip_local(a, 'secrets.env', '')!1110 1111 1112 mut flaky := FaultyBackend{1113 inner: remote.new_folder(remote_dir)!1114 fail_write: 31115 }1116 if _ := send_strip(a, mut flaky, 'secrets.env') {1117 assert false, 'the injected failure should have surfaced'1118 }1119 assert strip_pending(a, 'secrets.env')1120 assert strip_local(a, 'secrets.env', '')!.files_removed == 01121 report := send_strip(a, mut backend, 'secrets.env')!1122 assert report.records == 11123 assert !strip_pending(a, 'secrets.env')1124 assert !backend.get(object_key('proj', secret_hash))!.found1125 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1126 assert 'secrets.env' !in record.files.map(it.path)1127 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1128 assert 'secrets.env' !in b_record.files.map(it.path)11291130 1131 strip_local(a, 'shared.env', '')!1132 send_strip(a, mut backend, 'shared.env')!1133 assert backend.get(object_key('proj', shared_hash))!.found1134}1135113611371138fn test_offer_waits_for_an_unapplied_strip() {1139 base := scratch('strip_first')!1140 defer {1141 os.rmdir_all(base) or {}1142 }1143 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1144 mut a := open_repo(machine('strip_first_a')!)!1145 defer {1146 a.close()1147 }1148 mut b := open_repo(machine('strip_first_b')!)!1149 defer {1150 b.close()1151 }1152 put(a.root, 'secrets.env', 'api_key=1\n')!1153 make_save(a, 'add secrets')!1154 offer_now(a, mut backend)!1155 join(b, mut backend)!1156 strip_local(a, 'secrets.env', '')!1157 send_strip(a, mut backend, 'secrets.env')!1158 put(b.root, 'more.txt', 'B keeps working\n')!1159 later := make_save(b, 'B saves with the secret still in its tree')!1160 if _ := offer_now(b, mut backend) {1161 assert false, 'B offered records that still list the stripped path'1162 }1163 steal_now(b, mut backend)!1164 offer_now(b, mut backend)!1165 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1166 assert 'secrets.env' !in record.files.map(it.path)1167}1168116911701171fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1172 base := scratch('strip_twice')!1173 defer {1174 os.rmdir_all(base) or {}1175 }1176 remote_dir := os.join_path(base, 'remote')1177 mut backend := remote.new_folder(remote_dir)!1178 mut a := open_repo(machine('strip_twice_a')!)!1179 defer {1180 a.close()1181 }1182 put(a.root, 'secrets.env', 'first key\n')!1183 make_save(a, 'first key')!1184 offer_now(a, mut backend)!1185 strip_local(a, 'secrets.env', '')!1186 mut flaky := FaultyBackend{1187 inner: remote.new_folder(remote_dir)!1188 fail_delete: true1189 }1190 if _ := send_strip(a, mut flaky, 'secrets.env') {1191 assert false, 'the injected delete failure should have surfaced'1192 }1193 put(a.root, 'secrets.env', 'second key\n')!1194 put(a.root, 'other.txt', 'more work\n')!1195 make_save(a, 'more work')!1196 offer_now(a, mut backend)!1197 send_strip(a, mut backend, 'secrets.env')!1198 for key in ['first key\n', 'second key\n'] {1199 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1200 }1201}120212031204fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1205 base := scratch('strip_scopes')!1206 defer {1207 os.rmdir_all(base) or {}1208 }1209 remote_dir := os.join_path(base, 'remote')1210 mut backend := remote.new_folder(remote_dir)!1211 mut a := open_repo(machine('strip_scopes_a')!)!1212 defer {1213 a.close()1214 }1215 put(a.root, 'secrets.env', 'first key\n')!1216 first := make_save(a, 'first key')!1217 put(a.root, 'secrets.env', 'second key\n')!1218 second := make_save(a, 'second key')!1219 offer_now(a, mut backend)!1220 strip_local(a, 'secrets.env', first.id)!1221 1222 mut flaky := FaultyBackend{1223 inner: remote.new_folder(remote_dir)!1224 fail_write: 21225 }1226 if _ := send_strip(a, mut flaky, 'secrets.env') {1227 assert false, 'the injected failure should have surfaced'1228 }1229 strip_local(a, 'secrets.env', second.id)!1230 send_strip(a, mut backend, 'secrets.env')!1231 assert !strip_pending(a, 'secrets.env')1232 for save in [first, second] {1233 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1234 assert 'secrets.env' !in record.files.map(it.path)1235 }1236 for key in ['first key\n', 'second key\n'] {1237 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1238 }1239}1240124112421243fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1244 base := scratch('strip_again')!1245 defer {1246 os.rmdir_all(base) or {}1247 }1248 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1249 mut a := open_repo(machine('strip_again_a')!)!1250 defer {1251 a.close()1252 }1253 mut b := open_repo(machine('strip_again_b')!)!1254 defer {1255 b.close()1256 }1257 put(a.root, 'secrets.env', 'first key\n')!1258 make_save(a, 'first key')!1259 offer_now(a, mut backend)!1260 join(b, mut backend)!1261 strip_local(a, 'secrets.env', '')!1262 send_strip(a, mut backend, 'secrets.env')!1263 steal_now(b, mut backend)!1264 for repo in [a, b] {1265 put(repo.root, 'secrets.env', 'second key\n')!1266 if _ := make_save(repo, 'second key') {1267 assert false, 'a banned path must not make a save'1268 }1269 }1270}1271127212731274fn test_a_stripped_path_is_not_asked_about() {1275 base := scratch('clash_strip')!1276 defer {1277 os.rmdir_all(base) or {}1278 }1279 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1280 mut a := open_repo(machine('clash_strip_a')!)!1281 defer {1282 a.close()1283 }1284 mut b := open_repo(machine('clash_strip_b')!)!1285 defer {1286 b.close()1287 }1288 put(a.root, 'secrets.env', 'key one\n')!1289 make_save(a, 'base')!1290 offer_now(a, mut backend)!1291 join(b, mut backend)!1292 put(a.root, 'secrets.env', 'key two\n')!1293 make_save(a, 'A rotates')!1294 offer_now(a, mut backend)!1295 strip_local(a, 'secrets.env', '')!1296 send_strip(a, mut backend, 'secrets.env')!1297 put(b.root, 'secrets.env', 'key three\n')!1298 mine := make_save(b, 'B rotates')!1299 report := steal(b, mut backend, StealOptions{})!1300 assert report.clashes.len == 01301 for save in history.all_saves(b)! {1302 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1303 }1304 assert history.by_id(b, mine.id) != none1305}1306130713081309fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1310 base := scratch('clash_base_strip')!1311 defer {1312 os.rmdir_all(base) or {}1313 }1314 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1315 mut a := open_repo(machine('base_strip_a')!)!1316 defer {1317 a.close()1318 }1319 mut b := open_repo(machine('base_strip_b')!)!1320 defer {1321 b.close()1322 }1323 put(a.root, 'notes.txt', 'shared\n')!1324 common := make_save(a, 'base')!1325 offer_now(a, mut backend)!1326 join(b, mut backend)!1327 put(a.root, 'other.txt', 'A moves on\n')!1328 make_save(a, 'A moves on')!1329 offer_now(a, mut backend)!1330 strip_local(a, 'notes.txt', common.id)!1331 send_strip(a, mut backend, 'notes.txt')!1332 put(b.root, 'notes.txt', 'B edits\n')!1333 make_save(b, 'B edits notes')!1334 if _ := steal(b, mut backend, StealOptions{}) {1335 assert false, 'with the base stripped, notes.txt differs on both sides'1336 } else {1337 assert err.msg().contains('notes.txt changed on both machines')1338 }1339 report := steal(b, mut backend, StealOptions{1340 mine: true1341 })!1342 assert report.clashes == ['notes.txt']1343 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1344}1345134613471348fn test_steal_into_a_folder_with_unsaved_files_refuses() {1349 base := scratch('steal_unsaved')!1350 defer {1351 os.rmdir_all(base) or {}1352 }1353 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1354 mut a := open_repo(machine('unsaved_a')!)!1355 defer {1356 a.close()1357 }1358 put(a.root, 'notes.txt', 'from A\n')!1359 make_save(a, 'A writes')!1360 offer_now(a, mut backend)!1361 b_root := machine('unsaved_b')!1362 mut b := storage.create(b_root)!1363 defer {1364 b.close()1365 }1366 put(b_root, 'notes.txt', 'never saved\n')!1367 1368 b.set_state('remote_repo', 'proj')!1369 if _ := steal(b, mut backend, StealOptions{}) {1370 assert false, 'steal overwrote a folder with unsaved files'1371 } else {1372 assert err.msg() == no_saves_here1373 }1374 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1375 1376 os.rm(os.join_path(b_root, 'notes.txt'))!1377 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1378 steal(b, mut backend, StealOptions{})!1379 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1380}1381138213831384fn test_steal_reports_incoming_flagged_saves() {1385 base := scratch('flag_warn')!1386 defer {1387 os.rmdir_all(base) or {}1388 }1389 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1390 mut a := open_repo(machine('flag_warn_a')!)!1391 defer {1392 a.close()1393 }1394 mut b := open_repo(machine('flag_warn_b')!)!1395 defer {1396 b.close()1397 }1398 put(a.root, 'a.txt', 'one\n')!1399 make_save(a, 'fine')!1400 offer_now(a, mut backend)!1401 join(b, mut backend)!1402 put(a.root, 'a.txt', 'two\n')!1403 suspect := make_save(a, 'suspect')!1404 put(a.root, 'a.txt', 'three\n')!1405 make_save(a, 'also fine')!1406 events.flag(a, suspect.id, 'broke the build')!1407 offer_now(a, mut backend)!1408 report := steal_now(b, mut backend)!1409 assert report.flagged.map(it.save_id) == [suspect.id]1410 assert report.flagged[0].reason == 'broke the build'1411}141214131414fn head_of(store &storage.Store) history.Save {1415 return history.head(store) or { panic('this repo has no head') }1416}1417141814191420fn test_the_first_offer_records_the_remote_format() {1421 base := scratch('format_write')!1422 defer {1423 os.rmdir_all(base) or {}1424 }1425 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1426 mut a := open_repo(machine('format_write_a')!)!1427 defer {1428 a.close()1429 }1430 put(a.root, 'a.txt', 'one\n')!1431 make_save(a, 'first')!1432 offer_now(a, mut backend)!1433 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1434}1435143614371438fn test_a_remote_without_a_format_still_syncs() {1439 base := scratch('format_old')!1440 defer {1441 os.rmdir_all(base) or {}1442 }1443 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1444 mut a := open_repo(machine('format_old_a')!)!1445 defer {1446 a.close()1447 }1448 put(a.root, 'a.txt', 'one\n')!1449 make_save(a, 'first')!1450 offer_now(a, mut backend)!1451 1452 backend.delete(format_key('proj'))!1453 mut b := open_repo(machine('format_old_b')!)!1454 defer {1455 b.close()1456 }1457 join(b, mut backend)!1458 assert head_of(b).id == head_of(a).id1459 put(a.root, 'a.txt', 'two\n')!1460 make_save(a, 'second')!1461 offer_now(a, mut backend)!1462 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1463 report := steal_now(b, mut backend)!1464 assert report.head == head_of(a).id1465}1466146714681469fn test_an_unknown_remote_format_is_refused() {1470 base := scratch('format_unknown')!1471 defer {1472 os.rmdir_all(base) or {}1473 }1474 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1475 mut a := open_repo(machine('format_bad_a')!)!1476 defer {1477 a.close()1478 }1479 put(a.root, 'a.txt', 'one\n')!1480 make_save(a, 'first')!1481 offer_now(a, mut backend)!1482 backend.delete(format_key('proj'))!1483 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1484 if _ := offer_now(a, mut backend) {1485 assert false, 'offer must refuse a format it does not speak'1486 } else {1487 assert err.msg().contains('koh-remote 2')1488 }1489 if _ := steal_now(a, mut backend) {1490 assert false, 'steal must refuse a format it does not speak'1491 } else {1492 assert err.msg().contains('koh-remote 2')1493 }1494 if _ := fetch_repo(mut backend, 'proj') {1495 assert false, 'a steal must refuse a format it does not speak'1496 } else {1497 assert err.msg().contains('koh-remote 2')1498 }1499}15001501150215031504fn test_a_lost_format_write_is_read_back_and_refused() {1505 base := scratch('format_race')!1506 defer {1507 os.rmdir_all(base) or {}1508 }1509 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1510 mut a := open_repo(machine('format_race_a')!)!1511 defer {1512 a.close()1513 }1514 put(a.root, 'a.txt', 'one\n')!1515 make_save(a, 'first')!1516 mut backend := remote.Backend(FaultyBackend{1517 inner: inner1518 race_format: true1519 })1520 if _ := offer_now(a, mut backend) {1521 assert false, 'offer must refuse the format another machine wrote first'1522 } else {1523 assert err.msg().contains('koh-remote 2')1524 }1525}