exchange_test.v

78.9 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module exchange67import 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_steal_refuses_invalid_remote_save_metadata() {924	base := scratch('invalid_steal_metadata')!925	defer { os.rmdir_all(base) or {} }926	mut backend := remote.new_folder(os.join_path(base, 'remote'))!927	mut receiver := open_repo(machine('invalid_steal_metadata_receiver')!)!928	defer { receiver.close() }929	for repo, record in {930		'negative-time': SaveRecord{ id: 'aaaaaaaaaaaa', created_at: -1 }931		'negative-size': SaveRecord{ id: 'bbbbbbbbbbbb', size_bytes: -1 }932	} {933		backend.put_if_absent(head_key(repo), record.id.bytes())!934		backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!935		if _ := fetch_back_to_known(receiver, mut backend, repo) {936			assert false, 'invalid remote save metadata must be refused'937		} else {938			assert err.msg().contains('invalid metadata'), err.msg()939		}940	}941}942943fn test_steal_binds_remote_records_to_their_keys_and_head() {944	base := scratch('steal_record_identity')!945	defer { os.rmdir_all(base) or {} }946	mut backend := remote.new_folder(os.join_path(base, 'remote'))!947	mut receiver := open_repo(machine('steal_record_identity_receiver')!)!948	defer { receiver.close() }949	backend.put_if_absent(head_key('mismatch'), 'aaaaaaaaaaaa'.bytes())!950	backend.put_if_absent(save_key('mismatch', 'aaaaaaaaaaaa'), encode_save(SaveRecord{951		id: 'bbbbbbbbbbbb'952	}).bytes())!953	if _ := fetch_back_to_known(receiver, mut backend, 'mismatch') {954		assert false, 'a save record under another ID must be refused'955	} else {956		assert err.msg().contains('disagrees with'), err.msg()957	}958	backend.put_if_absent(head_key('missing-head'), 'cccccccccccc'.bytes())!959	backend.put_if_absent(save_key('missing-head', 'dddddddddddd'), encode_save(SaveRecord{960		id: 'dddddddddddd'961	}).bytes())!962	if _ := fetch_repo(mut backend, 'missing-head') {963		assert false, 'a whole-repo steal without its declared head must be refused'964	} else {965		assert err.msg().contains('head'), err.msg()966	}967}968969fn test_steal_refuses_invalid_object_hashes_before_reading_their_keys() {970	base := scratch('steal_bad_hash')!971	defer { os.rmdir_all(base) or {} }972	remote_dir := os.join_path(base, 'remote')973	mut folder := remote.new_folder(remote_dir)!974	mut receiver := open_repo(machine('steal_bad_hash_receiver')!)!975	defer { receiver.close() }976	folder.put_if_absent(head_key('bad-hash'), 'aaaaaaaaaaaa'.bytes())!977	folder.put_if_absent(save_key('bad-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{978		id:    'aaaaaaaaaaaa'979		files: [FileRecord{ path: 'file', hash: '../../outside' }]980	}).bytes())!981	mut guarded := FaultyBackend{982		inner:     remote.new_folder(remote_dir)!983		fail_read: 4984	}985	if _ := fetch_back_to_known(receiver, mut guarded, 'bad-hash') {986		assert false, 'an invalid object hash must be refused'987	} else {988		assert err.msg().contains('invalid object hash'), err.msg()989	}990}991992fn test_steal_drops_an_unsafe_path_before_reading_its_hash() {993	base := scratch('steal_unsafe_hash')!994	defer { os.rmdir_all(base) or {} }995	remote_dir := os.join_path(base, 'remote')996	mut folder := remote.new_folder(remote_dir)!997	mut receiver := open_repo(machine('steal_unsafe_hash_receiver')!)!998	defer { receiver.close() }999	folder.put_if_absent(head_key('unsafe-hash'), 'aaaaaaaaaaaa'.bytes())!1000	folder.put_if_absent(save_key('unsafe-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1001		id:    'aaaaaaaaaaaa'1002		files: [FileRecord{ path: '../outside', hash: '../../outside' }]1003	}).bytes())!1004	mut guarded := FaultyBackend{1005		inner:     remote.new_folder(remote_dir)!1006		fail_read: 41007	}1008	fetched := fetch_back_to_known(receiver, mut guarded, 'unsafe-hash')!1009	assert fetched.saves[0].files.len == 01010	assert fetched.unsafe_paths == ['../outside']1011}10121013fn test_steal_refuses_invalid_save_references() {1014	base := scratch('steal_bad_references')!1015	defer { os.rmdir_all(base) or {} }1016	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1017	mut receiver := open_repo(machine('steal_bad_references_receiver')!)!1018	defer { receiver.close() }1019	for repo, record in {1020		'bad-parent':  SaveRecord{ id: 'aaaaaaaaaaaa', parent: '../parent' }1021		'bad-restore': SaveRecord{ id: 'bbbbbbbbbbbb', restores: '../restore' }1022		'bad-replay':  SaveRecord{ id: 'cccccccccccc', replayed_from: '../replay' }1023	} {1024		backend.put_if_absent(head_key(repo), record.id.bytes())!1025		backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!1026		if _ := fetch_back_to_known(receiver, mut backend, repo) {1027			assert false, 'invalid remote save references must be refused'1028		} else {1029			assert err.msg().contains('invalid save ID'), err.msg()1030		}1031	}1032}10331034fn test_steal_accepts_migrated_six_character_save_ids() {1035	base := scratch('steal_six_ids')!1036	defer { os.rmdir_all(base) or {} }1037	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1038	backend.put_if_absent(head_key('six'), 'bbbbbb'.bytes())!1039	backend.put_if_absent(save_key('six', 'aaaaaa'), encode_save(SaveRecord{1040		id: 'aaaaaa'1041	}).bytes())!1042	backend.put_if_absent(save_key('six', 'bbbbbb'), encode_save(SaveRecord{1043		id:     'bbbbbb'1044		parent: 'aaaaaa'1045	}).bytes())!1046	whole := fetch_repo(mut backend, 'six')!1047	assert whole.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1048	mut receiver := open_repo(machine('steal_six_ids_receiver')!)!1049	defer { receiver.close() }1050	incremental := fetch_back_to_known(receiver, mut backend, 'six')!1051	assert incremental.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1052}10531054fn test_steal_refuses_a_remote_parent_cycle() {1055	base := scratch('steal_parent_cycle')!1056	defer { os.rmdir_all(base) or {} }1057	remote_dir := os.join_path(base, 'remote')1058	mut folder := remote.new_folder(remote_dir)!1059	mut receiver := open_repo(machine('steal_parent_cycle_receiver')!)!1060	defer { receiver.close() }1061	folder.put_if_absent(head_key('cycle'), 'aaaaaaaaaaaa'.bytes())!1062	folder.put_if_absent(save_key('cycle', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1063		id:     'aaaaaaaaaaaa'1064		parent: 'bbbbbbbbbbbb'1065	}).bytes())!1066	folder.put_if_absent(save_key('cycle', 'bbbbbbbbbbbb'), encode_save(SaveRecord{1067		id:     'bbbbbbbbbbbb'1068		parent: 'aaaaaaaaaaaa'1069	}).bytes())!1070	mut guarded := FaultyBackend{1071		inner:     remote.new_folder(remote_dir)!1072		fail_read: 51073	}1074	if _ := fetch_back_to_known(receiver, mut guarded, 'cycle') {1075		assert false, 'a remote parent cycle must be refused'1076	} else {1077		assert err.msg().contains('parent cycle'), err.msg()1078	}1079}10801081fn test_steal_binds_remote_events_to_their_keys() {1082	base := scratch('steal_event_identity')!1083	defer { os.rmdir_all(base) or {} }1084	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1085	mut receiver := open_repo(machine('steal_event_identity_receiver')!)!1086	defer { receiver.close() }1087	backend.put_if_absent(event_key('repo', 'aaaaaaaaaaaa'), encode_event(events.Event{1088		id:         'bbbbbbbbbbbb'1089		kind:       events.kind_flag1090		save_id:    'cccccccccccc'1091		created_at: 11092		machine:    'other'1093	}).bytes())!1094	if _ := fetch_events(receiver, mut backend, 'repo') {1095		assert false, 'an event record under another ID must be refused'1096	} else {1097		assert err.msg().contains('disagrees with'), err.msg()1098	}1099}11001101fn test_an_incoming_strip_drops_only_objects_it_makes_unused() {1102	mut receiver := open_repo(machine('incoming_strip_objects')!)!1103	defer { receiver.close() }1104	recovery := receiver.put_object('recovery'.bytes())!1105	secret := receiver.put_object('secret'.bytes())!1106	blob := receiver.object_blob(secret)!1107	write_fetched(receiver, Fetched{1108		repo_objects: {1109			secret: blob1110		}1111		saves:        [SaveRecord{1112			id:    'aaaaaaaaaaaa'1113			files: [FileRecord{ path: 'secret', hash: secret }]1114		}]1115		events_out:   [events.Event{1116			id:         'bbbbbbbbbbbb'1117			kind:       events.kind_strip1118			path:       'secret'1119			created_at: 11120			machine:    'other'1121		}]1122	})!1123	assert !receiver.has_object(secret)!1124	assert receiver.has_object(recovery)!1125}11261127fn test_an_incoming_strip_keeps_a_matching_preexisting_orphan() {1128	mut receiver := open_repo(machine('incoming_strip_matching_orphan')!)!1129	defer { receiver.close() }1130	orphan := receiver.put_object('same bytes'.bytes())!1131	write_fetched(receiver, Fetched{1132		saves:      [SaveRecord{1133			id:    'aaaaaaaaaaaa'1134			files: [FileRecord{ path: 'secret', hash: orphan }]1135		}]1136		events_out: [events.Event{1137			id:         'bbbbbbbbbbbb'1138			kind:       events.kind_strip1139			path:       'secret'1140			created_at: 11141			machine:    'other'1142		}]1143	})!1144	assert receiver.has_object(orphan)!1145}11461147fn test_an_interrupted_strip_is_idempotent() {1148	// The local half of a strip is one transaction, so an interrupted strip is1149	// simply a strip that did not happen; re-running it must land the same1150	// result once, not twice.1151	base := scratch('fault_strip')!1152	defer {1153		os.rmdir_all(base) or {}1154	}1155	mut a := open_repo(machine('fault_strip_a')!)!1156	defer {1157		a.close()1158	}1159	put(a.root, 'secret.txt', 'shh\n')!1160	put(a.root, 'keep.txt', 'fine\n')!1161	save := make_save(a, 'add both')!11621163	for _ in 0 .. 2 {1164		strip_local(a, 'secret.txt', '')!1165	}1166	files := history.files_of(a, save.id)!1167	// The repo's own `.kohignore` is tracked content, so it stays; only the1168	// stripped path goes.1169	assert files.map(it.path) == ['.kohignore', 'keep.txt']1170	assert events.strip_events(a)!.len == 11171}11721173// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into1174// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.1175// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,1176// but a save made on Linux can hold both.1177fn test_a_strip_matches_the_path_exactly() {1178	mut a := open_repo(machine('strip_exact')!)!1179	defer {1180		a.close()1181	}1182	hash := a.put_object('x\n'.bytes())!1183	mut files := []snapshot.FileEntry{}1184	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {1185		files << snapshot.FileEntry{1186			path: path1187			hash: hash1188		}1189	}1190	save := history.save_tree(a, '', files, history.SaveInput{1191		message: 'add all'1192	})!1193	assert strip_scope(a, 'docs', '')!.files == 11194	strip_local(a, 'docs', '')!1195	strip_local(a, 'a_b', '')!1196	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']1197}11981199fn test_a_strip_reaches_the_remote_and_other_machines() {1200	base := scratch('strip_sync')!1201	defer {1202		os.rmdir_all(base) or {}1203	}1204	mut a := open_repo(machine('strip_a')!)!1205	defer {1206		a.close()1207	}1208	mut b := open_repo(machine('strip_b')!)!1209	defer {1210		b.close()1211	}1212	put(a.root, 'secrets.env', 'api_key=1\n')!1213	put(a.root, 'keep.txt', 'fine\n')!1214	save := make_save(a, 'add both')!1215	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1216	offer_now(a, mut backend)!1217	join(b, mut backend)!1218	assert b.object_count()! > 012191220	// A strips the path locally, the way `koh strip` does, then tells the1221	// remote.1222	mut secret_hash := ''1223	for file in history.files_of(a, save.id)! {1224		if file.path == 'secrets.env' {1225			secret_hash = file.hash1226		}1227	}1228	assert secret_hash != ''1229	strip_local(a, 'secrets.env', '')!1230	report := send_strip(a, mut backend, 'secrets.env')!1231	assert report.event_sent1232	assert report.records == 112331234	// The remote's copy of the save no longer lists the file, and the object is1235	// gone from the remote.1236	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1237	for file in record.files {1238		assert file.path != 'secrets.env'1239	}1240	// The stripped object is gone from the remote; the ones still referenced1241	// are not.1242	assert !backend.get(object_key('proj', secret_hash))!.found1243	assert backend.list('proj/objects')!.len == 212441245	// Another machine hears about it on its next steal and applies it.1246	b.begin()!1247	fetched := fetch_back_to_known(b, mut backend, 'proj')!1248	write_fetched(b, fetched)!1249	b.end()!1250	mut paths := []string{}1251	for file in history.files_of(b, save.id)! {1252		paths << file.path1253	}1254	assert 'secrets.env' !in paths1255	assert 'keep.txt' in paths12561257	// The event arrives under its own ID, so it is applied once: B's offer adds1258	// no second copy. The path is banned for good: written again, the file is1259	// neither unsaved work nor ever walked into a save.1260	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1261	put(b.root, 'secrets.env', 'rotated\n')!1262	assert !dirty(b, history.head(b))!1263	put(b.root, 'keep.txt', 'still fine\n')!1264	later := make_save(b, 'edit keep')!1265	offer_now(b, mut backend)!1266	assert backend.list('proj/events')!.len == 11267	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1268}12691270// A strip removes a path from all of history, whenever a save was made. A1271// machine that saved the file after the strip, before it heard of it, loses1272// it from that save too on its next steal, so no copy anywhere keeps it.1273fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1274	base := scratch('strip_later')!1275	defer {1276		os.rmdir_all(base) or {}1277	}1278	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1279	mut a := open_repo(machine('strip_later_a')!)!1280	defer {1281		a.close()1282	}1283	mut b := open_repo(machine('strip_later_b')!)!1284	defer {1285		b.close()1286	}1287	put(a.root, 'secrets.env', 'api_key=1\n')!1288	old := make_save(a, 'leak the key')!1289	offer_now(a, mut backend)!1290	join(b, mut backend)!1291	strip_local(a, 'secrets.env', '')!1292	send_strip(a, mut backend, 'secrets.env')!1293	// B has not heard, and saves the file again an hour after the strip.1294	put(b.root, 'secrets.env', 'api_key=2\n')!1295	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1296		message:    'rotate the key'1297		created_at: events.strip_events(a)!.last().created_at + 36001298	})!1299	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1300	b.begin()!1301	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1302	b.end()!1303	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1304	offer_now(b, mut backend)!13051306	mut c := open_repo(machine('strip_later_c')!)!1307	defer {1308		c.close()1309	}1310	join(c, mut backend)!1311	for save in [old.id, later.id] {1312		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1313	}1314	assert !os.exists(os.join_path(c.root, 'secrets.env'))1315}13161317// A strip this machine already holds bans its path from saves that arrive1318// later too, not only from those a fetch brings with the strip.1319fn test_a_held_strip_reaches_saves_arriving_after_it() {1320	base := scratch('strip_held')!1321	defer {1322		os.rmdir_all(base) or {}1323	}1324	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1325	mut a := open_repo(machine('strip_held_a')!)!1326	defer {1327		a.close()1328	}1329	mut b := open_repo(machine('strip_held_b')!)!1330	defer {1331		b.close()1332	}1333	put(a.root, 'secrets.env', 'api_key=1\n')!1334	make_save(a, 'leak the key')!1335	offer_now(a, mut backend)!1336	join(b, mut backend)!1337	strip_local(b, 'secrets.env', '')!1338	put(a.root, 'keep.txt', 'fine\n')!1339	later := make_save(a, 'add keep')!1340	offer_now(a, mut backend)!1341	b.begin()!1342	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1343	b.end()!1344	paths := history.files_of(b, later.id)!.map(it.path)1345	assert 'secrets.env' !in paths1346	assert 'keep.txt' in paths1347}13481349// StripRace stands in for another machine that strips `path` completely, event1350// and rewrite, just before this machine's first save record lands.1351struct StripRace {1352mut:1353	inner remote.Backend1354	other &storage.Store1355	path  string1356	done  bool1357	// fail every put after the one the strip raced1358	fail_after_race bool1359	raced_put       bool1360	fail_delete     bool1361	fail_match      bool1362	// race at the first object instead of the first save record1363	on_objects bool1364	// fail listing events once the strip has raced1365	fail_events_after_race bool1366}13671368fn (mut r StripRace) get(key string) !remote.Object {1369	return r.inner.get(key)!1370}13711372fn (mut r StripRace) list(prefix string) ![]string {1373	if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1374		return error('injected list failure')1375	}1376	return r.inner.list(prefix)!1377}13781379fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1380	if r.fail_after_race && r.raced_put {1381		return error('injected write failure')1382	}1383	if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1384		r.done = true1385		r.raced_put = true1386		strip_local(r.other, r.path, '')!1387		send_strip(r.other, mut r.inner, r.path)!1388	}1389	return r.inner.put_if_absent(key, body)!1390}13911392fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1393	if r.fail_match {1394		return error('injected rewrite failure')1395	}1396	return r.inner.put_if_match(key, body, tag)!1397}13981399fn (mut r StripRace) delete(key string) ! {1400	if r.fail_delete {1401		return error('injected delete failure')1402	}1403	r.inner.delete(key)!1404}14051406// A strip that finishes on another machine while an offer uploads has already1407// rewritten the remote without the offer's records, so the offer takes back1408// what the strip reaches and leaves head where it was.1409fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1410	base := scratch('strip_race')!1411	defer {1412		os.rmdir_all(base) or {}1413	}1414	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1415	mut a := open_repo(machine('strip_race_a')!)!1416	defer {1417		a.close()1418	}1419	mut b := open_repo(machine('strip_race_b')!)!1420	defer {1421		b.close()1422	}1423	put(a.root, 'secrets.env', 'api_key=1\n')!1424	first := make_save(a, 'leak the key')!1425	offer_now(a, mut backend)!1426	join(b, mut backend)!1427	put(a.root, 'secrets.env', 'api_key=2\n')!1428	later := make_save(a, 'rotate the key')!1429	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1430	mut race := StripRace{1431		inner: backend1432		other: &b1433		path:  'secrets.env'1434	}1435	if _ := offer(a, mut race) {1436		assert false, 'an offer that raced a strip must refuse'1437	} else {1438		assert err.msg().contains('koh steal'), err.msg()1439	}1440	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1441	assert !backend.get(object_key('proj', secret))!.found1442	// Another offer may already have published it, so the record stays, stripped.1443	assert backend.get(save_key('proj', later.id))!.found1444	for key in backend.list('proj/saves')! {1445		record := decode_save(backend.get(key)!.body)!1446		assert 'secrets.env' !in record.files.map(it.path), record.id1447	}1448}14491450// Doctor's resend of a missing record can race a strip just as an offer can,1451// and takes back what the strip reaches in the same way.1452fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1453	base := scratch('resend_race')!1454	defer {1455		os.rmdir_all(base) or {}1456	}1457	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1458	mut a := open_repo(machine('resend_race_a')!)!1459	defer {1460		a.close()1461	}1462	mut b := open_repo(machine('resend_race_b')!)!1463	defer {1464		b.close()1465	}1466	put(a.root, 'secrets.env', 'api_key=1\n')!1467	first := make_save(a, 'leak the key')!1468	offer_now(a, mut backend)!1469	join(b, mut backend)!1470	backend.delete(save_key('proj', first.id))!1471	mut race := StripRace{1472		inner: backend1473		other: &b1474		path:  'secrets.env'1475	}1476	if _ := resend(a, mut race, 'proj', [], [first.id]) {1477		assert false, 'a resend that raced a strip must refuse'1478	} else {1479		assert err.msg().contains('koh steal'), err.msg()1480		// The restored record is still a repair Doctor made.1481		assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1482	}1483	// Head names the restored record, so it stays, less the stripped path.1484	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1485	assert 'secrets.env' !in record.files.map(it.path)1486	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1487}14881489// An object a strip reaches stays while a remote record still lists it under1490// another path, and a strip that landed before an upload failed is still applied.1491fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1492	base := scratch('resend_shared')!1493	defer {1494		os.rmdir_all(base) or {}1495	}1496	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1497	mut a := open_repo(machine('resend_shared_a')!)!1498	defer {1499		a.close()1500	}1501	mut b := open_repo(machine('resend_shared_b')!)!1502	defer {1503		b.close()1504	}1505	put(a.root, 'secrets.env', 'same\n')!1506	put(a.root, 'copy.txt', 'same\n')!1507	first := make_save(a, 'two paths, one object')!1508	offer_now(a, mut backend)!1509	join(b, mut backend)!1510	put(a.root, 'later.txt', 'later\n')!1511	second := make_save(a, 'not offered')!1512	shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1513	backend.delete(save_key('proj', first.id))!1514	backend.delete(shared)!1515	mut race := StripRace{1516		inner:           backend1517		other:           &b1518		path:            'secrets.env'1519		fail_after_race: true1520	}1521	if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1522		assert false, 'the injected failure should have surfaced'1523	} else {1524		assert err.msg().contains('injected'), err.msg()1525	}1526	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1527	paths := record.files.map(it.path)1528	assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1529	assert backend.get(shared)!.found1530}15311532// A note that does not parse, or that no recorded strip of its path backs, is1533// kept and changes nothing remote: sending it would lose the retry or strip1534// where no strip asked.1535fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1536	base := scratch('strip_note')!1537	defer {1538		os.rmdir_all(base) or {}1539	}1540	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1541	mut a := open_repo(machine('strip_note_a')!)!1542	defer {1543		a.close()1544	}1545	put(a.root, 'app', 'app\n')!1546	save := make_save(a, 'app')!1547	offer_now(a, mut backend)!1548	events.strip(a, '', 'other')!1549	for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1550		a.set_state(pending_key('app'), note)!1551		if _ := send_strip(a, mut backend, 'app') {1552			assert false, 'an untrusted note must be refused'1553		} else {1554			assert err.msg().contains('kept for recovery'), err.msg()1555		}1556		assert a.state(pending_key('app')) or { '' } == note1557		assert backend.list(events_prefix('proj'))!.len == 01558		assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1559	}1560	// Even behind a recorded strip, a note's hashes may name only objects.1561	events.strip(a, '', 'app')!1562	victim := os.join_path(base, 'remote', 'victim', 'head')1563	os.mkdir_all(os.dir(victim))!1564	os.write_file(victim, 'another repo')!1565	a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1566	if _ := send_strip(a, mut backend, 'app') {1567		assert false, 'a note naming a path outside the objects must be refused'1568	}1569	assert os.read_file(victim)! == 'another repo'1570	// Nor may a remote record's path-shaped hash be noted or deleted.1571	a.remove_state(pending_key('app'))!1572	requeue_strip(a, 'app')!1573	backend.delete(save_key('proj', save.id))!1574	backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1575		id:    save.id1576		files: [FileRecord{1577			path: 'app'1578			hash: '../../victim/head'1579		}]1580	}).bytes())!1581	send_strip(a, mut backend, 'app')!1582	assert os.read_file(victim)! == 'another repo'1583}15841585// A settlement that stops at a record leaves it naming an object the strip1586// deleted. A machine behind must still steal past it, and the note written1587// first finishes the strip once this repo has it.1588fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1589	base := scratch('settle_record')!1590	defer {1591		os.rmdir_all(base) or {}1592	}1593	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1594	mut a := open_repo(machine('settle_record_a')!)!1595	defer {1596		a.close()1597	}1598	mut b := open_repo(machine('settle_record_b')!)!1599	defer {1600		b.close()1601	}1602	mut c := open_repo(machine('settle_record_c')!)!1603	defer {1604		c.close()1605	}1606	put(a.root, 'readme.txt', 'hello\n')!1607	make_save(a, 'start')!1608	offer_now(a, mut backend)!1609	join(c, mut backend)!1610	put(a.root, 'secrets.env', 'api_key=1\n')!1611	leak := make_save(a, 'leak the key')!1612	offer_now(a, mut backend)!1613	join(b, mut backend)!1614	secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1615	backend.delete(save_key('proj', leak.id))!1616	backend.delete(secret)!1617	mut race := StripRace{1618		inner:      backend1619		other:      &b1620		path:       'secrets.env'1621		fail_match: true1622	}1623	if _ := resend(a, mut race, 'proj', [], [leak.id]) {1624		assert false, 'the injected rewrite failure should have surfaced'1625	}1626	assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1627	steal_now(c, mut backend)!1628	assert history.head(c) or { history.Save{} }.id == leak.id1629	assert strip_pending(a, 'secrets.env')1630	steal_now(a, mut backend)!1631	send_strip(a, mut backend, 'secrets.env')!1632	cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1633	assert 'secrets.env' !in cleaned.files.map(it.path)1634	assert !backend.get(secret)!.found1635}16361637// With no record to leave behind, the note alone carries a resent object that1638// a strip removed meanwhile, until a send after steal deletes it.1639fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1640	base := scratch('settle_object')!1641	defer {1642		os.rmdir_all(base) or {}1643	}1644	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1645	mut a := open_repo(machine('settle_object_a')!)!1646	defer {1647		a.close()1648	}1649	mut b := open_repo(machine('settle_object_b')!)!1650	defer {1651		b.close()1652	}1653	put(a.root, 'secrets.env', 'api_key=1\n')!1654	make_save(a, 'leak the key')!1655	offer_now(a, mut backend)!1656	join(b, mut backend)!1657	hash := storage.hash_hex('api_key=1\n'.bytes())1658	backend.delete(object_key('proj', hash))!1659	mut race := StripRace{1660		inner:       backend1661		other:       &b1662		path:        'secrets.env'1663		fail_delete: true1664		on_objects:  true1665	}1666	if _ := resend(a, mut race, 'proj', [hash], []) {1667		assert false, 'the injected delete failure should have surfaced'1668	}1669	assert backend.get(object_key('proj', hash))!.found1670	assert strip_pending(a, 'secrets.env')1671	steal_now(a, mut backend)!1672	send_strip(a, mut backend, 'secrets.env')!1673	assert !backend.get(object_key('proj', hash))!.found1674	assert !strip_pending(a, 'secrets.env')1675}16761677// When the late check itself fails, what the upload sent is kept, and Doctor1678// deletes what the strip removed once a steal brings the strip here.1679fn test_an_unsettled_upload_is_finished_after_steal() {1680	base := scratch('unsettled')!1681	defer {1682		os.rmdir_all(base) or {}1683	}1684	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1685	mut a := open_repo(machine('unsettled_a')!)!1686	defer {1687		a.close()1688	}1689	mut b := open_repo(machine('unsettled_b')!)!1690	defer {1691		b.close()1692	}1693	put(a.root, 'secrets.env', 'api_key=1\n')!1694	make_save(a, 'leak the key')!1695	offer_now(a, mut backend)!1696	join(b, mut backend)!1697	hash := storage.hash_hex('api_key=1\n'.bytes())1698	backend.delete(object_key('proj', hash))!1699	mut race := StripRace{1700		inner:                  backend1701		other:                  &b1702		path:                   'secrets.env'1703		on_objects:             true1704		fail_events_after_race: true1705	}1706	if _ := resend(a, mut race, 'proj', [hash], []) {1707		assert false, 'the injected list failure should have surfaced'1708	}1709	assert unsettled(a)! == [hash]1710	if _ := settle_unsettled(a, mut backend, 'proj') {1711		assert false, 'settling before the strip is here would keep its bytes'1712	}1713	steal_now(a, mut backend)!1714	settle_unsettled(a, mut backend, 'proj')!1715	assert !backend.get(object_key('proj', hash))!.found1716	assert unsettled(a)!.len == 01717}17181719// Settling never deletes what a local save still names, even when the local1720// copy is damaged, and reports what it deleted before a failure.1721fn test_settling_an_upload_keeps_named_objects_and_reports_deletions() {1722	base := scratch('settle_named')!1723	defer {1724		os.rmdir_all(base) or {}1725	}1726	remote_dir := os.join_path(base, 'remote')1727	mut backend := remote.new_folder(remote_dir)!1728	mut a := open_repo(machine('settle_named_a')!)!1729	defer {1730		a.close()1731	}1732	put(a.root, 'app', 'app\n')!1733	save := make_save(a, 'app')!1734	offer_now(a, mut backend)!1735	app := storage.hash_hex('app\n'.bytes())1736	gone := ['gone one', 'gone two'].map(storage.hash_hex(it.bytes()))1737	for hash in gone {1738		backend.put_if_absent(object_key('proj', hash), 'stripped'.bytes())!1739	}1740	backend.delete(save_key('proj', save.id))!1741	a.db.exec('PRAGMA foreign_keys=OFF')!1742	a.db.exec("DELETE FROM objects WHERE hash = '${app}'")!1743	a.set_state(unsettled_key, json2.encode([app, gone[0], gone[1]]))!1744	mut flaky := FaultyBackend{1745		inner:             remote.new_folder(remote_dir)!1746		fail_delete_after: 11747	}1748	if _ := settle_unsettled(a, mut flaky, 'proj') {1749		assert false, 'the injected delete failure should have surfaced'1750	} else {1751		assert (err as Partway).done == 'deleted 1 remote object', (err as Partway).done1752	}1753	assert backend.get(object_key('proj', app))!.found1754	assert unsettled(a)!.len == 31755}17561757// Deleting a damaged remote copy is work done, even when its replacement fails.1758fn test_a_resend_reports_a_damaged_copy_it_deleted() {1759	base := scratch('resend_deleted')!1760	defer {1761		os.rmdir_all(base) or {}1762	}1763	remote_dir := os.join_path(base, 'remote')1764	mut backend := remote.new_folder(remote_dir)!1765	mut a := open_repo(machine('resend_deleted_a')!)!1766	defer {1767		a.close()1768	}1769	put(a.root, 'app', 'app\n')!1770	make_save(a, 'app')!1771	hash := storage.hash_hex('app\n'.bytes())1772	backend.put_if_absent(object_key('proj', hash), 'damaged'.bytes())!1773	mut flaky := FaultyBackend{1774		inner:      remote.new_folder(remote_dir)!1775		fail_write: 11776	}1777	if _ := resend(a, mut flaky, 'proj', [hash], []) {1778		assert false, 'the injected write failure should have surfaced'1779	} else {1780		assert (err as Partway).done == 'deleted 1 damaged remote object', (err as Partway).done1781	}1782}17831784// A strip that sent its event before failing says so.1785fn test_a_strip_that_fails_after_its_event_reports_it() {1786	base := scratch('strip_event_partway')!1787	defer {1788		os.rmdir_all(base) or {}1789	}1790	remote_dir := os.join_path(base, 'remote')1791	mut backend := remote.new_folder(remote_dir)!1792	mut a := open_repo(machine('strip_event_partway_a')!)!1793	defer {1794		a.close()1795	}1796	put(a.root, 'secrets.env', 'api_key=1\n')!1797	make_save(a, 'leak the key')!1798	offer_now(a, mut backend)!1799	strip_local(a, 'secrets.env', '')!1800	mut flaky := FaultyBackend{1801		inner:     remote.new_folder(remote_dir)!1802		fail_list: '/saves'1803	}1804	if _ := send_strip(a, mut flaky, 'secrets.env') {1805		assert false, 'the injected list failure should have surfaced'1806	} else {1807		assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1808	}1809}18101811// A strip whose deletes fail after its rewrite keeps, in its note, the objects1812// the rewritten record no longer names, so the retry still deletes them.1813fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1814	base := scratch('strip_found')!1815	defer {1816		os.rmdir_all(base) or {}1817	}1818	remote_dir := os.join_path(base, 'remote')1819	mut backend := remote.new_folder(remote_dir)!1820	mut a := open_repo(machine('strip_found_a')!)!1821	defer {1822		a.close()1823	}1824	put(a.root, 'secrets.env', 'api_key=1\n')!1825	make_save(a, 'leak the key')!1826	offer_now(a, mut backend)!1827	strip_local(a, 'secrets.env', '')!1828	// A note rebuilt from the event alone, as Doctor's residue repair writes it.1829	a.remove_state(pending_key('secrets.env'))!1830	requeue_strip(a, 'secrets.env')!1831	mut flaky := FaultyBackend{1832		inner:       remote.new_folder(remote_dir)!1833		fail_delete: true1834	}1835	if _ := send_strip(a, mut flaky, 'secrets.env') {1836		assert false, 'the injected delete failure should have surfaced'1837	}1838	send_strip(a, mut backend, 'secrets.env')!1839	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1840	assert !strip_pending(a, 'secrets.env')1841}18421843// MovingRecord stands in for another machine rewriting a record between this1844// machine's read and its conditional write: the first conditional write finds1845// `other` already removed from the record.1846struct MovingRecord {1847mut:1848	inner remote.Backend1849	other string1850	moved bool1851}18521853fn (mut m MovingRecord) get(key string) !remote.Object {1854	return m.inner.get(key)!1855}18561857fn (mut m MovingRecord) list(prefix string) ![]string {1858	return m.inner.list(prefix)!1859}18601861fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1862	return m.inner.put_if_absent(key, body)!1863}18641865fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1866	if !m.moved {1867		m.moved = true1868		current := m.inner.get(key)!1869		record := decode_save(current.body)!1870		other := record.files.filter(it.path != m.other)1871		m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1872	}1873	return m.inner.put_if_match(key, body, tag)!1874}18751876fn (mut m MovingRecord) delete(key string) ! {1877	m.inner.delete(key)!1878}18791880fn test_a_stripped_record_that_moves_is_read_again() {1881	base := scratch('moving_record')!1882	defer {1883		os.rmdir_all(base) or {}1884	}1885	mut folder := remote.new_folder(os.join_path(base, 'remote'))!1886	key := save_key('proj', 'aaaaaaaaaaaa')1887	folder.put_if_absent(key, encode_save(SaveRecord{1888		id:    'aaaaaaaaaaaa'1889		files: [FileRecord{1890			path: 'secret-a'1891		}, FileRecord{1892			path: 'secret-b'1893		}, FileRecord{1894			path: 'keep'1895		}]1896	}).bytes())!1897	mut moving := MovingRecord{1898		inner: folder1899		other: 'secret-b'1900	}1901	strip_record(mut moving, key, 'aaaaaaaaaaaa', [1902		events.Event{1903			kind: events.kind_strip1904			path: 'secret-a'1905		},1906	])!1907	assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1908}19091910// An invalid strip event on the remote cannot make an upload take anything back.1911fn test_an_invalid_remote_strip_event_is_not_unapplied() {1912	base := scratch('invalid_remote_strip')!1913	defer {1914		os.rmdir_all(base) or {}1915	}1916	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1917	mut a := open_repo(machine('invalid_remote_strip_a')!)!1918	defer {1919		a.close()1920	}1921	bad := events.Event{1922		id:         'badstripevnt'1923		kind:       events.kind_strip1924		path:       'app'1925		created_at: -11926		machine:    'other'1927	}1928	backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1929	assert unapplied_strips(a, mut backend, 'proj')!.len == 01930}19311932// A resend that fails part way reports what it had already uploaded.1933fn test_a_failed_resend_reports_what_landed() {1934	base := scratch('resend_partway')!1935	defer {1936		os.rmdir_all(base) or {}1937	}1938	remote_dir := os.join_path(base, 'remote')1939	mut a := open_repo(machine('resend_partway_a')!)!1940	defer {1941		a.close()1942	}1943	put(a.root, 'one.txt', 'one\n')!1944	put(a.root, 'two.txt', 'two\n')!1945	make_save(a, 'two objects')!1946	hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1947	mut flaky := FaultyBackend{1948		inner:      remote.new_folder(remote_dir)!1949		fail_write: 21950	}1951	if _ := resend(a, mut flaky, 'proj', hashes, []) {1952		assert false, 'the injected failure should have surfaced'1953	} else {1954		assert err is Partway1955		assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1956	}1957}19581959// The local half lands before the remote hears anything, so a remote failure1960// leaves nothing to find locally; the pending note is what lets a re-run1961// finish. Objects a remote-only save still needs must survive the strip.1962fn test_an_interrupted_remote_strip_finishes_when_re_run() {1963	base := scratch('strip_resume')!1964	defer {1965		os.rmdir_all(base) or {}1966	}1967	remote_dir := os.join_path(base, 'remote')1968	mut backend := remote.new_folder(remote_dir)!1969	mut a := open_repo(machine('resume_a')!)!1970	defer {1971		a.close()1972	}1973	mut b := open_repo(machine('resume_b')!)!1974	defer {1975		b.close()1976	}1977	put(a.root, 'secrets.env', 'api_key=1\n')!1978	put(a.root, 'shared.env', 'same bytes on B\n')!1979	save := make_save(a, 'add secrets')!1980	offer_now(a, mut backend)!1981	join(b, mut backend)!1982	put(b.root, 'copy.txt', 'same bytes on B\n')!1983	b_save := make_save(b, 'B keeps a copy')!1984	offer_now(b, mut backend)!1985	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1986	secret_hash := storage.hash_hex('api_key=1\n'.bytes())19871988	strip_local(a, 'secrets.env', '')!1989	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1990	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1991	mut flaky := FaultyBackend{1992		inner:      remote.new_folder(remote_dir)!1993		fail_write: 31994	}1995	if _ := send_strip(a, mut flaky, 'secrets.env') {1996		assert false, 'the injected failure should have surfaced'1997	}1998	assert strip_pending(a, 'secrets.env')1999	assert strip_local(a, 'secrets.env', '')!.files_removed == 02000	report := send_strip(a, mut backend, 'secrets.env')!2001	assert report.records == 12002	assert !strip_pending(a, 'secrets.env')2003	assert !backend.get(object_key('proj', secret_hash))!.found2004	record := decode_save(backend.get(save_key('proj', save.id))!.body)!2005	assert 'secrets.env' !in record.files.map(it.path)2006	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!2007	assert 'secrets.env' !in b_record.files.map(it.path)20082009	// B's save still names the shared bytes, so stripping A's copy keeps them.2010	strip_local(a, 'shared.env', '')!2011	send_strip(a, mut backend, 'shared.env')!2012	assert backend.get(object_key('proj', shared_hash))!.found2013}20142015// A machine that has not heard of a strip must not offer: its records still2016// list the stripped path, and nothing would rewrite them on the remote again.2017fn test_offer_waits_for_an_unapplied_strip() {2018	base := scratch('strip_first')!2019	defer {2020		os.rmdir_all(base) or {}2021	}2022	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2023	mut a := open_repo(machine('strip_first_a')!)!2024	defer {2025		a.close()2026	}2027	mut b := open_repo(machine('strip_first_b')!)!2028	defer {2029		b.close()2030	}2031	put(a.root, 'secrets.env', 'api_key=1\n')!2032	make_save(a, 'add secrets')!2033	offer_now(a, mut backend)!2034	join(b, mut backend)!2035	strip_local(a, 'secrets.env', '')!2036	send_strip(a, mut backend, 'secrets.env')!2037	put(b.root, 'more.txt', 'B keeps working\n')!2038	later := make_save(b, 'B saves with the secret still in its tree')!2039	if _ := offer_now(b, mut backend) {2040		assert false, 'B offered records that still list the stripped path'2041	}2042	steal_now(b, mut backend)!2043	offer_now(b, mut backend)!2044	record := decode_save(backend.get(save_key('proj', later.id))!.body)!2045	assert 'secrets.env' !in record.files.map(it.path)2046}20472048// A strip whose object deletions failed must still delete them when it is run2049// again, and meanwhile the banned path cannot come back in through a save.2050fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {2051	base := scratch('strip_twice')!2052	defer {2053		os.rmdir_all(base) or {}2054	}2055	remote_dir := os.join_path(base, 'remote')2056	mut backend := remote.new_folder(remote_dir)!2057	mut a := open_repo(machine('strip_twice_a')!)!2058	defer {2059		a.close()2060	}2061	put(a.root, 'secrets.env', 'first key\n')!2062	make_save(a, 'first key')!2063	offer_now(a, mut backend)!2064	strip_local(a, 'secrets.env', '')!2065	mut flaky := FaultyBackend{2066		inner:       remote.new_folder(remote_dir)!2067		fail_delete: true2068	}2069	if _ := send_strip(a, mut flaky, 'secrets.env') {2070		assert false, 'the injected delete failure should have surfaced'2071	}2072	put(a.root, 'secrets.env', 'second key\n')!2073	put(a.root, 'other.txt', 'more work\n')!2074	make_save(a, 'more work')!2075	offer_now(a, mut backend)!2076	send_strip(a, mut backend, 'secrets.env')!2077	for key in ['first key\n', 'second key\n'] {2078		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found2079	}2080}20812082// Unfinished strips of one path in different scopes finish together.2083fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {2084	base := scratch('strip_scopes')!2085	defer {2086		os.rmdir_all(base) or {}2087	}2088	remote_dir := os.join_path(base, 'remote')2089	mut backend := remote.new_folder(remote_dir)!2090	mut a := open_repo(machine('strip_scopes_a')!)!2091	defer {2092		a.close()2093	}2094	put(a.root, 'secrets.env', 'first key\n')!2095	first := make_save(a, 'first key')!2096	put(a.root, 'secrets.env', 'second key\n')!2097	second := make_save(a, 'second key')!2098	offer_now(a, mut backend)!2099	strip_local(a, 'secrets.env', first.id)!2100	// Write 1 uploads the event; write 2, the record rewrite, fails.2101	mut flaky := FaultyBackend{2102		inner:      remote.new_folder(remote_dir)!2103		fail_write: 22104	}2105	if _ := send_strip(a, mut flaky, 'secrets.env') {2106		assert false, 'the injected failure should have surfaced'2107	}2108	strip_local(a, 'secrets.env', second.id)!2109	send_strip(a, mut backend, 'secrets.env')!2110	assert !strip_pending(a, 'secrets.env')2111	for save in [first, second] {2112		record := decode_save(backend.get(save_key('proj', save.id))!.body)!2113		assert 'secrets.env' !in record.files.map(it.path)2114	}2115	for key in ['first key\n', 'second key\n'] {2116		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found2117	}2118}21192120// A strip bans its path on every machine that has heard of it: written again,2121// the file makes no save, here or there, so there is never a second copy to strip.2122fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {2123	base := scratch('strip_again')!2124	defer {2125		os.rmdir_all(base) or {}2126	}2127	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2128	mut a := open_repo(machine('strip_again_a')!)!2129	defer {2130		a.close()2131	}2132	mut b := open_repo(machine('strip_again_b')!)!2133	defer {2134		b.close()2135	}2136	put(a.root, 'secrets.env', 'first key\n')!2137	make_save(a, 'first key')!2138	offer_now(a, mut backend)!2139	join(b, mut backend)!2140	strip_local(a, 'secrets.env', '')!2141	send_strip(a, mut backend, 'secrets.env')!2142	steal_now(b, mut backend)!2143	for repo in [a, b] {2144		put(repo.root, 'secrets.env', 'second key\n')!2145		if _ := make_save(repo, 'second key') {2146			assert false, 'a banned path must not make a save'2147		}2148	}2149}21502151// A clash on a path the other machine stripped is no clash: the strip removes2152// the path from every save, so there is nothing to choose.2153fn test_a_stripped_path_is_not_asked_about() {2154	base := scratch('clash_strip')!2155	defer {2156		os.rmdir_all(base) or {}2157	}2158	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2159	mut a := open_repo(machine('clash_strip_a')!)!2160	defer {2161		a.close()2162	}2163	mut b := open_repo(machine('clash_strip_b')!)!2164	defer {2165		b.close()2166	}2167	put(a.root, 'secrets.env', 'key one\n')!2168	make_save(a, 'base')!2169	offer_now(a, mut backend)!2170	join(b, mut backend)!2171	put(a.root, 'secrets.env', 'key two\n')!2172	make_save(a, 'A rotates')!2173	offer_now(a, mut backend)!2174	strip_local(a, 'secrets.env', '')!2175	send_strip(a, mut backend, 'secrets.env')!2176	put(b.root, 'secrets.env', 'key three\n')!2177	mine := make_save(b, 'B rotates')!2178	report := steal(b, mut backend, StealOptions{})!2179	assert report.clashes.len == 02180	for save in history.all_saves(b)! {2181		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)2182	}2183	assert history.by_id(b, mine.id) != none2184}21852186// A strip scoped to the shared base save changes what the replay sees; the2187// clashes must be found on the same trees, or the replay meets a conflict.2188fn test_a_strip_of_the_shared_base_is_seen_before_asking() {2189	base := scratch('clash_base_strip')!2190	defer {2191		os.rmdir_all(base) or {}2192	}2193	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2194	mut a := open_repo(machine('base_strip_a')!)!2195	defer {2196		a.close()2197	}2198	mut b := open_repo(machine('base_strip_b')!)!2199	defer {2200		b.close()2201	}2202	put(a.root, 'notes.txt', 'shared\n')!2203	common := make_save(a, 'base')!2204	offer_now(a, mut backend)!2205	join(b, mut backend)!2206	put(a.root, 'other.txt', 'A moves on\n')!2207	make_save(a, 'A moves on')!2208	offer_now(a, mut backend)!2209	strip_local(a, 'notes.txt', common.id)!2210	send_strip(a, mut backend, 'notes.txt')!2211	put(b.root, 'notes.txt', 'B edits\n')!2212	make_save(b, 'B edits notes')!2213	if _ := steal(b, mut backend, StealOptions{}) {2214		assert false, 'with the base stripped, notes.txt differs on both sides'2215	} else {2216		assert err.msg().contains('notes.txt changed on both machines')2217	}2218	report := steal(b, mut backend, StealOptions{2219		mine: true2220	})!2221	assert report.clashes == ['notes.txt']2222	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'2223}22242225// A folder with files but no saves has nothing to combine with; a steal that2226// fast-forwarded into it would overwrite whatever shares a name.2227fn test_steal_into_a_folder_with_unsaved_files_refuses() {2228	base := scratch('steal_unsaved')!2229	defer {2230		os.rmdir_all(base) or {}2231	}2232	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2233	mut a := open_repo(machine('unsaved_a')!)!2234	defer {2235		a.close()2236	}2237	put(a.root, 'notes.txt', 'from A\n')!2238	make_save(a, 'A writes')!2239	offer_now(a, mut backend)!2240	b_root := machine('unsaved_b')!2241	mut b := storage.create(b_root)!2242	defer {2243		b.close()2244	}2245	put(b_root, 'notes.txt', 'never saved\n')!2246	// Only a repo that already knows its remote name gets this far.2247	b.set_state('remote_repo', 'proj')!2248	if _ := steal(b, mut backend, StealOptions{}) {2249		assert false, 'steal overwrote a folder with unsaved files'2250	} else {2251		assert err.msg() == no_saves_here2252	}2253	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'2254	// A fresh `koh init` folder, holding only the default ignore list, is fine.2255	os.rm(os.join_path(b_root, 'notes.txt'))!2256	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!2257	steal(b, mut backend, StealOptions{})!2258	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'2259}22602261// The document asks steal to warn about every incoming flagged save, so the2262// report names them.2263fn test_steal_reports_incoming_flagged_saves() {2264	base := scratch('flag_warn')!2265	defer {2266		os.rmdir_all(base) or {}2267	}2268	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2269	mut a := open_repo(machine('flag_warn_a')!)!2270	defer {2271		a.close()2272	}2273	mut b := open_repo(machine('flag_warn_b')!)!2274	defer {2275		b.close()2276	}2277	put(a.root, 'a.txt', 'one\n')!2278	make_save(a, 'fine')!2279	offer_now(a, mut backend)!2280	join(b, mut backend)!2281	put(a.root, 'a.txt', 'two\n')!2282	suspect := make_save(a, 'suspect')!2283	put(a.root, 'a.txt', 'three\n')!2284	make_save(a, 'also fine')!2285	events.flag(a, suspect.id, 'broke the build')!2286	offer_now(a, mut backend)!2287	report := steal_now(b, mut backend)!2288	assert report.flagged.map(it.save_id) == [suspect.id]2289	assert report.flagged[0].reason == 'broke the build'2290}22912292// head_of unwraps the head for tests that know the repo has saves.2293fn head_of(store &storage.Store) history.Save {2294	return history.head(store) or { panic('this repo has no head') }2295}22962297// A remote says what it is, so two koh versions never half-understand one2298// bucket. The first offer of a repo records the layout.2299fn test_the_first_offer_records_the_remote_format() {2300	base := scratch('format_write')!2301	defer {2302		os.rmdir_all(base) or {}2303	}2304	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2305	mut a := open_repo(machine('format_write_a')!)!2306	defer {2307		a.close()2308	}2309	put(a.root, 'a.txt', 'one\n')!2310	make_save(a, 'first')!2311	offer_now(a, mut backend)!2312	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2313}23142315// A repo offered before the key existed is version 1: it still syncs, and the2316// next offer records the key then.2317fn test_a_remote_without_a_format_still_syncs() {2318	base := scratch('format_old')!2319	defer {2320		os.rmdir_all(base) or {}2321	}2322	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2323	mut a := open_repo(machine('format_old_a')!)!2324	defer {2325		a.close()2326	}2327	put(a.root, 'a.txt', 'one\n')!2328	make_save(a, 'first')!2329	offer_now(a, mut backend)!2330	// Stand in for a bucket offered before the key existed.2331	backend.delete(format_key('proj'))!2332	mut b := open_repo(machine('format_old_b')!)!2333	defer {2334		b.close()2335	}2336	join(b, mut backend)!2337	assert head_of(b).id == head_of(a).id2338	put(a.root, 'a.txt', 'two\n')!2339	make_save(a, 'second')!2340	offer_now(a, mut backend)!2341	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2342	report := steal_now(b, mut backend)!2343	assert report.head == head_of(a).id2344}23452346// A remote that speaks another layout is refused loudly, on offer and on2347// steal, rather than half-read.2348fn test_an_unknown_remote_format_is_refused() {2349	base := scratch('format_unknown')!2350	defer {2351		os.rmdir_all(base) or {}2352	}2353	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2354	mut a := open_repo(machine('format_bad_a')!)!2355	defer {2356		a.close()2357	}2358	put(a.root, 'a.txt', 'one\n')!2359	make_save(a, 'first')!2360	offer_now(a, mut backend)!2361	backend.delete(format_key('proj'))!2362	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2363	if _ := offer_now(a, mut backend) {2364		assert false, 'offer must refuse a format it does not speak'2365	} else {2366		assert err.msg().contains('koh-remote 2')2367	}2368	if _ := steal_now(a, mut backend) {2369		assert false, 'steal must refuse a format it does not speak'2370	} else {2371		assert err.msg().contains('koh-remote 2')2372	}2373	if _ := fetch_repo(mut backend, 'proj') {2374		assert false, 'a steal must refuse a format it does not speak'2375	} else {2376		assert err.msg().contains('koh-remote 2')2377	}2378}23792380// Two machines offering a new repo at once both find no format key; the loser2381// of the conditional write must read back the winner's value and refuse it,2382// not carry on against a remote it cannot read.2383fn test_a_lost_format_write_is_read_back_and_refused() {2384	base := scratch('format_race')!2385	defer {2386		os.rmdir_all(base) or {}2387	}2388	mut inner := remote.new_folder(os.join_path(base, 'remote'))!2389	mut a := open_repo(machine('format_race_a')!)!2390	defer {2391		a.close()2392	}2393	put(a.root, 'a.txt', 'one\n')!2394	make_save(a, 'first')!2395	mut backend := remote.Backend(FaultyBackend{2396		inner:       inner2397		race_format: true2398	})2399	if _ := offer_now(a, mut backend) {2400		assert false, 'offer must refuse the format another machine wrote first'2401	} else {2402		assert err.msg().contains('koh-remote 2')2403	}2404}24052406fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2407	hash_a := 'a'.repeat(64)2408	hash_b := 'b'.repeat(64)2409	whole := 'objects/${hash_a} 3\nabcobjects/${hash_b} 0\nend 0\n'2410	for size in [1, 2, 5, whole.len] {2411		mut bundle := Bundle{}2412		for at := 0; at < whole.len; at += size {2413			bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2414		}2415		bundle.finish()!2416		assert bundle.fetched.repo_objects == {2417			hash_a: 'abc'.bytes()2418			hash_b: []u8{}2419		}2420	}2421	for cut in ['objects/${hash_a} 3\nabc', 'objects/${hash_a} 4\nabc', 'objects/${hash_a} 3',2422		'objects/${hash_a} x\nabc', 'releases/v1 1\nx', 'end 0\nobjects/${hash_a} 3\nabc'] {2423		mut bundle := Bundle{}2424		bundle.feed(cut.bytes()) or { continue }2425		bundle.finish() or { continue }2426		assert false, cut2427	}2428}24292430fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2431	mut store := open_repo(machine('strip_batch')!)!2432	defer { store.close() }2433	put(store.root, 'private/a', 'a')!2434	put(store.root, 'secret', 'secret')!2435	put(store.root, 'keep', 'keep')!2436	save := make_save(store, 'initial')!2437	store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2438		WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2439	if _ := strip_local_batch(store, ['private', 'secret'], '') {2440		assert false, 'batch succeeded despite the second path failing'2441	}2442	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2443		'secret']2444	assert events.strip_events(store)!.len == 02445	assert !strip_pending(store, 'private')2446	store.db.exec('DROP TRIGGER block_secret')!2447	reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2448	assert reports.map(it.files_removed) == [1, 0, 1]2449	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2450	assert events.strip_events(store)!.len == 22451}24522453fn test_strip_batch_limits_every_path_to_selected_save() {2454	mut store := open_repo(machine('strip_batch_scope')!)!2455	defer { store.close() }2456	put(store.root, 'one', 'one')!2457	put(store.root, 'two', 'two')!2458	first := make_save(store, 'initial')!2459	put(store.root, 'keep', 'keep')!2460	second := make_save(store, 'next')!2461	strip_local_batch(store, ['one', 'two'], first.id)!2462	assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2463	assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2464	assert events.strip_events(store)!.all(it.save_id == first.id)2465}24662467fn test_strip_batch_keeps_independent_remote_retry_notes() {2468	mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2469	mut store := open_repo(machine('strip_batch_retry')!)!2470	defer { store.close() }2471	put(store.root, 'one', 'secret')!2472	put(store.root, 'two', 'secret')!2473	save := make_save(store, 'initial')!2474	offer_now(store, mut backend)!2475	strip_local_batch(store, ['one', 'two'], '')!2476	mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2477	if _ := send_strip(store, mut flaky, 'one') {2478		assert false, 'injected remote failure was not reported'2479	}2480	send_strip(store, mut backend, 'two')!2481	assert strip_pending(store, 'one') && !strip_pending(store, 'two')2482	partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2483	assert partial.files.map(it.path) == ['.kohignore', 'one']2484	assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2485	send_strip(store, mut backend, 'one')!2486	assert !strip_pending(store, 'one')2487	finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2488	assert finished.files.map(it.path) == ['.kohignore']2489	assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2490}