exchange_test.v

69.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_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// A strip that sent its event before failing says so.1534fn test_a_strip_that_fails_after_its_event_reports_it() {1535	base := scratch('strip_event_partway')!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('strip_event_partway_a')!)!1542	defer {1543		a.close()1544	}1545	put(a.root, 'secrets.env', 'api_key=1\n')!1546	make_save(a, 'leak the key')!1547	offer_now(a, mut backend)!1548	strip_local(a, 'secrets.env', '')!1549	mut flaky := FaultyBackend{1550		inner:     remote.new_folder(remote_dir)!1551		fail_list: '/saves'1552	}1553	if _ := send_strip(a, mut flaky, 'secrets.env') {1554		assert false, 'the injected list failure should have surfaced'1555	} else {1556		assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1557	}1558}15591560// A strip whose deletes fail after its rewrite keeps, in its note, the objects1561// the rewritten record no longer names, so the retry still deletes them.1562fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1563	base := scratch('strip_found')!1564	defer {1565		os.rmdir_all(base) or {}1566	}1567	remote_dir := os.join_path(base, 'remote')1568	mut backend := remote.new_folder(remote_dir)!1569	mut a := open_repo(machine('strip_found_a')!)!1570	defer {1571		a.close()1572	}1573	put(a.root, 'secrets.env', 'api_key=1\n')!1574	make_save(a, 'leak the key')!1575	offer_now(a, mut backend)!1576	strip_local(a, 'secrets.env', '')!1577	// A note rebuilt from the event alone, as Doctor's residue repair writes it.1578	a.remove_state(pending_key('secrets.env'))!1579	requeue_strip(a, 'secrets.env')!1580	mut flaky := FaultyBackend{1581		inner:       remote.new_folder(remote_dir)!1582		fail_delete: true1583	}1584	if _ := send_strip(a, mut flaky, 'secrets.env') {1585		assert false, 'the injected delete failure should have surfaced'1586	}1587	send_strip(a, mut backend, 'secrets.env')!1588	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1589	assert !strip_pending(a, 'secrets.env')1590}15911592// MovingRecord stands in for another machine rewriting a record between this1593// machine's read and its conditional write: the first conditional write finds1594// `other` already removed from the record.1595struct MovingRecord {1596mut:1597	inner remote.Backend1598	other string1599	moved bool1600}16011602fn (mut m MovingRecord) get(key string) !remote.Object {1603	return m.inner.get(key)!1604}16051606fn (mut m MovingRecord) list(prefix string) ![]string {1607	return m.inner.list(prefix)!1608}16091610fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1611	return m.inner.put_if_absent(key, body)!1612}16131614fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1615	if !m.moved {1616		m.moved = true1617		current := m.inner.get(key)!1618		record := decode_save(current.body)!1619		other := record.files.filter(it.path != m.other)1620		m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1621	}1622	return m.inner.put_if_match(key, body, tag)!1623}16241625fn (mut m MovingRecord) delete(key string) ! {1626	m.inner.delete(key)!1627}16281629fn test_a_stripped_record_that_moves_is_read_again() {1630	base := scratch('moving_record')!1631	defer {1632		os.rmdir_all(base) or {}1633	}1634	mut folder := remote.new_folder(os.join_path(base, 'remote'))!1635	key := save_key('proj', 'aaaaaaaaaaaa')1636	folder.put_if_absent(key, encode_save(SaveRecord{1637		id:    'aaaaaaaaaaaa'1638		files: [FileRecord{1639			path: 'secret-a'1640		}, FileRecord{1641			path: 'secret-b'1642		}, FileRecord{1643			path: 'keep'1644		}]1645	}).bytes())!1646	mut moving := MovingRecord{1647		inner: folder1648		other: 'secret-b'1649	}1650	strip_record(mut moving, key, 'aaaaaaaaaaaa', [1651		events.Event{1652			kind: events.kind_strip1653			path: 'secret-a'1654		},1655	])!1656	assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1657}16581659// An invalid strip event on the remote cannot make an upload take anything back.1660fn test_an_invalid_remote_strip_event_is_not_unapplied() {1661	base := scratch('invalid_remote_strip')!1662	defer {1663		os.rmdir_all(base) or {}1664	}1665	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1666	mut a := open_repo(machine('invalid_remote_strip_a')!)!1667	defer {1668		a.close()1669	}1670	bad := events.Event{1671		id:         'badstripevnt'1672		kind:       events.kind_strip1673		path:       'app'1674		created_at: -11675		machine:    'other'1676	}1677	backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1678	assert unapplied_strips(a, mut backend, 'proj')!.len == 01679}16801681// A resend that fails part way reports what it had already uploaded.1682fn test_a_failed_resend_reports_what_landed() {1683	base := scratch('resend_partway')!1684	defer {1685		os.rmdir_all(base) or {}1686	}1687	remote_dir := os.join_path(base, 'remote')1688	mut a := open_repo(machine('resend_partway_a')!)!1689	defer {1690		a.close()1691	}1692	put(a.root, 'one.txt', 'one\n')!1693	put(a.root, 'two.txt', 'two\n')!1694	make_save(a, 'two objects')!1695	hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1696	mut flaky := FaultyBackend{1697		inner:      remote.new_folder(remote_dir)!1698		fail_write: 21699	}1700	if _ := resend(a, mut flaky, 'proj', hashes, []) {1701		assert false, 'the injected failure should have surfaced'1702	} else {1703		assert err is Partway1704		assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1705	}1706}17071708// The local half lands before the remote hears anything, so a remote failure1709// leaves nothing to find locally; the pending note is what lets a re-run1710// finish. Objects a remote-only save still needs must survive the strip.1711fn test_an_interrupted_remote_strip_finishes_when_re_run() {1712	base := scratch('strip_resume')!1713	defer {1714		os.rmdir_all(base) or {}1715	}1716	remote_dir := os.join_path(base, 'remote')1717	mut backend := remote.new_folder(remote_dir)!1718	mut a := open_repo(machine('resume_a')!)!1719	defer {1720		a.close()1721	}1722	mut b := open_repo(machine('resume_b')!)!1723	defer {1724		b.close()1725	}1726	put(a.root, 'secrets.env', 'api_key=1\n')!1727	put(a.root, 'shared.env', 'same bytes on B\n')!1728	save := make_save(a, 'add secrets')!1729	offer_now(a, mut backend)!1730	join(b, mut backend)!1731	put(b.root, 'copy.txt', 'same bytes on B\n')!1732	b_save := make_save(b, 'B keeps a copy')!1733	offer_now(b, mut backend)!1734	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1735	secret_hash := storage.hash_hex('api_key=1\n'.bytes())17361737	strip_local(a, 'secrets.env', '')!1738	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1739	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1740	mut flaky := FaultyBackend{1741		inner:      remote.new_folder(remote_dir)!1742		fail_write: 31743	}1744	if _ := send_strip(a, mut flaky, 'secrets.env') {1745		assert false, 'the injected failure should have surfaced'1746	}1747	assert strip_pending(a, 'secrets.env')1748	assert strip_local(a, 'secrets.env', '')!.files_removed == 01749	report := send_strip(a, mut backend, 'secrets.env')!1750	assert report.records == 11751	assert !strip_pending(a, 'secrets.env')1752	assert !backend.get(object_key('proj', secret_hash))!.found1753	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1754	assert 'secrets.env' !in record.files.map(it.path)1755	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1756	assert 'secrets.env' !in b_record.files.map(it.path)17571758	// B's save still names the shared bytes, so stripping A's copy keeps them.1759	strip_local(a, 'shared.env', '')!1760	send_strip(a, mut backend, 'shared.env')!1761	assert backend.get(object_key('proj', shared_hash))!.found1762}17631764// A machine that has not heard of a strip must not offer: its records still1765// list the stripped path, and nothing would rewrite them on the remote again.1766fn test_offer_waits_for_an_unapplied_strip() {1767	base := scratch('strip_first')!1768	defer {1769		os.rmdir_all(base) or {}1770	}1771	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1772	mut a := open_repo(machine('strip_first_a')!)!1773	defer {1774		a.close()1775	}1776	mut b := open_repo(machine('strip_first_b')!)!1777	defer {1778		b.close()1779	}1780	put(a.root, 'secrets.env', 'api_key=1\n')!1781	make_save(a, 'add secrets')!1782	offer_now(a, mut backend)!1783	join(b, mut backend)!1784	strip_local(a, 'secrets.env', '')!1785	send_strip(a, mut backend, 'secrets.env')!1786	put(b.root, 'more.txt', 'B keeps working\n')!1787	later := make_save(b, 'B saves with the secret still in its tree')!1788	if _ := offer_now(b, mut backend) {1789		assert false, 'B offered records that still list the stripped path'1790	}1791	steal_now(b, mut backend)!1792	offer_now(b, mut backend)!1793	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1794	assert 'secrets.env' !in record.files.map(it.path)1795}17961797// A strip whose object deletions failed must still delete them when it is run1798// again, and meanwhile the banned path cannot come back in through a save.1799fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1800	base := scratch('strip_twice')!1801	defer {1802		os.rmdir_all(base) or {}1803	}1804	remote_dir := os.join_path(base, 'remote')1805	mut backend := remote.new_folder(remote_dir)!1806	mut a := open_repo(machine('strip_twice_a')!)!1807	defer {1808		a.close()1809	}1810	put(a.root, 'secrets.env', 'first key\n')!1811	make_save(a, 'first key')!1812	offer_now(a, mut backend)!1813	strip_local(a, 'secrets.env', '')!1814	mut flaky := FaultyBackend{1815		inner:       remote.new_folder(remote_dir)!1816		fail_delete: true1817	}1818	if _ := send_strip(a, mut flaky, 'secrets.env') {1819		assert false, 'the injected delete failure should have surfaced'1820	}1821	put(a.root, 'secrets.env', 'second key\n')!1822	put(a.root, 'other.txt', 'more work\n')!1823	make_save(a, 'more work')!1824	offer_now(a, mut backend)!1825	send_strip(a, mut backend, 'secrets.env')!1826	for key in ['first key\n', 'second key\n'] {1827		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1828	}1829}18301831// Unfinished strips of one path in different scopes finish together.1832fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1833	base := scratch('strip_scopes')!1834	defer {1835		os.rmdir_all(base) or {}1836	}1837	remote_dir := os.join_path(base, 'remote')1838	mut backend := remote.new_folder(remote_dir)!1839	mut a := open_repo(machine('strip_scopes_a')!)!1840	defer {1841		a.close()1842	}1843	put(a.root, 'secrets.env', 'first key\n')!1844	first := make_save(a, 'first key')!1845	put(a.root, 'secrets.env', 'second key\n')!1846	second := make_save(a, 'second key')!1847	offer_now(a, mut backend)!1848	strip_local(a, 'secrets.env', first.id)!1849	// Write 1 uploads the event; write 2, the record rewrite, fails.1850	mut flaky := FaultyBackend{1851		inner:      remote.new_folder(remote_dir)!1852		fail_write: 21853	}1854	if _ := send_strip(a, mut flaky, 'secrets.env') {1855		assert false, 'the injected failure should have surfaced'1856	}1857	strip_local(a, 'secrets.env', second.id)!1858	send_strip(a, mut backend, 'secrets.env')!1859	assert !strip_pending(a, 'secrets.env')1860	for save in [first, second] {1861		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1862		assert 'secrets.env' !in record.files.map(it.path)1863	}1864	for key in ['first key\n', 'second key\n'] {1865		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1866	}1867}18681869// A strip bans its path on every machine that has heard of it: written again,1870// the file makes no save, here or there, so there is never a second copy to strip.1871fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1872	base := scratch('strip_again')!1873	defer {1874		os.rmdir_all(base) or {}1875	}1876	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1877	mut a := open_repo(machine('strip_again_a')!)!1878	defer {1879		a.close()1880	}1881	mut b := open_repo(machine('strip_again_b')!)!1882	defer {1883		b.close()1884	}1885	put(a.root, 'secrets.env', 'first key\n')!1886	make_save(a, 'first key')!1887	offer_now(a, mut backend)!1888	join(b, mut backend)!1889	strip_local(a, 'secrets.env', '')!1890	send_strip(a, mut backend, 'secrets.env')!1891	steal_now(b, mut backend)!1892	for repo in [a, b] {1893		put(repo.root, 'secrets.env', 'second key\n')!1894		if _ := make_save(repo, 'second key') {1895			assert false, 'a banned path must not make a save'1896		}1897	}1898}18991900// A clash on a path the other machine stripped is no clash: the strip removes1901// the path from every save, so there is nothing to choose.1902fn test_a_stripped_path_is_not_asked_about() {1903	base := scratch('clash_strip')!1904	defer {1905		os.rmdir_all(base) or {}1906	}1907	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1908	mut a := open_repo(machine('clash_strip_a')!)!1909	defer {1910		a.close()1911	}1912	mut b := open_repo(machine('clash_strip_b')!)!1913	defer {1914		b.close()1915	}1916	put(a.root, 'secrets.env', 'key one\n')!1917	make_save(a, 'base')!1918	offer_now(a, mut backend)!1919	join(b, mut backend)!1920	put(a.root, 'secrets.env', 'key two\n')!1921	make_save(a, 'A rotates')!1922	offer_now(a, mut backend)!1923	strip_local(a, 'secrets.env', '')!1924	send_strip(a, mut backend, 'secrets.env')!1925	put(b.root, 'secrets.env', 'key three\n')!1926	mine := make_save(b, 'B rotates')!1927	report := steal(b, mut backend, StealOptions{})!1928	assert report.clashes.len == 01929	for save in history.all_saves(b)! {1930		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1931	}1932	assert history.by_id(b, mine.id) != none1933}19341935// A strip scoped to the shared base save changes what the replay sees; the1936// clashes must be found on the same trees, or the replay meets a conflict.1937fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1938	base := scratch('clash_base_strip')!1939	defer {1940		os.rmdir_all(base) or {}1941	}1942	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1943	mut a := open_repo(machine('base_strip_a')!)!1944	defer {1945		a.close()1946	}1947	mut b := open_repo(machine('base_strip_b')!)!1948	defer {1949		b.close()1950	}1951	put(a.root, 'notes.txt', 'shared\n')!1952	common := make_save(a, 'base')!1953	offer_now(a, mut backend)!1954	join(b, mut backend)!1955	put(a.root, 'other.txt', 'A moves on\n')!1956	make_save(a, 'A moves on')!1957	offer_now(a, mut backend)!1958	strip_local(a, 'notes.txt', common.id)!1959	send_strip(a, mut backend, 'notes.txt')!1960	put(b.root, 'notes.txt', 'B edits\n')!1961	make_save(b, 'B edits notes')!1962	if _ := steal(b, mut backend, StealOptions{}) {1963		assert false, 'with the base stripped, notes.txt differs on both sides'1964	} else {1965		assert err.msg().contains('notes.txt changed on both machines')1966	}1967	report := steal(b, mut backend, StealOptions{1968		mine: true1969	})!1970	assert report.clashes == ['notes.txt']1971	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1972}19731974// A folder with files but no saves has nothing to combine with; a steal that1975// fast-forwarded into it would overwrite whatever shares a name.1976fn test_steal_into_a_folder_with_unsaved_files_refuses() {1977	base := scratch('steal_unsaved')!1978	defer {1979		os.rmdir_all(base) or {}1980	}1981	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1982	mut a := open_repo(machine('unsaved_a')!)!1983	defer {1984		a.close()1985	}1986	put(a.root, 'notes.txt', 'from A\n')!1987	make_save(a, 'A writes')!1988	offer_now(a, mut backend)!1989	b_root := machine('unsaved_b')!1990	mut b := storage.create(b_root)!1991	defer {1992		b.close()1993	}1994	put(b_root, 'notes.txt', 'never saved\n')!1995	// Only a repo that already knows its remote name gets this far.1996	b.set_state('remote_repo', 'proj')!1997	if _ := steal(b, mut backend, StealOptions{}) {1998		assert false, 'steal overwrote a folder with unsaved files'1999	} else {2000		assert err.msg() == no_saves_here2001	}2002	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'2003	// A fresh `koh init` folder, holding only the default ignore list, is fine.2004	os.rm(os.join_path(b_root, 'notes.txt'))!2005	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!2006	steal(b, mut backend, StealOptions{})!2007	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'2008}20092010// The document asks steal to warn about every incoming flagged save, so the2011// report names them.2012fn test_steal_reports_incoming_flagged_saves() {2013	base := scratch('flag_warn')!2014	defer {2015		os.rmdir_all(base) or {}2016	}2017	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2018	mut a := open_repo(machine('flag_warn_a')!)!2019	defer {2020		a.close()2021	}2022	mut b := open_repo(machine('flag_warn_b')!)!2023	defer {2024		b.close()2025	}2026	put(a.root, 'a.txt', 'one\n')!2027	make_save(a, 'fine')!2028	offer_now(a, mut backend)!2029	join(b, mut backend)!2030	put(a.root, 'a.txt', 'two\n')!2031	suspect := make_save(a, 'suspect')!2032	put(a.root, 'a.txt', 'three\n')!2033	make_save(a, 'also fine')!2034	events.flag(a, suspect.id, 'broke the build')!2035	offer_now(a, mut backend)!2036	report := steal_now(b, mut backend)!2037	assert report.flagged.map(it.save_id) == [suspect.id]2038	assert report.flagged[0].reason == 'broke the build'2039}20402041// head_of unwraps the head for tests that know the repo has saves.2042fn head_of(store &storage.Store) history.Save {2043	return history.head(store) or { panic('this repo has no head') }2044}20452046// A remote says what it is, so two koh versions never half-understand one2047// bucket. The first offer of a repo records the layout.2048fn test_the_first_offer_records_the_remote_format() {2049	base := scratch('format_write')!2050	defer {2051		os.rmdir_all(base) or {}2052	}2053	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2054	mut a := open_repo(machine('format_write_a')!)!2055	defer {2056		a.close()2057	}2058	put(a.root, 'a.txt', 'one\n')!2059	make_save(a, 'first')!2060	offer_now(a, mut backend)!2061	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2062}20632064// A repo offered before the key existed is version 1: it still syncs, and the2065// next offer records the key then.2066fn test_a_remote_without_a_format_still_syncs() {2067	base := scratch('format_old')!2068	defer {2069		os.rmdir_all(base) or {}2070	}2071	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2072	mut a := open_repo(machine('format_old_a')!)!2073	defer {2074		a.close()2075	}2076	put(a.root, 'a.txt', 'one\n')!2077	make_save(a, 'first')!2078	offer_now(a, mut backend)!2079	// Stand in for a bucket offered before the key existed.2080	backend.delete(format_key('proj'))!2081	mut b := open_repo(machine('format_old_b')!)!2082	defer {2083		b.close()2084	}2085	join(b, mut backend)!2086	assert head_of(b).id == head_of(a).id2087	put(a.root, 'a.txt', 'two\n')!2088	make_save(a, 'second')!2089	offer_now(a, mut backend)!2090	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2091	report := steal_now(b, mut backend)!2092	assert report.head == head_of(a).id2093}20942095// A remote that speaks another layout is refused loudly, on offer and on2096// steal, rather than half-read.2097fn test_an_unknown_remote_format_is_refused() {2098	base := scratch('format_unknown')!2099	defer {2100		os.rmdir_all(base) or {}2101	}2102	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2103	mut a := open_repo(machine('format_bad_a')!)!2104	defer {2105		a.close()2106	}2107	put(a.root, 'a.txt', 'one\n')!2108	make_save(a, 'first')!2109	offer_now(a, mut backend)!2110	backend.delete(format_key('proj'))!2111	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2112	if _ := offer_now(a, mut backend) {2113		assert false, 'offer must refuse a format it does not speak'2114	} else {2115		assert err.msg().contains('koh-remote 2')2116	}2117	if _ := steal_now(a, mut backend) {2118		assert false, 'steal must refuse a format it does not speak'2119	} else {2120		assert err.msg().contains('koh-remote 2')2121	}2122	if _ := fetch_repo(mut backend, 'proj') {2123		assert false, 'a steal must refuse a format it does not speak'2124	} else {2125		assert err.msg().contains('koh-remote 2')2126	}2127}21282129// Two machines offering a new repo at once both find no format key; the loser2130// of the conditional write must read back the winner's value and refuse it,2131// not carry on against a remote it cannot read.2132fn test_a_lost_format_write_is_read_back_and_refused() {2133	base := scratch('format_race')!2134	defer {2135		os.rmdir_all(base) or {}2136	}2137	mut inner := remote.new_folder(os.join_path(base, 'remote'))!2138	mut a := open_repo(machine('format_race_a')!)!2139	defer {2140		a.close()2141	}2142	put(a.root, 'a.txt', 'one\n')!2143	make_save(a, 'first')!2144	mut backend := remote.Backend(FaultyBackend{2145		inner:       inner2146		race_format: true2147	})2148	if _ := offer_now(a, mut backend) {2149		assert false, 'offer must refuse the format another machine wrote first'2150	} else {2151		assert err.msg().contains('koh-remote 2')2152	}2153}21542155fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2156	whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2157	for size in [1, 2, 5, whole.len] {2158		mut bundle := Bundle{}2159		for at := 0; at < whole.len; at += size {2160			bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2161		}2162		bundle.finish()!2163		assert bundle.fetched.repo_objects == {2164			'aa': 'abc'.bytes()2165			'bb': []u8{}2166		}2167	}2168	for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2169		'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2170		mut bundle := Bundle{}2171		bundle.feed(cut.bytes()) or { continue }2172		bundle.finish() or { continue }2173		assert false, cut2174	}2175}21762177fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2178	mut store := open_repo(machine('strip_batch')!)!2179	defer { store.close() }2180	put(store.root, 'private/a', 'a')!2181	put(store.root, 'secret', 'secret')!2182	put(store.root, 'keep', 'keep')!2183	save := make_save(store, 'initial')!2184	store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2185		WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2186	if _ := strip_local_batch(store, ['private', 'secret'], '') {2187		assert false, 'batch succeeded despite the second path failing'2188	}2189	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2190		'secret']2191	assert events.strip_events(store)!.len == 02192	assert !strip_pending(store, 'private')2193	store.db.exec('DROP TRIGGER block_secret')!2194	reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2195	assert reports.map(it.files_removed) == [1, 0, 1]2196	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2197	assert events.strip_events(store)!.len == 22198}21992200fn test_strip_batch_limits_every_path_to_selected_save() {2201	mut store := open_repo(machine('strip_batch_scope')!)!2202	defer { store.close() }2203	put(store.root, 'one', 'one')!2204	put(store.root, 'two', 'two')!2205	first := make_save(store, 'initial')!2206	put(store.root, 'keep', 'keep')!2207	second := make_save(store, 'next')!2208	strip_local_batch(store, ['one', 'two'], first.id)!2209	assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2210	assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2211	assert events.strip_events(store)!.all(it.save_id == first.id)2212}22132214fn test_strip_batch_keeps_independent_remote_retry_notes() {2215	mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2216	mut store := open_repo(machine('strip_batch_retry')!)!2217	defer { store.close() }2218	put(store.root, 'one', 'secret')!2219	put(store.root, 'two', 'secret')!2220	save := make_save(store, 'initial')!2221	offer_now(store, mut backend)!2222	strip_local_batch(store, ['one', 'two'], '')!2223	mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2224	if _ := send_strip(store, mut flaky, 'one') {2225		assert false, 'injected remote failure was not reported'2226	}2227	send_strip(store, mut backend, 'two')!2228	assert strip_pending(store, 'one') && !strip_pending(store, 'two')2229	partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2230	assert partial.files.map(it.path) == ['.kohignore', 'one']2231	assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2232	send_strip(store, mut backend, 'one')!2233	assert !strip_pending(store, 'one')2234	finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2235	assert finished.files.map(it.path) == ['.kohignore']2236	assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2237}