exchange_test.v

70.8 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 json28import os9import src.events10import src.history11import src.remote12import src.snapshot13import src.storage1415// Global rules must not make source fixtures depend on the developer’s home.16fn testsuite_begin() {17	os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)18}1920fn test_arriving_record_drops_unsafe_paths_and_reports_them() {21	mut record := SaveRecord{22		files: [23			FileRecord{ path: 'safe.txt' },24			FileRecord{ path: '../outside.txt' },25			FileRecord{ path: 'bad\\name.txt' },26			FileRecord{ path: 'Icon\r' },27		]28	}29	dropped := drop_unsafe_paths(mut record)30	assert record.files.map(it.path) == ['safe.txt']31	assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']32}3334// An unpaired surrogate escape stands for its three bytes, so the record still35// decodes and only the path holding it is dropped; a real pair is kept.36fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {37	body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'38	mut record := decode_save(body.bytes())!39	dropped := drop_unsafe_paths(mut record)40	assert record.files.map(it.path) == ['ok😀']41	assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']42}4344// Machines keep their repo in a folder of the same NAME, because the remote key45// is the folder's name; that is what makes them the same repo to the remote.46fn machine(name string) !string {47	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')48	os.rmdir_all(os.dir(dir)) or {}49	os.mkdir_all(dir, os.MkdirParams{})!50	return dir51}5253fn scratch(name string) !string {54	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')55	os.rmdir_all(dir) or {}56	os.mkdir_all(dir, os.MkdirParams{})!57	return dir58}5960fn open_repo(dir string) !storage.Store {61	mut store := storage.create(dir)!62	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!63	return store64}6566fn put(root string, rel string, text string) ! {67	full := os.join_path(root, rel)68	os.mkdir_all(os.dir(full), os.MkdirParams{})!69	os.write_file(full, text)!70}7172fn make_save(store &storage.Store, message string) !history.Save {73	return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{74		message: message75	})!76}7778fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {79	return offer(store, mut backend)!80}8182fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {83	return steal(store, mut backend, StealOptions{84		theirs: true85	})!86}8788fn test_first_offer_publishes_the_whole_line() {89	base := scratch('first')!90	defer {91		os.rmdir_all(base) or {}92	}93	mut a := open_repo(machine('first_a')!)!94	defer {95		a.close()96	}97	put(a.root, 'a.txt', 'one\n')!98	make_save(a, 'first')!99	put(a.root, 'a.txt', 'two\n')!100	make_save(a, 'second')!101	mut backend := remote.new_folder(os.join_path(base, 'remote'))!102103	report := offer_now(a, mut backend)!104	assert report.repo == 'proj'105	assert report.saves == 2106	assert report.objects >= 2107	assert backend.get(head_key('proj'))!.found108	assert backend.list('proj/saves')!.len == 2109110	// A second offer with nothing new uploads nothing and still succeeds.111	again := offer_now(a, mut backend)!112	assert again.saves == 0113}114115fn test_the_repo_name_is_remembered_and_head_offered() {116	base := scratch('remember')!117	defer {118		os.rmdir_all(base) or {}119	}120	mut a := open_repo(machine('remember_a')!)!121	defer {122		a.close()123	}124	put(a.root, 'a.txt', 'one\n')!125	make_save(a, 'first')!126	mut backend := remote.new_folder(os.join_path(base, 'remote'))!127	offer_now(a, mut backend)!128	assert a.state('remote_repo') or { '' } == 'proj'129	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id130	// Offer's step 3: the new remote head is recorded in state.131	assert a.state('remote_head') or { '' } == head_of(a).id132133	// A second offer, with the remote already holding our head, must not134	// refuse: the name is remembered, so the remote is ours.135	put(a.root, 'a.txt', 'two\n')!136	make_save(a, 'second')!137	offer_now(a, mut backend)!138	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id139}140141fn test_a_repo_name_is_lowercased_at_first_offer() {142	base := scratch('lower')!143	defer {144		os.rmdir_all(base) or {}145	}146	dir := os.join_path(base, 'Haven')147	os.mkdir_all(dir, os.MkdirParams{})!148	mut a := open_repo(dir)!149	defer {150		a.close()151	}152	put(a.root, 'a.txt', 'one\n')!153	make_save(a, 'first')!154	mut backend := remote.new_folder(os.join_path(base, 'remote'))!155	assert offer_now(a, mut backend)!.repo == 'haven'156	assert backend.get(head_key('haven'))!.found157}158159fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {160	base := scratch('renamed')!161	defer {162		os.rmdir_all(base) or {}163	}164	mut a := open_repo(machine('renamed_a')!)!165	defer {166		a.close()167	}168	put(a.root, 'a.txt', 'one\n')!169	make_save(a, 'first')!170	remote_dir := os.join_path(base, 'remote')171	mut backend := remote.new_folder(remote_dir)!172	offer_now(a, mut backend)!173	// What Kepr's rename leaves: the history under the new name, a marker, no old name.174	os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!175	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!176	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!177178	put(a.root, 'a.txt', 'two\n')!179	make_save(a, 'second')!180	assert offer_now(a, mut backend)!.repo == 'tool'181	assert a.state('remote_repo') or { '' } == 'tool'182	assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id183	assert !backend.get(head_key('proj'))!.found184}185186fn test_a_rename_marker_naming_a_path_is_not_followed() {187	base := scratch('badmarker')!188	defer {189		os.rmdir_all(base) or {}190	}191	mut a := open_repo(machine('badmarker_a')!)!192	defer {193		a.close()194	}195	put(a.root, 'a.txt', 'one\n')!196	make_save(a, 'first')!197	remote_dir := os.join_path(base, 'remote')198	mut backend := remote.new_folder(remote_dir)!199	offer_now(a, mut backend)!200	os.rmdir_all(os.join_path(remote_dir, 'proj'))!201	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!202	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!203	assert offer_now(a, mut backend)!.repo == 'proj'204}205206fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {207	base := scratch('forward')!208	defer {209		os.rmdir_all(base) or {}210	}211	mut backend := remote.new_folder(os.join_path(base, 'remote'))!212	mut a := open_repo(machine('fwd_a')!)!213	defer {214		a.close()215	}216	put(a.root, 'a.txt', 'from A\n')!217	make_save(a, 'A writes')!218	offer_now(a, mut backend)!219220	// B starts empty and takes the whole repo.221	mut b := open_repo(machine('fwd_b')!)!222	defer {223		b.close()224	}225	fetched := fetch_repo(mut backend, 'proj')!226	b.begin()!227	write_fetched(b, fetched)!228	history.set_head(b, fetched.head)!229	b.end()!230	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!231	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'232	assert head_of(b).id == fetched.head233}234235// A machine that is only ahead has nothing to combine: stealing must leave its236// saves, and their IDs, exactly where they are.237fn test_steal_while_only_ahead_leaves_the_line_alone() {238	base := scratch('ahead')!239	defer {240		os.rmdir_all(base) or {}241	}242	mut backend := remote.new_folder(os.join_path(base, 'remote'))!243	mut a := open_repo(machine('ahead_a')!)!244	defer {245		a.close()246	}247	put(a.root, 'a.txt', 'one\n')!248	make_save(a, 'offered')!249	offer_now(a, mut backend)!250	put(a.root, 'a.txt', 'two\n')!251	ahead := make_save(a, 'not offered yet')!252	report := steal_now(a, mut backend)!253	assert report.replayed == 0254	assert head_of(a).id == ahead.id255	// Nothing was restored, so nothing is left marked as half-restored.256	assert !snapshot.restore_pending(a)257	assert history.all_saves(a)!.len == 2258	offer_now(a, mut backend)!259	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id260}261262// A link is invisible to the walk, so a tree holding one is clean; the files263// a steal brings in must still never land in the folder it points at.264fn test_steal_never_writes_through_a_symlinked_folder() {265	base := scratch('steal_link')!266	defer {267		os.rmdir_all(base) or {}268	}269	mut backend := remote.new_folder(os.join_path(base, 'remote'))!270	mut a := open_repo(machine('steal_link_a')!)!271	defer {272		a.close()273	}274	mut b := open_repo(machine('steal_link_b')!)!275	defer {276		b.close()277	}278	put(a.root, 'a.txt', 'one\n')!279	make_save(a, 'first')!280	offer_now(a, mut backend)!281	join(b, mut backend)!282	outside := os.join_path(base, 'outside')283	put(outside, 'a.md', 'precious\n')!284	os.symlink(outside, os.join_path(b.root, 'docs'))!285	put(a.root, 'docs/a.md', 'from A\n')!286	make_save(a, 'A adds docs')!287	offer_now(a, mut backend)!288	head_before := head_of(b).id289	if _ := steal_now(b, mut backend) {290		assert false, 'a steal writing through a symlinked folder must refuse'291	} else {292		assert err.msg().contains('docs')293	}294	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'295	assert head_of(b).id == head_before296}297298// A steal onto a half-restored tree would replay it as somebody's work.299fn test_steal_refuses_after_a_restore_cut_short() {300	base := scratch('steal_cut')!301	defer {302		os.rmdir_all(base) or {}303	}304	mut backend := remote.new_folder(os.join_path(base, 'remote'))!305	mut a := open_repo(machine('steal_cut_a')!)!306	defer {307		a.close()308	}309	put(a.root, 'a.txt', 'one\n')!310	make_save(a, 'first')!311	offer_now(a, mut backend)!312	a.set_state('restoring', 'yes')!313	if _ := steal_now(a, mut backend) {314		assert false, 'steal must refuse while a restore is unfinished'315	} else {316		assert err.msg().contains('koh revert')317	}318}319320// A replayed save keeps its original time but sits on a newer parent, so a321// steal cannot order saves by time.322fn test_a_steal_imports_parents_before_children() {323	base := scratch('steal_order')!324	defer {325		os.rmdir_all(base) or {}326	}327	mut backend := remote.new_folder(os.join_path(base, 'remote'))!328	mut a := open_repo(machine('order_a')!)!329	defer {330		a.close()331	}332	put(a.root, 'a.txt', 'one\n')!333	parent := make_save(a, 'parent')!334	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{335		message:    'replayed, older than its parent'336		created_at: parent.created_at - 3600337	})!338	offer_now(a, mut backend)!339	mut b := open_repo(machine('order_b')!)!340	defer {341		b.close()342	}343	join(b, mut backend)!344	assert head_of(b).id == child.id345}346347// A chmod is unsaved work too: a steal would restore the old mode over it.348fn test_steal_refuses_an_unsaved_executable_bit_change() {349	base := scratch('chmod')!350	defer {351		os.rmdir_all(base) or {}352	}353	mut backend := remote.new_folder(os.join_path(base, 'remote'))!354	mut a := open_repo(machine('chmod_a')!)!355	defer {356		a.close()357	}358	put(a.root, 'run.sh', 'echo hi\n')!359	make_save(a, 'script')!360	offer_now(a, mut backend)!361	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!362	if _ := steal_now(a, mut backend) {363		assert false, 'a chmod-only change should make the tree dirty'364	} else {365		assert err.msg() == dirty_tree366	}367}368369fn test_divergence_without_clashes_keeps_both_machines_work() {370	base := scratch('diverge')!371	defer {372		os.rmdir_all(base) or {}373	}374	mut backend := remote.new_folder(os.join_path(base, 'remote'))!375	mut a := open_repo(machine('div_a')!)!376	defer {377		a.close()378	}379	mut b := open_repo(machine('div_b')!)!380	defer {381		b.close()382	}383	put(a.root, 'shared.txt', 'base\n')!384	make_save(a, 'base')!385	offer_now(a, mut backend)!386387	// B joins, then both machines change a different file.388	join(b, mut backend)!389	put(a.root, 'fromA.txt', 'A\n')!390	make_save(a, 'A adds a file')!391	put(b.root, 'fromB.txt', 'B\n')!392	make_save(b, 'B adds a file')!393	offer_now(a, mut backend)!394395	// B's offer is refused: the remote has moved on.396	if _ := offer_now(b, mut backend) {397		assert false, 'offering on top of unknown remote saves should be refused'398	} else {399		assert err.msg().contains('run koh steal')400	}401402	report := steal_now(b, mut backend)!403	assert !report.forward404	assert report.replayed == 1405	assert report.clashes.len == 0406	// Both files are on B's line now, and on disk.407	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'408	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'409	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'410	// B's own save was replayed, not erased.411	mut saw_replay := false412	for save in history.all_saves(b)! {413		if save.replayed_from != '' {414			saw_replay = true415		}416	}417	assert saw_replay418	// And the remote now has B's work too.419	offer_now(b, mut backend)!420	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()421	assert line_head == head_of(b).id422}423424// An agent that edits a file and then undoes the edit leaves no net change, so425// the file is not a clash when the other machine changed it. Replaying the426// edit itself must not stop the steal: the remote's version simply stands.427// Likewise a file both machines ended up changing the same way.428fn test_a_local_change_undone_does_not_block_a_combine() {429	base := scratch('undone')!430	defer {431		os.rmdir_all(base) or {}432	}433	mut backend := remote.new_folder(os.join_path(base, 'remote'))!434	mut a := open_repo(machine('undone_a')!)!435	defer {436		a.close()437	}438	mut b := open_repo(machine('undone_b')!)!439	defer {440		b.close()441	}442	put(a.root, 'f.txt', 'base\n')!443	put(a.root, 'g.txt', 'base\n')!444	make_save(a, 'base')!445	offer_now(a, mut backend)!446	join(b, mut backend)!447448	put(a.root, 'f.txt', 'from A\n')!449	put(a.root, 'g.txt', 'same\n')!450	make_save(a, 'A edits both')!451	offer_now(a, mut backend)!452453	put(b.root, 'f.txt', 'tried on B\n')!454	put(b.root, 'g.txt', 'first try\n')!455	tried := make_save(b, 'B tries')!456	put(b.root, 'f.txt', 'base\n')!457	put(b.root, 'g.txt', 'same\n')!458	put(b.root, 'h.txt', 'B\n')!459	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!460461	report := steal_now(b, mut backend)!462	assert report.clashes.len == 0463	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'464	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'465	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'466	// "B tries" only touched files A changed, so it has nothing left to467	// replay; the second save carries h.txt. Both originals stay in history.468	assert report.replayed == 1469	assert head_of(b).replayed_from == undone.id470	ids := history.all_saves(b)!.map(it.id)471	assert tried.id in ids && undone.id in ids472	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')473}474475// A person deciding a clash may take minutes; the question must come before476// the write lock, or every agent's save on this repo waits and then fails.477fn test_clash_questions_are_asked_before_the_write_lock() {478	base := scratch('ask_unlocked')!479	defer {480		os.rmdir_all(base) or {}481	}482	mut backend := remote.new_folder(os.join_path(base, 'remote'))!483	mut a := open_repo(machine('ask_a')!)!484	defer {485		a.close()486	}487	mut b := open_repo(machine('ask_b')!)!488	defer {489		b.close()490	}491	put(a.root, 'same.txt', 'base\n')!492	make_save(a, 'base')!493	offer_now(a, mut backend)!494	join(b, mut backend)!495	put(a.root, 'same.txt', 'A version\n')!496	make_save(a, 'A edits same')!497	offer_now(a, mut backend)!498	put(b.root, 'same.txt', 'B version\n')!499	make_save(b, 'B edits same')!500	root := b.root501	// An agent saves while the person is still choosing.502	if _ := steal(b, mut backend, StealOptions{503		guide: fn [root] (preview StealPreview) !map[string]Side {504			other := storage.open(root) or { panic(err) }505			defer {506				other.close()507			}508			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }509			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{510				message: 'agent saves during the question'511			}) or { panic('steal held the write lock while asking: ${err}') }512			return {513				preview.clashes[0].path: Side.mine514			}515		}516	})517	{518		assert false, 'a steal whose line moved during the question must refuse'519	} else {520		assert err.msg().contains('changed while')521	}522	// The agent's save is still head, and a second steal combines it too.523	assert head_of(b).message == 'agent saves during the question'524	report := steal(b, mut backend, StealOptions{525		mine: true526	})!527	assert report.kept_mine == 1528	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'529	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'530}531532fn test_clashing_files_are_chosen_one_at_a_time() {533	base := scratch('clash')!534	defer {535		os.rmdir_all(base) or {}536	}537	mut backend := remote.new_folder(os.join_path(base, 'remote'))!538	mut a := open_repo(machine('clash_a')!)!539	defer {540		a.close()541	}542	mut b := open_repo(machine('clash_b')!)!543	defer {544		b.close()545	}546	put(a.root, 'same.txt', 'base\n')!547	put(a.root, 'other.txt', 'quiet\n')!548	make_save(a, 'base')!549	offer_now(a, mut backend)!550	join(b, mut backend)!551552	// Both machines edit the same file, differently.553	put(a.root, 'same.txt', 'A version\n')!554	make_save(a, 'A edits same')!555	offer_now(a, mut backend)!556	put(b.root, 'same.txt', 'B version\n')!557	make_save(b, 'B edits same')!558559	// Without a decision, steal refuses and says which files clash.560	if _ := steal(b, mut backend, StealOptions{}) {561		assert false, 'an undecided clash must be refused'562	} else {563		assert err.msg().contains('--mine') || err.msg().contains('--theirs')564		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.565		if err is ClashError {566			assert err.paths == ['same.txt']567		} else {568			assert false, 'an undecided clash must be a ClashError, not ${err}'569		}570	}571	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,572		head_of(b).id)!).len == 0573574	// Choosing the remote's version ends with A's content.575	report := steal_now(b, mut backend)!576	assert report.clashes == ['same.txt']577	assert report.kept_mine == 0578	assert report.kept_versions == ['same.txt:${report.previous_head}']579	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'580	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'581}582583fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {584	base := scratch('guided_cancel')!585	defer { os.rmdir_all(base) or {} }586	mut backend := remote.new_folder(os.join_path(base, 'remote'))!587	mut a := open_repo(machine('guided_cancel_a')!)!588	defer { a.close() }589	mut b := open_repo(machine('guided_cancel_b')!)!590	defer { b.close() }591	put(a.root, 'same.txt', 'base\n')!592	make_save(a, 'base')!593	offer_now(a, mut backend)!594	join(b, mut backend)!595	put(a.root, 'same.txt', 'remote\n')!596	remote_head := make_save(a, 'remote change')!597	offer_now(a, mut backend)!598	put(b.root, 'same.txt', 'mine\n')!599	local_head := make_save(b, 'local change')!600601	if _ := steal(b, mut backend, StealOptions{602		guide: fn (preview StealPreview) !map[string]Side {603			assert preview.clashes.len == 1604			return error('cancelled')605		}606	}) {607		assert false, 'the guide cancelled'608	} else {609		assert err.msg() == 'cancelled'610	}611	assert head_of(b).id == local_head.id612	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'613	assert history.by_id(b, remote_head.id) != none614	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1615}616617fn test_choosing_mine_keeps_the_local_version() {618	base := scratch('mine')!619	defer {620		os.rmdir_all(base) or {}621	}622	mut backend := remote.new_folder(os.join_path(base, 'remote'))!623	mut a := open_repo(machine('mine_a')!)!624	defer {625		a.close()626	}627	mut b := open_repo(machine('mine_b')!)!628	defer {629		b.close()630	}631	put(a.root, 'same.txt', 'base\n')!632	make_save(a, 'base')!633	offer_now(a, mut backend)!634	join(b, mut backend)!635	put(a.root, 'same.txt', 'A version\n')!636	make_save(a, 'A edits')!637	offer_now(a, mut backend)!638	put(b.root, 'same.txt', 'B version\n')!639	make_save(b, 'B edits')!640641	report := steal(b, mut backend, StealOptions{642		mine: true643	})!644	assert report.kept_mine == 1645	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'646	// The final save says what it kept.647	assert head_of(b).message.contains('combine: kept mine for 1 file')648	// The pre-combine tree is still reachable in history.649	assert head_of(b).restores == ''650	assert history.all_saves(b)!.len >= 4651}652653fn test_flags_travel_with_the_saves() {654	base := scratch('flags')!655	defer {656		os.rmdir_all(base) or {}657	}658	mut backend := remote.new_folder(os.join_path(base, 'remote'))!659	mut a := open_repo(machine('flag_a')!)!660	defer {661		a.close()662	}663	mut b := open_repo(machine('flag_b')!)!664	defer {665		b.close()666	}667	put(a.root, 'a.txt', 'one\n')!668	save := make_save(a, 'suspect save')!669	events.flag(a, save.id, 'look at this')!670	offer_now(a, mut backend)!671672	// B has nothing yet, so a fetch brings the save and its flag across.673	fetched := fetch_back_to_known(b, mut backend, 'proj')!674	assert fetched.events_out.len == 1675	assert fetched.events_out[0].kind == events.kind_flag676	b.begin()!677	write_fetched(b, fetched)!678	history.set_head(b, fetched.head)!679	b.end()!680	state := events.flag_state(b, save.id)!681	assert state.flagged682	assert state.reason == 'look at this'683}684685fn test_a_second_offer_racing_head_is_refused_not_forced() {686	base := scratch('race')!687	defer {688		os.rmdir_all(base) or {}689	}690	mut backend := remote.new_folder(os.join_path(base, 'remote'))!691	mut a := open_repo(machine('race_a')!)!692	defer {693		a.close()694	}695	put(a.root, 'a.txt', 'one\n')!696	make_save(a, 'base')!697	offer_now(a, mut backend)!698699	// Someone else replaces head behind our back.700	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),701		backend.get(head_key('proj'))!.tag)!702703	put(a.root, 'a.txt', 'two\n')!704	make_save(a, 'second')!705	if _ := offer_now(a, mut backend) {706		assert false, 'offering while the remote head moved must be refused'707	} else {708		assert err.msg().contains('run koh steal')709	}710}711712// join makes a brand new machine share the remote's line.713fn join(store &storage.Store, mut backend remote.Backend) ! {714	fetched := fetch_repo(mut backend, 'proj')!715	store.begin()!716	write_fetched(store, fetched)!717	history.set_head(store, fetched.head)!718	store.end()!719	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,720		fetched.head)!, [])!721	store.set_state('remote_repo', 'proj')!722}723724struct FaultyBackend {725mut:726	inner      remote.Backend727	writes     int728	reads      int729	fail_write int // fail when the write counter reaches this, 0 never730	fail_read  int731	// fail every delete while set732	fail_delete bool733	// race_format stands in for another machine writing the format key first:734	// the first `format` write loses, to a value this koh does not speak.735	race_format bool736	raced       bool737	// fail listing keys under a prefix ending in this738	fail_list string739	// fail every delete after this many succeed, 0 never740	fail_delete_after int741	deletes           int742}743744fn (mut f FaultyBackend) get(key string) !remote.Object {745	f.reads++746	if f.fail_read > 0 && f.reads >= f.fail_read {747		return error('injected read failure')748	}749	return f.inner.get(key)!750}751752fn (mut f FaultyBackend) list(prefix string) ![]string {753	if f.fail_list != '' && prefix.ends_with(f.fail_list) {754		return error('injected list failure')755	}756	return f.inner.list(prefix)!757}758759fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {760	f.writes++761	if f.fail_write > 0 && f.writes >= f.fail_write {762		return error('injected write failure')763	}764	if f.race_format && !f.raced && key.ends_with('/format') {765		f.raced = true766		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!767		return false768	}769	return f.inner.put_if_absent(key, body)!770}771772fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {773	f.writes++774	if f.fail_write > 0 && f.writes >= f.fail_write {775		return error('injected write failure')776	}777	return f.inner.put_if_match(key, body, tag)!778}779780fn (mut f FaultyBackend) delete(key string) ! {781	if f.fail_delete || (f.fail_delete_after > 0 && f.deletes >= f.fail_delete_after) {782		return error('injected delete failure')783	}784	f.deletes++785	f.inner.delete(key)!786}787788// Every save offers after it is made, so a one-file change must cost one789// object, not one conditional write per file in the repo: on S3 each of those790// is a round trip.791fn test_an_offer_sends_only_the_objects_a_save_adds() {792	base := scratch('offer_diff')!793	defer {794		os.rmdir_all(base) or {}795	}796	mut a := open_repo(machine('offer_diff_a')!)!797	defer {798		a.close()799	}800	for i in 0 .. 20 {801		put(a.root, 'f${i}.txt', 'file ${i}\n')!802	}803	make_save(a, 'twenty files')!804	mut backend := FaultyBackend{805		inner: remote.new_folder(os.join_path(base, 'remote'))!806	}807	offer(a, mut backend)!808	put(a.root, 'f3.txt', 'changed\n')!809	make_save(a, 'one change')!810	before := backend.writes811	report := offer(a, mut backend)!812	assert report.objects == 1813	// One object, one save record, one head swap.814	assert backend.writes - before == 3815}816817fn test_a_failed_offer_is_safe_to_re_run() {818	base := scratch('fault_offer')!819	defer {820		os.rmdir_all(base) or {}821	}822	remote_dir := os.join_path(base, 'remote')823	mut a := open_repo(machine('fault_a')!)!824	defer {825		a.close()826	}827	put(a.root, 'a.txt', 'one\n')!828	make_save(a, 'first')!829	put(a.root, 'a.txt', 'two\n')!830	make_save(a, 'second')!831832	// The first offer dies after the first write.833	mut flaky := FaultyBackend{834		inner:      remote.new_folder(remote_dir)!835		fail_write: 2836	}837	if _ := offer(a, mut flaky) {838		assert false, 'the injected failure should have surfaced'839	}840841	// Re-running with a healthy remote finishes the job: "already exists" is842	// success, so nothing is lost and nothing is duplicated.843	mut healthy := remote.new_folder(remote_dir)!844	report := offer(a, mut healthy)!845	assert report.saves == 2846	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id847	assert healthy.list('proj/saves')!.len == 2848}849850// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,851// and it must neither remove anything nor hold up an offer.852fn test_a_strip_with_an_illegal_path_is_ignored() {853	base := scratch('illegal_strip')!854	defer {855		os.rmdir_all(base) or {}856	}857	mut backend := remote.new_folder(os.join_path(base, 'remote'))!858	mut a := open_repo(machine('illegal_strip_a')!)!859	defer {860		a.close()861	}862	mut b := open_repo(machine('illegal_strip_b')!)!863	defer {864		b.close()865	}866	put(a.root, 'a/x.txt', 'kept\n')!867	make_save(a, 'add a folder')!868	offer_now(a, mut backend)!869	for id, path in {870		'aaaaaaaaaaaa': 'a//'871		'bbbbbbbbbbbb': 'a\\ud800'872	} {873		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!874	}875	join(b, mut backend)!876	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)877	put(a.root, 'more.txt', 'more\n')!878	make_save(a, 'more')!879	offer_now(a, mut backend)!880}881882fn test_a_failed_steal_leaves_nothing_behind() {883	base := scratch('fault_steal')!884	defer {885		os.rmdir_all(base) or {}886	}887	remote_dir := os.join_path(base, 'remote')888	mut a := open_repo(machine('fault_s_a')!)!889	defer {890		a.close()891	}892	mut b := open_repo(machine('fault_s_b')!)!893	defer {894		b.close()895	}896	put(a.root, 'a.txt', 'from A\n')!897	make_save(a, 'A writes')!898	mut healthy := remote.new_folder(remote_dir)!899	offer(a, mut healthy)!900901	// B's fetch fails half way through reading the remote.902	mut flaky := FaultyBackend{903		inner:     remote.new_folder(remote_dir)!904		fail_read: 2905	}906	if _ := fetch_back_to_known(b, mut flaky, 'proj') {907		assert false, 'the injected read failure should have surfaced'908	}909	// Nothing was written: the store is untouched and still has no saves.910	assert history.head(b) == none911	assert b.object_count()! == 0912913	// A healthy retry works.914	fetched := fetch_back_to_known(b, mut healthy, 'proj')!915	b.begin()!916	write_fetched(b, fetched)!917	history.set_head(b, fetched.head)!918	b.end()!919	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!920	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'921}922923fn test_an_interrupted_strip_is_idempotent() {924	// The local half of a strip is one transaction, so an interrupted strip is925	// simply a strip that did not happen; re-running it must land the same926	// result once, not twice.927	base := scratch('fault_strip')!928	defer {929		os.rmdir_all(base) or {}930	}931	mut a := open_repo(machine('fault_strip_a')!)!932	defer {933		a.close()934	}935	put(a.root, 'secret.txt', 'shh\n')!936	put(a.root, 'keep.txt', 'fine\n')!937	save := make_save(a, 'add both')!938939	for _ in 0 .. 2 {940		strip_local(a, 'secret.txt', '')!941	}942	files := history.files_of(a, save.id)!943	// The repo's own `.kohignore` is tracked content, so it stays; only the944	// stripped path goes.945	assert files.map(it.path) == ['.kohignore', 'keep.txt']946	assert events.strip_events(a)!.len == 1947}948949// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into950// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.951// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,952// but a save made on Linux can hold both.953fn test_a_strip_matches_the_path_exactly() {954	mut a := open_repo(machine('strip_exact')!)!955	defer {956		a.close()957	}958	hash := a.put_object('x\n'.bytes())!959	mut files := []snapshot.FileEntry{}960	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {961		files << snapshot.FileEntry{962			path: path963			hash: hash964		}965	}966	save := history.save_tree(a, '', files, history.SaveInput{967		message: 'add all'968	})!969	assert strip_scope(a, 'docs', '')!.files == 1970	strip_local(a, 'docs', '')!971	strip_local(a, 'a_b', '')!972	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']973}974975fn test_a_strip_reaches_the_remote_and_other_machines() {976	base := scratch('strip_sync')!977	defer {978		os.rmdir_all(base) or {}979	}980	mut a := open_repo(machine('strip_a')!)!981	defer {982		a.close()983	}984	mut b := open_repo(machine('strip_b')!)!985	defer {986		b.close()987	}988	put(a.root, 'secrets.env', 'api_key=1\n')!989	put(a.root, 'keep.txt', 'fine\n')!990	save := make_save(a, 'add both')!991	mut backend := remote.new_folder(os.join_path(base, 'remote'))!992	offer_now(a, mut backend)!993	join(b, mut backend)!994	assert b.object_count()! > 0995996	// A strips the path locally, the way `koh strip` does, then tells the997	// remote.998	mut secret_hash := ''999	for file in history.files_of(a, save.id)! {1000		if file.path == 'secrets.env' {1001			secret_hash = file.hash1002		}1003	}1004	assert secret_hash != ''1005	strip_local(a, 'secrets.env', '')!1006	report := send_strip(a, mut backend, 'secrets.env')!1007	assert report.event_sent1008	assert report.records == 110091010	// The remote's copy of the save no longer lists the file, and the object is1011	// gone from the remote.1012	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1013	for file in record.files {1014		assert file.path != 'secrets.env'1015	}1016	// The stripped object is gone from the remote; the ones still referenced1017	// are not.1018	assert !backend.get(object_key('proj', secret_hash))!.found1019	assert backend.list('proj/objects')!.len == 210201021	// Another machine hears about it on its next steal and applies it.1022	b.begin()!1023	fetched := fetch_back_to_known(b, mut backend, 'proj')!1024	write_fetched(b, fetched)!1025	b.end()!1026	mut paths := []string{}1027	for file in history.files_of(b, save.id)! {1028		paths << file.path1029	}1030	assert 'secrets.env' !in paths1031	assert 'keep.txt' in paths10321033	// The event arrives under its own ID, so it is applied once: B's offer adds1034	// no second copy. The path is banned for good: written again, the file is1035	// neither unsaved work nor ever walked into a save.1036	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1037	put(b.root, 'secrets.env', 'rotated\n')!1038	assert !dirty(b, history.head(b))!1039	put(b.root, 'keep.txt', 'still fine\n')!1040	later := make_save(b, 'edit keep')!1041	offer_now(b, mut backend)!1042	assert backend.list('proj/events')!.len == 11043	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1044}10451046// A strip removes a path from all of history, whenever a save was made. A1047// machine that saved the file after the strip, before it heard of it, loses1048// it from that save too on its next steal, so no copy anywhere keeps it.1049fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1050	base := scratch('strip_later')!1051	defer {1052		os.rmdir_all(base) or {}1053	}1054	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1055	mut a := open_repo(machine('strip_later_a')!)!1056	defer {1057		a.close()1058	}1059	mut b := open_repo(machine('strip_later_b')!)!1060	defer {1061		b.close()1062	}1063	put(a.root, 'secrets.env', 'api_key=1\n')!1064	old := make_save(a, 'leak the key')!1065	offer_now(a, mut backend)!1066	join(b, mut backend)!1067	strip_local(a, 'secrets.env', '')!1068	send_strip(a, mut backend, 'secrets.env')!1069	// B has not heard, and saves the file again an hour after the strip.1070	put(b.root, 'secrets.env', 'api_key=2\n')!1071	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1072		message:    'rotate the key'1073		created_at: events.strip_events(a)!.last().created_at + 36001074	})!1075	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1076	b.begin()!1077	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1078	b.end()!1079	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1080	offer_now(b, mut backend)!10811082	mut c := open_repo(machine('strip_later_c')!)!1083	defer {1084		c.close()1085	}1086	join(c, mut backend)!1087	for save in [old.id, later.id] {1088		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1089	}1090	assert !os.exists(os.join_path(c.root, 'secrets.env'))1091}10921093// A strip this machine already holds bans its path from saves that arrive1094// later too, not only from those a fetch brings with the strip.1095fn test_a_held_strip_reaches_saves_arriving_after_it() {1096	base := scratch('strip_held')!1097	defer {1098		os.rmdir_all(base) or {}1099	}1100	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1101	mut a := open_repo(machine('strip_held_a')!)!1102	defer {1103		a.close()1104	}1105	mut b := open_repo(machine('strip_held_b')!)!1106	defer {1107		b.close()1108	}1109	put(a.root, 'secrets.env', 'api_key=1\n')!1110	make_save(a, 'leak the key')!1111	offer_now(a, mut backend)!1112	join(b, mut backend)!1113	strip_local(b, 'secrets.env', '')!1114	put(a.root, 'keep.txt', 'fine\n')!1115	later := make_save(a, 'add keep')!1116	offer_now(a, mut backend)!1117	b.begin()!1118	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1119	b.end()!1120	paths := history.files_of(b, later.id)!.map(it.path)1121	assert 'secrets.env' !in paths1122	assert 'keep.txt' in paths1123}11241125// StripRace stands in for another machine that strips `path` completely, event1126// and rewrite, just before this machine's first save record lands.1127struct StripRace {1128mut:1129	inner remote.Backend1130	other &storage.Store1131	path  string1132	done  bool1133	// fail every put after the one the strip raced1134	fail_after_race bool1135	raced_put       bool1136	fail_delete     bool1137	fail_match      bool1138	// race at the first object instead of the first save record1139	on_objects bool1140	// fail listing events once the strip has raced1141	fail_events_after_race bool1142}11431144fn (mut r StripRace) get(key string) !remote.Object {1145	return r.inner.get(key)!1146}11471148fn (mut r StripRace) list(prefix string) ![]string {1149	if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1150		return error('injected list failure')1151	}1152	return r.inner.list(prefix)!1153}11541155fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1156	if r.fail_after_race && r.raced_put {1157		return error('injected write failure')1158	}1159	if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1160		r.done = true1161		r.raced_put = true1162		strip_local(r.other, r.path, '')!1163		send_strip(r.other, mut r.inner, r.path)!1164	}1165	return r.inner.put_if_absent(key, body)!1166}11671168fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1169	if r.fail_match {1170		return error('injected rewrite failure')1171	}1172	return r.inner.put_if_match(key, body, tag)!1173}11741175fn (mut r StripRace) delete(key string) ! {1176	if r.fail_delete {1177		return error('injected delete failure')1178	}1179	r.inner.delete(key)!1180}11811182// A strip that finishes on another machine while an offer uploads has already1183// rewritten the remote without the offer's records, so the offer takes back1184// what the strip reaches and leaves head where it was.1185fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1186	base := scratch('strip_race')!1187	defer {1188		os.rmdir_all(base) or {}1189	}1190	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1191	mut a := open_repo(machine('strip_race_a')!)!1192	defer {1193		a.close()1194	}1195	mut b := open_repo(machine('strip_race_b')!)!1196	defer {1197		b.close()1198	}1199	put(a.root, 'secrets.env', 'api_key=1\n')!1200	first := make_save(a, 'leak the key')!1201	offer_now(a, mut backend)!1202	join(b, mut backend)!1203	put(a.root, 'secrets.env', 'api_key=2\n')!1204	later := make_save(a, 'rotate the key')!1205	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1206	mut race := StripRace{1207		inner: backend1208		other: &b1209		path:  'secrets.env'1210	}1211	if _ := offer(a, mut race) {1212		assert false, 'an offer that raced a strip must refuse'1213	} else {1214		assert err.msg().contains('koh steal'), err.msg()1215	}1216	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1217	assert !backend.get(object_key('proj', secret))!.found1218	// Another offer may already have published it, so the record stays, stripped.1219	assert backend.get(save_key('proj', later.id))!.found1220	for key in backend.list('proj/saves')! {1221		record := decode_save(backend.get(key)!.body)!1222		assert 'secrets.env' !in record.files.map(it.path), record.id1223	}1224}12251226// Doctor's resend of a missing record can race a strip just as an offer can,1227// and takes back what the strip reaches in the same way.1228fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1229	base := scratch('resend_race')!1230	defer {1231		os.rmdir_all(base) or {}1232	}1233	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1234	mut a := open_repo(machine('resend_race_a')!)!1235	defer {1236		a.close()1237	}1238	mut b := open_repo(machine('resend_race_b')!)!1239	defer {1240		b.close()1241	}1242	put(a.root, 'secrets.env', 'api_key=1\n')!1243	first := make_save(a, 'leak the key')!1244	offer_now(a, mut backend)!1245	join(b, mut backend)!1246	backend.delete(save_key('proj', first.id))!1247	mut race := StripRace{1248		inner: backend1249		other: &b1250		path:  'secrets.env'1251	}1252	if _ := resend(a, mut race, 'proj', [], [first.id]) {1253		assert false, 'a resend that raced a strip must refuse'1254	} else {1255		assert err.msg().contains('koh steal'), err.msg()1256		// The restored record is still a repair Doctor made.1257		assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1258	}1259	// Head names the restored record, so it stays, less the stripped path.1260	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1261	assert 'secrets.env' !in record.files.map(it.path)1262	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1263}12641265// An object a strip reaches stays while a remote record still lists it under1266// another path, and a strip that landed before an upload failed is still applied.1267fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1268	base := scratch('resend_shared')!1269	defer {1270		os.rmdir_all(base) or {}1271	}1272	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1273	mut a := open_repo(machine('resend_shared_a')!)!1274	defer {1275		a.close()1276	}1277	mut b := open_repo(machine('resend_shared_b')!)!1278	defer {1279		b.close()1280	}1281	put(a.root, 'secrets.env', 'same\n')!1282	put(a.root, 'copy.txt', 'same\n')!1283	first := make_save(a, 'two paths, one object')!1284	offer_now(a, mut backend)!1285	join(b, mut backend)!1286	put(a.root, 'later.txt', 'later\n')!1287	second := make_save(a, 'not offered')!1288	shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1289	backend.delete(save_key('proj', first.id))!1290	backend.delete(shared)!1291	mut race := StripRace{1292		inner:           backend1293		other:           &b1294		path:            'secrets.env'1295		fail_after_race: true1296	}1297	if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1298		assert false, 'the injected failure should have surfaced'1299	} else {1300		assert err.msg().contains('injected'), err.msg()1301	}1302	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1303	paths := record.files.map(it.path)1304	assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1305	assert backend.get(shared)!.found1306}13071308// A note that does not parse, or that no recorded strip of its path backs, is1309// kept and changes nothing remote: sending it would lose the retry or strip1310// where no strip asked.1311fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1312	base := scratch('strip_note')!1313	defer {1314		os.rmdir_all(base) or {}1315	}1316	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1317	mut a := open_repo(machine('strip_note_a')!)!1318	defer {1319		a.close()1320	}1321	put(a.root, 'app', 'app\n')!1322	save := make_save(a, 'app')!1323	offer_now(a, mut backend)!1324	events.strip(a, '', 'other')!1325	for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1326		a.set_state(pending_key('app'), note)!1327		if _ := send_strip(a, mut backend, 'app') {1328			assert false, 'an untrusted note must be refused'1329		} else {1330			assert err.msg().contains('kept for recovery'), err.msg()1331		}1332		assert a.state(pending_key('app')) or { '' } == note1333		assert backend.list(events_prefix('proj'))!.len == 01334		assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1335	}1336	// Even behind a recorded strip, a note's hashes may name only objects.1337	events.strip(a, '', 'app')!1338	victim := os.join_path(base, 'remote', 'victim', 'head')1339	os.mkdir_all(os.dir(victim))!1340	os.write_file(victim, 'another repo')!1341	a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1342	if _ := send_strip(a, mut backend, 'app') {1343		assert false, 'a note naming a path outside the objects must be refused'1344	}1345	assert os.read_file(victim)! == 'another repo'1346	// Nor may a remote record's path-shaped hash be noted or deleted.1347	a.remove_state(pending_key('app'))!1348	requeue_strip(a, 'app')!1349	backend.delete(save_key('proj', save.id))!1350	backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1351		id:    save.id1352		files: [FileRecord{1353			path: 'app'1354			hash: '../../victim/head'1355		}]1356	}).bytes())!1357	send_strip(a, mut backend, 'app')!1358	assert os.read_file(victim)! == 'another repo'1359}13601361// A settlement that stops at a record leaves it naming an object the strip1362// deleted. A machine behind must still steal past it, and the note written1363// first finishes the strip once this repo has it.1364fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1365	base := scratch('settle_record')!1366	defer {1367		os.rmdir_all(base) or {}1368	}1369	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1370	mut a := open_repo(machine('settle_record_a')!)!1371	defer {1372		a.close()1373	}1374	mut b := open_repo(machine('settle_record_b')!)!1375	defer {1376		b.close()1377	}1378	mut c := open_repo(machine('settle_record_c')!)!1379	defer {1380		c.close()1381	}1382	put(a.root, 'readme.txt', 'hello\n')!1383	make_save(a, 'start')!1384	offer_now(a, mut backend)!1385	join(c, mut backend)!1386	put(a.root, 'secrets.env', 'api_key=1\n')!1387	leak := make_save(a, 'leak the key')!1388	offer_now(a, mut backend)!1389	join(b, mut backend)!1390	secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1391	backend.delete(save_key('proj', leak.id))!1392	backend.delete(secret)!1393	mut race := StripRace{1394		inner:      backend1395		other:      &b1396		path:       'secrets.env'1397		fail_match: true1398	}1399	if _ := resend(a, mut race, 'proj', [], [leak.id]) {1400		assert false, 'the injected rewrite failure should have surfaced'1401	}1402	assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1403	steal_now(c, mut backend)!1404	assert history.head(c) or { history.Save{} }.id == leak.id1405	assert strip_pending(a, 'secrets.env')1406	steal_now(a, mut backend)!1407	send_strip(a, mut backend, 'secrets.env')!1408	cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1409	assert 'secrets.env' !in cleaned.files.map(it.path)1410	assert !backend.get(secret)!.found1411}14121413// With no record to leave behind, the note alone carries a resent object that1414// a strip removed meanwhile, until a send after steal deletes it.1415fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1416	base := scratch('settle_object')!1417	defer {1418		os.rmdir_all(base) or {}1419	}1420	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1421	mut a := open_repo(machine('settle_object_a')!)!1422	defer {1423		a.close()1424	}1425	mut b := open_repo(machine('settle_object_b')!)!1426	defer {1427		b.close()1428	}1429	put(a.root, 'secrets.env', 'api_key=1\n')!1430	make_save(a, 'leak the key')!1431	offer_now(a, mut backend)!1432	join(b, mut backend)!1433	hash := storage.hash_hex('api_key=1\n'.bytes())1434	backend.delete(object_key('proj', hash))!1435	mut race := StripRace{1436		inner:       backend1437		other:       &b1438		path:        'secrets.env'1439		fail_delete: true1440		on_objects:  true1441	}1442	if _ := resend(a, mut race, 'proj', [hash], []) {1443		assert false, 'the injected delete failure should have surfaced'1444	}1445	assert backend.get(object_key('proj', hash))!.found1446	assert strip_pending(a, 'secrets.env')1447	steal_now(a, mut backend)!1448	send_strip(a, mut backend, 'secrets.env')!1449	assert !backend.get(object_key('proj', hash))!.found1450	assert !strip_pending(a, 'secrets.env')1451}14521453// When the late check itself fails, what the upload sent is kept, and Doctor1454// deletes what the strip removed once a steal brings the strip here.1455fn test_an_unsettled_upload_is_finished_after_steal() {1456	base := scratch('unsettled')!1457	defer {1458		os.rmdir_all(base) or {}1459	}1460	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1461	mut a := open_repo(machine('unsettled_a')!)!1462	defer {1463		a.close()1464	}1465	mut b := open_repo(machine('unsettled_b')!)!1466	defer {1467		b.close()1468	}1469	put(a.root, 'secrets.env', 'api_key=1\n')!1470	make_save(a, 'leak the key')!1471	offer_now(a, mut backend)!1472	join(b, mut backend)!1473	hash := storage.hash_hex('api_key=1\n'.bytes())1474	backend.delete(object_key('proj', hash))!1475	mut race := StripRace{1476		inner:                  backend1477		other:                  &b1478		path:                   'secrets.env'1479		on_objects:             true1480		fail_events_after_race: true1481	}1482	if _ := resend(a, mut race, 'proj', [hash], []) {1483		assert false, 'the injected list failure should have surfaced'1484	}1485	assert unsettled(a)! == [hash]1486	if _ := settle_unsettled(a, mut backend, 'proj') {1487		assert false, 'settling before the strip is here would keep its bytes'1488	}1489	steal_now(a, mut backend)!1490	settle_unsettled(a, mut backend, 'proj')!1491	assert !backend.get(object_key('proj', hash))!.found1492	assert unsettled(a)!.len == 01493}14941495// Settling never deletes what a local save still names, even when the local1496// copy is damaged, and reports what it deleted before a failure.1497fn test_settling_an_upload_keeps_named_objects_and_reports_deletions() {1498	base := scratch('settle_named')!1499	defer {1500		os.rmdir_all(base) or {}1501	}1502	remote_dir := os.join_path(base, 'remote')1503	mut backend := remote.new_folder(remote_dir)!1504	mut a := open_repo(machine('settle_named_a')!)!1505	defer {1506		a.close()1507	}1508	put(a.root, 'app', 'app\n')!1509	save := make_save(a, 'app')!1510	offer_now(a, mut backend)!1511	app := storage.hash_hex('app\n'.bytes())1512	gone := ['gone one', 'gone two'].map(storage.hash_hex(it.bytes()))1513	for hash in gone {1514		backend.put_if_absent(object_key('proj', hash), 'stripped'.bytes())!1515	}1516	backend.delete(save_key('proj', save.id))!1517	a.db.exec('PRAGMA foreign_keys=OFF')!1518	a.db.exec("DELETE FROM objects WHERE hash = '${app}'")!1519	a.set_state(unsettled_key, json2.encode([app, gone[0], gone[1]]))!1520	mut flaky := FaultyBackend{1521		inner:             remote.new_folder(remote_dir)!1522		fail_delete_after: 11523	}1524	if _ := settle_unsettled(a, mut flaky, 'proj') {1525		assert false, 'the injected delete failure should have surfaced'1526	} else {1527		assert (err as Partway).done == 'deleted 1 remote object', (err as Partway).done1528	}1529	assert backend.get(object_key('proj', app))!.found1530	assert unsettled(a)!.len == 31531}15321533// Deleting a damaged remote copy is work done, even when its replacement fails.1534fn test_a_resend_reports_a_damaged_copy_it_deleted() {1535	base := scratch('resend_deleted')!1536	defer {1537		os.rmdir_all(base) or {}1538	}1539	remote_dir := os.join_path(base, 'remote')1540	mut backend := remote.new_folder(remote_dir)!1541	mut a := open_repo(machine('resend_deleted_a')!)!1542	defer {1543		a.close()1544	}1545	put(a.root, 'app', 'app\n')!1546	make_save(a, 'app')!1547	hash := storage.hash_hex('app\n'.bytes())1548	backend.put_if_absent(object_key('proj', hash), 'damaged'.bytes())!1549	mut flaky := FaultyBackend{1550		inner:      remote.new_folder(remote_dir)!1551		fail_write: 11552	}1553	if _ := resend(a, mut flaky, 'proj', [hash], []) {1554		assert false, 'the injected write failure should have surfaced'1555	} else {1556		assert (err as Partway).done == 'deleted 1 damaged remote object', (err as Partway).done1557	}1558}15591560// A strip that sent its event before failing says so.1561fn test_a_strip_that_fails_after_its_event_reports_it() {1562	base := scratch('strip_event_partway')!1563	defer {1564		os.rmdir_all(base) or {}1565	}1566	remote_dir := os.join_path(base, 'remote')1567	mut backend := remote.new_folder(remote_dir)!1568	mut a := open_repo(machine('strip_event_partway_a')!)!1569	defer {1570		a.close()1571	}1572	put(a.root, 'secrets.env', 'api_key=1\n')!1573	make_save(a, 'leak the key')!1574	offer_now(a, mut backend)!1575	strip_local(a, 'secrets.env', '')!1576	mut flaky := FaultyBackend{1577		inner:     remote.new_folder(remote_dir)!1578		fail_list: '/saves'1579	}1580	if _ := send_strip(a, mut flaky, 'secrets.env') {1581		assert false, 'the injected list failure should have surfaced'1582	} else {1583		assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1584	}1585}15861587// A strip whose deletes fail after its rewrite keeps, in its note, the objects1588// the rewritten record no longer names, so the retry still deletes them.1589fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1590	base := scratch('strip_found')!1591	defer {1592		os.rmdir_all(base) or {}1593	}1594	remote_dir := os.join_path(base, 'remote')1595	mut backend := remote.new_folder(remote_dir)!1596	mut a := open_repo(machine('strip_found_a')!)!1597	defer {1598		a.close()1599	}1600	put(a.root, 'secrets.env', 'api_key=1\n')!1601	make_save(a, 'leak the key')!1602	offer_now(a, mut backend)!1603	strip_local(a, 'secrets.env', '')!1604	// A note rebuilt from the event alone, as Doctor's residue repair writes it.1605	a.remove_state(pending_key('secrets.env'))!1606	requeue_strip(a, 'secrets.env')!1607	mut flaky := FaultyBackend{1608		inner:       remote.new_folder(remote_dir)!1609		fail_delete: true1610	}1611	if _ := send_strip(a, mut flaky, 'secrets.env') {1612		assert false, 'the injected delete failure should have surfaced'1613	}1614	send_strip(a, mut backend, 'secrets.env')!1615	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1616	assert !strip_pending(a, 'secrets.env')1617}16181619// MovingRecord stands in for another machine rewriting a record between this1620// machine's read and its conditional write: the first conditional write finds1621// `other` already removed from the record.1622struct MovingRecord {1623mut:1624	inner remote.Backend1625	other string1626	moved bool1627}16281629fn (mut m MovingRecord) get(key string) !remote.Object {1630	return m.inner.get(key)!1631}16321633fn (mut m MovingRecord) list(prefix string) ![]string {1634	return m.inner.list(prefix)!1635}16361637fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1638	return m.inner.put_if_absent(key, body)!1639}16401641fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1642	if !m.moved {1643		m.moved = true1644		current := m.inner.get(key)!1645		record := decode_save(current.body)!1646		other := record.files.filter(it.path != m.other)1647		m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1648	}1649	return m.inner.put_if_match(key, body, tag)!1650}16511652fn (mut m MovingRecord) delete(key string) ! {1653	m.inner.delete(key)!1654}16551656fn test_a_stripped_record_that_moves_is_read_again() {1657	base := scratch('moving_record')!1658	defer {1659		os.rmdir_all(base) or {}1660	}1661	mut folder := remote.new_folder(os.join_path(base, 'remote'))!1662	key := save_key('proj', 'aaaaaaaaaaaa')1663	folder.put_if_absent(key, encode_save(SaveRecord{1664		id:    'aaaaaaaaaaaa'1665		files: [FileRecord{1666			path: 'secret-a'1667		}, FileRecord{1668			path: 'secret-b'1669		}, FileRecord{1670			path: 'keep'1671		}]1672	}).bytes())!1673	mut moving := MovingRecord{1674		inner: folder1675		other: 'secret-b'1676	}1677	strip_record(mut moving, key, 'aaaaaaaaaaaa', [1678		events.Event{1679			kind: events.kind_strip1680			path: 'secret-a'1681		},1682	])!1683	assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1684}16851686// An invalid strip event on the remote cannot make an upload take anything back.1687fn test_an_invalid_remote_strip_event_is_not_unapplied() {1688	base := scratch('invalid_remote_strip')!1689	defer {1690		os.rmdir_all(base) or {}1691	}1692	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1693	mut a := open_repo(machine('invalid_remote_strip_a')!)!1694	defer {1695		a.close()1696	}1697	bad := events.Event{1698		id:         'badstripevnt'1699		kind:       events.kind_strip1700		path:       'app'1701		created_at: -11702		machine:    'other'1703	}1704	backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1705	assert unapplied_strips(a, mut backend, 'proj')!.len == 01706}17071708// A resend that fails part way reports what it had already uploaded.1709fn test_a_failed_resend_reports_what_landed() {1710	base := scratch('resend_partway')!1711	defer {1712		os.rmdir_all(base) or {}1713	}1714	remote_dir := os.join_path(base, 'remote')1715	mut a := open_repo(machine('resend_partway_a')!)!1716	defer {1717		a.close()1718	}1719	put(a.root, 'one.txt', 'one\n')!1720	put(a.root, 'two.txt', 'two\n')!1721	make_save(a, 'two objects')!1722	hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1723	mut flaky := FaultyBackend{1724		inner:      remote.new_folder(remote_dir)!1725		fail_write: 21726	}1727	if _ := resend(a, mut flaky, 'proj', hashes, []) {1728		assert false, 'the injected failure should have surfaced'1729	} else {1730		assert err is Partway1731		assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1732	}1733}17341735// The local half lands before the remote hears anything, so a remote failure1736// leaves nothing to find locally; the pending note is what lets a re-run1737// finish. Objects a remote-only save still needs must survive the strip.1738fn test_an_interrupted_remote_strip_finishes_when_re_run() {1739	base := scratch('strip_resume')!1740	defer {1741		os.rmdir_all(base) or {}1742	}1743	remote_dir := os.join_path(base, 'remote')1744	mut backend := remote.new_folder(remote_dir)!1745	mut a := open_repo(machine('resume_a')!)!1746	defer {1747		a.close()1748	}1749	mut b := open_repo(machine('resume_b')!)!1750	defer {1751		b.close()1752	}1753	put(a.root, 'secrets.env', 'api_key=1\n')!1754	put(a.root, 'shared.env', 'same bytes on B\n')!1755	save := make_save(a, 'add secrets')!1756	offer_now(a, mut backend)!1757	join(b, mut backend)!1758	put(b.root, 'copy.txt', 'same bytes on B\n')!1759	b_save := make_save(b, 'B keeps a copy')!1760	offer_now(b, mut backend)!1761	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1762	secret_hash := storage.hash_hex('api_key=1\n'.bytes())17631764	strip_local(a, 'secrets.env', '')!1765	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1766	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1767	mut flaky := FaultyBackend{1768		inner:      remote.new_folder(remote_dir)!1769		fail_write: 31770	}1771	if _ := send_strip(a, mut flaky, 'secrets.env') {1772		assert false, 'the injected failure should have surfaced'1773	}1774	assert strip_pending(a, 'secrets.env')1775	assert strip_local(a, 'secrets.env', '')!.files_removed == 01776	report := send_strip(a, mut backend, 'secrets.env')!1777	assert report.records == 11778	assert !strip_pending(a, 'secrets.env')1779	assert !backend.get(object_key('proj', secret_hash))!.found1780	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1781	assert 'secrets.env' !in record.files.map(it.path)1782	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1783	assert 'secrets.env' !in b_record.files.map(it.path)17841785	// B's save still names the shared bytes, so stripping A's copy keeps them.1786	strip_local(a, 'shared.env', '')!1787	send_strip(a, mut backend, 'shared.env')!1788	assert backend.get(object_key('proj', shared_hash))!.found1789}17901791// A machine that has not heard of a strip must not offer: its records still1792// list the stripped path, and nothing would rewrite them on the remote again.1793fn test_offer_waits_for_an_unapplied_strip() {1794	base := scratch('strip_first')!1795	defer {1796		os.rmdir_all(base) or {}1797	}1798	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1799	mut a := open_repo(machine('strip_first_a')!)!1800	defer {1801		a.close()1802	}1803	mut b := open_repo(machine('strip_first_b')!)!1804	defer {1805		b.close()1806	}1807	put(a.root, 'secrets.env', 'api_key=1\n')!1808	make_save(a, 'add secrets')!1809	offer_now(a, mut backend)!1810	join(b, mut backend)!1811	strip_local(a, 'secrets.env', '')!1812	send_strip(a, mut backend, 'secrets.env')!1813	put(b.root, 'more.txt', 'B keeps working\n')!1814	later := make_save(b, 'B saves with the secret still in its tree')!1815	if _ := offer_now(b, mut backend) {1816		assert false, 'B offered records that still list the stripped path'1817	}1818	steal_now(b, mut backend)!1819	offer_now(b, mut backend)!1820	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1821	assert 'secrets.env' !in record.files.map(it.path)1822}18231824// A strip whose object deletions failed must still delete them when it is run1825// again, and meanwhile the banned path cannot come back in through a save.1826fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1827	base := scratch('strip_twice')!1828	defer {1829		os.rmdir_all(base) or {}1830	}1831	remote_dir := os.join_path(base, 'remote')1832	mut backend := remote.new_folder(remote_dir)!1833	mut a := open_repo(machine('strip_twice_a')!)!1834	defer {1835		a.close()1836	}1837	put(a.root, 'secrets.env', 'first key\n')!1838	make_save(a, 'first key')!1839	offer_now(a, mut backend)!1840	strip_local(a, 'secrets.env', '')!1841	mut flaky := FaultyBackend{1842		inner:       remote.new_folder(remote_dir)!1843		fail_delete: true1844	}1845	if _ := send_strip(a, mut flaky, 'secrets.env') {1846		assert false, 'the injected delete failure should have surfaced'1847	}1848	put(a.root, 'secrets.env', 'second key\n')!1849	put(a.root, 'other.txt', 'more work\n')!1850	make_save(a, 'more work')!1851	offer_now(a, mut backend)!1852	send_strip(a, mut backend, 'secrets.env')!1853	for key in ['first key\n', 'second key\n'] {1854		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1855	}1856}18571858// Unfinished strips of one path in different scopes finish together.1859fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1860	base := scratch('strip_scopes')!1861	defer {1862		os.rmdir_all(base) or {}1863	}1864	remote_dir := os.join_path(base, 'remote')1865	mut backend := remote.new_folder(remote_dir)!1866	mut a := open_repo(machine('strip_scopes_a')!)!1867	defer {1868		a.close()1869	}1870	put(a.root, 'secrets.env', 'first key\n')!1871	first := make_save(a, 'first key')!1872	put(a.root, 'secrets.env', 'second key\n')!1873	second := make_save(a, 'second key')!1874	offer_now(a, mut backend)!1875	strip_local(a, 'secrets.env', first.id)!1876	// Write 1 uploads the event; write 2, the record rewrite, fails.1877	mut flaky := FaultyBackend{1878		inner:      remote.new_folder(remote_dir)!1879		fail_write: 21880	}1881	if _ := send_strip(a, mut flaky, 'secrets.env') {1882		assert false, 'the injected failure should have surfaced'1883	}1884	strip_local(a, 'secrets.env', second.id)!1885	send_strip(a, mut backend, 'secrets.env')!1886	assert !strip_pending(a, 'secrets.env')1887	for save in [first, second] {1888		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1889		assert 'secrets.env' !in record.files.map(it.path)1890	}1891	for key in ['first key\n', 'second key\n'] {1892		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1893	}1894}18951896// A strip bans its path on every machine that has heard of it: written again,1897// the file makes no save, here or there, so there is never a second copy to strip.1898fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1899	base := scratch('strip_again')!1900	defer {1901		os.rmdir_all(base) or {}1902	}1903	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1904	mut a := open_repo(machine('strip_again_a')!)!1905	defer {1906		a.close()1907	}1908	mut b := open_repo(machine('strip_again_b')!)!1909	defer {1910		b.close()1911	}1912	put(a.root, 'secrets.env', 'first key\n')!1913	make_save(a, 'first key')!1914	offer_now(a, mut backend)!1915	join(b, mut backend)!1916	strip_local(a, 'secrets.env', '')!1917	send_strip(a, mut backend, 'secrets.env')!1918	steal_now(b, mut backend)!1919	for repo in [a, b] {1920		put(repo.root, 'secrets.env', 'second key\n')!1921		if _ := make_save(repo, 'second key') {1922			assert false, 'a banned path must not make a save'1923		}1924	}1925}19261927// A clash on a path the other machine stripped is no clash: the strip removes1928// the path from every save, so there is nothing to choose.1929fn test_a_stripped_path_is_not_asked_about() {1930	base := scratch('clash_strip')!1931	defer {1932		os.rmdir_all(base) or {}1933	}1934	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1935	mut a := open_repo(machine('clash_strip_a')!)!1936	defer {1937		a.close()1938	}1939	mut b := open_repo(machine('clash_strip_b')!)!1940	defer {1941		b.close()1942	}1943	put(a.root, 'secrets.env', 'key one\n')!1944	make_save(a, 'base')!1945	offer_now(a, mut backend)!1946	join(b, mut backend)!1947	put(a.root, 'secrets.env', 'key two\n')!1948	make_save(a, 'A rotates')!1949	offer_now(a, mut backend)!1950	strip_local(a, 'secrets.env', '')!1951	send_strip(a, mut backend, 'secrets.env')!1952	put(b.root, 'secrets.env', 'key three\n')!1953	mine := make_save(b, 'B rotates')!1954	report := steal(b, mut backend, StealOptions{})!1955	assert report.clashes.len == 01956	for save in history.all_saves(b)! {1957		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1958	}1959	assert history.by_id(b, mine.id) != none1960}19611962// A strip scoped to the shared base save changes what the replay sees; the1963// clashes must be found on the same trees, or the replay meets a conflict.1964fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1965	base := scratch('clash_base_strip')!1966	defer {1967		os.rmdir_all(base) or {}1968	}1969	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1970	mut a := open_repo(machine('base_strip_a')!)!1971	defer {1972		a.close()1973	}1974	mut b := open_repo(machine('base_strip_b')!)!1975	defer {1976		b.close()1977	}1978	put(a.root, 'notes.txt', 'shared\n')!1979	common := make_save(a, 'base')!1980	offer_now(a, mut backend)!1981	join(b, mut backend)!1982	put(a.root, 'other.txt', 'A moves on\n')!1983	make_save(a, 'A moves on')!1984	offer_now(a, mut backend)!1985	strip_local(a, 'notes.txt', common.id)!1986	send_strip(a, mut backend, 'notes.txt')!1987	put(b.root, 'notes.txt', 'B edits\n')!1988	make_save(b, 'B edits notes')!1989	if _ := steal(b, mut backend, StealOptions{}) {1990		assert false, 'with the base stripped, notes.txt differs on both sides'1991	} else {1992		assert err.msg().contains('notes.txt changed on both machines')1993	}1994	report := steal(b, mut backend, StealOptions{1995		mine: true1996	})!1997	assert report.clashes == ['notes.txt']1998	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1999}20002001// A folder with files but no saves has nothing to combine with; a steal that2002// fast-forwarded into it would overwrite whatever shares a name.2003fn test_steal_into_a_folder_with_unsaved_files_refuses() {2004	base := scratch('steal_unsaved')!2005	defer {2006		os.rmdir_all(base) or {}2007	}2008	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2009	mut a := open_repo(machine('unsaved_a')!)!2010	defer {2011		a.close()2012	}2013	put(a.root, 'notes.txt', 'from A\n')!2014	make_save(a, 'A writes')!2015	offer_now(a, mut backend)!2016	b_root := machine('unsaved_b')!2017	mut b := storage.create(b_root)!2018	defer {2019		b.close()2020	}2021	put(b_root, 'notes.txt', 'never saved\n')!2022	// Only a repo that already knows its remote name gets this far.2023	b.set_state('remote_repo', 'proj')!2024	if _ := steal(b, mut backend, StealOptions{}) {2025		assert false, 'steal overwrote a folder with unsaved files'2026	} else {2027		assert err.msg() == no_saves_here2028	}2029	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'2030	// A fresh `koh init` folder, holding only the default ignore list, is fine.2031	os.rm(os.join_path(b_root, 'notes.txt'))!2032	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!2033	steal(b, mut backend, StealOptions{})!2034	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'2035}20362037// The document asks steal to warn about every incoming flagged save, so the2038// report names them.2039fn test_steal_reports_incoming_flagged_saves() {2040	base := scratch('flag_warn')!2041	defer {2042		os.rmdir_all(base) or {}2043	}2044	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2045	mut a := open_repo(machine('flag_warn_a')!)!2046	defer {2047		a.close()2048	}2049	mut b := open_repo(machine('flag_warn_b')!)!2050	defer {2051		b.close()2052	}2053	put(a.root, 'a.txt', 'one\n')!2054	make_save(a, 'fine')!2055	offer_now(a, mut backend)!2056	join(b, mut backend)!2057	put(a.root, 'a.txt', 'two\n')!2058	suspect := make_save(a, 'suspect')!2059	put(a.root, 'a.txt', 'three\n')!2060	make_save(a, 'also fine')!2061	events.flag(a, suspect.id, 'broke the build')!2062	offer_now(a, mut backend)!2063	report := steal_now(b, mut backend)!2064	assert report.flagged.map(it.save_id) == [suspect.id]2065	assert report.flagged[0].reason == 'broke the build'2066}20672068// head_of unwraps the head for tests that know the repo has saves.2069fn head_of(store &storage.Store) history.Save {2070	return history.head(store) or { panic('this repo has no head') }2071}20722073// A remote says what it is, so two koh versions never half-understand one2074// bucket. The first offer of a repo records the layout.2075fn test_the_first_offer_records_the_remote_format() {2076	base := scratch('format_write')!2077	defer {2078		os.rmdir_all(base) or {}2079	}2080	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2081	mut a := open_repo(machine('format_write_a')!)!2082	defer {2083		a.close()2084	}2085	put(a.root, 'a.txt', 'one\n')!2086	make_save(a, 'first')!2087	offer_now(a, mut backend)!2088	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2089}20902091// A repo offered before the key existed is version 1: it still syncs, and the2092// next offer records the key then.2093fn test_a_remote_without_a_format_still_syncs() {2094	base := scratch('format_old')!2095	defer {2096		os.rmdir_all(base) or {}2097	}2098	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2099	mut a := open_repo(machine('format_old_a')!)!2100	defer {2101		a.close()2102	}2103	put(a.root, 'a.txt', 'one\n')!2104	make_save(a, 'first')!2105	offer_now(a, mut backend)!2106	// Stand in for a bucket offered before the key existed.2107	backend.delete(format_key('proj'))!2108	mut b := open_repo(machine('format_old_b')!)!2109	defer {2110		b.close()2111	}2112	join(b, mut backend)!2113	assert head_of(b).id == head_of(a).id2114	put(a.root, 'a.txt', 'two\n')!2115	make_save(a, 'second')!2116	offer_now(a, mut backend)!2117	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2118	report := steal_now(b, mut backend)!2119	assert report.head == head_of(a).id2120}21212122// A remote that speaks another layout is refused loudly, on offer and on2123// steal, rather than half-read.2124fn test_an_unknown_remote_format_is_refused() {2125	base := scratch('format_unknown')!2126	defer {2127		os.rmdir_all(base) or {}2128	}2129	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2130	mut a := open_repo(machine('format_bad_a')!)!2131	defer {2132		a.close()2133	}2134	put(a.root, 'a.txt', 'one\n')!2135	make_save(a, 'first')!2136	offer_now(a, mut backend)!2137	backend.delete(format_key('proj'))!2138	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2139	if _ := offer_now(a, mut backend) {2140		assert false, 'offer must refuse a format it does not speak'2141	} else {2142		assert err.msg().contains('koh-remote 2')2143	}2144	if _ := steal_now(a, mut backend) {2145		assert false, 'steal must refuse a format it does not speak'2146	} else {2147		assert err.msg().contains('koh-remote 2')2148	}2149	if _ := fetch_repo(mut backend, 'proj') {2150		assert false, 'a steal must refuse a format it does not speak'2151	} else {2152		assert err.msg().contains('koh-remote 2')2153	}2154}21552156// Two machines offering a new repo at once both find no format key; the loser2157// of the conditional write must read back the winner's value and refuse it,2158// not carry on against a remote it cannot read.2159fn test_a_lost_format_write_is_read_back_and_refused() {2160	base := scratch('format_race')!2161	defer {2162		os.rmdir_all(base) or {}2163	}2164	mut inner := remote.new_folder(os.join_path(base, 'remote'))!2165	mut a := open_repo(machine('format_race_a')!)!2166	defer {2167		a.close()2168	}2169	put(a.root, 'a.txt', 'one\n')!2170	make_save(a, 'first')!2171	mut backend := remote.Backend(FaultyBackend{2172		inner:       inner2173		race_format: true2174	})2175	if _ := offer_now(a, mut backend) {2176		assert false, 'offer must refuse the format another machine wrote first'2177	} else {2178		assert err.msg().contains('koh-remote 2')2179	}2180}21812182fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2183	whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2184	for size in [1, 2, 5, whole.len] {2185		mut bundle := Bundle{}2186		for at := 0; at < whole.len; at += size {2187			bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2188		}2189		bundle.finish()!2190		assert bundle.fetched.repo_objects == {2191			'aa': 'abc'.bytes()2192			'bb': []u8{}2193		}2194	}2195	for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2196		'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2197		mut bundle := Bundle{}2198		bundle.feed(cut.bytes()) or { continue }2199		bundle.finish() or { continue }2200		assert false, cut2201	}2202}22032204fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2205	mut store := open_repo(machine('strip_batch')!)!2206	defer { store.close() }2207	put(store.root, 'private/a', 'a')!2208	put(store.root, 'secret', 'secret')!2209	put(store.root, 'keep', 'keep')!2210	save := make_save(store, 'initial')!2211	store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2212		WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2213	if _ := strip_local_batch(store, ['private', 'secret'], '') {2214		assert false, 'batch succeeded despite the second path failing'2215	}2216	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2217		'secret']2218	assert events.strip_events(store)!.len == 02219	assert !strip_pending(store, 'private')2220	store.db.exec('DROP TRIGGER block_secret')!2221	reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2222	assert reports.map(it.files_removed) == [1, 0, 1]2223	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2224	assert events.strip_events(store)!.len == 22225}22262227fn test_strip_batch_limits_every_path_to_selected_save() {2228	mut store := open_repo(machine('strip_batch_scope')!)!2229	defer { store.close() }2230	put(store.root, 'one', 'one')!2231	put(store.root, 'two', 'two')!2232	first := make_save(store, 'initial')!2233	put(store.root, 'keep', 'keep')!2234	second := make_save(store, 'next')!2235	strip_local_batch(store, ['one', 'two'], first.id)!2236	assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2237	assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2238	assert events.strip_events(store)!.all(it.save_id == first.id)2239}22402241fn test_strip_batch_keeps_independent_remote_retry_notes() {2242	mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2243	mut store := open_repo(machine('strip_batch_retry')!)!2244	defer { store.close() }2245	put(store.root, 'one', 'secret')!2246	put(store.root, 'two', 'secret')!2247	save := make_save(store, 'initial')!2248	offer_now(store, mut backend)!2249	strip_local_batch(store, ['one', 'two'], '')!2250	mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2251	if _ := send_strip(store, mut flaky, 'one') {2252		assert false, 'injected remote failure was not reported'2253	}2254	send_strip(store, mut backend, 'two')!2255	assert strip_pending(store, 'one') && !strip_pending(store, 'two')2256	partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2257	assert partial.files.map(it.path) == ['.kohignore', 'one']2258	assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2259	send_strip(store, mut backend, 'one')!2260	assert !strip_pending(store, 'one')2261	finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2262	assert finished.files.map(it.path) == ['.kohignore']2263	assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2264}