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_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {154 base := scratch('forward')!155 defer {156 os.rmdir_all(base) or {}157 }158 mut backend := remote.new_folder(os.join_path(base, 'remote'))!159 mut a := open_repo(machine('fwd_a')!)!160 defer {161 a.close()162 }163 put(a.root, 'a.txt', 'from A\n')!164 make_save(a, 'A writes')!165 offer_now(a, mut backend)!166167 168 mut b := open_repo(machine('fwd_b')!)!169 defer {170 b.close()171 }172 fetched := fetch_repo(mut backend, 'proj')!173 b.begin()!174 write_fetched(b, fetched)!175 history.set_head(b, fetched.head)!176 b.end()!177 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!178 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'179 assert head_of(b).id == fetched.head180}181182183184fn test_steal_while_only_ahead_leaves_the_line_alone() {185 base := scratch('ahead')!186 defer {187 os.rmdir_all(base) or {}188 }189 mut backend := remote.new_folder(os.join_path(base, 'remote'))!190 mut a := open_repo(machine('ahead_a')!)!191 defer {192 a.close()193 }194 put(a.root, 'a.txt', 'one\n')!195 make_save(a, 'offered')!196 offer_now(a, mut backend)!197 put(a.root, 'a.txt', 'two\n')!198 ahead := make_save(a, 'not offered yet')!199 report := steal_now(a, mut backend)!200 assert report.replayed == 0201 assert head_of(a).id == ahead.id202 203 assert !snapshot.restore_pending(a)204 assert history.all_saves(a)!.len == 2205 offer_now(a, mut backend)!206 assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id207}208209210211fn test_steal_never_writes_through_a_symlinked_folder() {212 base := scratch('steal_link')!213 defer {214 os.rmdir_all(base) or {}215 }216 mut backend := remote.new_folder(os.join_path(base, 'remote'))!217 mut a := open_repo(machine('steal_link_a')!)!218 defer {219 a.close()220 }221 mut b := open_repo(machine('steal_link_b')!)!222 defer {223 b.close()224 }225 put(a.root, 'a.txt', 'one\n')!226 make_save(a, 'first')!227 offer_now(a, mut backend)!228 join(b, mut backend)!229 outside := os.join_path(base, 'outside')230 put(outside, 'a.md', 'precious\n')!231 os.symlink(outside, os.join_path(b.root, 'docs'))!232 put(a.root, 'docs/a.md', 'from A\n')!233 make_save(a, 'A adds docs')!234 offer_now(a, mut backend)!235 head_before := head_of(b).id236 if _ := steal_now(b, mut backend) {237 assert false, 'a steal writing through a symlinked folder must refuse'238 } else {239 assert err.msg().contains('docs')240 }241 assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'242 assert head_of(b).id == head_before243}244245246fn test_steal_refuses_after_a_restore_cut_short() {247 base := scratch('steal_cut')!248 defer {249 os.rmdir_all(base) or {}250 }251 mut backend := remote.new_folder(os.join_path(base, 'remote'))!252 mut a := open_repo(machine('steal_cut_a')!)!253 defer {254 a.close()255 }256 put(a.root, 'a.txt', 'one\n')!257 make_save(a, 'first')!258 offer_now(a, mut backend)!259 a.set_state('restoring', 'yes')!260 if _ := steal_now(a, mut backend) {261 assert false, 'steal must refuse while a restore is unfinished'262 } else {263 assert err.msg().contains('koh revert')264 }265}266267268269fn test_a_steal_imports_parents_before_children() {270 base := scratch('steal_order')!271 defer {272 os.rmdir_all(base) or {}273 }274 mut backend := remote.new_folder(os.join_path(base, 'remote'))!275 mut a := open_repo(machine('order_a')!)!276 defer {277 a.close()278 }279 put(a.root, 'a.txt', 'one\n')!280 parent := make_save(a, 'parent')!281 child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{282 message: 'replayed, older than its parent'283 created_at: parent.created_at - 3600284 })!285 offer_now(a, mut backend)!286 mut b := open_repo(machine('order_b')!)!287 defer {288 b.close()289 }290 join(b, mut backend)!291 assert head_of(b).id == child.id292}293294295fn test_steal_refuses_an_unsaved_executable_bit_change() {296 base := scratch('chmod')!297 defer {298 os.rmdir_all(base) or {}299 }300 mut backend := remote.new_folder(os.join_path(base, 'remote'))!301 mut a := open_repo(machine('chmod_a')!)!302 defer {303 a.close()304 }305 put(a.root, 'run.sh', 'echo hi\n')!306 make_save(a, 'script')!307 offer_now(a, mut backend)!308 os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!309 if _ := steal_now(a, mut backend) {310 assert false, 'a chmod-only change should make the tree dirty'311 } else {312 assert err.msg() == dirty_tree313 }314}315316fn test_divergence_without_clashes_keeps_both_machines_work() {317 base := scratch('diverge')!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('div_a')!)!323 defer {324 a.close()325 }326 mut b := open_repo(machine('div_b')!)!327 defer {328 b.close()329 }330 put(a.root, 'shared.txt', 'base\n')!331 make_save(a, 'base')!332 offer_now(a, mut backend)!333334 335 join(b, mut backend)!336 put(a.root, 'fromA.txt', 'A\n')!337 make_save(a, 'A adds a file')!338 put(b.root, 'fromB.txt', 'B\n')!339 make_save(b, 'B adds a file')!340 offer_now(a, mut backend)!341342 343 if _ := offer_now(b, mut backend) {344 assert false, 'offering on top of unknown remote saves should be refused'345 } else {346 assert err.msg().contains('run koh steal')347 }348349 report := steal_now(b, mut backend)!350 assert !report.forward351 assert report.replayed == 1352 assert report.clashes.len == 0353 354 assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'355 assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'356 assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'357 358 mut saw_replay := false359 for save in history.all_saves(b)! {360 if save.replayed_from != '' {361 saw_replay = true362 }363 }364 assert saw_replay365 366 offer_now(b, mut backend)!367 line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()368 assert line_head == head_of(b).id369}370371372373374375fn test_a_local_change_undone_does_not_block_a_combine() {376 base := scratch('undone')!377 defer {378 os.rmdir_all(base) or {}379 }380 mut backend := remote.new_folder(os.join_path(base, 'remote'))!381 mut a := open_repo(machine('undone_a')!)!382 defer {383 a.close()384 }385 mut b := open_repo(machine('undone_b')!)!386 defer {387 b.close()388 }389 put(a.root, 'f.txt', 'base\n')!390 put(a.root, 'g.txt', 'base\n')!391 make_save(a, 'base')!392 offer_now(a, mut backend)!393 join(b, mut backend)!394395 put(a.root, 'f.txt', 'from A\n')!396 put(a.root, 'g.txt', 'same\n')!397 make_save(a, 'A edits both')!398 offer_now(a, mut backend)!399400 put(b.root, 'f.txt', 'tried on B\n')!401 put(b.root, 'g.txt', 'first try\n')!402 tried := make_save(b, 'B tries')!403 put(b.root, 'f.txt', 'base\n')!404 put(b.root, 'g.txt', 'same\n')!405 put(b.root, 'h.txt', 'B\n')!406 undone := make_save(b, 'B undoes f, lands on the same g, adds h')!407408 report := steal_now(b, mut backend)!409 assert report.clashes.len == 0410 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'411 assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'412 assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'413 414 415 assert report.replayed == 1416 assert head_of(b).replayed_from == undone.id417 ids := history.all_saves(b)!.map(it.id)418 assert tried.id in ids && undone.id in ids419 assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')420}421422423424fn test_clash_questions_are_asked_before_the_write_lock() {425 base := scratch('ask_unlocked')!426 defer {427 os.rmdir_all(base) or {}428 }429 mut backend := remote.new_folder(os.join_path(base, 'remote'))!430 mut a := open_repo(machine('ask_a')!)!431 defer {432 a.close()433 }434 mut b := open_repo(machine('ask_b')!)!435 defer {436 b.close()437 }438 put(a.root, 'same.txt', 'base\n')!439 make_save(a, 'base')!440 offer_now(a, mut backend)!441 join(b, mut backend)!442 put(a.root, 'same.txt', 'A version\n')!443 make_save(a, 'A edits same')!444 offer_now(a, mut backend)!445 put(b.root, 'same.txt', 'B version\n')!446 make_save(b, 'B edits same')!447 root := b.root448 449 if _ := steal(b, mut backend, StealOptions{450 guide: fn [root] (preview StealPreview) !map[string]Side {451 other := storage.open(root) or { panic(err) }452 defer {453 other.close()454 }455 os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }456 history.save_worktree(other, root, snapshot.load(root), history.SaveInput{457 message: 'agent saves during the question'458 }) or { panic('steal held the write lock while asking: ${err}') }459 return {460 preview.clashes[0].path: Side.mine461 }462 }463 })464 {465 assert false, 'a steal whose line moved during the question must refuse'466 } else {467 assert err.msg().contains('changed while')468 }469 470 assert head_of(b).message == 'agent saves during the question'471 report := steal(b, mut backend, StealOptions{472 mine: true473 })!474 assert report.kept_mine == 1475 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'476 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'477}478479fn test_clashing_files_are_chosen_one_at_a_time() {480 base := scratch('clash')!481 defer {482 os.rmdir_all(base) or {}483 }484 mut backend := remote.new_folder(os.join_path(base, 'remote'))!485 mut a := open_repo(machine('clash_a')!)!486 defer {487 a.close()488 }489 mut b := open_repo(machine('clash_b')!)!490 defer {491 b.close()492 }493 put(a.root, 'same.txt', 'base\n')!494 put(a.root, 'other.txt', 'quiet\n')!495 make_save(a, 'base')!496 offer_now(a, mut backend)!497 join(b, mut backend)!498499 500 put(a.root, 'same.txt', 'A version\n')!501 make_save(a, 'A edits same')!502 offer_now(a, mut backend)!503 put(b.root, 'same.txt', 'B version\n')!504 make_save(b, 'B edits same')!505506 507 if _ := steal(b, mut backend, StealOptions{}) {508 assert false, 'an undecided clash must be refused'509 } else {510 assert err.msg().contains('--mine') || err.msg().contains('--theirs')511 512 if err is ClashError {513 assert err.paths == ['same.txt']514 } else {515 assert false, 'an undecided clash must be a ClashError, not ${err}'516 }517 }518 assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,519 head_of(b).id)!).len == 0520521 522 report := steal_now(b, mut backend)!523 assert report.clashes == ['same.txt']524 assert report.kept_mine == 0525 assert report.kept_versions == ['same.txt:${report.previous_head}']526 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'527 assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'528}529530fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {531 base := scratch('guided_cancel')!532 defer { os.rmdir_all(base) or {} }533 mut backend := remote.new_folder(os.join_path(base, 'remote'))!534 mut a := open_repo(machine('guided_cancel_a')!)!535 defer { a.close() }536 mut b := open_repo(machine('guided_cancel_b')!)!537 defer { b.close() }538 put(a.root, 'same.txt', 'base\n')!539 make_save(a, 'base')!540 offer_now(a, mut backend)!541 join(b, mut backend)!542 put(a.root, 'same.txt', 'remote\n')!543 remote_head := make_save(a, 'remote change')!544 offer_now(a, mut backend)!545 put(b.root, 'same.txt', 'mine\n')!546 local_head := make_save(b, 'local change')!547548 if _ := steal(b, mut backend, StealOptions{549 guide: fn (preview StealPreview) !map[string]Side {550 assert preview.clashes.len == 1551 return error('cancelled')552 }553 }) {554 assert false, 'the guide cancelled'555 } else {556 assert err.msg() == 'cancelled'557 }558 assert head_of(b).id == local_head.id559 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'560 assert history.by_id(b, remote_head.id) != none561 assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1562}563564fn test_choosing_mine_keeps_the_local_version() {565 base := scratch('mine')!566 defer {567 os.rmdir_all(base) or {}568 }569 mut backend := remote.new_folder(os.join_path(base, 'remote'))!570 mut a := open_repo(machine('mine_a')!)!571 defer {572 a.close()573 }574 mut b := open_repo(machine('mine_b')!)!575 defer {576 b.close()577 }578 put(a.root, 'same.txt', 'base\n')!579 make_save(a, 'base')!580 offer_now(a, mut backend)!581 join(b, mut backend)!582 put(a.root, 'same.txt', 'A version\n')!583 make_save(a, 'A edits')!584 offer_now(a, mut backend)!585 put(b.root, 'same.txt', 'B version\n')!586 make_save(b, 'B edits')!587588 report := steal(b, mut backend, StealOptions{589 mine: true590 })!591 assert report.kept_mine == 1592 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'593 594 assert head_of(b).message.contains('combine: kept mine for 1 file')595 596 assert head_of(b).restores == ''597 assert history.all_saves(b)!.len >= 4598}599600fn test_flags_travel_with_the_saves() {601 base := scratch('flags')!602 defer {603 os.rmdir_all(base) or {}604 }605 mut backend := remote.new_folder(os.join_path(base, 'remote'))!606 mut a := open_repo(machine('flag_a')!)!607 defer {608 a.close()609 }610 mut b := open_repo(machine('flag_b')!)!611 defer {612 b.close()613 }614 put(a.root, 'a.txt', 'one\n')!615 save := make_save(a, 'suspect save')!616 events.flag(a, save.id, 'look at this')!617 offer_now(a, mut backend)!618619 620 fetched := fetch_back_to_known(b, mut backend, 'proj')!621 assert fetched.events_out.len == 1622 assert fetched.events_out[0].kind == events.kind_flag623 b.begin()!624 write_fetched(b, fetched)!625 history.set_head(b, fetched.head)!626 b.end()!627 state := events.flag_state(b, save.id)!628 assert state.flagged629 assert state.reason == 'look at this'630}631632fn test_a_second_offer_racing_head_is_refused_not_forced() {633 base := scratch('race')!634 defer {635 os.rmdir_all(base) or {}636 }637 mut backend := remote.new_folder(os.join_path(base, 'remote'))!638 mut a := open_repo(machine('race_a')!)!639 defer {640 a.close()641 }642 put(a.root, 'a.txt', 'one\n')!643 make_save(a, 'base')!644 offer_now(a, mut backend)!645646 647 assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),648 backend.get(head_key('proj'))!.tag)!649650 put(a.root, 'a.txt', 'two\n')!651 make_save(a, 'second')!652 if _ := offer_now(a, mut backend) {653 assert false, 'offering while the remote head moved must be refused'654 } else {655 assert err.msg().contains('run koh steal')656 }657}658659660fn join(store &storage.Store, mut backend remote.Backend) ! {661 fetched := fetch_repo(mut backend, 'proj')!662 store.begin()!663 write_fetched(store, fetched)!664 history.set_head(store, fetched.head)!665 store.end()!666 snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,667 fetched.head)!, [])!668 store.set_state('remote_repo', 'proj')!669}670671struct FaultyBackend {672mut:673 inner remote.Backend674 writes int675 reads int676 fail_write int 677 fail_read int678 679 fail_delete bool680 681 682 race_format bool683 raced bool684}685686fn (mut f FaultyBackend) get(key string) !remote.Object {687 f.reads++688 if f.fail_read > 0 && f.reads >= f.fail_read {689 return error('injected read failure')690 }691 return f.inner.get(key)!692}693694fn (mut f FaultyBackend) list(prefix string) ![]string {695 return f.inner.list(prefix)!696}697698fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {699 f.writes++700 if f.fail_write > 0 && f.writes >= f.fail_write {701 return error('injected write failure')702 }703 if f.race_format && !f.raced && key.ends_with('/format') {704 f.raced = true705 f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!706 return false707 }708 return f.inner.put_if_absent(key, body)!709}710711fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {712 f.writes++713 if f.fail_write > 0 && f.writes >= f.fail_write {714 return error('injected write failure')715 }716 return f.inner.put_if_match(key, body, tag)!717}718719fn (mut f FaultyBackend) delete(key string) ! {720 if f.fail_delete {721 return error('injected delete failure')722 }723 f.inner.delete(key)!724}725726727728729fn test_an_offer_sends_only_the_objects_a_save_adds() {730 base := scratch('offer_diff')!731 defer {732 os.rmdir_all(base) or {}733 }734 mut a := open_repo(machine('offer_diff_a')!)!735 defer {736 a.close()737 }738 for i in 0 .. 20 {739 put(a.root, 'f${i}.txt', 'file ${i}\n')!740 }741 make_save(a, 'twenty files')!742 mut backend := FaultyBackend{743 inner: remote.new_folder(os.join_path(base, 'remote'))!744 }745 offer(a, mut backend)!746 put(a.root, 'f3.txt', 'changed\n')!747 make_save(a, 'one change')!748 before := backend.writes749 report := offer(a, mut backend)!750 assert report.objects == 1751 752 assert backend.writes - before == 3753}754755fn test_a_failed_offer_is_safe_to_re_run() {756 base := scratch('fault_offer')!757 defer {758 os.rmdir_all(base) or {}759 }760 remote_dir := os.join_path(base, 'remote')761 mut a := open_repo(machine('fault_a')!)!762 defer {763 a.close()764 }765 put(a.root, 'a.txt', 'one\n')!766 make_save(a, 'first')!767 put(a.root, 'a.txt', 'two\n')!768 make_save(a, 'second')!769770 771 mut flaky := FaultyBackend{772 inner: remote.new_folder(remote_dir)!773 fail_write: 2774 }775 if _ := offer(a, mut flaky) {776 assert false, 'the injected failure should have surfaced'777 }778779 780 781 mut healthy := remote.new_folder(remote_dir)!782 report := offer(a, mut healthy)!783 assert report.saves == 2784 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id785 assert healthy.list('proj/saves')!.len == 2786}787788789790fn test_a_strip_with_an_illegal_path_is_ignored() {791 base := scratch('illegal_strip')!792 defer {793 os.rmdir_all(base) or {}794 }795 mut backend := remote.new_folder(os.join_path(base, 'remote'))!796 mut a := open_repo(machine('illegal_strip_a')!)!797 defer {798 a.close()799 }800 mut b := open_repo(machine('illegal_strip_b')!)!801 defer {802 b.close()803 }804 put(a.root, 'a/x.txt', 'kept\n')!805 make_save(a, 'add a folder')!806 offer_now(a, mut backend)!807 for id, path in {808 'aaaaaaaaaaaa': 'a//'809 'bbbbbbbbbbbb': 'a\\ud800'810 } {811 backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!812 }813 join(b, mut backend)!814 assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)815 put(a.root, 'more.txt', 'more\n')!816 make_save(a, 'more')!817 offer_now(a, mut backend)!818}819820fn test_a_failed_steal_leaves_nothing_behind() {821 base := scratch('fault_steal')!822 defer {823 os.rmdir_all(base) or {}824 }825 remote_dir := os.join_path(base, 'remote')826 mut a := open_repo(machine('fault_s_a')!)!827 defer {828 a.close()829 }830 mut b := open_repo(machine('fault_s_b')!)!831 defer {832 b.close()833 }834 put(a.root, 'a.txt', 'from A\n')!835 make_save(a, 'A writes')!836 mut healthy := remote.new_folder(remote_dir)!837 offer(a, mut healthy)!838839 840 mut flaky := FaultyBackend{841 inner: remote.new_folder(remote_dir)!842 fail_read: 2843 }844 if _ := fetch_back_to_known(b, mut flaky, 'proj') {845 assert false, 'the injected read failure should have surfaced'846 }847 848 assert history.head(b) == none849 assert b.object_count()! == 0850851 852 fetched := fetch_back_to_known(b, mut healthy, 'proj')!853 b.begin()!854 write_fetched(b, fetched)!855 history.set_head(b, fetched.head)!856 b.end()!857 snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!858 assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'859}860861fn test_an_interrupted_strip_is_idempotent() {862 863 864 865 base := scratch('fault_strip')!866 defer {867 os.rmdir_all(base) or {}868 }869 mut a := open_repo(machine('fault_strip_a')!)!870 defer {871 a.close()872 }873 put(a.root, 'secret.txt', 'shh\n')!874 put(a.root, 'keep.txt', 'fine\n')!875 save := make_save(a, 'add both')!876877 for _ in 0 .. 2 {878 strip_local(a, 'secret.txt', '')!879 }880 files := history.files_of(a, save.id)!881 882 883 assert files.map(it.path) == ['.kohignore', 'keep.txt']884 assert events.strip_events(a)!.len == 1885}886887888889890891fn test_a_strip_matches_the_path_exactly() {892 mut a := open_repo(machine('strip_exact')!)!893 defer {894 a.close()895 }896 hash := a.put_object('x\n'.bytes())!897 mut files := []snapshot.FileEntry{}898 for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {899 files << snapshot.FileEntry{900 path: path901 hash: hash902 }903 }904 save := history.save_tree(a, '', files, history.SaveInput{905 message: 'add all'906 })!907 assert strip_scope(a, 'docs', '')!.files == 1908 strip_local(a, 'docs', '')!909 strip_local(a, 'a_b', '')!910 assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']911}912913fn test_a_strip_reaches_the_remote_and_other_machines() {914 base := scratch('strip_sync')!915 defer {916 os.rmdir_all(base) or {}917 }918 mut a := open_repo(machine('strip_a')!)!919 defer {920 a.close()921 }922 mut b := open_repo(machine('strip_b')!)!923 defer {924 b.close()925 }926 put(a.root, 'secrets.env', 'api_key=1\n')!927 put(a.root, 'keep.txt', 'fine\n')!928 save := make_save(a, 'add both')!929 mut backend := remote.new_folder(os.join_path(base, 'remote'))!930 offer_now(a, mut backend)!931 join(b, mut backend)!932 assert b.object_count()! > 0933934 935 936 mut secret_hash := ''937 for file in history.files_of(a, save.id)! {938 if file.path == 'secrets.env' {939 secret_hash = file.hash940 }941 }942 assert secret_hash != ''943 strip_local(a, 'secrets.env', '')!944 report := send_strip(a, mut backend, 'secrets.env')!945 assert report.event_sent946 assert report.records == 1947948 949 950 record := decode_save(backend.get(save_key('proj', save.id))!.body)!951 for file in record.files {952 assert file.path != 'secrets.env'953 }954 955 956 assert !backend.get(object_key('proj', secret_hash))!.found957 assert backend.list('proj/objects')!.len == 2958959 960 b.begin()!961 fetched := fetch_back_to_known(b, mut backend, 'proj')!962 write_fetched(b, fetched)!963 b.end()!964 mut paths := []string{}965 for file in history.files_of(b, save.id)! {966 paths << file.path967 }968 assert 'secrets.env' !in paths969 assert 'keep.txt' in paths970971 972 973 974 assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)975 put(b.root, 'secrets.env', 'rotated\n')!976 assert !dirty(b, history.head(b))!977 put(b.root, 'keep.txt', 'still fine\n')!978 later := make_save(b, 'edit keep')!979 offer_now(b, mut backend)!980 assert backend.list('proj/events')!.len == 1981 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)982}983984985986987fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {988 base := scratch('strip_later')!989 defer {990 os.rmdir_all(base) or {}991 }992 mut backend := remote.new_folder(os.join_path(base, 'remote'))!993 mut a := open_repo(machine('strip_later_a')!)!994 defer {995 a.close()996 }997 mut b := open_repo(machine('strip_later_b')!)!998 defer {999 b.close()1000 }1001 put(a.root, 'secrets.env', 'api_key=1\n')!1002 old := make_save(a, 'leak the key')!1003 offer_now(a, mut backend)!1004 join(b, mut backend)!1005 strip_local(a, 'secrets.env', '')!1006 send_strip(a, mut backend, 'secrets.env')!1007 1008 put(b.root, 'secrets.env', 'api_key=2\n')!1009 later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1010 message: 'rotate the key'1011 created_at: events.strip_events(a)!.last().created_at + 36001012 })!1013 assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1014 b.begin()!1015 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1016 b.end()!1017 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1018 offer_now(b, mut backend)!10191020 mut c := open_repo(machine('strip_later_c')!)!1021 defer {1022 c.close()1023 }1024 join(c, mut backend)!1025 for save in [old.id, later.id] {1026 assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1027 }1028 assert !os.exists(os.join_path(c.root, 'secrets.env'))1029}1030103110321033fn test_a_held_strip_reaches_saves_arriving_after_it() {1034 base := scratch('strip_held')!1035 defer {1036 os.rmdir_all(base) or {}1037 }1038 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1039 mut a := open_repo(machine('strip_held_a')!)!1040 defer {1041 a.close()1042 }1043 mut b := open_repo(machine('strip_held_b')!)!1044 defer {1045 b.close()1046 }1047 put(a.root, 'secrets.env', 'api_key=1\n')!1048 make_save(a, 'leak the key')!1049 offer_now(a, mut backend)!1050 join(b, mut backend)!1051 strip_local(b, 'secrets.env', '')!1052 put(a.root, 'keep.txt', 'fine\n')!1053 later := make_save(a, 'add keep')!1054 offer_now(a, mut backend)!1055 b.begin()!1056 write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1057 b.end()!1058 paths := history.files_of(b, later.id)!.map(it.path)1059 assert 'secrets.env' !in paths1060 assert 'keep.txt' in paths1061}1062106310641065struct StripRace {1066mut:1067 inner remote.Backend1068 other &storage.Store1069 path string1070 done bool1071}10721073fn (mut r StripRace) get(key string) !remote.Object {1074 return r.inner.get(key)!1075}10761077fn (mut r StripRace) list(prefix string) ![]string {1078 return r.inner.list(prefix)!1079}10801081fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1082 if !r.done && key.contains('/saves/') {1083 r.done = true1084 strip_local(r.other, r.path, '')!1085 send_strip(r.other, mut r.inner, r.path)!1086 }1087 return r.inner.put_if_absent(key, body)!1088}10891090fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1091 return r.inner.put_if_match(key, body, tag)!1092}10931094fn (mut r StripRace) delete(key string) ! {1095 r.inner.delete(key)!1096}10971098109911001101fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1102 base := scratch('strip_race')!1103 defer {1104 os.rmdir_all(base) or {}1105 }1106 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1107 mut a := open_repo(machine('strip_race_a')!)!1108 defer {1109 a.close()1110 }1111 mut b := open_repo(machine('strip_race_b')!)!1112 defer {1113 b.close()1114 }1115 put(a.root, 'secrets.env', 'api_key=1\n')!1116 first := make_save(a, 'leak the key')!1117 offer_now(a, mut backend)!1118 join(b, mut backend)!1119 put(a.root, 'secrets.env', 'api_key=2\n')!1120 later := make_save(a, 'rotate the key')!1121 secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1122 mut race := StripRace{1123 inner: backend1124 other: &b1125 path: 'secrets.env'1126 }1127 if _ := offer(a, mut race) {1128 assert false, 'an offer that raced a strip must refuse'1129 } else {1130 assert err.msg().contains('koh steal'), err.msg()1131 }1132 assert backend.get(head_key('proj'))!.body.bytestr() == first.id1133 assert !backend.get(object_key('proj', secret))!.found1134 for key in backend.list('proj/saves')! {1135 record := decode_save(backend.get(key)!.body)!1136 assert 'secrets.env' !in record.files.map(it.path), record.id1137 }1138}11391140114111421143fn test_an_interrupted_remote_strip_finishes_when_re_run() {1144 base := scratch('strip_resume')!1145 defer {1146 os.rmdir_all(base) or {}1147 }1148 remote_dir := os.join_path(base, 'remote')1149 mut backend := remote.new_folder(remote_dir)!1150 mut a := open_repo(machine('resume_a')!)!1151 defer {1152 a.close()1153 }1154 mut b := open_repo(machine('resume_b')!)!1155 defer {1156 b.close()1157 }1158 put(a.root, 'secrets.env', 'api_key=1\n')!1159 put(a.root, 'shared.env', 'same bytes on B\n')!1160 save := make_save(a, 'add secrets')!1161 offer_now(a, mut backend)!1162 join(b, mut backend)!1163 put(b.root, 'copy.txt', 'same bytes on B\n')!1164 b_save := make_save(b, 'B keeps a copy')!1165 offer_now(b, mut backend)!1166 shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1167 secret_hash := storage.hash_hex('api_key=1\n'.bytes())11681169 strip_local(a, 'secrets.env', '')!1170 1171 1172 mut flaky := FaultyBackend{1173 inner: remote.new_folder(remote_dir)!1174 fail_write: 31175 }1176 if _ := send_strip(a, mut flaky, 'secrets.env') {1177 assert false, 'the injected failure should have surfaced'1178 }1179 assert strip_pending(a, 'secrets.env')1180 assert strip_local(a, 'secrets.env', '')!.files_removed == 01181 report := send_strip(a, mut backend, 'secrets.env')!1182 assert report.records == 11183 assert !strip_pending(a, 'secrets.env')1184 assert !backend.get(object_key('proj', secret_hash))!.found1185 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1186 assert 'secrets.env' !in record.files.map(it.path)1187 b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1188 assert 'secrets.env' !in b_record.files.map(it.path)11891190 1191 strip_local(a, 'shared.env', '')!1192 send_strip(a, mut backend, 'shared.env')!1193 assert backend.get(object_key('proj', shared_hash))!.found1194}1195119611971198fn test_offer_waits_for_an_unapplied_strip() {1199 base := scratch('strip_first')!1200 defer {1201 os.rmdir_all(base) or {}1202 }1203 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1204 mut a := open_repo(machine('strip_first_a')!)!1205 defer {1206 a.close()1207 }1208 mut b := open_repo(machine('strip_first_b')!)!1209 defer {1210 b.close()1211 }1212 put(a.root, 'secrets.env', 'api_key=1\n')!1213 make_save(a, 'add secrets')!1214 offer_now(a, mut backend)!1215 join(b, mut backend)!1216 strip_local(a, 'secrets.env', '')!1217 send_strip(a, mut backend, 'secrets.env')!1218 put(b.root, 'more.txt', 'B keeps working\n')!1219 later := make_save(b, 'B saves with the secret still in its tree')!1220 if _ := offer_now(b, mut backend) {1221 assert false, 'B offered records that still list the stripped path'1222 }1223 steal_now(b, mut backend)!1224 offer_now(b, mut backend)!1225 record := decode_save(backend.get(save_key('proj', later.id))!.body)!1226 assert 'secrets.env' !in record.files.map(it.path)1227}1228122912301231fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1232 base := scratch('strip_twice')!1233 defer {1234 os.rmdir_all(base) or {}1235 }1236 remote_dir := os.join_path(base, 'remote')1237 mut backend := remote.new_folder(remote_dir)!1238 mut a := open_repo(machine('strip_twice_a')!)!1239 defer {1240 a.close()1241 }1242 put(a.root, 'secrets.env', 'first key\n')!1243 make_save(a, 'first key')!1244 offer_now(a, mut backend)!1245 strip_local(a, 'secrets.env', '')!1246 mut flaky := FaultyBackend{1247 inner: remote.new_folder(remote_dir)!1248 fail_delete: true1249 }1250 if _ := send_strip(a, mut flaky, 'secrets.env') {1251 assert false, 'the injected delete failure should have surfaced'1252 }1253 put(a.root, 'secrets.env', 'second key\n')!1254 put(a.root, 'other.txt', 'more work\n')!1255 make_save(a, 'more work')!1256 offer_now(a, mut backend)!1257 send_strip(a, mut backend, 'secrets.env')!1258 for key in ['first key\n', 'second key\n'] {1259 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1260 }1261}126212631264fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1265 base := scratch('strip_scopes')!1266 defer {1267 os.rmdir_all(base) or {}1268 }1269 remote_dir := os.join_path(base, 'remote')1270 mut backend := remote.new_folder(remote_dir)!1271 mut a := open_repo(machine('strip_scopes_a')!)!1272 defer {1273 a.close()1274 }1275 put(a.root, 'secrets.env', 'first key\n')!1276 first := make_save(a, 'first key')!1277 put(a.root, 'secrets.env', 'second key\n')!1278 second := make_save(a, 'second key')!1279 offer_now(a, mut backend)!1280 strip_local(a, 'secrets.env', first.id)!1281 1282 mut flaky := FaultyBackend{1283 inner: remote.new_folder(remote_dir)!1284 fail_write: 21285 }1286 if _ := send_strip(a, mut flaky, 'secrets.env') {1287 assert false, 'the injected failure should have surfaced'1288 }1289 strip_local(a, 'secrets.env', second.id)!1290 send_strip(a, mut backend, 'secrets.env')!1291 assert !strip_pending(a, 'secrets.env')1292 for save in [first, second] {1293 record := decode_save(backend.get(save_key('proj', save.id))!.body)!1294 assert 'secrets.env' !in record.files.map(it.path)1295 }1296 for key in ['first key\n', 'second key\n'] {1297 assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1298 }1299}1300130113021303fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1304 base := scratch('strip_again')!1305 defer {1306 os.rmdir_all(base) or {}1307 }1308 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1309 mut a := open_repo(machine('strip_again_a')!)!1310 defer {1311 a.close()1312 }1313 mut b := open_repo(machine('strip_again_b')!)!1314 defer {1315 b.close()1316 }1317 put(a.root, 'secrets.env', 'first key\n')!1318 make_save(a, 'first key')!1319 offer_now(a, mut backend)!1320 join(b, mut backend)!1321 strip_local(a, 'secrets.env', '')!1322 send_strip(a, mut backend, 'secrets.env')!1323 steal_now(b, mut backend)!1324 for repo in [a, b] {1325 put(repo.root, 'secrets.env', 'second key\n')!1326 if _ := make_save(repo, 'second key') {1327 assert false, 'a banned path must not make a save'1328 }1329 }1330}1331133213331334fn test_a_stripped_path_is_not_asked_about() {1335 base := scratch('clash_strip')!1336 defer {1337 os.rmdir_all(base) or {}1338 }1339 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1340 mut a := open_repo(machine('clash_strip_a')!)!1341 defer {1342 a.close()1343 }1344 mut b := open_repo(machine('clash_strip_b')!)!1345 defer {1346 b.close()1347 }1348 put(a.root, 'secrets.env', 'key one\n')!1349 make_save(a, 'base')!1350 offer_now(a, mut backend)!1351 join(b, mut backend)!1352 put(a.root, 'secrets.env', 'key two\n')!1353 make_save(a, 'A rotates')!1354 offer_now(a, mut backend)!1355 strip_local(a, 'secrets.env', '')!1356 send_strip(a, mut backend, 'secrets.env')!1357 put(b.root, 'secrets.env', 'key three\n')!1358 mine := make_save(b, 'B rotates')!1359 report := steal(b, mut backend, StealOptions{})!1360 assert report.clashes.len == 01361 for save in history.all_saves(b)! {1362 assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1363 }1364 assert history.by_id(b, mine.id) != none1365}1366136713681369fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1370 base := scratch('clash_base_strip')!1371 defer {1372 os.rmdir_all(base) or {}1373 }1374 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1375 mut a := open_repo(machine('base_strip_a')!)!1376 defer {1377 a.close()1378 }1379 mut b := open_repo(machine('base_strip_b')!)!1380 defer {1381 b.close()1382 }1383 put(a.root, 'notes.txt', 'shared\n')!1384 common := make_save(a, 'base')!1385 offer_now(a, mut backend)!1386 join(b, mut backend)!1387 put(a.root, 'other.txt', 'A moves on\n')!1388 make_save(a, 'A moves on')!1389 offer_now(a, mut backend)!1390 strip_local(a, 'notes.txt', common.id)!1391 send_strip(a, mut backend, 'notes.txt')!1392 put(b.root, 'notes.txt', 'B edits\n')!1393 make_save(b, 'B edits notes')!1394 if _ := steal(b, mut backend, StealOptions{}) {1395 assert false, 'with the base stripped, notes.txt differs on both sides'1396 } else {1397 assert err.msg().contains('notes.txt changed on both machines')1398 }1399 report := steal(b, mut backend, StealOptions{1400 mine: true1401 })!1402 assert report.clashes == ['notes.txt']1403 assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1404}1405140614071408fn test_steal_into_a_folder_with_unsaved_files_refuses() {1409 base := scratch('steal_unsaved')!1410 defer {1411 os.rmdir_all(base) or {}1412 }1413 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1414 mut a := open_repo(machine('unsaved_a')!)!1415 defer {1416 a.close()1417 }1418 put(a.root, 'notes.txt', 'from A\n')!1419 make_save(a, 'A writes')!1420 offer_now(a, mut backend)!1421 b_root := machine('unsaved_b')!1422 mut b := storage.create(b_root)!1423 defer {1424 b.close()1425 }1426 put(b_root, 'notes.txt', 'never saved\n')!1427 1428 b.set_state('remote_repo', 'proj')!1429 if _ := steal(b, mut backend, StealOptions{}) {1430 assert false, 'steal overwrote a folder with unsaved files'1431 } else {1432 assert err.msg() == no_saves_here1433 }1434 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1435 1436 os.rm(os.join_path(b_root, 'notes.txt'))!1437 put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1438 steal(b, mut backend, StealOptions{})!1439 assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1440}1441144214431444fn test_steal_reports_incoming_flagged_saves() {1445 base := scratch('flag_warn')!1446 defer {1447 os.rmdir_all(base) or {}1448 }1449 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1450 mut a := open_repo(machine('flag_warn_a')!)!1451 defer {1452 a.close()1453 }1454 mut b := open_repo(machine('flag_warn_b')!)!1455 defer {1456 b.close()1457 }1458 put(a.root, 'a.txt', 'one\n')!1459 make_save(a, 'fine')!1460 offer_now(a, mut backend)!1461 join(b, mut backend)!1462 put(a.root, 'a.txt', 'two\n')!1463 suspect := make_save(a, 'suspect')!1464 put(a.root, 'a.txt', 'three\n')!1465 make_save(a, 'also fine')!1466 events.flag(a, suspect.id, 'broke the build')!1467 offer_now(a, mut backend)!1468 report := steal_now(b, mut backend)!1469 assert report.flagged.map(it.save_id) == [suspect.id]1470 assert report.flagged[0].reason == 'broke the build'1471}147214731474fn head_of(store &storage.Store) history.Save {1475 return history.head(store) or { panic('this repo has no head') }1476}1477147814791480fn test_the_first_offer_records_the_remote_format() {1481 base := scratch('format_write')!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_write_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 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1494}1495149614971498fn test_a_remote_without_a_format_still_syncs() {1499 base := scratch('format_old')!1500 defer {1501 os.rmdir_all(base) or {}1502 }1503 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1504 mut a := open_repo(machine('format_old_a')!)!1505 defer {1506 a.close()1507 }1508 put(a.root, 'a.txt', 'one\n')!1509 make_save(a, 'first')!1510 offer_now(a, mut backend)!1511 1512 backend.delete(format_key('proj'))!1513 mut b := open_repo(machine('format_old_b')!)!1514 defer {1515 b.close()1516 }1517 join(b, mut backend)!1518 assert head_of(b).id == head_of(a).id1519 put(a.root, 'a.txt', 'two\n')!1520 make_save(a, 'second')!1521 offer_now(a, mut backend)!1522 assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1523 report := steal_now(b, mut backend)!1524 assert report.head == head_of(a).id1525}1526152715281529fn test_an_unknown_remote_format_is_refused() {1530 base := scratch('format_unknown')!1531 defer {1532 os.rmdir_all(base) or {}1533 }1534 mut backend := remote.new_folder(os.join_path(base, 'remote'))!1535 mut a := open_repo(machine('format_bad_a')!)!1536 defer {1537 a.close()1538 }1539 put(a.root, 'a.txt', 'one\n')!1540 make_save(a, 'first')!1541 offer_now(a, mut backend)!1542 backend.delete(format_key('proj'))!1543 backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1544 if _ := offer_now(a, mut backend) {1545 assert false, 'offer must refuse a format it does not speak'1546 } else {1547 assert err.msg().contains('koh-remote 2')1548 }1549 if _ := steal_now(a, mut backend) {1550 assert false, 'steal must refuse a format it does not speak'1551 } else {1552 assert err.msg().contains('koh-remote 2')1553 }1554 if _ := fetch_repo(mut backend, 'proj') {1555 assert false, 'a steal must refuse a format it does not speak'1556 } else {1557 assert err.msg().contains('koh-remote 2')1558 }1559}15601561156215631564fn test_a_lost_format_write_is_read_back_and_refused() {1565 base := scratch('format_race')!1566 defer {1567 os.rmdir_all(base) or {}1568 }1569 mut inner := remote.new_folder(os.join_path(base, 'remote'))!1570 mut a := open_repo(machine('format_race_a')!)!1571 defer {1572 a.close()1573 }1574 put(a.root, 'a.txt', 'one\n')!1575 make_save(a, 'first')!1576 mut backend := remote.Backend(FaultyBackend{1577 inner: inner1578 race_format: true1579 })1580 if _ := offer_now(a, mut backend) {1581 assert false, 'offer must refuse the format another machine wrote first'1582 } else {1583 assert err.msg().contains('koh-remote 2')1584 }1585}