exchange_test.v

48.3 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_a_repo_name_is_lowercased_at_first_offer() {136	base := scratch('lower')!137	defer {138		os.rmdir_all(base) or {}139	}140	dir := os.join_path(base, 'Haven')141	os.mkdir_all(dir, os.MkdirParams{})!142	mut a := open_repo(dir)!143	defer {144		a.close()145	}146	put(a.root, 'a.txt', 'one\n')!147	make_save(a, 'first')!148	mut backend := remote.new_folder(os.join_path(base, 'remote'))!149	assert offer_now(a, mut backend)!.repo == 'haven'150	assert backend.get(head_key('haven'))!.found151}152153fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {154	base := scratch('forward')!155	defer {156		os.rmdir_all(base) or {}157	}158	mut backend := remote.new_folder(os.join_path(base, 'remote'))!159	mut a := open_repo(machine('fwd_a')!)!160	defer {161		a.close()162	}163	put(a.root, 'a.txt', 'from A\n')!164	make_save(a, 'A writes')!165	offer_now(a, mut backend)!166167	// B starts empty and takes the whole repo.168	mut b := open_repo(machine('fwd_b')!)!169	defer {170		b.close()171	}172	fetched := fetch_repo(mut backend, 'proj')!173	b.begin()!174	write_fetched(b, fetched)!175	history.set_head(b, fetched.head)!176	b.end()!177	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!178	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'179	assert head_of(b).id == fetched.head180}181182// A machine that is only ahead has nothing to combine: stealing must leave its183// saves, and their IDs, exactly where they are.184fn test_steal_while_only_ahead_leaves_the_line_alone() {185	base := scratch('ahead')!186	defer {187		os.rmdir_all(base) or {}188	}189	mut backend := remote.new_folder(os.join_path(base, 'remote'))!190	mut a := open_repo(machine('ahead_a')!)!191	defer {192		a.close()193	}194	put(a.root, 'a.txt', 'one\n')!195	make_save(a, 'offered')!196	offer_now(a, mut backend)!197	put(a.root, 'a.txt', 'two\n')!198	ahead := make_save(a, 'not offered yet')!199	report := steal_now(a, mut backend)!200	assert report.replayed == 0201	assert head_of(a).id == ahead.id202	// Nothing was restored, so nothing is left marked as half-restored.203	assert !snapshot.restore_pending(a)204	assert history.all_saves(a)!.len == 2205	offer_now(a, mut backend)!206	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id207}208209// A link is invisible to the walk, so a tree holding one is clean; the files210// a steal brings in must still never land in the folder it points at.211fn test_steal_never_writes_through_a_symlinked_folder() {212	base := scratch('steal_link')!213	defer {214		os.rmdir_all(base) or {}215	}216	mut backend := remote.new_folder(os.join_path(base, 'remote'))!217	mut a := open_repo(machine('steal_link_a')!)!218	defer {219		a.close()220	}221	mut b := open_repo(machine('steal_link_b')!)!222	defer {223		b.close()224	}225	put(a.root, 'a.txt', 'one\n')!226	make_save(a, 'first')!227	offer_now(a, mut backend)!228	join(b, mut backend)!229	outside := os.join_path(base, 'outside')230	put(outside, 'a.md', 'precious\n')!231	os.symlink(outside, os.join_path(b.root, 'docs'))!232	put(a.root, 'docs/a.md', 'from A\n')!233	make_save(a, 'A adds docs')!234	offer_now(a, mut backend)!235	head_before := head_of(b).id236	if _ := steal_now(b, mut backend) {237		assert false, 'a steal writing through a symlinked folder must refuse'238	} else {239		assert err.msg().contains('docs')240	}241	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'242	assert head_of(b).id == head_before243}244245// A steal onto a half-restored tree would replay it as somebody's work.246fn test_steal_refuses_after_a_restore_cut_short() {247	base := scratch('steal_cut')!248	defer {249		os.rmdir_all(base) or {}250	}251	mut backend := remote.new_folder(os.join_path(base, 'remote'))!252	mut a := open_repo(machine('steal_cut_a')!)!253	defer {254		a.close()255	}256	put(a.root, 'a.txt', 'one\n')!257	make_save(a, 'first')!258	offer_now(a, mut backend)!259	a.set_state('restoring', 'yes')!260	if _ := steal_now(a, mut backend) {261		assert false, 'steal must refuse while a restore is unfinished'262	} else {263		assert err.msg().contains('koh revert')264	}265}266267// A replayed save keeps its original time but sits on a newer parent, so a268// steal cannot order saves by time.269fn test_a_steal_imports_parents_before_children() {270	base := scratch('steal_order')!271	defer {272		os.rmdir_all(base) or {}273	}274	mut backend := remote.new_folder(os.join_path(base, 'remote'))!275	mut a := open_repo(machine('order_a')!)!276	defer {277		a.close()278	}279	put(a.root, 'a.txt', 'one\n')!280	parent := make_save(a, 'parent')!281	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{282		message:    'replayed, older than its parent'283		created_at: parent.created_at - 3600284	})!285	offer_now(a, mut backend)!286	mut b := open_repo(machine('order_b')!)!287	defer {288		b.close()289	}290	join(b, mut backend)!291	assert head_of(b).id == child.id292}293294// A chmod is unsaved work too: a steal would restore the old mode over it.295fn test_steal_refuses_an_unsaved_executable_bit_change() {296	base := scratch('chmod')!297	defer {298		os.rmdir_all(base) or {}299	}300	mut backend := remote.new_folder(os.join_path(base, 'remote'))!301	mut a := open_repo(machine('chmod_a')!)!302	defer {303		a.close()304	}305	put(a.root, 'run.sh', 'echo hi\n')!306	make_save(a, 'script')!307	offer_now(a, mut backend)!308	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!309	if _ := steal_now(a, mut backend) {310		assert false, 'a chmod-only change should make the tree dirty'311	} else {312		assert err.msg() == dirty_tree313	}314}315316fn test_divergence_without_clashes_keeps_both_machines_work() {317	base := scratch('diverge')!318	defer {319		os.rmdir_all(base) or {}320	}321	mut backend := remote.new_folder(os.join_path(base, 'remote'))!322	mut a := open_repo(machine('div_a')!)!323	defer {324		a.close()325	}326	mut b := open_repo(machine('div_b')!)!327	defer {328		b.close()329	}330	put(a.root, 'shared.txt', 'base\n')!331	make_save(a, 'base')!332	offer_now(a, mut backend)!333334	// B joins, then both machines change a different file.335	join(b, mut backend)!336	put(a.root, 'fromA.txt', 'A\n')!337	make_save(a, 'A adds a file')!338	put(b.root, 'fromB.txt', 'B\n')!339	make_save(b, 'B adds a file')!340	offer_now(a, mut backend)!341342	// B's offer is refused: the remote has moved on.343	if _ := offer_now(b, mut backend) {344		assert false, 'offering on top of unknown remote saves should be refused'345	} else {346		assert err.msg().contains('run koh steal')347	}348349	report := steal_now(b, mut backend)!350	assert !report.forward351	assert report.replayed == 1352	assert report.clashes.len == 0353	// Both files are on B's line now, and on disk.354	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'355	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'356	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'357	// B's own save was replayed, not erased.358	mut saw_replay := false359	for save in history.all_saves(b)! {360		if save.replayed_from != '' {361			saw_replay = true362		}363	}364	assert saw_replay365	// And the remote now has B's work too.366	offer_now(b, mut backend)!367	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()368	assert line_head == head_of(b).id369}370371// An agent that edits a file and then undoes the edit leaves no net change, so372// the file is not a clash when the other machine changed it. Replaying the373// edit itself must not stop the steal: the remote's version simply stands.374// Likewise a file both machines ended up changing the same way.375fn test_a_local_change_undone_does_not_block_a_combine() {376	base := scratch('undone')!377	defer {378		os.rmdir_all(base) or {}379	}380	mut backend := remote.new_folder(os.join_path(base, 'remote'))!381	mut a := open_repo(machine('undone_a')!)!382	defer {383		a.close()384	}385	mut b := open_repo(machine('undone_b')!)!386	defer {387		b.close()388	}389	put(a.root, 'f.txt', 'base\n')!390	put(a.root, 'g.txt', 'base\n')!391	make_save(a, 'base')!392	offer_now(a, mut backend)!393	join(b, mut backend)!394395	put(a.root, 'f.txt', 'from A\n')!396	put(a.root, 'g.txt', 'same\n')!397	make_save(a, 'A edits both')!398	offer_now(a, mut backend)!399400	put(b.root, 'f.txt', 'tried on B\n')!401	put(b.root, 'g.txt', 'first try\n')!402	tried := make_save(b, 'B tries')!403	put(b.root, 'f.txt', 'base\n')!404	put(b.root, 'g.txt', 'same\n')!405	put(b.root, 'h.txt', 'B\n')!406	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!407408	report := steal_now(b, mut backend)!409	assert report.clashes.len == 0410	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'411	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'412	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'413	// "B tries" only touched files A changed, so it has nothing left to414	// replay; the second save carries h.txt. Both originals stay in history.415	assert report.replayed == 1416	assert head_of(b).replayed_from == undone.id417	ids := history.all_saves(b)!.map(it.id)418	assert tried.id in ids && undone.id in ids419	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')420}421422// A person deciding a clash may take minutes; the question must come before423// the write lock, or every agent's save on this repo waits and then fails.424fn test_clash_questions_are_asked_before_the_write_lock() {425	base := scratch('ask_unlocked')!426	defer {427		os.rmdir_all(base) or {}428	}429	mut backend := remote.new_folder(os.join_path(base, 'remote'))!430	mut a := open_repo(machine('ask_a')!)!431	defer {432		a.close()433	}434	mut b := open_repo(machine('ask_b')!)!435	defer {436		b.close()437	}438	put(a.root, 'same.txt', 'base\n')!439	make_save(a, 'base')!440	offer_now(a, mut backend)!441	join(b, mut backend)!442	put(a.root, 'same.txt', 'A version\n')!443	make_save(a, 'A edits same')!444	offer_now(a, mut backend)!445	put(b.root, 'same.txt', 'B version\n')!446	make_save(b, 'B edits same')!447	root := b.root448	// An agent saves while the person is still choosing.449	if _ := steal(b, mut backend, StealOptions{450		guide: fn [root] (preview StealPreview) !map[string]Side {451			other := storage.open(root) or { panic(err) }452			defer {453				other.close()454			}455			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }456			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{457				message: 'agent saves during the question'458			}) or { panic('steal held the write lock while asking: ${err}') }459			return {460				preview.clashes[0].path: Side.mine461			}462		}463	})464	{465		assert false, 'a steal whose line moved during the question must refuse'466	} else {467		assert err.msg().contains('changed while')468	}469	// The agent's save is still head, and a second steal combines it too.470	assert head_of(b).message == 'agent saves during the question'471	report := steal(b, mut backend, StealOptions{472		mine: true473	})!474	assert report.kept_mine == 1475	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'476	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'477}478479fn test_clashing_files_are_chosen_one_at_a_time() {480	base := scratch('clash')!481	defer {482		os.rmdir_all(base) or {}483	}484	mut backend := remote.new_folder(os.join_path(base, 'remote'))!485	mut a := open_repo(machine('clash_a')!)!486	defer {487		a.close()488	}489	mut b := open_repo(machine('clash_b')!)!490	defer {491		b.close()492	}493	put(a.root, 'same.txt', 'base\n')!494	put(a.root, 'other.txt', 'quiet\n')!495	make_save(a, 'base')!496	offer_now(a, mut backend)!497	join(b, mut backend)!498499	// Both machines edit the same file, differently.500	put(a.root, 'same.txt', 'A version\n')!501	make_save(a, 'A edits same')!502	offer_now(a, mut backend)!503	put(b.root, 'same.txt', 'B version\n')!504	make_save(b, 'B edits same')!505506	// Without a decision, steal refuses and says which files clash.507	if _ := steal(b, mut backend, StealOptions{}) {508		assert false, 'an undecided clash must be refused'509	} else {510		assert err.msg().contains('--mine') || err.msg().contains('--theirs')511		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.512		if err is ClashError {513			assert err.paths == ['same.txt']514		} else {515			assert false, 'an undecided clash must be a ClashError, not ${err}'516		}517	}518	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,519		head_of(b).id)!).len == 0520521	// Choosing the remote's version ends with A's content.522	report := steal_now(b, mut backend)!523	assert report.clashes == ['same.txt']524	assert report.kept_mine == 0525	assert report.kept_versions == ['same.txt:${report.previous_head}']526	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'527	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'528}529530fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {531	base := scratch('guided_cancel')!532	defer { os.rmdir_all(base) or {} }533	mut backend := remote.new_folder(os.join_path(base, 'remote'))!534	mut a := open_repo(machine('guided_cancel_a')!)!535	defer { a.close() }536	mut b := open_repo(machine('guided_cancel_b')!)!537	defer { b.close() }538	put(a.root, 'same.txt', 'base\n')!539	make_save(a, 'base')!540	offer_now(a, mut backend)!541	join(b, mut backend)!542	put(a.root, 'same.txt', 'remote\n')!543	remote_head := make_save(a, 'remote change')!544	offer_now(a, mut backend)!545	put(b.root, 'same.txt', 'mine\n')!546	local_head := make_save(b, 'local change')!547548	if _ := steal(b, mut backend, StealOptions{549		guide: fn (preview StealPreview) !map[string]Side {550			assert preview.clashes.len == 1551			return error('cancelled')552		}553	}) {554		assert false, 'the guide cancelled'555	} else {556		assert err.msg() == 'cancelled'557	}558	assert head_of(b).id == local_head.id559	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'560	assert history.by_id(b, remote_head.id) != none561	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1562}563564fn test_choosing_mine_keeps_the_local_version() {565	base := scratch('mine')!566	defer {567		os.rmdir_all(base) or {}568	}569	mut backend := remote.new_folder(os.join_path(base, 'remote'))!570	mut a := open_repo(machine('mine_a')!)!571	defer {572		a.close()573	}574	mut b := open_repo(machine('mine_b')!)!575	defer {576		b.close()577	}578	put(a.root, 'same.txt', 'base\n')!579	make_save(a, 'base')!580	offer_now(a, mut backend)!581	join(b, mut backend)!582	put(a.root, 'same.txt', 'A version\n')!583	make_save(a, 'A edits')!584	offer_now(a, mut backend)!585	put(b.root, 'same.txt', 'B version\n')!586	make_save(b, 'B edits')!587588	report := steal(b, mut backend, StealOptions{589		mine: true590	})!591	assert report.kept_mine == 1592	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'593	// The final save says what it kept.594	assert head_of(b).message.contains('combine: kept mine for 1 file')595	// The pre-combine tree is still reachable in history.596	assert head_of(b).restores == ''597	assert history.all_saves(b)!.len >= 4598}599600fn test_flags_travel_with_the_saves() {601	base := scratch('flags')!602	defer {603		os.rmdir_all(base) or {}604	}605	mut backend := remote.new_folder(os.join_path(base, 'remote'))!606	mut a := open_repo(machine('flag_a')!)!607	defer {608		a.close()609	}610	mut b := open_repo(machine('flag_b')!)!611	defer {612		b.close()613	}614	put(a.root, 'a.txt', 'one\n')!615	save := make_save(a, 'suspect save')!616	events.flag(a, save.id, 'look at this')!617	offer_now(a, mut backend)!618619	// B has nothing yet, so a fetch brings the save and its flag across.620	fetched := fetch_back_to_known(b, mut backend, 'proj')!621	assert fetched.events_out.len == 1622	assert fetched.events_out[0].kind == events.kind_flag623	b.begin()!624	write_fetched(b, fetched)!625	history.set_head(b, fetched.head)!626	b.end()!627	state := events.flag_state(b, save.id)!628	assert state.flagged629	assert state.reason == 'look at this'630}631632fn test_a_second_offer_racing_head_is_refused_not_forced() {633	base := scratch('race')!634	defer {635		os.rmdir_all(base) or {}636	}637	mut backend := remote.new_folder(os.join_path(base, 'remote'))!638	mut a := open_repo(machine('race_a')!)!639	defer {640		a.close()641	}642	put(a.root, 'a.txt', 'one\n')!643	make_save(a, 'base')!644	offer_now(a, mut backend)!645646	// Someone else replaces head behind our back.647	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),648		backend.get(head_key('proj'))!.tag)!649650	put(a.root, 'a.txt', 'two\n')!651	make_save(a, 'second')!652	if _ := offer_now(a, mut backend) {653		assert false, 'offering while the remote head moved must be refused'654	} else {655		assert err.msg().contains('run koh steal')656	}657}658659// join makes a brand new machine share the remote's line.660fn join(store &storage.Store, mut backend remote.Backend) ! {661	fetched := fetch_repo(mut backend, 'proj')!662	store.begin()!663	write_fetched(store, fetched)!664	history.set_head(store, fetched.head)!665	store.end()!666	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,667		fetched.head)!, [])!668	store.set_state('remote_repo', 'proj')!669}670671struct FaultyBackend {672mut:673	inner      remote.Backend674	writes     int675	reads      int676	fail_write int // fail when the write counter reaches this, 0 never677	fail_read  int678	// fail every delete while set679	fail_delete bool680	// race_format stands in for another machine writing the format key first:681	// the first `format` write loses, to a value this koh does not speak.682	race_format bool683	raced       bool684}685686fn (mut f FaultyBackend) get(key string) !remote.Object {687	f.reads++688	if f.fail_read > 0 && f.reads >= f.fail_read {689		return error('injected read failure')690	}691	return f.inner.get(key)!692}693694fn (mut f FaultyBackend) list(prefix string) ![]string {695	return f.inner.list(prefix)!696}697698fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {699	f.writes++700	if f.fail_write > 0 && f.writes >= f.fail_write {701		return error('injected write failure')702	}703	if f.race_format && !f.raced && key.ends_with('/format') {704		f.raced = true705		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!706		return false707	}708	return f.inner.put_if_absent(key, body)!709}710711fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {712	f.writes++713	if f.fail_write > 0 && f.writes >= f.fail_write {714		return error('injected write failure')715	}716	return f.inner.put_if_match(key, body, tag)!717}718719fn (mut f FaultyBackend) delete(key string) ! {720	if f.fail_delete {721		return error('injected delete failure')722	}723	f.inner.delete(key)!724}725726// Every save offers after it is made, so a one-file change must cost one727// object, not one conditional write per file in the repo: on S3 each of those728// is a round trip.729fn test_an_offer_sends_only_the_objects_a_save_adds() {730	base := scratch('offer_diff')!731	defer {732		os.rmdir_all(base) or {}733	}734	mut a := open_repo(machine('offer_diff_a')!)!735	defer {736		a.close()737	}738	for i in 0 .. 20 {739		put(a.root, 'f${i}.txt', 'file ${i}\n')!740	}741	make_save(a, 'twenty files')!742	mut backend := FaultyBackend{743		inner: remote.new_folder(os.join_path(base, 'remote'))!744	}745	offer(a, mut backend)!746	put(a.root, 'f3.txt', 'changed\n')!747	make_save(a, 'one change')!748	before := backend.writes749	report := offer(a, mut backend)!750	assert report.objects == 1751	// One object, one save record, one head swap.752	assert backend.writes - before == 3753}754755fn test_a_failed_offer_is_safe_to_re_run() {756	base := scratch('fault_offer')!757	defer {758		os.rmdir_all(base) or {}759	}760	remote_dir := os.join_path(base, 'remote')761	mut a := open_repo(machine('fault_a')!)!762	defer {763		a.close()764	}765	put(a.root, 'a.txt', 'one\n')!766	make_save(a, 'first')!767	put(a.root, 'a.txt', 'two\n')!768	make_save(a, 'second')!769770	// The first offer dies after the first write.771	mut flaky := FaultyBackend{772		inner:      remote.new_folder(remote_dir)!773		fail_write: 2774	}775	if _ := offer(a, mut flaky) {776		assert false, 'the injected failure should have surfaced'777	}778779	// Re-running with a healthy remote finishes the job: "already exists" is780	// success, so nothing is lost and nothing is duplicated.781	mut healthy := remote.new_folder(remote_dir)!782	report := offer(a, mut healthy)!783	assert report.saves == 2784	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id785	assert healthy.list('proj/saves')!.len == 2786}787788// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,789// and it must neither remove anything nor hold up an offer.790fn test_a_strip_with_an_illegal_path_is_ignored() {791	base := scratch('illegal_strip')!792	defer {793		os.rmdir_all(base) or {}794	}795	mut backend := remote.new_folder(os.join_path(base, 'remote'))!796	mut a := open_repo(machine('illegal_strip_a')!)!797	defer {798		a.close()799	}800	mut b := open_repo(machine('illegal_strip_b')!)!801	defer {802		b.close()803	}804	put(a.root, 'a/x.txt', 'kept\n')!805	make_save(a, 'add a folder')!806	offer_now(a, mut backend)!807	for id, path in {808		'aaaaaaaaaaaa': 'a//'809		'bbbbbbbbbbbb': 'a\\ud800'810	} {811		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!812	}813	join(b, mut backend)!814	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)815	put(a.root, 'more.txt', 'more\n')!816	make_save(a, 'more')!817	offer_now(a, mut backend)!818}819820fn test_a_failed_steal_leaves_nothing_behind() {821	base := scratch('fault_steal')!822	defer {823		os.rmdir_all(base) or {}824	}825	remote_dir := os.join_path(base, 'remote')826	mut a := open_repo(machine('fault_s_a')!)!827	defer {828		a.close()829	}830	mut b := open_repo(machine('fault_s_b')!)!831	defer {832		b.close()833	}834	put(a.root, 'a.txt', 'from A\n')!835	make_save(a, 'A writes')!836	mut healthy := remote.new_folder(remote_dir)!837	offer(a, mut healthy)!838839	// B's fetch fails half way through reading the remote.840	mut flaky := FaultyBackend{841		inner:     remote.new_folder(remote_dir)!842		fail_read: 2843	}844	if _ := fetch_back_to_known(b, mut flaky, 'proj') {845		assert false, 'the injected read failure should have surfaced'846	}847	// Nothing was written: the store is untouched and still has no saves.848	assert history.head(b) == none849	assert b.object_count()! == 0850851	// A healthy retry works.852	fetched := fetch_back_to_known(b, mut healthy, 'proj')!853	b.begin()!854	write_fetched(b, fetched)!855	history.set_head(b, fetched.head)!856	b.end()!857	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!858	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'859}860861fn test_an_interrupted_strip_is_idempotent() {862	// The local half of a strip is one transaction, so an interrupted strip is863	// simply a strip that did not happen; re-running it must land the same864	// result once, not twice.865	base := scratch('fault_strip')!866	defer {867		os.rmdir_all(base) or {}868	}869	mut a := open_repo(machine('fault_strip_a')!)!870	defer {871		a.close()872	}873	put(a.root, 'secret.txt', 'shh\n')!874	put(a.root, 'keep.txt', 'fine\n')!875	save := make_save(a, 'add both')!876877	for _ in 0 .. 2 {878		strip_local(a, 'secret.txt', '')!879	}880	files := history.files_of(a, save.id)!881	// The repo's own `.kohignore` is tracked content, so it stays; only the882	// stripped path goes.883	assert files.map(it.path) == ['.kohignore', 'keep.txt']884	assert events.strip_events(a)!.len == 1885}886887// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into888// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.889// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,890// but a save made on Linux can hold both.891fn test_a_strip_matches_the_path_exactly() {892	mut a := open_repo(machine('strip_exact')!)!893	defer {894		a.close()895	}896	hash := a.put_object('x\n'.bytes())!897	mut files := []snapshot.FileEntry{}898	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {899		files << snapshot.FileEntry{900			path: path901			hash: hash902		}903	}904	save := history.save_tree(a, '', files, history.SaveInput{905		message: 'add all'906	})!907	assert strip_scope(a, 'docs', '')!.files == 1908	strip_local(a, 'docs', '')!909	strip_local(a, 'a_b', '')!910	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']911}912913fn test_a_strip_reaches_the_remote_and_other_machines() {914	base := scratch('strip_sync')!915	defer {916		os.rmdir_all(base) or {}917	}918	mut a := open_repo(machine('strip_a')!)!919	defer {920		a.close()921	}922	mut b := open_repo(machine('strip_b')!)!923	defer {924		b.close()925	}926	put(a.root, 'secrets.env', 'api_key=1\n')!927	put(a.root, 'keep.txt', 'fine\n')!928	save := make_save(a, 'add both')!929	mut backend := remote.new_folder(os.join_path(base, 'remote'))!930	offer_now(a, mut backend)!931	join(b, mut backend)!932	assert b.object_count()! > 0933934	// A strips the path locally, the way `koh strip` does, then tells the935	// remote.936	mut secret_hash := ''937	for file in history.files_of(a, save.id)! {938		if file.path == 'secrets.env' {939			secret_hash = file.hash940		}941	}942	assert secret_hash != ''943	strip_local(a, 'secrets.env', '')!944	report := send_strip(a, mut backend, 'secrets.env')!945	assert report.event_sent946	assert report.records == 1947948	// The remote's copy of the save no longer lists the file, and the object is949	// gone from the remote.950	record := decode_save(backend.get(save_key('proj', save.id))!.body)!951	for file in record.files {952		assert file.path != 'secrets.env'953	}954	// The stripped object is gone from the remote; the ones still referenced955	// are not.956	assert !backend.get(object_key('proj', secret_hash))!.found957	assert backend.list('proj/objects')!.len == 2958959	// Another machine hears about it on its next steal and applies it.960	b.begin()!961	fetched := fetch_back_to_known(b, mut backend, 'proj')!962	write_fetched(b, fetched)!963	b.end()!964	mut paths := []string{}965	for file in history.files_of(b, save.id)! {966		paths << file.path967	}968	assert 'secrets.env' !in paths969	assert 'keep.txt' in paths970971	// The event arrives under its own ID, so it is applied once: B's offer adds972	// no second copy. The path is banned for good: written again, the file is973	// neither unsaved work nor ever walked into a save.974	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)975	put(b.root, 'secrets.env', 'rotated\n')!976	assert !dirty(b, history.head(b))!977	put(b.root, 'keep.txt', 'still fine\n')!978	later := make_save(b, 'edit keep')!979	offer_now(b, mut backend)!980	assert backend.list('proj/events')!.len == 1981	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)982}983984// A strip removes a path from all of history, whenever a save was made. A985// machine that saved the file after the strip, before it heard of it, loses986// it from that save too on its next steal, so no copy anywhere keeps it.987fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {988	base := scratch('strip_later')!989	defer {990		os.rmdir_all(base) or {}991	}992	mut backend := remote.new_folder(os.join_path(base, 'remote'))!993	mut a := open_repo(machine('strip_later_a')!)!994	defer {995		a.close()996	}997	mut b := open_repo(machine('strip_later_b')!)!998	defer {999		b.close()1000	}1001	put(a.root, 'secrets.env', 'api_key=1\n')!1002	old := make_save(a, 'leak the key')!1003	offer_now(a, mut backend)!1004	join(b, mut backend)!1005	strip_local(a, 'secrets.env', '')!1006	send_strip(a, mut backend, 'secrets.env')!1007	// B has not heard, and saves the file again an hour after the strip.1008	put(b.root, 'secrets.env', 'api_key=2\n')!1009	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1010		message:    'rotate the key'1011		created_at: events.strip_events(a)!.last().created_at + 36001012	})!1013	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1014	b.begin()!1015	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1016	b.end()!1017	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1018	offer_now(b, mut backend)!10191020	mut c := open_repo(machine('strip_later_c')!)!1021	defer {1022		c.close()1023	}1024	join(c, mut backend)!1025	for save in [old.id, later.id] {1026		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1027	}1028	assert !os.exists(os.join_path(c.root, 'secrets.env'))1029}10301031// A strip this machine already holds bans its path from saves that arrive1032// later too, not only from those a fetch brings with the strip.1033fn test_a_held_strip_reaches_saves_arriving_after_it() {1034	base := scratch('strip_held')!1035	defer {1036		os.rmdir_all(base) or {}1037	}1038	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1039	mut a := open_repo(machine('strip_held_a')!)!1040	defer {1041		a.close()1042	}1043	mut b := open_repo(machine('strip_held_b')!)!1044	defer {1045		b.close()1046	}1047	put(a.root, 'secrets.env', 'api_key=1\n')!1048	make_save(a, 'leak the key')!1049	offer_now(a, mut backend)!1050	join(b, mut backend)!1051	strip_local(b, 'secrets.env', '')!1052	put(a.root, 'keep.txt', 'fine\n')!1053	later := make_save(a, 'add keep')!1054	offer_now(a, mut backend)!1055	b.begin()!1056	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1057	b.end()!1058	paths := history.files_of(b, later.id)!.map(it.path)1059	assert 'secrets.env' !in paths1060	assert 'keep.txt' in paths1061}10621063// StripRace stands in for another machine that strips `path` completely, event1064// and rewrite, just before this machine's first save record lands.1065struct StripRace {1066mut:1067	inner remote.Backend1068	other &storage.Store1069	path  string1070	done  bool1071}10721073fn (mut r StripRace) get(key string) !remote.Object {1074	return r.inner.get(key)!1075}10761077fn (mut r StripRace) list(prefix string) ![]string {1078	return r.inner.list(prefix)!1079}10801081fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1082	if !r.done && key.contains('/saves/') {1083		r.done = true1084		strip_local(r.other, r.path, '')!1085		send_strip(r.other, mut r.inner, r.path)!1086	}1087	return r.inner.put_if_absent(key, body)!1088}10891090fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1091	return r.inner.put_if_match(key, body, tag)!1092}10931094fn (mut r StripRace) delete(key string) ! {1095	r.inner.delete(key)!1096}10971098// A strip that finishes on another machine while an offer uploads has already1099// rewritten the remote without the offer's records, so the offer takes back1100// what the strip reaches and leaves head where it was.1101fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1102	base := scratch('strip_race')!1103	defer {1104		os.rmdir_all(base) or {}1105	}1106	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1107	mut a := open_repo(machine('strip_race_a')!)!1108	defer {1109		a.close()1110	}1111	mut b := open_repo(machine('strip_race_b')!)!1112	defer {1113		b.close()1114	}1115	put(a.root, 'secrets.env', 'api_key=1\n')!1116	first := make_save(a, 'leak the key')!1117	offer_now(a, mut backend)!1118	join(b, mut backend)!1119	put(a.root, 'secrets.env', 'api_key=2\n')!1120	later := make_save(a, 'rotate the key')!1121	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1122	mut race := StripRace{1123		inner: backend1124		other: &b1125		path:  'secrets.env'1126	}1127	if _ := offer(a, mut race) {1128		assert false, 'an offer that raced a strip must refuse'1129	} else {1130		assert err.msg().contains('koh steal'), err.msg()1131	}1132	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1133	assert !backend.get(object_key('proj', secret))!.found1134	for key in backend.list('proj/saves')! {1135		record := decode_save(backend.get(key)!.body)!1136		assert 'secrets.env' !in record.files.map(it.path), record.id1137	}1138}11391140// The local half lands before the remote hears anything, so a remote failure1141// leaves nothing to find locally; the pending note is what lets a re-run1142// finish. Objects a remote-only save still needs must survive the strip.1143fn test_an_interrupted_remote_strip_finishes_when_re_run() {1144	base := scratch('strip_resume')!1145	defer {1146		os.rmdir_all(base) or {}1147	}1148	remote_dir := os.join_path(base, 'remote')1149	mut backend := remote.new_folder(remote_dir)!1150	mut a := open_repo(machine('resume_a')!)!1151	defer {1152		a.close()1153	}1154	mut b := open_repo(machine('resume_b')!)!1155	defer {1156		b.close()1157	}1158	put(a.root, 'secrets.env', 'api_key=1\n')!1159	put(a.root, 'shared.env', 'same bytes on B\n')!1160	save := make_save(a, 'add secrets')!1161	offer_now(a, mut backend)!1162	join(b, mut backend)!1163	put(b.root, 'copy.txt', 'same bytes on B\n')!1164	b_save := make_save(b, 'B keeps a copy')!1165	offer_now(b, mut backend)!1166	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1167	secret_hash := storage.hash_hex('api_key=1\n'.bytes())11681169	strip_local(a, 'secrets.env', '')!1170	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1171	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1172	mut flaky := FaultyBackend{1173		inner:      remote.new_folder(remote_dir)!1174		fail_write: 31175	}1176	if _ := send_strip(a, mut flaky, 'secrets.env') {1177		assert false, 'the injected failure should have surfaced'1178	}1179	assert strip_pending(a, 'secrets.env')1180	assert strip_local(a, 'secrets.env', '')!.files_removed == 01181	report := send_strip(a, mut backend, 'secrets.env')!1182	assert report.records == 11183	assert !strip_pending(a, 'secrets.env')1184	assert !backend.get(object_key('proj', secret_hash))!.found1185	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1186	assert 'secrets.env' !in record.files.map(it.path)1187	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1188	assert 'secrets.env' !in b_record.files.map(it.path)11891190	// B's save still names the shared bytes, so stripping A's copy keeps them.1191	strip_local(a, 'shared.env', '')!1192	send_strip(a, mut backend, 'shared.env')!1193	assert backend.get(object_key('proj', shared_hash))!.found1194}11951196// A machine that has not heard of a strip must not offer: its records still1197// list the stripped path, and nothing would rewrite them on the remote again.1198fn test_offer_waits_for_an_unapplied_strip() {1199	base := scratch('strip_first')!1200	defer {1201		os.rmdir_all(base) or {}1202	}1203	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1204	mut a := open_repo(machine('strip_first_a')!)!1205	defer {1206		a.close()1207	}1208	mut b := open_repo(machine('strip_first_b')!)!1209	defer {1210		b.close()1211	}1212	put(a.root, 'secrets.env', 'api_key=1\n')!1213	make_save(a, 'add secrets')!1214	offer_now(a, mut backend)!1215	join(b, mut backend)!1216	strip_local(a, 'secrets.env', '')!1217	send_strip(a, mut backend, 'secrets.env')!1218	put(b.root, 'more.txt', 'B keeps working\n')!1219	later := make_save(b, 'B saves with the secret still in its tree')!1220	if _ := offer_now(b, mut backend) {1221		assert false, 'B offered records that still list the stripped path'1222	}1223	steal_now(b, mut backend)!1224	offer_now(b, mut backend)!1225	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1226	assert 'secrets.env' !in record.files.map(it.path)1227}12281229// A strip whose object deletions failed must still delete them when it is run1230// again, and meanwhile the banned path cannot come back in through a save.1231fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1232	base := scratch('strip_twice')!1233	defer {1234		os.rmdir_all(base) or {}1235	}1236	remote_dir := os.join_path(base, 'remote')1237	mut backend := remote.new_folder(remote_dir)!1238	mut a := open_repo(machine('strip_twice_a')!)!1239	defer {1240		a.close()1241	}1242	put(a.root, 'secrets.env', 'first key\n')!1243	make_save(a, 'first key')!1244	offer_now(a, mut backend)!1245	strip_local(a, 'secrets.env', '')!1246	mut flaky := FaultyBackend{1247		inner:       remote.new_folder(remote_dir)!1248		fail_delete: true1249	}1250	if _ := send_strip(a, mut flaky, 'secrets.env') {1251		assert false, 'the injected delete failure should have surfaced'1252	}1253	put(a.root, 'secrets.env', 'second key\n')!1254	put(a.root, 'other.txt', 'more work\n')!1255	make_save(a, 'more work')!1256	offer_now(a, mut backend)!1257	send_strip(a, mut backend, 'secrets.env')!1258	for key in ['first key\n', 'second key\n'] {1259		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1260	}1261}12621263// Unfinished strips of one path in different scopes finish together.1264fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1265	base := scratch('strip_scopes')!1266	defer {1267		os.rmdir_all(base) or {}1268	}1269	remote_dir := os.join_path(base, 'remote')1270	mut backend := remote.new_folder(remote_dir)!1271	mut a := open_repo(machine('strip_scopes_a')!)!1272	defer {1273		a.close()1274	}1275	put(a.root, 'secrets.env', 'first key\n')!1276	first := make_save(a, 'first key')!1277	put(a.root, 'secrets.env', 'second key\n')!1278	second := make_save(a, 'second key')!1279	offer_now(a, mut backend)!1280	strip_local(a, 'secrets.env', first.id)!1281	// Write 1 uploads the event; write 2, the record rewrite, fails.1282	mut flaky := FaultyBackend{1283		inner:      remote.new_folder(remote_dir)!1284		fail_write: 21285	}1286	if _ := send_strip(a, mut flaky, 'secrets.env') {1287		assert false, 'the injected failure should have surfaced'1288	}1289	strip_local(a, 'secrets.env', second.id)!1290	send_strip(a, mut backend, 'secrets.env')!1291	assert !strip_pending(a, 'secrets.env')1292	for save in [first, second] {1293		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1294		assert 'secrets.env' !in record.files.map(it.path)1295	}1296	for key in ['first key\n', 'second key\n'] {1297		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1298	}1299}13001301// A strip bans its path on every machine that has heard of it: written again,1302// the file makes no save, here or there, so there is never a second copy to strip.1303fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1304	base := scratch('strip_again')!1305	defer {1306		os.rmdir_all(base) or {}1307	}1308	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1309	mut a := open_repo(machine('strip_again_a')!)!1310	defer {1311		a.close()1312	}1313	mut b := open_repo(machine('strip_again_b')!)!1314	defer {1315		b.close()1316	}1317	put(a.root, 'secrets.env', 'first key\n')!1318	make_save(a, 'first key')!1319	offer_now(a, mut backend)!1320	join(b, mut backend)!1321	strip_local(a, 'secrets.env', '')!1322	send_strip(a, mut backend, 'secrets.env')!1323	steal_now(b, mut backend)!1324	for repo in [a, b] {1325		put(repo.root, 'secrets.env', 'second key\n')!1326		if _ := make_save(repo, 'second key') {1327			assert false, 'a banned path must not make a save'1328		}1329	}1330}13311332// A clash on a path the other machine stripped is no clash: the strip removes1333// the path from every save, so there is nothing to choose.1334fn test_a_stripped_path_is_not_asked_about() {1335	base := scratch('clash_strip')!1336	defer {1337		os.rmdir_all(base) or {}1338	}1339	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1340	mut a := open_repo(machine('clash_strip_a')!)!1341	defer {1342		a.close()1343	}1344	mut b := open_repo(machine('clash_strip_b')!)!1345	defer {1346		b.close()1347	}1348	put(a.root, 'secrets.env', 'key one\n')!1349	make_save(a, 'base')!1350	offer_now(a, mut backend)!1351	join(b, mut backend)!1352	put(a.root, 'secrets.env', 'key two\n')!1353	make_save(a, 'A rotates')!1354	offer_now(a, mut backend)!1355	strip_local(a, 'secrets.env', '')!1356	send_strip(a, mut backend, 'secrets.env')!1357	put(b.root, 'secrets.env', 'key three\n')!1358	mine := make_save(b, 'B rotates')!1359	report := steal(b, mut backend, StealOptions{})!1360	assert report.clashes.len == 01361	for save in history.all_saves(b)! {1362		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1363	}1364	assert history.by_id(b, mine.id) != none1365}13661367// A strip scoped to the shared base save changes what the replay sees; the1368// clashes must be found on the same trees, or the replay meets a conflict.1369fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1370	base := scratch('clash_base_strip')!1371	defer {1372		os.rmdir_all(base) or {}1373	}1374	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1375	mut a := open_repo(machine('base_strip_a')!)!1376	defer {1377		a.close()1378	}1379	mut b := open_repo(machine('base_strip_b')!)!1380	defer {1381		b.close()1382	}1383	put(a.root, 'notes.txt', 'shared\n')!1384	common := make_save(a, 'base')!1385	offer_now(a, mut backend)!1386	join(b, mut backend)!1387	put(a.root, 'other.txt', 'A moves on\n')!1388	make_save(a, 'A moves on')!1389	offer_now(a, mut backend)!1390	strip_local(a, 'notes.txt', common.id)!1391	send_strip(a, mut backend, 'notes.txt')!1392	put(b.root, 'notes.txt', 'B edits\n')!1393	make_save(b, 'B edits notes')!1394	if _ := steal(b, mut backend, StealOptions{}) {1395		assert false, 'with the base stripped, notes.txt differs on both sides'1396	} else {1397		assert err.msg().contains('notes.txt changed on both machines')1398	}1399	report := steal(b, mut backend, StealOptions{1400		mine: true1401	})!1402	assert report.clashes == ['notes.txt']1403	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1404}14051406// A folder with files but no saves has nothing to combine with; a steal that1407// fast-forwarded into it would overwrite whatever shares a name.1408fn test_steal_into_a_folder_with_unsaved_files_refuses() {1409	base := scratch('steal_unsaved')!1410	defer {1411		os.rmdir_all(base) or {}1412	}1413	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1414	mut a := open_repo(machine('unsaved_a')!)!1415	defer {1416		a.close()1417	}1418	put(a.root, 'notes.txt', 'from A\n')!1419	make_save(a, 'A writes')!1420	offer_now(a, mut backend)!1421	b_root := machine('unsaved_b')!1422	mut b := storage.create(b_root)!1423	defer {1424		b.close()1425	}1426	put(b_root, 'notes.txt', 'never saved\n')!1427	// Only a repo that already knows its remote name gets this far.1428	b.set_state('remote_repo', 'proj')!1429	if _ := steal(b, mut backend, StealOptions{}) {1430		assert false, 'steal overwrote a folder with unsaved files'1431	} else {1432		assert err.msg() == no_saves_here1433	}1434	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1435	// A fresh `koh init` folder, holding only the default ignore list, is fine.1436	os.rm(os.join_path(b_root, 'notes.txt'))!1437	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1438	steal(b, mut backend, StealOptions{})!1439	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1440}14411442// The document asks steal to warn about every incoming flagged save, so the1443// report names them.1444fn test_steal_reports_incoming_flagged_saves() {1445	base := scratch('flag_warn')!1446	defer {1447		os.rmdir_all(base) or {}1448	}1449	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1450	mut a := open_repo(machine('flag_warn_a')!)!1451	defer {1452		a.close()1453	}1454	mut b := open_repo(machine('flag_warn_b')!)!1455	defer {1456		b.close()1457	}1458	put(a.root, 'a.txt', 'one\n')!1459	make_save(a, 'fine')!1460	offer_now(a, mut backend)!1461	join(b, mut backend)!1462	put(a.root, 'a.txt', 'two\n')!1463	suspect := make_save(a, 'suspect')!1464	put(a.root, 'a.txt', 'three\n')!1465	make_save(a, 'also fine')!1466	events.flag(a, suspect.id, 'broke the build')!1467	offer_now(a, mut backend)!1468	report := steal_now(b, mut backend)!1469	assert report.flagged.map(it.save_id) == [suspect.id]1470	assert report.flagged[0].reason == 'broke the build'1471}14721473// head_of unwraps the head for tests that know the repo has saves.1474fn head_of(store &storage.Store) history.Save {1475	return history.head(store) or { panic('this repo has no head') }1476}14771478// A remote says what it is, so two koh versions never half-understand one1479// bucket. The first offer of a repo records the layout.1480fn test_the_first_offer_records_the_remote_format() {1481	base := scratch('format_write')!1482	defer {1483		os.rmdir_all(base) or {}1484	}1485	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1486	mut a := open_repo(machine('format_write_a')!)!1487	defer {1488		a.close()1489	}1490	put(a.root, 'a.txt', 'one\n')!1491	make_save(a, 'first')!1492	offer_now(a, mut backend)!1493	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1494}14951496// A repo offered before the key existed is version 1: it still syncs, and the1497// next offer records the key then.1498fn test_a_remote_without_a_format_still_syncs() {1499	base := scratch('format_old')!1500	defer {1501		os.rmdir_all(base) or {}1502	}1503	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1504	mut a := open_repo(machine('format_old_a')!)!1505	defer {1506		a.close()1507	}1508	put(a.root, 'a.txt', 'one\n')!1509	make_save(a, 'first')!1510	offer_now(a, mut backend)!1511	// Stand in for a bucket offered before the key existed.1512	backend.delete(format_key('proj'))!1513	mut b := open_repo(machine('format_old_b')!)!1514	defer {1515		b.close()1516	}1517	join(b, mut backend)!1518	assert head_of(b).id == head_of(a).id1519	put(a.root, 'a.txt', 'two\n')!1520	make_save(a, 'second')!1521	offer_now(a, mut backend)!1522	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1523	report := steal_now(b, mut backend)!1524	assert report.head == head_of(a).id1525}15261527// A remote that speaks another layout is refused loudly, on offer and on1528// steal, rather than half-read.1529fn test_an_unknown_remote_format_is_refused() {1530	base := scratch('format_unknown')!1531	defer {1532		os.rmdir_all(base) or {}1533	}1534	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1535	mut a := open_repo(machine('format_bad_a')!)!1536	defer {1537		a.close()1538	}1539	put(a.root, 'a.txt', 'one\n')!1540	make_save(a, 'first')!1541	offer_now(a, mut backend)!1542	backend.delete(format_key('proj'))!1543	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1544	if _ := offer_now(a, mut backend) {1545		assert false, 'offer must refuse a format it does not speak'1546	} else {1547		assert err.msg().contains('koh-remote 2')1548	}1549	if _ := steal_now(a, mut backend) {1550		assert false, 'steal must refuse a format it does not speak'1551	} else {1552		assert err.msg().contains('koh-remote 2')1553	}1554	if _ := fetch_repo(mut backend, 'proj') {1555		assert false, 'a steal must refuse a format it does not speak'1556	} else {1557		assert err.msg().contains('koh-remote 2')1558	}1559}15601561// Two machines offering a new repo at once both find no format key; the loser1562// of the conditional write must read back the winner's value and refuse it,1563// not carry on against a remote it cannot read.1564fn test_a_lost_format_write_is_read_back_and_refused() {1565	base := scratch('format_race')!1566	defer {1567		os.rmdir_all(base) or {}1568	}1569	mut inner := remote.new_folder(os.join_path(base, 'remote'))!1570	mut a := open_repo(machine('format_race_a')!)!1571	defer {1572		a.close()1573	}1574	put(a.root, 'a.txt', 'one\n')!1575	make_save(a, 'first')!1576	mut backend := remote.Backend(FaultyBackend{1577		inner:       inner1578		race_format: true1579	})1580	if _ := offer_now(a, mut backend) {1581		assert false, 'offer must refuse the format another machine wrote first'1582	} else {1583		assert err.msg().contains('koh-remote 2')1584	}1585}