//! Two-machine tests: two repos and one folder remote standing in for the Mac //! and the PC. This is where offer, steal and combine are actually proven — //! the folder backend exists so these run offline. module exchange import os import src.events import src.history import src.remote import src.snapshot import src.storage fn test_arriving_record_drops_unsafe_paths_and_reports_them() { mut record := SaveRecord{ files: [ FileRecord{ path: 'safe.txt' }, FileRecord{ path: '../outside.txt' }, FileRecord{ path: 'bad\\name.txt' }, FileRecord{ path: 'Icon\r' }, ] } dropped := drop_unsafe_paths(mut record) assert record.files.map(it.path) == ['safe.txt'] assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt'] } // An unpaired surrogate escape stands for its three bytes, so the record still // decodes and only the path holding it is dropped; a real pair is kept. fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() { body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}' mut record := decode_save(body.bytes())! dropped := drop_unsafe_paths(mut record) assert record.files.map(it.path) == ['ok😀'] assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x'] } // Machines keep their repo in a folder of the same NAME, because the remote key // is the folder's name; that is what makes them the same repo to the remote. fn machine(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj') os.rmdir_all(os.dir(dir)) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn scratch(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn open_repo(dir string) !storage.Store { mut store := storage.create(dir)! os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')! return store } fn put(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } fn make_save(store &storage.Store, message string) !history.Save { return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{ message: message })! } fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport { return offer(store, mut backend)! } fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport { return steal(store, mut backend, StealOptions{ theirs: true })! } fn test_first_offer_publishes_the_whole_line() { base := scratch('first')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('first_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! report := offer_now(a, mut backend)! assert report.repo == 'proj' assert report.saves == 2 assert report.objects >= 2 assert backend.get(head_key('proj'))!.found assert backend.list('proj/saves')!.len == 2 // A second offer with nothing new uploads nothing and still succeeds. again := offer_now(a, mut backend)! assert again.saves == 0 } fn test_the_repo_name_is_remembered_and_head_offered() { base := scratch('remember')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('remember_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! offer_now(a, mut backend)! assert a.state('remote_repo') or { '' } == 'proj' assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id // Offer's step 3: the new remote head is recorded in state. assert a.state('remote_head') or { '' } == head_of(a).id // A second offer, with the remote already holding our head, must not // refuse: the name is remembered, so the remote is ours. put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! offer_now(a, mut backend)! assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id } fn test_a_repo_name_is_lowercased_at_first_offer() { base := scratch('lower')! defer { os.rmdir_all(base) or {} } dir := os.join_path(base, 'Haven') os.mkdir_all(dir, os.MkdirParams{})! mut a := open_repo(dir)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! assert offer_now(a, mut backend)!.repo == 'haven' assert backend.get(head_key('haven'))!.found } fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() { base := scratch('renamed')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('renamed_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! offer_now(a, mut backend)! // What Kepr's rename leaves: the history under the new name, a marker, no old name. os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))! os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})! os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')! put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! assert offer_now(a, mut backend)!.repo == 'tool' assert a.state('remote_repo') or { '' } == 'tool' assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id assert !backend.get(head_key('proj'))!.found } fn test_a_rename_marker_naming_a_path_is_not_followed() { base := scratch('badmarker')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('badmarker_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! offer_now(a, mut backend)! os.rmdir_all(os.join_path(remote_dir, 'proj'))! os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})! os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')! assert offer_now(a, mut backend)!.repo == 'proj' } fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() { base := scratch('forward')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('fwd_a')!)! defer { a.close() } put(a.root, 'a.txt', 'from A\n')! make_save(a, 'A writes')! offer_now(a, mut backend)! // B starts empty and takes the whole repo. mut b := open_repo(machine('fwd_b')!)! defer { b.close() } fetched := fetch_repo(mut backend, 'proj')! b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.end()! snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])! assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n' assert head_of(b).id == fetched.head } // A machine that is only ahead has nothing to combine: stealing must leave its // saves, and their IDs, exactly where they are. fn test_steal_while_only_ahead_leaves_the_line_alone() { base := scratch('ahead')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('ahead_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'offered')! offer_now(a, mut backend)! put(a.root, 'a.txt', 'two\n')! ahead := make_save(a, 'not offered yet')! report := steal_now(a, mut backend)! assert report.replayed == 0 assert head_of(a).id == ahead.id // Nothing was restored, so nothing is left marked as half-restored. assert !snapshot.restore_pending(a) assert history.all_saves(a)!.len == 2 offer_now(a, mut backend)! assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id } // A link is invisible to the walk, so a tree holding one is clean; the files // a steal brings in must still never land in the folder it points at. fn test_steal_never_writes_through_a_symlinked_folder() { base := scratch('steal_link')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('steal_link_a')!)! defer { a.close() } mut b := open_repo(machine('steal_link_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! offer_now(a, mut backend)! join(b, mut backend)! outside := os.join_path(base, 'outside') put(outside, 'a.md', 'precious\n')! os.symlink(outside, os.join_path(b.root, 'docs'))! put(a.root, 'docs/a.md', 'from A\n')! make_save(a, 'A adds docs')! offer_now(a, mut backend)! head_before := head_of(b).id if _ := steal_now(b, mut backend) { assert false, 'a steal writing through a symlinked folder must refuse' } else { assert err.msg().contains('docs') } assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n' assert head_of(b).id == head_before } // A steal onto a half-restored tree would replay it as somebody's work. fn test_steal_refuses_after_a_restore_cut_short() { base := scratch('steal_cut')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('steal_cut_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! offer_now(a, mut backend)! a.set_state('restoring', 'yes')! if _ := steal_now(a, mut backend) { assert false, 'steal must refuse while a restore is unfinished' } else { assert err.msg().contains('koh revert') } } // A replayed save keeps its original time but sits on a newer parent, so a // steal cannot order saves by time. fn test_a_steal_imports_parents_before_children() { base := scratch('steal_order')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('order_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! parent := make_save(a, 'parent')! child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{ message: 'replayed, older than its parent' created_at: parent.created_at - 3600 })! offer_now(a, mut backend)! mut b := open_repo(machine('order_b')!)! defer { b.close() } join(b, mut backend)! assert head_of(b).id == child.id } // A chmod is unsaved work too: a steal would restore the old mode over it. fn test_steal_refuses_an_unsaved_executable_bit_change() { base := scratch('chmod')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('chmod_a')!)! defer { a.close() } put(a.root, 'run.sh', 'echo hi\n')! make_save(a, 'script')! offer_now(a, mut backend)! os.chmod(os.join_path(a.root, 'run.sh'), 0o755)! if _ := steal_now(a, mut backend) { assert false, 'a chmod-only change should make the tree dirty' } else { assert err.msg() == dirty_tree } } fn test_divergence_without_clashes_keeps_both_machines_work() { base := scratch('diverge')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('div_a')!)! defer { a.close() } mut b := open_repo(machine('div_b')!)! defer { b.close() } put(a.root, 'shared.txt', 'base\n')! make_save(a, 'base')! offer_now(a, mut backend)! // B joins, then both machines change a different file. join(b, mut backend)! put(a.root, 'fromA.txt', 'A\n')! make_save(a, 'A adds a file')! put(b.root, 'fromB.txt', 'B\n')! make_save(b, 'B adds a file')! offer_now(a, mut backend)! // B's offer is refused: the remote has moved on. if _ := offer_now(b, mut backend) { assert false, 'offering on top of unknown remote saves should be refused' } else { assert err.msg().contains('run koh steal') } report := steal_now(b, mut backend)! assert !report.forward assert report.replayed == 1 assert report.clashes.len == 0 // Both files are on B's line now, and on disk. assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n' assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n' assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n' // B's own save was replayed, not erased. mut saw_replay := false for save in history.all_saves(b)! { if save.replayed_from != '' { saw_replay = true } } assert saw_replay // And the remote now has B's work too. offer_now(b, mut backend)! line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space() assert line_head == head_of(b).id } // An agent that edits a file and then undoes the edit leaves no net change, so // the file is not a clash when the other machine changed it. Replaying the // edit itself must not stop the steal: the remote's version simply stands. // Likewise a file both machines ended up changing the same way. fn test_a_local_change_undone_does_not_block_a_combine() { base := scratch('undone')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('undone_a')!)! defer { a.close() } mut b := open_repo(machine('undone_b')!)! defer { b.close() } put(a.root, 'f.txt', 'base\n')! put(a.root, 'g.txt', 'base\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'f.txt', 'from A\n')! put(a.root, 'g.txt', 'same\n')! make_save(a, 'A edits both')! offer_now(a, mut backend)! put(b.root, 'f.txt', 'tried on B\n')! put(b.root, 'g.txt', 'first try\n')! tried := make_save(b, 'B tries')! put(b.root, 'f.txt', 'base\n')! put(b.root, 'g.txt', 'same\n')! put(b.root, 'h.txt', 'B\n')! undone := make_save(b, 'B undoes f, lands on the same g, adds h')! report := steal_now(b, mut backend)! assert report.clashes.len == 0 assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n' assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n' assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n' // "B tries" only touched files A changed, so it has nothing left to // replay; the second save carries h.txt. Both originals stay in history. assert report.replayed == 1 assert head_of(b).replayed_from == undone.id ids := history.all_saves(b)!.map(it.id) assert tried.id in ids && undone.id in ids assert history.files_of(b, tried.id)!.any(it.path == 'f.txt') } // A person deciding a clash may take minutes; the question must come before // the write lock, or every agent's save on this repo waits and then fails. fn test_clash_questions_are_asked_before_the_write_lock() { base := scratch('ask_unlocked')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('ask_a')!)! defer { a.close() } mut b := open_repo(machine('ask_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'same.txt', 'A version\n')! make_save(a, 'A edits same')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! make_save(b, 'B edits same')! root := b.root // An agent saves while the person is still choosing. if _ := steal(b, mut backend, StealOptions{ guide: fn [root] (preview StealPreview) !map[string]Side { other := storage.open(root) or { panic(err) } defer { other.close() } os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) } history.save_worktree(other, root, snapshot.load(root), history.SaveInput{ message: 'agent saves during the question' }) or { panic('steal held the write lock while asking: ${err}') } return { preview.clashes[0].path: Side.mine } } }) { assert false, 'a steal whose line moved during the question must refuse' } else { assert err.msg().contains('changed while') } // The agent's save is still head, and a second steal combines it too. assert head_of(b).message == 'agent saves during the question' report := steal(b, mut backend, StealOptions{ mine: true })! assert report.kept_mine == 1 assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n' assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n' } fn test_clashing_files_are_chosen_one_at_a_time() { base := scratch('clash')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('clash_a')!)! defer { a.close() } mut b := open_repo(machine('clash_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! put(a.root, 'other.txt', 'quiet\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! // Both machines edit the same file, differently. put(a.root, 'same.txt', 'A version\n')! make_save(a, 'A edits same')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! make_save(b, 'B edits same')! // Without a decision, steal refuses and says which files clash. if _ := steal(b, mut backend, StealOptions{}) { assert false, 'an undecided clash must be refused' } else { assert err.msg().contains('--mine') || err.msg().contains('--theirs') // `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence. if err is ClashError { assert err.paths == ['same.txt'] } else { assert false, 'an undecided clash must be a ClashError, not ${err}' } } assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!).len == 0 // Choosing the remote's version ends with A's content. report := steal_now(b, mut backend)! assert report.clashes == ['same.txt'] assert report.kept_mine == 0 assert report.kept_versions == ['same.txt:${report.previous_head}'] assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n' assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n' } fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() { base := scratch('guided_cancel')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('guided_cancel_a')!)! defer { a.close() } mut b := open_repo(machine('guided_cancel_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'same.txt', 'remote\n')! remote_head := make_save(a, 'remote change')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'mine\n')! local_head := make_save(b, 'local change')! if _ := steal(b, mut backend, StealOptions{ guide: fn (preview StealPreview) !map[string]Side { assert preview.clashes.len == 1 return error('cancelled') } }) { assert false, 'the guide cancelled' } else { assert err.msg() == 'cancelled' } assert head_of(b).id == local_head.id assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n' assert history.by_id(b, remote_head.id) != none assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1 } fn test_choosing_mine_keeps_the_local_version() { base := scratch('mine')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('mine_a')!)! defer { a.close() } mut b := open_repo(machine('mine_b')!)! defer { b.close() } put(a.root, 'same.txt', 'base\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'same.txt', 'A version\n')! make_save(a, 'A edits')! offer_now(a, mut backend)! put(b.root, 'same.txt', 'B version\n')! make_save(b, 'B edits')! report := steal(b, mut backend, StealOptions{ mine: true })! assert report.kept_mine == 1 assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n' // The final save says what it kept. assert head_of(b).message.contains('combine: kept mine for 1 file') // The pre-combine tree is still reachable in history. assert head_of(b).restores == '' assert history.all_saves(b)!.len >= 4 } fn test_flags_travel_with_the_saves() { base := scratch('flags')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('flag_a')!)! defer { a.close() } mut b := open_repo(machine('flag_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! save := make_save(a, 'suspect save')! events.flag(a, save.id, 'look at this')! offer_now(a, mut backend)! // B has nothing yet, so a fetch brings the save and its flag across. fetched := fetch_back_to_known(b, mut backend, 'proj')! assert fetched.events_out.len == 1 assert fetched.events_out[0].kind == events.kind_flag b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.end()! state := events.flag_state(b, save.id)! assert state.flagged assert state.reason == 'look at this' } fn test_a_second_offer_racing_head_is_refused_not_forced() { base := scratch('race')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('race_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'base')! offer_now(a, mut backend)! // Someone else replaces head behind our back. assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(), backend.get(head_key('proj'))!.tag)! put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! if _ := offer_now(a, mut backend) { assert false, 'offering while the remote head moved must be refused' } else { assert err.msg().contains('run koh steal') } } // join makes a brand new machine share the remote's line. fn join(store &storage.Store, mut backend remote.Backend) ! { fetched := fetch_repo(mut backend, 'proj')! store.begin()! write_fetched(store, fetched)! history.set_head(store, fetched.head)! store.end()! snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store, fetched.head)!, [])! store.set_state('remote_repo', 'proj')! } struct FaultyBackend { mut: inner remote.Backend writes int reads int fail_write int // fail when the write counter reaches this, 0 never fail_read int // fail every delete while set fail_delete bool // race_format stands in for another machine writing the format key first: // the first `format` write loses, to a value this koh does not speak. race_format bool raced bool } fn (mut f FaultyBackend) get(key string) !remote.Object { f.reads++ if f.fail_read > 0 && f.reads >= f.fail_read { return error('injected read failure') } return f.inner.get(key)! } fn (mut f FaultyBackend) list(prefix string) ![]string { return f.inner.list(prefix)! } fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool { f.writes++ if f.fail_write > 0 && f.writes >= f.fail_write { return error('injected write failure') } if f.race_format && !f.raced && key.ends_with('/format') { f.raced = true f.inner.put_if_absent(key, 'koh-remote 2'.bytes())! return false } return f.inner.put_if_absent(key, body)! } fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool { f.writes++ if f.fail_write > 0 && f.writes >= f.fail_write { return error('injected write failure') } return f.inner.put_if_match(key, body, tag)! } fn (mut f FaultyBackend) delete(key string) ! { if f.fail_delete { return error('injected delete failure') } f.inner.delete(key)! } // Every save offers after it is made, so a one-file change must cost one // object, not one conditional write per file in the repo: on S3 each of those // is a round trip. fn test_an_offer_sends_only_the_objects_a_save_adds() { base := scratch('offer_diff')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('offer_diff_a')!)! defer { a.close() } for i in 0 .. 20 { put(a.root, 'f${i}.txt', 'file ${i}\n')! } make_save(a, 'twenty files')! mut backend := FaultyBackend{ inner: remote.new_folder(os.join_path(base, 'remote'))! } offer(a, mut backend)! put(a.root, 'f3.txt', 'changed\n')! make_save(a, 'one change')! before := backend.writes report := offer(a, mut backend)! assert report.objects == 1 // One object, one save record, one head swap. assert backend.writes - before == 3 } fn test_a_failed_offer_is_safe_to_re_run() { base := scratch('fault_offer')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut a := open_repo(machine('fault_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! // The first offer dies after the first write. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 2 } if _ := offer(a, mut flaky) { assert false, 'the injected failure should have surfaced' } // Re-running with a healthy remote finishes the job: "already exists" is // success, so nothing is lost and nothing is duplicated. mut healthy := remote.new_folder(remote_dir)! report := offer(a, mut healthy)! assert report.saves == 2 assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id assert healthy.list('proj/saves')!.len == 2 } // A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out, // and it must neither remove anything nor hold up an offer. fn test_a_strip_with_an_illegal_path_is_ignored() { base := scratch('illegal_strip')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('illegal_strip_a')!)! defer { a.close() } mut b := open_repo(machine('illegal_strip_b')!)! defer { b.close() } put(a.root, 'a/x.txt', 'kept\n')! make_save(a, 'add a folder')! offer_now(a, mut backend)! for id, path in { 'aaaaaaaaaaaa': 'a//' 'bbbbbbbbbbbb': 'a\\ud800' } { backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())! } join(b, mut backend)! assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path) put(a.root, 'more.txt', 'more\n')! make_save(a, 'more')! offer_now(a, mut backend)! } fn test_a_failed_steal_leaves_nothing_behind() { base := scratch('fault_steal')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut a := open_repo(machine('fault_s_a')!)! defer { a.close() } mut b := open_repo(machine('fault_s_b')!)! defer { b.close() } put(a.root, 'a.txt', 'from A\n')! make_save(a, 'A writes')! mut healthy := remote.new_folder(remote_dir)! offer(a, mut healthy)! // B's fetch fails half way through reading the remote. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_read: 2 } if _ := fetch_back_to_known(b, mut flaky, 'proj') { assert false, 'the injected read failure should have surfaced' } // Nothing was written: the store is untouched and still has no saves. assert history.head(b) == none assert b.object_count()! == 0 // A healthy retry works. fetched := fetch_back_to_known(b, mut healthy, 'proj')! b.begin()! write_fetched(b, fetched)! history.set_head(b, fetched.head)! b.end()! snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])! assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n' } fn test_an_interrupted_strip_is_idempotent() { // The local half of a strip is one transaction, so an interrupted strip is // simply a strip that did not happen; re-running it must land the same // result once, not twice. base := scratch('fault_strip')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('fault_strip_a')!)! defer { a.close() } put(a.root, 'secret.txt', 'shh\n')! put(a.root, 'keep.txt', 'fine\n')! save := make_save(a, 'add both')! for _ in 0 .. 2 { strip_local(a, 'secret.txt', '')! } files := history.files_of(a, save.id)! // The repo's own `.kohignore` is tracked content, so it stays; only the // stripped path goes. assert files.map(it.path) == ['.kohignore', 'keep.txt'] assert events.strip_events(a)!.len == 1 } // SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into // `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly. // The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk, // but a save made on Linux can hold both. fn test_a_strip_matches_the_path_exactly() { mut a := open_repo(machine('strip_exact')!)! defer { a.close() } hash := a.put_object('x\n'.bytes())! mut files := []snapshot.FileEntry{} for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] { files << snapshot.FileEntry{ path: path hash: hash } } save := history.save_tree(a, '', files, history.SaveInput{ message: 'add all' })! assert strip_scope(a, 'docs', '')!.files == 1 strip_local(a, 'docs', '')! strip_local(a, 'a_b', '')! assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt'] } fn test_a_strip_reaches_the_remote_and_other_machines() { base := scratch('strip_sync')! defer { os.rmdir_all(base) or {} } mut a := open_repo(machine('strip_a')!)! defer { a.close() } mut b := open_repo(machine('strip_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! put(a.root, 'keep.txt', 'fine\n')! save := make_save(a, 'add both')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! offer_now(a, mut backend)! join(b, mut backend)! assert b.object_count()! > 0 // A strips the path locally, the way `koh strip` does, then tells the // remote. mut secret_hash := '' for file in history.files_of(a, save.id)! { if file.path == 'secrets.env' { secret_hash = file.hash } } assert secret_hash != '' strip_local(a, 'secrets.env', '')! report := send_strip(a, mut backend, 'secrets.env')! assert report.event_sent assert report.records == 1 // The remote's copy of the save no longer lists the file, and the object is // gone from the remote. record := decode_save(backend.get(save_key('proj', save.id))!.body)! for file in record.files { assert file.path != 'secrets.env' } // The stripped object is gone from the remote; the ones still referenced // are not. assert !backend.get(object_key('proj', secret_hash))!.found assert backend.list('proj/objects')!.len == 2 // Another machine hears about it on its next steal and applies it. b.begin()! fetched := fetch_back_to_known(b, mut backend, 'proj')! write_fetched(b, fetched)! b.end()! mut paths := []string{} for file in history.files_of(b, save.id)! { paths << file.path } assert 'secrets.env' !in paths assert 'keep.txt' in paths // The event arrives under its own ID, so it is applied once: B's offer adds // no second copy. The path is banned for good: written again, the file is // neither unsaved work nor ever walked into a save. assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id) put(b.root, 'secrets.env', 'rotated\n')! assert !dirty(b, history.head(b))! put(b.root, 'keep.txt', 'still fine\n')! later := make_save(b, 'edit keep')! offer_now(b, mut backend)! assert backend.list('proj/events')!.len == 1 assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path) } // A strip removes a path from all of history, whenever a save was made. A // machine that saved the file after the strip, before it heard of it, loses // it from that save too on its next steal, so no copy anywhere keeps it. fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() { base := scratch('strip_later')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_later_a')!)! defer { a.close() } mut b := open_repo(machine('strip_later_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! old := make_save(a, 'leak the key')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(a, 'secrets.env', '')! send_strip(a, mut backend, 'secrets.env')! // B has not heard, and saves the file again an hour after the strip. put(b.root, 'secrets.env', 'api_key=2\n')! later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{ message: 'rotate the key' created_at: events.strip_events(a)!.last().created_at + 3600 })! assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path) b.begin()! write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)! b.end()! assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path) offer_now(b, mut backend)! mut c := open_repo(machine('strip_later_c')!)! defer { c.close() } join(c, mut backend)! for save in [old.id, later.id] { assert 'secrets.env' !in history.files_of(c, save)!.map(it.path) } assert !os.exists(os.join_path(c.root, 'secrets.env')) } // A strip this machine already holds bans its path from saves that arrive // later too, not only from those a fetch brings with the strip. fn test_a_held_strip_reaches_saves_arriving_after_it() { base := scratch('strip_held')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_held_a')!)! defer { a.close() } mut b := open_repo(machine('strip_held_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! make_save(a, 'leak the key')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(b, 'secrets.env', '')! put(a.root, 'keep.txt', 'fine\n')! later := make_save(a, 'add keep')! offer_now(a, mut backend)! b.begin()! write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)! b.end()! paths := history.files_of(b, later.id)!.map(it.path) assert 'secrets.env' !in paths assert 'keep.txt' in paths } // StripRace stands in for another machine that strips `path` completely, event // and rewrite, just before this machine's first save record lands. struct StripRace { mut: inner remote.Backend other &storage.Store path string done bool } fn (mut r StripRace) get(key string) !remote.Object { return r.inner.get(key)! } fn (mut r StripRace) list(prefix string) ![]string { return r.inner.list(prefix)! } fn (mut r StripRace) put_if_absent(key string, body []u8) !bool { if !r.done && key.contains('/saves/') { r.done = true strip_local(r.other, r.path, '')! send_strip(r.other, mut r.inner, r.path)! } return r.inner.put_if_absent(key, body)! } fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool { return r.inner.put_if_match(key, body, tag)! } fn (mut r StripRace) delete(key string) ! { r.inner.delete(key)! } // A strip that finishes on another machine while an offer uploads has already // rewritten the remote without the offer's records, so the offer takes back // what the strip reaches and leaves head where it was. fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() { base := scratch('strip_race')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_race_a')!)! defer { a.close() } mut b := open_repo(machine('strip_race_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! first := make_save(a, 'leak the key')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'secrets.env', 'api_key=2\n')! later := make_save(a, 'rotate the key')! secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash mut race := StripRace{ inner: backend other: &b path: 'secrets.env' } if _ := offer(a, mut race) { assert false, 'an offer that raced a strip must refuse' } else { assert err.msg().contains('koh steal'), err.msg() } assert backend.get(head_key('proj'))!.body.bytestr() == first.id assert !backend.get(object_key('proj', secret))!.found for key in backend.list('proj/saves')! { record := decode_save(backend.get(key)!.body)! assert 'secrets.env' !in record.files.map(it.path), record.id } } // The local half lands before the remote hears anything, so a remote failure // leaves nothing to find locally; the pending note is what lets a re-run // finish. Objects a remote-only save still needs must survive the strip. fn test_an_interrupted_remote_strip_finishes_when_re_run() { base := scratch('strip_resume')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('resume_a')!)! defer { a.close() } mut b := open_repo(machine('resume_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! put(a.root, 'shared.env', 'same bytes on B\n')! save := make_save(a, 'add secrets')! offer_now(a, mut backend)! join(b, mut backend)! put(b.root, 'copy.txt', 'same bytes on B\n')! b_save := make_save(b, 'B keeps a copy')! offer_now(b, mut backend)! shared_hash := storage.hash_hex('same bytes on B\n'.bytes()) secret_hash := storage.hash_hex('api_key=1\n'.bytes()) strip_local(a, 'secrets.env', '')! // Write 1 uploads the event and write 2 rewrites the first record; B's save, // made from A's tree, lists the file too, and its rewrite (write 3) fails. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 3 } if _ := send_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected failure should have surfaced' } assert strip_pending(a, 'secrets.env') assert strip_local(a, 'secrets.env', '')!.files_removed == 0 report := send_strip(a, mut backend, 'secrets.env')! assert report.records == 1 assert !strip_pending(a, 'secrets.env') assert !backend.get(object_key('proj', secret_hash))!.found record := decode_save(backend.get(save_key('proj', save.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)! assert 'secrets.env' !in b_record.files.map(it.path) // B's save still names the shared bytes, so stripping A's copy keeps them. strip_local(a, 'shared.env', '')! send_strip(a, mut backend, 'shared.env')! assert backend.get(object_key('proj', shared_hash))!.found } // A machine that has not heard of a strip must not offer: its records still // list the stripped path, and nothing would rewrite them on the remote again. fn test_offer_waits_for_an_unapplied_strip() { base := scratch('strip_first')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_first_a')!)! defer { a.close() } mut b := open_repo(machine('strip_first_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'api_key=1\n')! make_save(a, 'add secrets')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(a, 'secrets.env', '')! send_strip(a, mut backend, 'secrets.env')! put(b.root, 'more.txt', 'B keeps working\n')! later := make_save(b, 'B saves with the secret still in its tree')! if _ := offer_now(b, mut backend) { assert false, 'B offered records that still list the stripped path' } steal_now(b, mut backend)! offer_now(b, mut backend)! record := decode_save(backend.get(save_key('proj', later.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) } // A strip whose object deletions failed must still delete them when it is run // again, and meanwhile the banned path cannot come back in through a save. fn test_a_strip_whose_deletes_failed_finishes_when_run_again() { base := scratch('strip_twice')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('strip_twice_a')!)! defer { a.close() } put(a.root, 'secrets.env', 'first key\n')! make_save(a, 'first key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_delete: true } if _ := send_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected delete failure should have surfaced' } put(a.root, 'secrets.env', 'second key\n')! put(a.root, 'other.txt', 'more work\n')! make_save(a, 'more work')! offer_now(a, mut backend)! send_strip(a, mut backend, 'secrets.env')! for key in ['first key\n', 'second key\n'] { assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found } } // Unfinished strips of one path in different scopes finish together. fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() { base := scratch('strip_scopes')! defer { os.rmdir_all(base) or {} } remote_dir := os.join_path(base, 'remote') mut backend := remote.new_folder(remote_dir)! mut a := open_repo(machine('strip_scopes_a')!)! defer { a.close() } put(a.root, 'secrets.env', 'first key\n')! first := make_save(a, 'first key')! put(a.root, 'secrets.env', 'second key\n')! second := make_save(a, 'second key')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', first.id)! // Write 1 uploads the event; write 2, the record rewrite, fails. mut flaky := FaultyBackend{ inner: remote.new_folder(remote_dir)! fail_write: 2 } if _ := send_strip(a, mut flaky, 'secrets.env') { assert false, 'the injected failure should have surfaced' } strip_local(a, 'secrets.env', second.id)! send_strip(a, mut backend, 'secrets.env')! assert !strip_pending(a, 'secrets.env') for save in [first, second] { record := decode_save(backend.get(save_key('proj', save.id))!.body)! assert 'secrets.env' !in record.files.map(it.path) } for key in ['first key\n', 'second key\n'] { assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found } } // A strip bans its path on every machine that has heard of it: written again, // the file makes no save, here or there, so there is never a second copy to strip. fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() { base := scratch('strip_again')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('strip_again_a')!)! defer { a.close() } mut b := open_repo(machine('strip_again_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'first key\n')! make_save(a, 'first key')! offer_now(a, mut backend)! join(b, mut backend)! strip_local(a, 'secrets.env', '')! send_strip(a, mut backend, 'secrets.env')! steal_now(b, mut backend)! for repo in [a, b] { put(repo.root, 'secrets.env', 'second key\n')! if _ := make_save(repo, 'second key') { assert false, 'a banned path must not make a save' } } } // A clash on a path the other machine stripped is no clash: the strip removes // the path from every save, so there is nothing to choose. fn test_a_stripped_path_is_not_asked_about() { base := scratch('clash_strip')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('clash_strip_a')!)! defer { a.close() } mut b := open_repo(machine('clash_strip_b')!)! defer { b.close() } put(a.root, 'secrets.env', 'key one\n')! make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'secrets.env', 'key two\n')! make_save(a, 'A rotates')! offer_now(a, mut backend)! strip_local(a, 'secrets.env', '')! send_strip(a, mut backend, 'secrets.env')! put(b.root, 'secrets.env', 'key three\n')! mine := make_save(b, 'B rotates')! report := steal(b, mut backend, StealOptions{})! assert report.clashes.len == 0 for save in history.all_saves(b)! { assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path) } assert history.by_id(b, mine.id) != none } // A strip scoped to the shared base save changes what the replay sees; the // clashes must be found on the same trees, or the replay meets a conflict. fn test_a_strip_of_the_shared_base_is_seen_before_asking() { base := scratch('clash_base_strip')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('base_strip_a')!)! defer { a.close() } mut b := open_repo(machine('base_strip_b')!)! defer { b.close() } put(a.root, 'notes.txt', 'shared\n')! common := make_save(a, 'base')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'other.txt', 'A moves on\n')! make_save(a, 'A moves on')! offer_now(a, mut backend)! strip_local(a, 'notes.txt', common.id)! send_strip(a, mut backend, 'notes.txt')! put(b.root, 'notes.txt', 'B edits\n')! make_save(b, 'B edits notes')! if _ := steal(b, mut backend, StealOptions{}) { assert false, 'with the base stripped, notes.txt differs on both sides' } else { assert err.msg().contains('notes.txt changed on both machines') } report := steal(b, mut backend, StealOptions{ mine: true })! assert report.clashes == ['notes.txt'] assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n' } // A folder with files but no saves has nothing to combine with; a steal that // fast-forwarded into it would overwrite whatever shares a name. fn test_steal_into_a_folder_with_unsaved_files_refuses() { base := scratch('steal_unsaved')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('unsaved_a')!)! defer { a.close() } put(a.root, 'notes.txt', 'from A\n')! make_save(a, 'A writes')! offer_now(a, mut backend)! b_root := machine('unsaved_b')! mut b := storage.create(b_root)! defer { b.close() } put(b_root, 'notes.txt', 'never saved\n')! // Only a repo that already knows its remote name gets this far. b.set_state('remote_repo', 'proj')! if _ := steal(b, mut backend, StealOptions{}) { assert false, 'steal overwrote a folder with unsaved files' } else { assert err.msg() == no_saves_here } assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n' // A fresh `koh init` folder, holding only the default ignore list, is fine. os.rm(os.join_path(b_root, 'notes.txt'))! put(b_root, snapshot.ignore_file, snapshot.default_ignore)! steal(b, mut backend, StealOptions{})! assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n' } // The document asks steal to warn about every incoming flagged save, so the // report names them. fn test_steal_reports_incoming_flagged_saves() { base := scratch('flag_warn')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('flag_warn_a')!)! defer { a.close() } mut b := open_repo(machine('flag_warn_b')!)! defer { b.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'fine')! offer_now(a, mut backend)! join(b, mut backend)! put(a.root, 'a.txt', 'two\n')! suspect := make_save(a, 'suspect')! put(a.root, 'a.txt', 'three\n')! make_save(a, 'also fine')! events.flag(a, suspect.id, 'broke the build')! offer_now(a, mut backend)! report := steal_now(b, mut backend)! assert report.flagged.map(it.save_id) == [suspect.id] assert report.flagged[0].reason == 'broke the build' } // head_of unwraps the head for tests that know the repo has saves. fn head_of(store &storage.Store) history.Save { return history.head(store) or { panic('this repo has no head') } } // A remote says what it is, so two koh versions never half-understand one // bucket. The first offer of a repo records the layout. fn test_the_first_offer_records_the_remote_format() { base := scratch('format_write')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('format_write_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! offer_now(a, mut backend)! assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value } // A repo offered before the key existed is version 1: it still syncs, and the // next offer records the key then. fn test_a_remote_without_a_format_still_syncs() { base := scratch('format_old')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('format_old_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! offer_now(a, mut backend)! // Stand in for a bucket offered before the key existed. backend.delete(format_key('proj'))! mut b := open_repo(machine('format_old_b')!)! defer { b.close() } join(b, mut backend)! assert head_of(b).id == head_of(a).id put(a.root, 'a.txt', 'two\n')! make_save(a, 'second')! offer_now(a, mut backend)! assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value report := steal_now(b, mut backend)! assert report.head == head_of(a).id } // A remote that speaks another layout is refused loudly, on offer and on // steal, rather than half-read. fn test_an_unknown_remote_format_is_refused() { base := scratch('format_unknown')! defer { os.rmdir_all(base) or {} } mut backend := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('format_bad_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! offer_now(a, mut backend)! backend.delete(format_key('proj'))! backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())! if _ := offer_now(a, mut backend) { assert false, 'offer must refuse a format it does not speak' } else { assert err.msg().contains('koh-remote 2') } if _ := steal_now(a, mut backend) { assert false, 'steal must refuse a format it does not speak' } else { assert err.msg().contains('koh-remote 2') } if _ := fetch_repo(mut backend, 'proj') { assert false, 'a steal must refuse a format it does not speak' } else { assert err.msg().contains('koh-remote 2') } } // Two machines offering a new repo at once both find no format key; the loser // of the conditional write must read back the winner's value and refuse it, // not carry on against a remote it cannot read. fn test_a_lost_format_write_is_read_back_and_refused() { base := scratch('format_race')! defer { os.rmdir_all(base) or {} } mut inner := remote.new_folder(os.join_path(base, 'remote'))! mut a := open_repo(machine('format_race_a')!)! defer { a.close() } put(a.root, 'a.txt', 'one\n')! make_save(a, 'first')! mut backend := remote.Backend(FaultyBackend{ inner: inner race_format: true }) if _ := offer_now(a, mut backend) { assert false, 'offer must refuse the format another machine wrote first' } else { assert err.msg().contains('koh-remote 2') } } fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() { whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n' for size in [1, 2, 5, whole.len] { mut bundle := Bundle{} for at := 0; at < whole.len; at += size { bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())! } bundle.finish()! assert bundle.fetched.repo_objects == { 'aa': 'abc'.bytes() 'bb': []u8{} } } for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc', 'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] { mut bundle := Bundle{} bundle.feed(cut.bytes()) or { continue } bundle.finish() or { continue } assert false, cut } }