exchange_test.v

47.9 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module 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}2728// An unpaired surrogate escape stands for its three bytes, so the record still29// decodes and only the path holding it is dropped; a real pair is kept.30fn 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}3738// Machines keep their repo in a folder of the same NAME, because the remote key39// is the folder's name; that is what makes them the same repo to the remote.40fn 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	// A second offer with nothing new uploads nothing and still succeeds.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	// Offer's step 3: the new remote head is recorded in state.125	assert a.state('remote_head') or { '' } == head_of(a).id126127	// A second offer, with the remote already holding our head, must not128	// refuse: the name is remembered, so the remote is ours.129	put(a.root, 'a.txt', 'two\n')!130	make_save(a, 'second')!131	offer_now(a, mut backend)!132	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id133}134135fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {136	base := scratch('forward')!137	defer {138		os.rmdir_all(base) or {}139	}140	mut backend := remote.new_folder(os.join_path(base, 'remote'))!141	mut a := open_repo(machine('fwd_a')!)!142	defer {143		a.close()144	}145	put(a.root, 'a.txt', 'from A\n')!146	make_save(a, 'A writes')!147	offer_now(a, mut backend)!148149	// B starts empty and takes the whole repo.150	mut b := open_repo(machine('fwd_b')!)!151	defer {152		b.close()153	}154	fetched := fetch_repo(mut backend, 'proj')!155	b.begin()!156	write_fetched(b, fetched)!157	history.set_head(b, fetched.head)!158	b.end()!159	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!160	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'161	assert head_of(b).id == fetched.head162}163164// A machine that is only ahead has nothing to combine: stealing must leave its165// saves, and their IDs, exactly where they are.166fn test_steal_while_only_ahead_leaves_the_line_alone() {167	base := scratch('ahead')!168	defer {169		os.rmdir_all(base) or {}170	}171	mut backend := remote.new_folder(os.join_path(base, 'remote'))!172	mut a := open_repo(machine('ahead_a')!)!173	defer {174		a.close()175	}176	put(a.root, 'a.txt', 'one\n')!177	make_save(a, 'offered')!178	offer_now(a, mut backend)!179	put(a.root, 'a.txt', 'two\n')!180	ahead := make_save(a, 'not offered yet')!181	report := steal_now(a, mut backend)!182	assert report.replayed == 0183	assert head_of(a).id == ahead.id184	// Nothing was restored, so nothing is left marked as half-restored.185	assert !snapshot.restore_pending(a)186	assert history.all_saves(a)!.len == 2187	offer_now(a, mut backend)!188	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id189}190191// A link is invisible to the walk, so a tree holding one is clean; the files192// a steal brings in must still never land in the folder it points at.193fn test_steal_never_writes_through_a_symlinked_folder() {194	base := scratch('steal_link')!195	defer {196		os.rmdir_all(base) or {}197	}198	mut backend := remote.new_folder(os.join_path(base, 'remote'))!199	mut a := open_repo(machine('steal_link_a')!)!200	defer {201		a.close()202	}203	mut b := open_repo(machine('steal_link_b')!)!204	defer {205		b.close()206	}207	put(a.root, 'a.txt', 'one\n')!208	make_save(a, 'first')!209	offer_now(a, mut backend)!210	join(b, mut backend)!211	outside := os.join_path(base, 'outside')212	put(outside, 'a.md', 'precious\n')!213	os.symlink(outside, os.join_path(b.root, 'docs'))!214	put(a.root, 'docs/a.md', 'from A\n')!215	make_save(a, 'A adds docs')!216	offer_now(a, mut backend)!217	head_before := head_of(b).id218	if _ := steal_now(b, mut backend) {219		assert false, 'a steal writing through a symlinked folder must refuse'220	} else {221		assert err.msg().contains('docs')222	}223	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'224	assert head_of(b).id == head_before225}226227// A steal onto a half-restored tree would replay it as somebody's work.228fn test_steal_refuses_after_a_restore_cut_short() {229	base := scratch('steal_cut')!230	defer {231		os.rmdir_all(base) or {}232	}233	mut backend := remote.new_folder(os.join_path(base, 'remote'))!234	mut a := open_repo(machine('steal_cut_a')!)!235	defer {236		a.close()237	}238	put(a.root, 'a.txt', 'one\n')!239	make_save(a, 'first')!240	offer_now(a, mut backend)!241	a.set_state('restoring', 'yes')!242	if _ := steal_now(a, mut backend) {243		assert false, 'steal must refuse while a restore is unfinished'244	} else {245		assert err.msg().contains('koh revert')246	}247}248249// A replayed save keeps its original time but sits on a newer parent, so a250// steal cannot order saves by time.251fn test_a_steal_imports_parents_before_children() {252	base := scratch('steal_order')!253	defer {254		os.rmdir_all(base) or {}255	}256	mut backend := remote.new_folder(os.join_path(base, 'remote'))!257	mut a := open_repo(machine('order_a')!)!258	defer {259		a.close()260	}261	put(a.root, 'a.txt', 'one\n')!262	parent := make_save(a, 'parent')!263	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{264		message:    'replayed, older than its parent'265		created_at: parent.created_at - 3600266	})!267	offer_now(a, mut backend)!268	mut b := open_repo(machine('order_b')!)!269	defer {270		b.close()271	}272	join(b, mut backend)!273	assert head_of(b).id == child.id274}275276// A chmod is unsaved work too: a steal would restore the old mode over it.277fn test_steal_refuses_an_unsaved_executable_bit_change() {278	base := scratch('chmod')!279	defer {280		os.rmdir_all(base) or {}281	}282	mut backend := remote.new_folder(os.join_path(base, 'remote'))!283	mut a := open_repo(machine('chmod_a')!)!284	defer {285		a.close()286	}287	put(a.root, 'run.sh', 'echo hi\n')!288	make_save(a, 'script')!289	offer_now(a, mut backend)!290	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!291	if _ := steal_now(a, mut backend) {292		assert false, 'a chmod-only change should make the tree dirty'293	} else {294		assert err.msg() == dirty_tree295	}296}297298fn test_divergence_without_clashes_keeps_both_machines_work() {299	base := scratch('diverge')!300	defer {301		os.rmdir_all(base) or {}302	}303	mut backend := remote.new_folder(os.join_path(base, 'remote'))!304	mut a := open_repo(machine('div_a')!)!305	defer {306		a.close()307	}308	mut b := open_repo(machine('div_b')!)!309	defer {310		b.close()311	}312	put(a.root, 'shared.txt', 'base\n')!313	make_save(a, 'base')!314	offer_now(a, mut backend)!315316	// B joins, then both machines change a different file.317	join(b, mut backend)!318	put(a.root, 'fromA.txt', 'A\n')!319	make_save(a, 'A adds a file')!320	put(b.root, 'fromB.txt', 'B\n')!321	make_save(b, 'B adds a file')!322	offer_now(a, mut backend)!323324	// B's offer is refused: the remote has moved on.325	if _ := offer_now(b, mut backend) {326		assert false, 'offering on top of unknown remote saves should be refused'327	} else {328		assert err.msg().contains('run koh steal')329	}330331	report := steal_now(b, mut backend)!332	assert !report.forward333	assert report.replayed == 1334	assert report.clashes.len == 0335	// Both files are on B's line now, and on disk.336	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'337	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'338	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'339	// B's own save was replayed, not erased.340	mut saw_replay := false341	for save in history.all_saves(b)! {342		if save.replayed_from != '' {343			saw_replay = true344		}345	}346	assert saw_replay347	// And the remote now has B's work too.348	offer_now(b, mut backend)!349	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()350	assert line_head == head_of(b).id351}352353// An agent that edits a file and then undoes the edit leaves no net change, so354// the file is not a clash when the other machine changed it. Replaying the355// edit itself must not stop the steal: the remote's version simply stands.356// Likewise a file both machines ended up changing the same way.357fn test_a_local_change_undone_does_not_block_a_combine() {358	base := scratch('undone')!359	defer {360		os.rmdir_all(base) or {}361	}362	mut backend := remote.new_folder(os.join_path(base, 'remote'))!363	mut a := open_repo(machine('undone_a')!)!364	defer {365		a.close()366	}367	mut b := open_repo(machine('undone_b')!)!368	defer {369		b.close()370	}371	put(a.root, 'f.txt', 'base\n')!372	put(a.root, 'g.txt', 'base\n')!373	make_save(a, 'base')!374	offer_now(a, mut backend)!375	join(b, mut backend)!376377	put(a.root, 'f.txt', 'from A\n')!378	put(a.root, 'g.txt', 'same\n')!379	make_save(a, 'A edits both')!380	offer_now(a, mut backend)!381382	put(b.root, 'f.txt', 'tried on B\n')!383	put(b.root, 'g.txt', 'first try\n')!384	tried := make_save(b, 'B tries')!385	put(b.root, 'f.txt', 'base\n')!386	put(b.root, 'g.txt', 'same\n')!387	put(b.root, 'h.txt', 'B\n')!388	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!389390	report := steal_now(b, mut backend)!391	assert report.clashes.len == 0392	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'393	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'394	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'395	// "B tries" only touched files A changed, so it has nothing left to396	// replay; the second save carries h.txt. Both originals stay in history.397	assert report.replayed == 1398	assert head_of(b).replayed_from == undone.id399	ids := history.all_saves(b)!.map(it.id)400	assert tried.id in ids && undone.id in ids401	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')402}403404// A person deciding a clash may take minutes; the question must come before405// the write lock, or every agent's save on this repo waits and then fails.406fn test_clash_questions_are_asked_before_the_write_lock() {407	base := scratch('ask_unlocked')!408	defer {409		os.rmdir_all(base) or {}410	}411	mut backend := remote.new_folder(os.join_path(base, 'remote'))!412	mut a := open_repo(machine('ask_a')!)!413	defer {414		a.close()415	}416	mut b := open_repo(machine('ask_b')!)!417	defer {418		b.close()419	}420	put(a.root, 'same.txt', 'base\n')!421	make_save(a, 'base')!422	offer_now(a, mut backend)!423	join(b, mut backend)!424	put(a.root, 'same.txt', 'A version\n')!425	make_save(a, 'A edits same')!426	offer_now(a, mut backend)!427	put(b.root, 'same.txt', 'B version\n')!428	make_save(b, 'B edits same')!429	root := b.root430	// An agent saves while the person is still choosing.431	if _ := steal(b, mut backend, StealOptions{432		guide: fn [root] (preview StealPreview) !map[string]Side {433			other := storage.open(root) or { panic(err) }434			defer {435				other.close()436			}437			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }438			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{439				message: 'agent saves during the question'440			}) or { panic('steal held the write lock while asking: ${err}') }441			return {442				preview.clashes[0].path: Side.mine443			}444		}445	})446	{447		assert false, 'a steal whose line moved during the question must refuse'448	} else {449		assert err.msg().contains('changed while')450	}451	// The agent's save is still head, and a second steal combines it too.452	assert head_of(b).message == 'agent saves during the question'453	report := steal(b, mut backend, StealOptions{454		mine: true455	})!456	assert report.kept_mine == 1457	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'458	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'459}460461fn test_clashing_files_are_chosen_one_at_a_time() {462	base := scratch('clash')!463	defer {464		os.rmdir_all(base) or {}465	}466	mut backend := remote.new_folder(os.join_path(base, 'remote'))!467	mut a := open_repo(machine('clash_a')!)!468	defer {469		a.close()470	}471	mut b := open_repo(machine('clash_b')!)!472	defer {473		b.close()474	}475	put(a.root, 'same.txt', 'base\n')!476	put(a.root, 'other.txt', 'quiet\n')!477	make_save(a, 'base')!478	offer_now(a, mut backend)!479	join(b, mut backend)!480481	// Both machines edit the same file, differently.482	put(a.root, 'same.txt', 'A version\n')!483	make_save(a, 'A edits same')!484	offer_now(a, mut backend)!485	put(b.root, 'same.txt', 'B version\n')!486	make_save(b, 'B edits same')!487488	// Without a decision, steal refuses and says which files clash.489	if _ := steal(b, mut backend, StealOptions{}) {490		assert false, 'an undecided clash must be refused'491	} else {492		assert err.msg().contains('--mine') || err.msg().contains('--theirs')493		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.494		if err is ClashError {495			assert err.paths == ['same.txt']496		} else {497			assert false, 'an undecided clash must be a ClashError, not ${err}'498		}499	}500	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,501		head_of(b).id)!).len == 0502503	// Choosing the remote's version ends with A's content.504	report := steal_now(b, mut backend)!505	assert report.clashes == ['same.txt']506	assert report.kept_mine == 0507	assert report.kept_versions == ['same.txt:${report.previous_head}']508	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'509	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'510}511512fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {513	base := scratch('guided_cancel')!514	defer { os.rmdir_all(base) or {} }515	mut backend := remote.new_folder(os.join_path(base, 'remote'))!516	mut a := open_repo(machine('guided_cancel_a')!)!517	defer { a.close() }518	mut b := open_repo(machine('guided_cancel_b')!)!519	defer { b.close() }520	put(a.root, 'same.txt', 'base\n')!521	make_save(a, 'base')!522	offer_now(a, mut backend)!523	join(b, mut backend)!524	put(a.root, 'same.txt', 'remote\n')!525	remote_head := make_save(a, 'remote change')!526	offer_now(a, mut backend)!527	put(b.root, 'same.txt', 'mine\n')!528	local_head := make_save(b, 'local change')!529530	if _ := steal(b, mut backend, StealOptions{531		guide: fn (preview StealPreview) !map[string]Side {532			assert preview.clashes.len == 1533			return error('cancelled')534		}535	}) {536		assert false, 'the guide cancelled'537	} else {538		assert err.msg() == 'cancelled'539	}540	assert head_of(b).id == local_head.id541	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'542	assert history.by_id(b, remote_head.id) != none543	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1544}545546fn test_choosing_mine_keeps_the_local_version() {547	base := scratch('mine')!548	defer {549		os.rmdir_all(base) or {}550	}551	mut backend := remote.new_folder(os.join_path(base, 'remote'))!552	mut a := open_repo(machine('mine_a')!)!553	defer {554		a.close()555	}556	mut b := open_repo(machine('mine_b')!)!557	defer {558		b.close()559	}560	put(a.root, 'same.txt', 'base\n')!561	make_save(a, 'base')!562	offer_now(a, mut backend)!563	join(b, mut backend)!564	put(a.root, 'same.txt', 'A version\n')!565	make_save(a, 'A edits')!566	offer_now(a, mut backend)!567	put(b.root, 'same.txt', 'B version\n')!568	make_save(b, 'B edits')!569570	report := steal(b, mut backend, StealOptions{571		mine: true572	})!573	assert report.kept_mine == 1574	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'575	// The final save says what it kept.576	assert head_of(b).message.contains('combine: kept mine for 1 file')577	// The pre-combine tree is still reachable in history.578	assert head_of(b).restores == ''579	assert history.all_saves(b)!.len >= 4580}581582fn test_flags_travel_with_the_saves() {583	base := scratch('flags')!584	defer {585		os.rmdir_all(base) or {}586	}587	mut backend := remote.new_folder(os.join_path(base, 'remote'))!588	mut a := open_repo(machine('flag_a')!)!589	defer {590		a.close()591	}592	mut b := open_repo(machine('flag_b')!)!593	defer {594		b.close()595	}596	put(a.root, 'a.txt', 'one\n')!597	save := make_save(a, 'suspect save')!598	events.flag(a, save.id, 'look at this')!599	offer_now(a, mut backend)!600601	// B has nothing yet, so a fetch brings the save and its flag across.602	fetched := fetch_back_to_known(b, mut backend, 'proj')!603	assert fetched.events_out.len == 1604	assert fetched.events_out[0].kind == events.kind_flag605	b.begin()!606	write_fetched(b, fetched)!607	history.set_head(b, fetched.head)!608	b.end()!609	state := events.flag_state(b, save.id)!610	assert state.flagged611	assert state.reason == 'look at this'612}613614fn test_a_second_offer_racing_head_is_refused_not_forced() {615	base := scratch('race')!616	defer {617		os.rmdir_all(base) or {}618	}619	mut backend := remote.new_folder(os.join_path(base, 'remote'))!620	mut a := open_repo(machine('race_a')!)!621	defer {622		a.close()623	}624	put(a.root, 'a.txt', 'one\n')!625	make_save(a, 'base')!626	offer_now(a, mut backend)!627628	// Someone else replaces head behind our back.629	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),630		backend.get(head_key('proj'))!.tag)!631632	put(a.root, 'a.txt', 'two\n')!633	make_save(a, 'second')!634	if _ := offer_now(a, mut backend) {635		assert false, 'offering while the remote head moved must be refused'636	} else {637		assert err.msg().contains('run koh steal')638	}639}640641// join makes a brand new machine share the remote's line.642fn join(store &storage.Store, mut backend remote.Backend) ! {643	fetched := fetch_repo(mut backend, 'proj')!644	store.begin()!645	write_fetched(store, fetched)!646	history.set_head(store, fetched.head)!647	store.end()!648	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,649		fetched.head)!, [])!650	store.set_state('remote_repo', 'proj')!651}652653struct FaultyBackend {654mut:655	inner      remote.Backend656	writes     int657	reads      int658	fail_write int // fail when the write counter reaches this, 0 never659	fail_read  int660	// fail every delete while set661	fail_delete bool662	// race_format stands in for another machine writing the format key first:663	// the first `format` write loses, to a value this koh does not speak.664	race_format bool665	raced       bool666}667668fn (mut f FaultyBackend) get(key string) !remote.Object {669	f.reads++670	if f.fail_read > 0 && f.reads >= f.fail_read {671		return error('injected read failure')672	}673	return f.inner.get(key)!674}675676fn (mut f FaultyBackend) list(prefix string) ![]string {677	return f.inner.list(prefix)!678}679680fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {681	f.writes++682	if f.fail_write > 0 && f.writes >= f.fail_write {683		return error('injected write failure')684	}685	if f.race_format && !f.raced && key.ends_with('/format') {686		f.raced = true687		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!688		return false689	}690	return f.inner.put_if_absent(key, body)!691}692693fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {694	f.writes++695	if f.fail_write > 0 && f.writes >= f.fail_write {696		return error('injected write failure')697	}698	return f.inner.put_if_match(key, body, tag)!699}700701fn (mut f FaultyBackend) delete(key string) ! {702	if f.fail_delete {703		return error('injected delete failure')704	}705	f.inner.delete(key)!706}707708// Every save offers after it is made, so a one-file change must cost one709// object, not one conditional write per file in the repo: on S3 each of those710// is a round trip.711fn test_an_offer_sends_only_the_objects_a_save_adds() {712	base := scratch('offer_diff')!713	defer {714		os.rmdir_all(base) or {}715	}716	mut a := open_repo(machine('offer_diff_a')!)!717	defer {718		a.close()719	}720	for i in 0 .. 20 {721		put(a.root, 'f${i}.txt', 'file ${i}\n')!722	}723	make_save(a, 'twenty files')!724	mut backend := FaultyBackend{725		inner: remote.new_folder(os.join_path(base, 'remote'))!726	}727	offer(a, mut backend)!728	put(a.root, 'f3.txt', 'changed\n')!729	make_save(a, 'one change')!730	before := backend.writes731	report := offer(a, mut backend)!732	assert report.objects == 1733	// One object, one save record, one head swap.734	assert backend.writes - before == 3735}736737fn test_a_failed_offer_is_safe_to_re_run() {738	base := scratch('fault_offer')!739	defer {740		os.rmdir_all(base) or {}741	}742	remote_dir := os.join_path(base, 'remote')743	mut a := open_repo(machine('fault_a')!)!744	defer {745		a.close()746	}747	put(a.root, 'a.txt', 'one\n')!748	make_save(a, 'first')!749	put(a.root, 'a.txt', 'two\n')!750	make_save(a, 'second')!751752	// The first offer dies after the first write.753	mut flaky := FaultyBackend{754		inner:      remote.new_folder(remote_dir)!755		fail_write: 2756	}757	if _ := offer(a, mut flaky) {758		assert false, 'the injected failure should have surfaced'759	}760761	// Re-running with a healthy remote finishes the job: "already exists" is762	// success, so nothing is lost and nothing is duplicated.763	mut healthy := remote.new_folder(remote_dir)!764	report := offer(a, mut healthy)!765	assert report.saves == 2766	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id767	assert healthy.list('proj/saves')!.len == 2768}769770// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,771// and it must neither remove anything nor hold up an offer.772fn test_a_strip_with_an_illegal_path_is_ignored() {773	base := scratch('illegal_strip')!774	defer {775		os.rmdir_all(base) or {}776	}777	mut backend := remote.new_folder(os.join_path(base, 'remote'))!778	mut a := open_repo(machine('illegal_strip_a')!)!779	defer {780		a.close()781	}782	mut b := open_repo(machine('illegal_strip_b')!)!783	defer {784		b.close()785	}786	put(a.root, 'a/x.txt', 'kept\n')!787	make_save(a, 'add a folder')!788	offer_now(a, mut backend)!789	for id, path in {790		'aaaaaaaaaaaa': 'a//'791		'bbbbbbbbbbbb': 'a\\ud800'792	} {793		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!794	}795	join(b, mut backend)!796	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)797	put(a.root, 'more.txt', 'more\n')!798	make_save(a, 'more')!799	offer_now(a, mut backend)!800}801802fn test_a_failed_steal_leaves_nothing_behind() {803	base := scratch('fault_steal')!804	defer {805		os.rmdir_all(base) or {}806	}807	remote_dir := os.join_path(base, 'remote')808	mut a := open_repo(machine('fault_s_a')!)!809	defer {810		a.close()811	}812	mut b := open_repo(machine('fault_s_b')!)!813	defer {814		b.close()815	}816	put(a.root, 'a.txt', 'from A\n')!817	make_save(a, 'A writes')!818	mut healthy := remote.new_folder(remote_dir)!819	offer(a, mut healthy)!820821	// B's fetch fails half way through reading the remote.822	mut flaky := FaultyBackend{823		inner:     remote.new_folder(remote_dir)!824		fail_read: 2825	}826	if _ := fetch_back_to_known(b, mut flaky, 'proj') {827		assert false, 'the injected read failure should have surfaced'828	}829	// Nothing was written: the store is untouched and still has no saves.830	assert history.head(b) == none831	assert b.object_count()! == 0832833	// A healthy retry works.834	fetched := fetch_back_to_known(b, mut healthy, 'proj')!835	b.begin()!836	write_fetched(b, fetched)!837	history.set_head(b, fetched.head)!838	b.end()!839	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!840	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'841}842843fn test_an_interrupted_strip_is_idempotent() {844	// The local half of a strip is one transaction, so an interrupted strip is845	// simply a strip that did not happen; re-running it must land the same846	// result once, not twice.847	base := scratch('fault_strip')!848	defer {849		os.rmdir_all(base) or {}850	}851	mut a := open_repo(machine('fault_strip_a')!)!852	defer {853		a.close()854	}855	put(a.root, 'secret.txt', 'shh\n')!856	put(a.root, 'keep.txt', 'fine\n')!857	save := make_save(a, 'add both')!858859	for _ in 0 .. 2 {860		strip_local(a, 'secret.txt', '')!861	}862	files := history.files_of(a, save.id)!863	// The repo's own `.kohignore` is tracked content, so it stays; only the864	// stripped path goes.865	assert files.map(it.path) == ['.kohignore', 'keep.txt']866	assert events.strip_events(a)!.len == 1867}868869// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into870// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.871// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,872// but a save made on Linux can hold both.873fn test_a_strip_matches_the_path_exactly() {874	mut a := open_repo(machine('strip_exact')!)!875	defer {876		a.close()877	}878	hash := a.put_object('x\n'.bytes())!879	mut files := []snapshot.FileEntry{}880	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {881		files << snapshot.FileEntry{882			path: path883			hash: hash884		}885	}886	save := history.save_tree(a, '', files, history.SaveInput{887		message: 'add all'888	})!889	assert strip_scope(a, 'docs', '')!.files == 1890	strip_local(a, 'docs', '')!891	strip_local(a, 'a_b', '')!892	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']893}894895fn test_a_strip_reaches_the_remote_and_other_machines() {896	base := scratch('strip_sync')!897	defer {898		os.rmdir_all(base) or {}899	}900	mut a := open_repo(machine('strip_a')!)!901	defer {902		a.close()903	}904	mut b := open_repo(machine('strip_b')!)!905	defer {906		b.close()907	}908	put(a.root, 'secrets.env', 'api_key=1\n')!909	put(a.root, 'keep.txt', 'fine\n')!910	save := make_save(a, 'add both')!911	mut backend := remote.new_folder(os.join_path(base, 'remote'))!912	offer_now(a, mut backend)!913	join(b, mut backend)!914	assert b.object_count()! > 0915916	// A strips the path locally, the way `koh strip` does, then tells the917	// remote.918	mut secret_hash := ''919	for file in history.files_of(a, save.id)! {920		if file.path == 'secrets.env' {921			secret_hash = file.hash922		}923	}924	assert secret_hash != ''925	strip_local(a, 'secrets.env', '')!926	report := send_strip(a, mut backend, 'secrets.env')!927	assert report.event_sent928	assert report.records == 1929930	// The remote's copy of the save no longer lists the file, and the object is931	// gone from the remote.932	record := decode_save(backend.get(save_key('proj', save.id))!.body)!933	for file in record.files {934		assert file.path != 'secrets.env'935	}936	// The stripped object is gone from the remote; the ones still referenced937	// are not.938	assert !backend.get(object_key('proj', secret_hash))!.found939	assert backend.list('proj/objects')!.len == 2940941	// Another machine hears about it on its next steal and applies it.942	b.begin()!943	fetched := fetch_back_to_known(b, mut backend, 'proj')!944	write_fetched(b, fetched)!945	b.end()!946	mut paths := []string{}947	for file in history.files_of(b, save.id)! {948		paths << file.path949	}950	assert 'secrets.env' !in paths951	assert 'keep.txt' in paths952953	// The event arrives under its own ID, so it is applied once: B's offer adds954	// no second copy. The path is banned for good: written again, the file is955	// neither unsaved work nor ever walked into a save.956	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)957	put(b.root, 'secrets.env', 'rotated\n')!958	assert !dirty(b, history.head(b))!959	put(b.root, 'keep.txt', 'still fine\n')!960	later := make_save(b, 'edit keep')!961	offer_now(b, mut backend)!962	assert backend.list('proj/events')!.len == 1963	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)964}965966// A strip removes a path from all of history, whenever a save was made. A967// machine that saved the file after the strip, before it heard of it, loses968// it from that save too on its next steal, so no copy anywhere keeps it.969fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {970	base := scratch('strip_later')!971	defer {972		os.rmdir_all(base) or {}973	}974	mut backend := remote.new_folder(os.join_path(base, 'remote'))!975	mut a := open_repo(machine('strip_later_a')!)!976	defer {977		a.close()978	}979	mut b := open_repo(machine('strip_later_b')!)!980	defer {981		b.close()982	}983	put(a.root, 'secrets.env', 'api_key=1\n')!984	old := make_save(a, 'leak the key')!985	offer_now(a, mut backend)!986	join(b, mut backend)!987	strip_local(a, 'secrets.env', '')!988	send_strip(a, mut backend, 'secrets.env')!989	// B has not heard, and saves the file again an hour after the strip.990	put(b.root, 'secrets.env', 'api_key=2\n')!991	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{992		message:    'rotate the key'993		created_at: events.strip_events(a)!.last().created_at + 3600994	})!995	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)996	b.begin()!997	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!998	b.end()!999	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1000	offer_now(b, mut backend)!10011002	mut c := open_repo(machine('strip_later_c')!)!1003	defer {1004		c.close()1005	}1006	join(c, mut backend)!1007	for save in [old.id, later.id] {1008		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1009	}1010	assert !os.exists(os.join_path(c.root, 'secrets.env'))1011}10121013// A strip this machine already holds bans its path from saves that arrive1014// later too, not only from those a fetch brings with the strip.1015fn test_a_held_strip_reaches_saves_arriving_after_it() {1016	base := scratch('strip_held')!1017	defer {1018		os.rmdir_all(base) or {}1019	}1020	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1021	mut a := open_repo(machine('strip_held_a')!)!1022	defer {1023		a.close()1024	}1025	mut b := open_repo(machine('strip_held_b')!)!1026	defer {1027		b.close()1028	}1029	put(a.root, 'secrets.env', 'api_key=1\n')!1030	make_save(a, 'leak the key')!1031	offer_now(a, mut backend)!1032	join(b, mut backend)!1033	strip_local(b, 'secrets.env', '')!1034	put(a.root, 'keep.txt', 'fine\n')!1035	later := make_save(a, 'add keep')!1036	offer_now(a, mut backend)!1037	b.begin()!1038	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1039	b.end()!1040	paths := history.files_of(b, later.id)!.map(it.path)1041	assert 'secrets.env' !in paths1042	assert 'keep.txt' in paths1043}10441045// StripRace stands in for another machine that strips `path` completely, event1046// and rewrite, just before this machine's first save record lands.1047struct StripRace {1048mut:1049	inner remote.Backend1050	other &storage.Store1051	path  string1052	done  bool1053}10541055fn (mut r StripRace) get(key string) !remote.Object {1056	return r.inner.get(key)!1057}10581059fn (mut r StripRace) list(prefix string) ![]string {1060	return r.inner.list(prefix)!1061}10621063fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1064	if !r.done && key.contains('/saves/') {1065		r.done = true1066		strip_local(r.other, r.path, '')!1067		send_strip(r.other, mut r.inner, r.path)!1068	}1069	return r.inner.put_if_absent(key, body)!1070}10711072fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1073	return r.inner.put_if_match(key, body, tag)!1074}10751076fn (mut r StripRace) delete(key string) ! {1077	r.inner.delete(key)!1078}10791080// A strip that finishes on another machine while an offer uploads has already1081// rewritten the remote without the offer's records, so the offer takes back1082// what the strip reaches and leaves head where it was.1083fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1084	base := scratch('strip_race')!1085	defer {1086		os.rmdir_all(base) or {}1087	}1088	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1089	mut a := open_repo(machine('strip_race_a')!)!1090	defer {1091		a.close()1092	}1093	mut b := open_repo(machine('strip_race_b')!)!1094	defer {1095		b.close()1096	}1097	put(a.root, 'secrets.env', 'api_key=1\n')!1098	first := make_save(a, 'leak the key')!1099	offer_now(a, mut backend)!1100	join(b, mut backend)!1101	put(a.root, 'secrets.env', 'api_key=2\n')!1102	later := make_save(a, 'rotate the key')!1103	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1104	mut race := StripRace{1105		inner: backend1106		other: &b1107		path:  'secrets.env'1108	}1109	if _ := offer(a, mut race) {1110		assert false, 'an offer that raced a strip must refuse'1111	} else {1112		assert err.msg().contains('koh steal'), err.msg()1113	}1114	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1115	assert !backend.get(object_key('proj', secret))!.found1116	for key in backend.list('proj/saves')! {1117		record := decode_save(backend.get(key)!.body)!1118		assert 'secrets.env' !in record.files.map(it.path), record.id1119	}1120}11211122// The local half lands before the remote hears anything, so a remote failure1123// leaves nothing to find locally; the pending note is what lets a re-run1124// finish. Objects a remote-only save still needs must survive the strip.1125fn test_an_interrupted_remote_strip_finishes_when_re_run() {1126	base := scratch('strip_resume')!1127	defer {1128		os.rmdir_all(base) or {}1129	}1130	remote_dir := os.join_path(base, 'remote')1131	mut backend := remote.new_folder(remote_dir)!1132	mut a := open_repo(machine('resume_a')!)!1133	defer {1134		a.close()1135	}1136	mut b := open_repo(machine('resume_b')!)!1137	defer {1138		b.close()1139	}1140	put(a.root, 'secrets.env', 'api_key=1\n')!1141	put(a.root, 'shared.env', 'same bytes on B\n')!1142	save := make_save(a, 'add secrets')!1143	offer_now(a, mut backend)!1144	join(b, mut backend)!1145	put(b.root, 'copy.txt', 'same bytes on B\n')!1146	b_save := make_save(b, 'B keeps a copy')!1147	offer_now(b, mut backend)!1148	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1149	secret_hash := storage.hash_hex('api_key=1\n'.bytes())11501151	strip_local(a, 'secrets.env', '')!1152	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1153	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1154	mut flaky := FaultyBackend{1155		inner:      remote.new_folder(remote_dir)!1156		fail_write: 31157	}1158	if _ := send_strip(a, mut flaky, 'secrets.env') {1159		assert false, 'the injected failure should have surfaced'1160	}1161	assert strip_pending(a, 'secrets.env')1162	assert strip_local(a, 'secrets.env', '')!.files_removed == 01163	report := send_strip(a, mut backend, 'secrets.env')!1164	assert report.records == 11165	assert !strip_pending(a, 'secrets.env')1166	assert !backend.get(object_key('proj', secret_hash))!.found1167	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1168	assert 'secrets.env' !in record.files.map(it.path)1169	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1170	assert 'secrets.env' !in b_record.files.map(it.path)11711172	// B's save still names the shared bytes, so stripping A's copy keeps them.1173	strip_local(a, 'shared.env', '')!1174	send_strip(a, mut backend, 'shared.env')!1175	assert backend.get(object_key('proj', shared_hash))!.found1176}11771178// A machine that has not heard of a strip must not offer: its records still1179// list the stripped path, and nothing would rewrite them on the remote again.1180fn test_offer_waits_for_an_unapplied_strip() {1181	base := scratch('strip_first')!1182	defer {1183		os.rmdir_all(base) or {}1184	}1185	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1186	mut a := open_repo(machine('strip_first_a')!)!1187	defer {1188		a.close()1189	}1190	mut b := open_repo(machine('strip_first_b')!)!1191	defer {1192		b.close()1193	}1194	put(a.root, 'secrets.env', 'api_key=1\n')!1195	make_save(a, 'add secrets')!1196	offer_now(a, mut backend)!1197	join(b, mut backend)!1198	strip_local(a, 'secrets.env', '')!1199	send_strip(a, mut backend, 'secrets.env')!1200	put(b.root, 'more.txt', 'B keeps working\n')!1201	later := make_save(b, 'B saves with the secret still in its tree')!1202	if _ := offer_now(b, mut backend) {1203		assert false, 'B offered records that still list the stripped path'1204	}1205	steal_now(b, mut backend)!1206	offer_now(b, mut backend)!1207	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1208	assert 'secrets.env' !in record.files.map(it.path)1209}12101211// A strip whose object deletions failed must still delete them when it is run1212// again, and meanwhile the banned path cannot come back in through a save.1213fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1214	base := scratch('strip_twice')!1215	defer {1216		os.rmdir_all(base) or {}1217	}1218	remote_dir := os.join_path(base, 'remote')1219	mut backend := remote.new_folder(remote_dir)!1220	mut a := open_repo(machine('strip_twice_a')!)!1221	defer {1222		a.close()1223	}1224	put(a.root, 'secrets.env', 'first key\n')!1225	make_save(a, 'first key')!1226	offer_now(a, mut backend)!1227	strip_local(a, 'secrets.env', '')!1228	mut flaky := FaultyBackend{1229		inner:       remote.new_folder(remote_dir)!1230		fail_delete: true1231	}1232	if _ := send_strip(a, mut flaky, 'secrets.env') {1233		assert false, 'the injected delete failure should have surfaced'1234	}1235	put(a.root, 'secrets.env', 'second key\n')!1236	put(a.root, 'other.txt', 'more work\n')!1237	make_save(a, 'more work')!1238	offer_now(a, mut backend)!1239	send_strip(a, mut backend, 'secrets.env')!1240	for key in ['first key\n', 'second key\n'] {1241		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1242	}1243}12441245// Unfinished strips of one path in different scopes finish together.1246fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1247	base := scratch('strip_scopes')!1248	defer {1249		os.rmdir_all(base) or {}1250	}1251	remote_dir := os.join_path(base, 'remote')1252	mut backend := remote.new_folder(remote_dir)!1253	mut a := open_repo(machine('strip_scopes_a')!)!1254	defer {1255		a.close()1256	}1257	put(a.root, 'secrets.env', 'first key\n')!1258	first := make_save(a, 'first key')!1259	put(a.root, 'secrets.env', 'second key\n')!1260	second := make_save(a, 'second key')!1261	offer_now(a, mut backend)!1262	strip_local(a, 'secrets.env', first.id)!1263	// Write 1 uploads the event; write 2, the record rewrite, fails.1264	mut flaky := FaultyBackend{1265		inner:      remote.new_folder(remote_dir)!1266		fail_write: 21267	}1268	if _ := send_strip(a, mut flaky, 'secrets.env') {1269		assert false, 'the injected failure should have surfaced'1270	}1271	strip_local(a, 'secrets.env', second.id)!1272	send_strip(a, mut backend, 'secrets.env')!1273	assert !strip_pending(a, 'secrets.env')1274	for save in [first, second] {1275		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1276		assert 'secrets.env' !in record.files.map(it.path)1277	}1278	for key in ['first key\n', 'second key\n'] {1279		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1280	}1281}12821283// A strip bans its path on every machine that has heard of it: written again,1284// the file makes no save, here or there, so there is never a second copy to strip.1285fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1286	base := scratch('strip_again')!1287	defer {1288		os.rmdir_all(base) or {}1289	}1290	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1291	mut a := open_repo(machine('strip_again_a')!)!1292	defer {1293		a.close()1294	}1295	mut b := open_repo(machine('strip_again_b')!)!1296	defer {1297		b.close()1298	}1299	put(a.root, 'secrets.env', 'first key\n')!1300	make_save(a, 'first key')!1301	offer_now(a, mut backend)!1302	join(b, mut backend)!1303	strip_local(a, 'secrets.env', '')!1304	send_strip(a, mut backend, 'secrets.env')!1305	steal_now(b, mut backend)!1306	for repo in [a, b] {1307		put(repo.root, 'secrets.env', 'second key\n')!1308		if _ := make_save(repo, 'second key') {1309			assert false, 'a banned path must not make a save'1310		}1311	}1312}13131314// A clash on a path the other machine stripped is no clash: the strip removes1315// the path from every save, so there is nothing to choose.1316fn test_a_stripped_path_is_not_asked_about() {1317	base := scratch('clash_strip')!1318	defer {1319		os.rmdir_all(base) or {}1320	}1321	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1322	mut a := open_repo(machine('clash_strip_a')!)!1323	defer {1324		a.close()1325	}1326	mut b := open_repo(machine('clash_strip_b')!)!1327	defer {1328		b.close()1329	}1330	put(a.root, 'secrets.env', 'key one\n')!1331	make_save(a, 'base')!1332	offer_now(a, mut backend)!1333	join(b, mut backend)!1334	put(a.root, 'secrets.env', 'key two\n')!1335	make_save(a, 'A rotates')!1336	offer_now(a, mut backend)!1337	strip_local(a, 'secrets.env', '')!1338	send_strip(a, mut backend, 'secrets.env')!1339	put(b.root, 'secrets.env', 'key three\n')!1340	mine := make_save(b, 'B rotates')!1341	report := steal(b, mut backend, StealOptions{})!1342	assert report.clashes.len == 01343	for save in history.all_saves(b)! {1344		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1345	}1346	assert history.by_id(b, mine.id) != none1347}13481349// A strip scoped to the shared base save changes what the replay sees; the1350// clashes must be found on the same trees, or the replay meets a conflict.1351fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1352	base := scratch('clash_base_strip')!1353	defer {1354		os.rmdir_all(base) or {}1355	}1356	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1357	mut a := open_repo(machine('base_strip_a')!)!1358	defer {1359		a.close()1360	}1361	mut b := open_repo(machine('base_strip_b')!)!1362	defer {1363		b.close()1364	}1365	put(a.root, 'notes.txt', 'shared\n')!1366	common := make_save(a, 'base')!1367	offer_now(a, mut backend)!1368	join(b, mut backend)!1369	put(a.root, 'other.txt', 'A moves on\n')!1370	make_save(a, 'A moves on')!1371	offer_now(a, mut backend)!1372	strip_local(a, 'notes.txt', common.id)!1373	send_strip(a, mut backend, 'notes.txt')!1374	put(b.root, 'notes.txt', 'B edits\n')!1375	make_save(b, 'B edits notes')!1376	if _ := steal(b, mut backend, StealOptions{}) {1377		assert false, 'with the base stripped, notes.txt differs on both sides'1378	} else {1379		assert err.msg().contains('notes.txt changed on both machines')1380	}1381	report := steal(b, mut backend, StealOptions{1382		mine: true1383	})!1384	assert report.clashes == ['notes.txt']1385	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1386}13871388// A folder with files but no saves has nothing to combine with; a steal that1389// fast-forwarded into it would overwrite whatever shares a name.1390fn test_steal_into_a_folder_with_unsaved_files_refuses() {1391	base := scratch('steal_unsaved')!1392	defer {1393		os.rmdir_all(base) or {}1394	}1395	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1396	mut a := open_repo(machine('unsaved_a')!)!1397	defer {1398		a.close()1399	}1400	put(a.root, 'notes.txt', 'from A\n')!1401	make_save(a, 'A writes')!1402	offer_now(a, mut backend)!1403	b_root := machine('unsaved_b')!1404	mut b := storage.create(b_root)!1405	defer {1406		b.close()1407	}1408	put(b_root, 'notes.txt', 'never saved\n')!1409	// Only a repo that already knows its remote name gets this far.1410	b.set_state('remote_repo', 'proj')!1411	if _ := steal(b, mut backend, StealOptions{}) {1412		assert false, 'steal overwrote a folder with unsaved files'1413	} else {1414		assert err.msg() == no_saves_here1415	}1416	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1417	// A fresh `koh init` folder, holding only the default ignore list, is fine.1418	os.rm(os.join_path(b_root, 'notes.txt'))!1419	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1420	steal(b, mut backend, StealOptions{})!1421	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1422}14231424// The document asks steal to warn about every incoming flagged save, so the1425// report names them.1426fn test_steal_reports_incoming_flagged_saves() {1427	base := scratch('flag_warn')!1428	defer {1429		os.rmdir_all(base) or {}1430	}1431	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1432	mut a := open_repo(machine('flag_warn_a')!)!1433	defer {1434		a.close()1435	}1436	mut b := open_repo(machine('flag_warn_b')!)!1437	defer {1438		b.close()1439	}1440	put(a.root, 'a.txt', 'one\n')!1441	make_save(a, 'fine')!1442	offer_now(a, mut backend)!1443	join(b, mut backend)!1444	put(a.root, 'a.txt', 'two\n')!1445	suspect := make_save(a, 'suspect')!1446	put(a.root, 'a.txt', 'three\n')!1447	make_save(a, 'also fine')!1448	events.flag(a, suspect.id, 'broke the build')!1449	offer_now(a, mut backend)!1450	report := steal_now(b, mut backend)!1451	assert report.flagged.map(it.save_id) == [suspect.id]1452	assert report.flagged[0].reason == 'broke the build'1453}14541455// head_of unwraps the head for tests that know the repo has saves.1456fn head_of(store &storage.Store) history.Save {1457	return history.head(store) or { panic('this repo has no head') }1458}14591460// A remote says what it is, so two koh versions never half-understand one1461// bucket. The first offer of a repo records the layout.1462fn test_the_first_offer_records_the_remote_format() {1463	base := scratch('format_write')!1464	defer {1465		os.rmdir_all(base) or {}1466	}1467	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1468	mut a := open_repo(machine('format_write_a')!)!1469	defer {1470		a.close()1471	}1472	put(a.root, 'a.txt', 'one\n')!1473	make_save(a, 'first')!1474	offer_now(a, mut backend)!1475	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1476}14771478// A repo offered before the key existed is version 1: it still syncs, and the1479// next offer records the key then.1480fn test_a_remote_without_a_format_still_syncs() {1481	base := scratch('format_old')!1482	defer {1483		os.rmdir_all(base) or {}1484	}1485	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1486	mut a := open_repo(machine('format_old_a')!)!1487	defer {1488		a.close()1489	}1490	put(a.root, 'a.txt', 'one\n')!1491	make_save(a, 'first')!1492	offer_now(a, mut backend)!1493	// Stand in for a bucket offered before the key existed.1494	backend.delete(format_key('proj'))!1495	mut b := open_repo(machine('format_old_b')!)!1496	defer {1497		b.close()1498	}1499	join(b, mut backend)!1500	assert head_of(b).id == head_of(a).id1501	put(a.root, 'a.txt', 'two\n')!1502	make_save(a, 'second')!1503	offer_now(a, mut backend)!1504	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1505	report := steal_now(b, mut backend)!1506	assert report.head == head_of(a).id1507}15081509// A remote that speaks another layout is refused loudly, on offer and on1510// steal, rather than half-read.1511fn test_an_unknown_remote_format_is_refused() {1512	base := scratch('format_unknown')!1513	defer {1514		os.rmdir_all(base) or {}1515	}1516	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1517	mut a := open_repo(machine('format_bad_a')!)!1518	defer {1519		a.close()1520	}1521	put(a.root, 'a.txt', 'one\n')!1522	make_save(a, 'first')!1523	offer_now(a, mut backend)!1524	backend.delete(format_key('proj'))!1525	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1526	if _ := offer_now(a, mut backend) {1527		assert false, 'offer must refuse a format it does not speak'1528	} else {1529		assert err.msg().contains('koh-remote 2')1530	}1531	if _ := steal_now(a, mut backend) {1532		assert false, 'steal must refuse a format it does not speak'1533	} else {1534		assert err.msg().contains('koh-remote 2')1535	}1536	if _ := fetch_repo(mut backend, 'proj') {1537		assert false, 'a steal must refuse a format it does not speak'1538	} else {1539		assert err.msg().contains('koh-remote 2')1540	}1541}15421543// Two machines offering a new repo at once both find no format key; the loser1544// of the conditional write must read back the winner's value and refuse it,1545// not carry on against a remote it cannot read.1546fn test_a_lost_format_write_is_read_back_and_refused() {1547	base := scratch('format_race')!1548	defer {1549		os.rmdir_all(base) or {}1550	}1551	mut inner := remote.new_folder(os.join_path(base, 'remote'))!1552	mut a := open_repo(machine('format_race_a')!)!1553	defer {1554		a.close()1555	}1556	put(a.root, 'a.txt', 'one\n')!1557	make_save(a, 'first')!1558	mut backend := remote.Backend(FaultyBackend{1559		inner:       inner1560		race_format: true1561	})1562	if _ := offer_now(a, mut backend) {1563		assert false, 'offer must refuse the format another machine wrote first'1564	} else {1565		assert err.msg().contains('koh-remote 2')1566	}1567}