exchange_test.v

68.4 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module exchange67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314// Global rules must not make source fixtures depend on the developer’s home.15fn testsuite_begin() {16	os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17}1819fn test_arriving_record_drops_unsafe_paths_and_reports_them() {20	mut record := SaveRecord{21		files: [22			FileRecord{ path: 'safe.txt' },23			FileRecord{ path: '../outside.txt' },24			FileRecord{ path: 'bad\\name.txt' },25			FileRecord{ path: 'Icon\r' },26		]27	}28	dropped := drop_unsafe_paths(mut record)29	assert record.files.map(it.path) == ['safe.txt']30	assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']31}3233// An unpaired surrogate escape stands for its three bytes, so the record still34// decodes and only the path holding it is dropped; a real pair is kept.35fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {36	body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'37	mut record := decode_save(body.bytes())!38	dropped := drop_unsafe_paths(mut record)39	assert record.files.map(it.path) == ['ok😀']40	assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']41}4243// Machines keep their repo in a folder of the same NAME, because the remote key44// is the folder's name; that is what makes them the same repo to the remote.45fn machine(name string) !string {46	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')47	os.rmdir_all(os.dir(dir)) or {}48	os.mkdir_all(dir, os.MkdirParams{})!49	return dir50}5152fn scratch(name string) !string {53	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')54	os.rmdir_all(dir) or {}55	os.mkdir_all(dir, os.MkdirParams{})!56	return dir57}5859fn open_repo(dir string) !storage.Store {60	mut store := storage.create(dir)!61	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!62	return store63}6465fn put(root string, rel string, text string) ! {66	full := os.join_path(root, rel)67	os.mkdir_all(os.dir(full), os.MkdirParams{})!68	os.write_file(full, text)!69}7071fn make_save(store &storage.Store, message string) !history.Save {72	return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{73		message: message74	})!75}7677fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {78	return offer(store, mut backend)!79}8081fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {82	return steal(store, mut backend, StealOptions{83		theirs: true84	})!85}8687fn test_first_offer_publishes_the_whole_line() {88	base := scratch('first')!89	defer {90		os.rmdir_all(base) or {}91	}92	mut a := open_repo(machine('first_a')!)!93	defer {94		a.close()95	}96	put(a.root, 'a.txt', 'one\n')!97	make_save(a, 'first')!98	put(a.root, 'a.txt', 'two\n')!99	make_save(a, 'second')!100	mut backend := remote.new_folder(os.join_path(base, 'remote'))!101102	report := offer_now(a, mut backend)!103	assert report.repo == 'proj'104	assert report.saves == 2105	assert report.objects >= 2106	assert backend.get(head_key('proj'))!.found107	assert backend.list('proj/saves')!.len == 2108109	// A second offer with nothing new uploads nothing and still succeeds.110	again := offer_now(a, mut backend)!111	assert again.saves == 0112}113114fn test_the_repo_name_is_remembered_and_head_offered() {115	base := scratch('remember')!116	defer {117		os.rmdir_all(base) or {}118	}119	mut a := open_repo(machine('remember_a')!)!120	defer {121		a.close()122	}123	put(a.root, 'a.txt', 'one\n')!124	make_save(a, 'first')!125	mut backend := remote.new_folder(os.join_path(base, 'remote'))!126	offer_now(a, mut backend)!127	assert a.state('remote_repo') or { '' } == 'proj'128	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id129	// Offer's step 3: the new remote head is recorded in state.130	assert a.state('remote_head') or { '' } == head_of(a).id131132	// A second offer, with the remote already holding our head, must not133	// refuse: the name is remembered, so the remote is ours.134	put(a.root, 'a.txt', 'two\n')!135	make_save(a, 'second')!136	offer_now(a, mut backend)!137	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id138}139140fn test_a_repo_name_is_lowercased_at_first_offer() {141	base := scratch('lower')!142	defer {143		os.rmdir_all(base) or {}144	}145	dir := os.join_path(base, 'Haven')146	os.mkdir_all(dir, os.MkdirParams{})!147	mut a := open_repo(dir)!148	defer {149		a.close()150	}151	put(a.root, 'a.txt', 'one\n')!152	make_save(a, 'first')!153	mut backend := remote.new_folder(os.join_path(base, 'remote'))!154	assert offer_now(a, mut backend)!.repo == 'haven'155	assert backend.get(head_key('haven'))!.found156}157158fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {159	base := scratch('renamed')!160	defer {161		os.rmdir_all(base) or {}162	}163	mut a := open_repo(machine('renamed_a')!)!164	defer {165		a.close()166	}167	put(a.root, 'a.txt', 'one\n')!168	make_save(a, 'first')!169	remote_dir := os.join_path(base, 'remote')170	mut backend := remote.new_folder(remote_dir)!171	offer_now(a, mut backend)!172	// What Kepr's rename leaves: the history under the new name, a marker, no old name.173	os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!174	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!175	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!176177	put(a.root, 'a.txt', 'two\n')!178	make_save(a, 'second')!179	assert offer_now(a, mut backend)!.repo == 'tool'180	assert a.state('remote_repo') or { '' } == 'tool'181	assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id182	assert !backend.get(head_key('proj'))!.found183}184185fn test_a_rename_marker_naming_a_path_is_not_followed() {186	base := scratch('badmarker')!187	defer {188		os.rmdir_all(base) or {}189	}190	mut a := open_repo(machine('badmarker_a')!)!191	defer {192		a.close()193	}194	put(a.root, 'a.txt', 'one\n')!195	make_save(a, 'first')!196	remote_dir := os.join_path(base, 'remote')197	mut backend := remote.new_folder(remote_dir)!198	offer_now(a, mut backend)!199	os.rmdir_all(os.join_path(remote_dir, 'proj'))!200	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!201	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!202	assert offer_now(a, mut backend)!.repo == 'proj'203}204205fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {206	base := scratch('forward')!207	defer {208		os.rmdir_all(base) or {}209	}210	mut backend := remote.new_folder(os.join_path(base, 'remote'))!211	mut a := open_repo(machine('fwd_a')!)!212	defer {213		a.close()214	}215	put(a.root, 'a.txt', 'from A\n')!216	make_save(a, 'A writes')!217	offer_now(a, mut backend)!218219	// B starts empty and takes the whole repo.220	mut b := open_repo(machine('fwd_b')!)!221	defer {222		b.close()223	}224	fetched := fetch_repo(mut backend, 'proj')!225	b.begin()!226	write_fetched(b, fetched)!227	history.set_head(b, fetched.head)!228	b.end()!229	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!230	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'231	assert head_of(b).id == fetched.head232}233234// A machine that is only ahead has nothing to combine: stealing must leave its235// saves, and their IDs, exactly where they are.236fn test_steal_while_only_ahead_leaves_the_line_alone() {237	base := scratch('ahead')!238	defer {239		os.rmdir_all(base) or {}240	}241	mut backend := remote.new_folder(os.join_path(base, 'remote'))!242	mut a := open_repo(machine('ahead_a')!)!243	defer {244		a.close()245	}246	put(a.root, 'a.txt', 'one\n')!247	make_save(a, 'offered')!248	offer_now(a, mut backend)!249	put(a.root, 'a.txt', 'two\n')!250	ahead := make_save(a, 'not offered yet')!251	report := steal_now(a, mut backend)!252	assert report.replayed == 0253	assert head_of(a).id == ahead.id254	// Nothing was restored, so nothing is left marked as half-restored.255	assert !snapshot.restore_pending(a)256	assert history.all_saves(a)!.len == 2257	offer_now(a, mut backend)!258	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id259}260261// A link is invisible to the walk, so a tree holding one is clean; the files262// a steal brings in must still never land in the folder it points at.263fn test_steal_never_writes_through_a_symlinked_folder() {264	base := scratch('steal_link')!265	defer {266		os.rmdir_all(base) or {}267	}268	mut backend := remote.new_folder(os.join_path(base, 'remote'))!269	mut a := open_repo(machine('steal_link_a')!)!270	defer {271		a.close()272	}273	mut b := open_repo(machine('steal_link_b')!)!274	defer {275		b.close()276	}277	put(a.root, 'a.txt', 'one\n')!278	make_save(a, 'first')!279	offer_now(a, mut backend)!280	join(b, mut backend)!281	outside := os.join_path(base, 'outside')282	put(outside, 'a.md', 'precious\n')!283	os.symlink(outside, os.join_path(b.root, 'docs'))!284	put(a.root, 'docs/a.md', 'from A\n')!285	make_save(a, 'A adds docs')!286	offer_now(a, mut backend)!287	head_before := head_of(b).id288	if _ := steal_now(b, mut backend) {289		assert false, 'a steal writing through a symlinked folder must refuse'290	} else {291		assert err.msg().contains('docs')292	}293	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'294	assert head_of(b).id == head_before295}296297// A steal onto a half-restored tree would replay it as somebody's work.298fn test_steal_refuses_after_a_restore_cut_short() {299	base := scratch('steal_cut')!300	defer {301		os.rmdir_all(base) or {}302	}303	mut backend := remote.new_folder(os.join_path(base, 'remote'))!304	mut a := open_repo(machine('steal_cut_a')!)!305	defer {306		a.close()307	}308	put(a.root, 'a.txt', 'one\n')!309	make_save(a, 'first')!310	offer_now(a, mut backend)!311	a.set_state('restoring', 'yes')!312	if _ := steal_now(a, mut backend) {313		assert false, 'steal must refuse while a restore is unfinished'314	} else {315		assert err.msg().contains('koh revert')316	}317}318319// A replayed save keeps its original time but sits on a newer parent, so a320// steal cannot order saves by time.321fn test_a_steal_imports_parents_before_children() {322	base := scratch('steal_order')!323	defer {324		os.rmdir_all(base) or {}325	}326	mut backend := remote.new_folder(os.join_path(base, 'remote'))!327	mut a := open_repo(machine('order_a')!)!328	defer {329		a.close()330	}331	put(a.root, 'a.txt', 'one\n')!332	parent := make_save(a, 'parent')!333	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{334		message:    'replayed, older than its parent'335		created_at: parent.created_at - 3600336	})!337	offer_now(a, mut backend)!338	mut b := open_repo(machine('order_b')!)!339	defer {340		b.close()341	}342	join(b, mut backend)!343	assert head_of(b).id == child.id344}345346// A chmod is unsaved work too: a steal would restore the old mode over it.347fn test_steal_refuses_an_unsaved_executable_bit_change() {348	base := scratch('chmod')!349	defer {350		os.rmdir_all(base) or {}351	}352	mut backend := remote.new_folder(os.join_path(base, 'remote'))!353	mut a := open_repo(machine('chmod_a')!)!354	defer {355		a.close()356	}357	put(a.root, 'run.sh', 'echo hi\n')!358	make_save(a, 'script')!359	offer_now(a, mut backend)!360	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!361	if _ := steal_now(a, mut backend) {362		assert false, 'a chmod-only change should make the tree dirty'363	} else {364		assert err.msg() == dirty_tree365	}366}367368fn test_divergence_without_clashes_keeps_both_machines_work() {369	base := scratch('diverge')!370	defer {371		os.rmdir_all(base) or {}372	}373	mut backend := remote.new_folder(os.join_path(base, 'remote'))!374	mut a := open_repo(machine('div_a')!)!375	defer {376		a.close()377	}378	mut b := open_repo(machine('div_b')!)!379	defer {380		b.close()381	}382	put(a.root, 'shared.txt', 'base\n')!383	make_save(a, 'base')!384	offer_now(a, mut backend)!385386	// B joins, then both machines change a different file.387	join(b, mut backend)!388	put(a.root, 'fromA.txt', 'A\n')!389	make_save(a, 'A adds a file')!390	put(b.root, 'fromB.txt', 'B\n')!391	make_save(b, 'B adds a file')!392	offer_now(a, mut backend)!393394	// B's offer is refused: the remote has moved on.395	if _ := offer_now(b, mut backend) {396		assert false, 'offering on top of unknown remote saves should be refused'397	} else {398		assert err.msg().contains('run koh steal')399	}400401	report := steal_now(b, mut backend)!402	assert !report.forward403	assert report.replayed == 1404	assert report.clashes.len == 0405	// Both files are on B's line now, and on disk.406	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'407	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'408	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'409	// B's own save was replayed, not erased.410	mut saw_replay := false411	for save in history.all_saves(b)! {412		if save.replayed_from != '' {413			saw_replay = true414		}415	}416	assert saw_replay417	// And the remote now has B's work too.418	offer_now(b, mut backend)!419	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()420	assert line_head == head_of(b).id421}422423// An agent that edits a file and then undoes the edit leaves no net change, so424// the file is not a clash when the other machine changed it. Replaying the425// edit itself must not stop the steal: the remote's version simply stands.426// Likewise a file both machines ended up changing the same way.427fn test_a_local_change_undone_does_not_block_a_combine() {428	base := scratch('undone')!429	defer {430		os.rmdir_all(base) or {}431	}432	mut backend := remote.new_folder(os.join_path(base, 'remote'))!433	mut a := open_repo(machine('undone_a')!)!434	defer {435		a.close()436	}437	mut b := open_repo(machine('undone_b')!)!438	defer {439		b.close()440	}441	put(a.root, 'f.txt', 'base\n')!442	put(a.root, 'g.txt', 'base\n')!443	make_save(a, 'base')!444	offer_now(a, mut backend)!445	join(b, mut backend)!446447	put(a.root, 'f.txt', 'from A\n')!448	put(a.root, 'g.txt', 'same\n')!449	make_save(a, 'A edits both')!450	offer_now(a, mut backend)!451452	put(b.root, 'f.txt', 'tried on B\n')!453	put(b.root, 'g.txt', 'first try\n')!454	tried := make_save(b, 'B tries')!455	put(b.root, 'f.txt', 'base\n')!456	put(b.root, 'g.txt', 'same\n')!457	put(b.root, 'h.txt', 'B\n')!458	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!459460	report := steal_now(b, mut backend)!461	assert report.clashes.len == 0462	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'463	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'464	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'465	// "B tries" only touched files A changed, so it has nothing left to466	// replay; the second save carries h.txt. Both originals stay in history.467	assert report.replayed == 1468	assert head_of(b).replayed_from == undone.id469	ids := history.all_saves(b)!.map(it.id)470	assert tried.id in ids && undone.id in ids471	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')472}473474// A person deciding a clash may take minutes; the question must come before475// the write lock, or every agent's save on this repo waits and then fails.476fn test_clash_questions_are_asked_before_the_write_lock() {477	base := scratch('ask_unlocked')!478	defer {479		os.rmdir_all(base) or {}480	}481	mut backend := remote.new_folder(os.join_path(base, 'remote'))!482	mut a := open_repo(machine('ask_a')!)!483	defer {484		a.close()485	}486	mut b := open_repo(machine('ask_b')!)!487	defer {488		b.close()489	}490	put(a.root, 'same.txt', 'base\n')!491	make_save(a, 'base')!492	offer_now(a, mut backend)!493	join(b, mut backend)!494	put(a.root, 'same.txt', 'A version\n')!495	make_save(a, 'A edits same')!496	offer_now(a, mut backend)!497	put(b.root, 'same.txt', 'B version\n')!498	make_save(b, 'B edits same')!499	root := b.root500	// An agent saves while the person is still choosing.501	if _ := steal(b, mut backend, StealOptions{502		guide: fn [root] (preview StealPreview) !map[string]Side {503			other := storage.open(root) or { panic(err) }504			defer {505				other.close()506			}507			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }508			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{509				message: 'agent saves during the question'510			}) or { panic('steal held the write lock while asking: ${err}') }511			return {512				preview.clashes[0].path: Side.mine513			}514		}515	})516	{517		assert false, 'a steal whose line moved during the question must refuse'518	} else {519		assert err.msg().contains('changed while')520	}521	// The agent's save is still head, and a second steal combines it too.522	assert head_of(b).message == 'agent saves during the question'523	report := steal(b, mut backend, StealOptions{524		mine: true525	})!526	assert report.kept_mine == 1527	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'528	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'529}530531fn test_clashing_files_are_chosen_one_at_a_time() {532	base := scratch('clash')!533	defer {534		os.rmdir_all(base) or {}535	}536	mut backend := remote.new_folder(os.join_path(base, 'remote'))!537	mut a := open_repo(machine('clash_a')!)!538	defer {539		a.close()540	}541	mut b := open_repo(machine('clash_b')!)!542	defer {543		b.close()544	}545	put(a.root, 'same.txt', 'base\n')!546	put(a.root, 'other.txt', 'quiet\n')!547	make_save(a, 'base')!548	offer_now(a, mut backend)!549	join(b, mut backend)!550551	// Both machines edit the same file, differently.552	put(a.root, 'same.txt', 'A version\n')!553	make_save(a, 'A edits same')!554	offer_now(a, mut backend)!555	put(b.root, 'same.txt', 'B version\n')!556	make_save(b, 'B edits same')!557558	// Without a decision, steal refuses and says which files clash.559	if _ := steal(b, mut backend, StealOptions{}) {560		assert false, 'an undecided clash must be refused'561	} else {562		assert err.msg().contains('--mine') || err.msg().contains('--theirs')563		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.564		if err is ClashError {565			assert err.paths == ['same.txt']566		} else {567			assert false, 'an undecided clash must be a ClashError, not ${err}'568		}569	}570	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,571		head_of(b).id)!).len == 0572573	// Choosing the remote's version ends with A's content.574	report := steal_now(b, mut backend)!575	assert report.clashes == ['same.txt']576	assert report.kept_mine == 0577	assert report.kept_versions == ['same.txt:${report.previous_head}']578	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'579	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'580}581582fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {583	base := scratch('guided_cancel')!584	defer { os.rmdir_all(base) or {} }585	mut backend := remote.new_folder(os.join_path(base, 'remote'))!586	mut a := open_repo(machine('guided_cancel_a')!)!587	defer { a.close() }588	mut b := open_repo(machine('guided_cancel_b')!)!589	defer { b.close() }590	put(a.root, 'same.txt', 'base\n')!591	make_save(a, 'base')!592	offer_now(a, mut backend)!593	join(b, mut backend)!594	put(a.root, 'same.txt', 'remote\n')!595	remote_head := make_save(a, 'remote change')!596	offer_now(a, mut backend)!597	put(b.root, 'same.txt', 'mine\n')!598	local_head := make_save(b, 'local change')!599600	if _ := steal(b, mut backend, StealOptions{601		guide: fn (preview StealPreview) !map[string]Side {602			assert preview.clashes.len == 1603			return error('cancelled')604		}605	}) {606		assert false, 'the guide cancelled'607	} else {608		assert err.msg() == 'cancelled'609	}610	assert head_of(b).id == local_head.id611	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'612	assert history.by_id(b, remote_head.id) != none613	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1614}615616fn test_choosing_mine_keeps_the_local_version() {617	base := scratch('mine')!618	defer {619		os.rmdir_all(base) or {}620	}621	mut backend := remote.new_folder(os.join_path(base, 'remote'))!622	mut a := open_repo(machine('mine_a')!)!623	defer {624		a.close()625	}626	mut b := open_repo(machine('mine_b')!)!627	defer {628		b.close()629	}630	put(a.root, 'same.txt', 'base\n')!631	make_save(a, 'base')!632	offer_now(a, mut backend)!633	join(b, mut backend)!634	put(a.root, 'same.txt', 'A version\n')!635	make_save(a, 'A edits')!636	offer_now(a, mut backend)!637	put(b.root, 'same.txt', 'B version\n')!638	make_save(b, 'B edits')!639640	report := steal(b, mut backend, StealOptions{641		mine: true642	})!643	assert report.kept_mine == 1644	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'645	// The final save says what it kept.646	assert head_of(b).message.contains('combine: kept mine for 1 file')647	// The pre-combine tree is still reachable in history.648	assert head_of(b).restores == ''649	assert history.all_saves(b)!.len >= 4650}651652fn test_flags_travel_with_the_saves() {653	base := scratch('flags')!654	defer {655		os.rmdir_all(base) or {}656	}657	mut backend := remote.new_folder(os.join_path(base, 'remote'))!658	mut a := open_repo(machine('flag_a')!)!659	defer {660		a.close()661	}662	mut b := open_repo(machine('flag_b')!)!663	defer {664		b.close()665	}666	put(a.root, 'a.txt', 'one\n')!667	save := make_save(a, 'suspect save')!668	events.flag(a, save.id, 'look at this')!669	offer_now(a, mut backend)!670671	// B has nothing yet, so a fetch brings the save and its flag across.672	fetched := fetch_back_to_known(b, mut backend, 'proj')!673	assert fetched.events_out.len == 1674	assert fetched.events_out[0].kind == events.kind_flag675	b.begin()!676	write_fetched(b, fetched)!677	history.set_head(b, fetched.head)!678	b.end()!679	state := events.flag_state(b, save.id)!680	assert state.flagged681	assert state.reason == 'look at this'682}683684fn test_a_second_offer_racing_head_is_refused_not_forced() {685	base := scratch('race')!686	defer {687		os.rmdir_all(base) or {}688	}689	mut backend := remote.new_folder(os.join_path(base, 'remote'))!690	mut a := open_repo(machine('race_a')!)!691	defer {692		a.close()693	}694	put(a.root, 'a.txt', 'one\n')!695	make_save(a, 'base')!696	offer_now(a, mut backend)!697698	// Someone else replaces head behind our back.699	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),700		backend.get(head_key('proj'))!.tag)!701702	put(a.root, 'a.txt', 'two\n')!703	make_save(a, 'second')!704	if _ := offer_now(a, mut backend) {705		assert false, 'offering while the remote head moved must be refused'706	} else {707		assert err.msg().contains('run koh steal')708	}709}710711// join makes a brand new machine share the remote's line.712fn join(store &storage.Store, mut backend remote.Backend) ! {713	fetched := fetch_repo(mut backend, 'proj')!714	store.begin()!715	write_fetched(store, fetched)!716	history.set_head(store, fetched.head)!717	store.end()!718	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,719		fetched.head)!, [])!720	store.set_state('remote_repo', 'proj')!721}722723struct FaultyBackend {724mut:725	inner      remote.Backend726	writes     int727	reads      int728	fail_write int // fail when the write counter reaches this, 0 never729	fail_read  int730	// fail every delete while set731	fail_delete bool732	// race_format stands in for another machine writing the format key first:733	// the first `format` write loses, to a value this koh does not speak.734	race_format bool735	raced       bool736	// fail listing keys under a prefix ending in this737	fail_list string738}739740fn (mut f FaultyBackend) get(key string) !remote.Object {741	f.reads++742	if f.fail_read > 0 && f.reads >= f.fail_read {743		return error('injected read failure')744	}745	return f.inner.get(key)!746}747748fn (mut f FaultyBackend) list(prefix string) ![]string {749	if f.fail_list != '' && prefix.ends_with(f.fail_list) {750		return error('injected list failure')751	}752	return f.inner.list(prefix)!753}754755fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {756	f.writes++757	if f.fail_write > 0 && f.writes >= f.fail_write {758		return error('injected write failure')759	}760	if f.race_format && !f.raced && key.ends_with('/format') {761		f.raced = true762		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!763		return false764	}765	return f.inner.put_if_absent(key, body)!766}767768fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {769	f.writes++770	if f.fail_write > 0 && f.writes >= f.fail_write {771		return error('injected write failure')772	}773	return f.inner.put_if_match(key, body, tag)!774}775776fn (mut f FaultyBackend) delete(key string) ! {777	if f.fail_delete {778		return error('injected delete failure')779	}780	f.inner.delete(key)!781}782783// Every save offers after it is made, so a one-file change must cost one784// object, not one conditional write per file in the repo: on S3 each of those785// is a round trip.786fn test_an_offer_sends_only_the_objects_a_save_adds() {787	base := scratch('offer_diff')!788	defer {789		os.rmdir_all(base) or {}790	}791	mut a := open_repo(machine('offer_diff_a')!)!792	defer {793		a.close()794	}795	for i in 0 .. 20 {796		put(a.root, 'f${i}.txt', 'file ${i}\n')!797	}798	make_save(a, 'twenty files')!799	mut backend := FaultyBackend{800		inner: remote.new_folder(os.join_path(base, 'remote'))!801	}802	offer(a, mut backend)!803	put(a.root, 'f3.txt', 'changed\n')!804	make_save(a, 'one change')!805	before := backend.writes806	report := offer(a, mut backend)!807	assert report.objects == 1808	// One object, one save record, one head swap.809	assert backend.writes - before == 3810}811812fn test_a_failed_offer_is_safe_to_re_run() {813	base := scratch('fault_offer')!814	defer {815		os.rmdir_all(base) or {}816	}817	remote_dir := os.join_path(base, 'remote')818	mut a := open_repo(machine('fault_a')!)!819	defer {820		a.close()821	}822	put(a.root, 'a.txt', 'one\n')!823	make_save(a, 'first')!824	put(a.root, 'a.txt', 'two\n')!825	make_save(a, 'second')!826827	// The first offer dies after the first write.828	mut flaky := FaultyBackend{829		inner:      remote.new_folder(remote_dir)!830		fail_write: 2831	}832	if _ := offer(a, mut flaky) {833		assert false, 'the injected failure should have surfaced'834	}835836	// Re-running with a healthy remote finishes the job: "already exists" is837	// success, so nothing is lost and nothing is duplicated.838	mut healthy := remote.new_folder(remote_dir)!839	report := offer(a, mut healthy)!840	assert report.saves == 2841	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id842	assert healthy.list('proj/saves')!.len == 2843}844845// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,846// and it must neither remove anything nor hold up an offer.847fn test_a_strip_with_an_illegal_path_is_ignored() {848	base := scratch('illegal_strip')!849	defer {850		os.rmdir_all(base) or {}851	}852	mut backend := remote.new_folder(os.join_path(base, 'remote'))!853	mut a := open_repo(machine('illegal_strip_a')!)!854	defer {855		a.close()856	}857	mut b := open_repo(machine('illegal_strip_b')!)!858	defer {859		b.close()860	}861	put(a.root, 'a/x.txt', 'kept\n')!862	make_save(a, 'add a folder')!863	offer_now(a, mut backend)!864	for id, path in {865		'aaaaaaaaaaaa': 'a//'866		'bbbbbbbbbbbb': 'a\\ud800'867	} {868		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!869	}870	join(b, mut backend)!871	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)872	put(a.root, 'more.txt', 'more\n')!873	make_save(a, 'more')!874	offer_now(a, mut backend)!875}876877fn test_a_failed_steal_leaves_nothing_behind() {878	base := scratch('fault_steal')!879	defer {880		os.rmdir_all(base) or {}881	}882	remote_dir := os.join_path(base, 'remote')883	mut a := open_repo(machine('fault_s_a')!)!884	defer {885		a.close()886	}887	mut b := open_repo(machine('fault_s_b')!)!888	defer {889		b.close()890	}891	put(a.root, 'a.txt', 'from A\n')!892	make_save(a, 'A writes')!893	mut healthy := remote.new_folder(remote_dir)!894	offer(a, mut healthy)!895896	// B's fetch fails half way through reading the remote.897	mut flaky := FaultyBackend{898		inner:     remote.new_folder(remote_dir)!899		fail_read: 2900	}901	if _ := fetch_back_to_known(b, mut flaky, 'proj') {902		assert false, 'the injected read failure should have surfaced'903	}904	// Nothing was written: the store is untouched and still has no saves.905	assert history.head(b) == none906	assert b.object_count()! == 0907908	// A healthy retry works.909	fetched := fetch_back_to_known(b, mut healthy, 'proj')!910	b.begin()!911	write_fetched(b, fetched)!912	history.set_head(b, fetched.head)!913	b.end()!914	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!915	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'916}917918fn test_an_interrupted_strip_is_idempotent() {919	// The local half of a strip is one transaction, so an interrupted strip is920	// simply a strip that did not happen; re-running it must land the same921	// result once, not twice.922	base := scratch('fault_strip')!923	defer {924		os.rmdir_all(base) or {}925	}926	mut a := open_repo(machine('fault_strip_a')!)!927	defer {928		a.close()929	}930	put(a.root, 'secret.txt', 'shh\n')!931	put(a.root, 'keep.txt', 'fine\n')!932	save := make_save(a, 'add both')!933934	for _ in 0 .. 2 {935		strip_local(a, 'secret.txt', '')!936	}937	files := history.files_of(a, save.id)!938	// The repo's own `.kohignore` is tracked content, so it stays; only the939	// stripped path goes.940	assert files.map(it.path) == ['.kohignore', 'keep.txt']941	assert events.strip_events(a)!.len == 1942}943944// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into945// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.946// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,947// but a save made on Linux can hold both.948fn test_a_strip_matches_the_path_exactly() {949	mut a := open_repo(machine('strip_exact')!)!950	defer {951		a.close()952	}953	hash := a.put_object('x\n'.bytes())!954	mut files := []snapshot.FileEntry{}955	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {956		files << snapshot.FileEntry{957			path: path958			hash: hash959		}960	}961	save := history.save_tree(a, '', files, history.SaveInput{962		message: 'add all'963	})!964	assert strip_scope(a, 'docs', '')!.files == 1965	strip_local(a, 'docs', '')!966	strip_local(a, 'a_b', '')!967	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']968}969970fn test_a_strip_reaches_the_remote_and_other_machines() {971	base := scratch('strip_sync')!972	defer {973		os.rmdir_all(base) or {}974	}975	mut a := open_repo(machine('strip_a')!)!976	defer {977		a.close()978	}979	mut b := open_repo(machine('strip_b')!)!980	defer {981		b.close()982	}983	put(a.root, 'secrets.env', 'api_key=1\n')!984	put(a.root, 'keep.txt', 'fine\n')!985	save := make_save(a, 'add both')!986	mut backend := remote.new_folder(os.join_path(base, 'remote'))!987	offer_now(a, mut backend)!988	join(b, mut backend)!989	assert b.object_count()! > 0990991	// A strips the path locally, the way `koh strip` does, then tells the992	// remote.993	mut secret_hash := ''994	for file in history.files_of(a, save.id)! {995		if file.path == 'secrets.env' {996			secret_hash = file.hash997		}998	}999	assert secret_hash != ''1000	strip_local(a, 'secrets.env', '')!1001	report := send_strip(a, mut backend, 'secrets.env')!1002	assert report.event_sent1003	assert report.records == 110041005	// The remote's copy of the save no longer lists the file, and the object is1006	// gone from the remote.1007	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1008	for file in record.files {1009		assert file.path != 'secrets.env'1010	}1011	// The stripped object is gone from the remote; the ones still referenced1012	// are not.1013	assert !backend.get(object_key('proj', secret_hash))!.found1014	assert backend.list('proj/objects')!.len == 210151016	// Another machine hears about it on its next steal and applies it.1017	b.begin()!1018	fetched := fetch_back_to_known(b, mut backend, 'proj')!1019	write_fetched(b, fetched)!1020	b.end()!1021	mut paths := []string{}1022	for file in history.files_of(b, save.id)! {1023		paths << file.path1024	}1025	assert 'secrets.env' !in paths1026	assert 'keep.txt' in paths10271028	// The event arrives under its own ID, so it is applied once: B's offer adds1029	// no second copy. The path is banned for good: written again, the file is1030	// neither unsaved work nor ever walked into a save.1031	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1032	put(b.root, 'secrets.env', 'rotated\n')!1033	assert !dirty(b, history.head(b))!1034	put(b.root, 'keep.txt', 'still fine\n')!1035	later := make_save(b, 'edit keep')!1036	offer_now(b, mut backend)!1037	assert backend.list('proj/events')!.len == 11038	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1039}10401041// A strip removes a path from all of history, whenever a save was made. A1042// machine that saved the file after the strip, before it heard of it, loses1043// it from that save too on its next steal, so no copy anywhere keeps it.1044fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1045	base := scratch('strip_later')!1046	defer {1047		os.rmdir_all(base) or {}1048	}1049	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1050	mut a := open_repo(machine('strip_later_a')!)!1051	defer {1052		a.close()1053	}1054	mut b := open_repo(machine('strip_later_b')!)!1055	defer {1056		b.close()1057	}1058	put(a.root, 'secrets.env', 'api_key=1\n')!1059	old := make_save(a, 'leak the key')!1060	offer_now(a, mut backend)!1061	join(b, mut backend)!1062	strip_local(a, 'secrets.env', '')!1063	send_strip(a, mut backend, 'secrets.env')!1064	// B has not heard, and saves the file again an hour after the strip.1065	put(b.root, 'secrets.env', 'api_key=2\n')!1066	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1067		message:    'rotate the key'1068		created_at: events.strip_events(a)!.last().created_at + 36001069	})!1070	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1071	b.begin()!1072	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1073	b.end()!1074	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1075	offer_now(b, mut backend)!10761077	mut c := open_repo(machine('strip_later_c')!)!1078	defer {1079		c.close()1080	}1081	join(c, mut backend)!1082	for save in [old.id, later.id] {1083		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1084	}1085	assert !os.exists(os.join_path(c.root, 'secrets.env'))1086}10871088// A strip this machine already holds bans its path from saves that arrive1089// later too, not only from those a fetch brings with the strip.1090fn test_a_held_strip_reaches_saves_arriving_after_it() {1091	base := scratch('strip_held')!1092	defer {1093		os.rmdir_all(base) or {}1094	}1095	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1096	mut a := open_repo(machine('strip_held_a')!)!1097	defer {1098		a.close()1099	}1100	mut b := open_repo(machine('strip_held_b')!)!1101	defer {1102		b.close()1103	}1104	put(a.root, 'secrets.env', 'api_key=1\n')!1105	make_save(a, 'leak the key')!1106	offer_now(a, mut backend)!1107	join(b, mut backend)!1108	strip_local(b, 'secrets.env', '')!1109	put(a.root, 'keep.txt', 'fine\n')!1110	later := make_save(a, 'add keep')!1111	offer_now(a, mut backend)!1112	b.begin()!1113	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1114	b.end()!1115	paths := history.files_of(b, later.id)!.map(it.path)1116	assert 'secrets.env' !in paths1117	assert 'keep.txt' in paths1118}11191120// StripRace stands in for another machine that strips `path` completely, event1121// and rewrite, just before this machine's first save record lands.1122struct StripRace {1123mut:1124	inner remote.Backend1125	other &storage.Store1126	path  string1127	done  bool1128	// fail every put after the one the strip raced1129	fail_after_race bool1130	raced_put       bool1131	fail_delete     bool1132	fail_match      bool1133	// race at the first object instead of the first save record1134	on_objects bool1135	// fail listing events once the strip has raced1136	fail_events_after_race bool1137}11381139fn (mut r StripRace) get(key string) !remote.Object {1140	return r.inner.get(key)!1141}11421143fn (mut r StripRace) list(prefix string) ![]string {1144	if r.fail_events_after_race && r.raced_put && prefix.ends_with('/events') {1145		return error('injected list failure')1146	}1147	return r.inner.list(prefix)!1148}11491150fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1151	if r.fail_after_race && r.raced_put {1152		return error('injected write failure')1153	}1154	if !r.done && key.contains(if r.on_objects { '/objects/' } else { '/saves/' }) {1155		r.done = true1156		r.raced_put = true1157		strip_local(r.other, r.path, '')!1158		send_strip(r.other, mut r.inner, r.path)!1159	}1160	return r.inner.put_if_absent(key, body)!1161}11621163fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1164	if r.fail_match {1165		return error('injected rewrite failure')1166	}1167	return r.inner.put_if_match(key, body, tag)!1168}11691170fn (mut r StripRace) delete(key string) ! {1171	if r.fail_delete {1172		return error('injected delete failure')1173	}1174	r.inner.delete(key)!1175}11761177// A strip that finishes on another machine while an offer uploads has already1178// rewritten the remote without the offer's records, so the offer takes back1179// what the strip reaches and leaves head where it was.1180fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1181	base := scratch('strip_race')!1182	defer {1183		os.rmdir_all(base) or {}1184	}1185	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1186	mut a := open_repo(machine('strip_race_a')!)!1187	defer {1188		a.close()1189	}1190	mut b := open_repo(machine('strip_race_b')!)!1191	defer {1192		b.close()1193	}1194	put(a.root, 'secrets.env', 'api_key=1\n')!1195	first := make_save(a, 'leak the key')!1196	offer_now(a, mut backend)!1197	join(b, mut backend)!1198	put(a.root, 'secrets.env', 'api_key=2\n')!1199	later := make_save(a, 'rotate the key')!1200	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1201	mut race := StripRace{1202		inner: backend1203		other: &b1204		path:  'secrets.env'1205	}1206	if _ := offer(a, mut race) {1207		assert false, 'an offer that raced a strip must refuse'1208	} else {1209		assert err.msg().contains('koh steal'), err.msg()1210	}1211	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1212	assert !backend.get(object_key('proj', secret))!.found1213	// Another offer may already have published it, so the record stays, stripped.1214	assert backend.get(save_key('proj', later.id))!.found1215	for key in backend.list('proj/saves')! {1216		record := decode_save(backend.get(key)!.body)!1217		assert 'secrets.env' !in record.files.map(it.path), record.id1218	}1219}12201221// Doctor's resend of a missing record can race a strip just as an offer can,1222// and takes back what the strip reaches in the same way.1223fn test_a_resend_racing_a_strip_leaves_no_stripped_path_behind() {1224	base := scratch('resend_race')!1225	defer {1226		os.rmdir_all(base) or {}1227	}1228	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1229	mut a := open_repo(machine('resend_race_a')!)!1230	defer {1231		a.close()1232	}1233	mut b := open_repo(machine('resend_race_b')!)!1234	defer {1235		b.close()1236	}1237	put(a.root, 'secrets.env', 'api_key=1\n')!1238	first := make_save(a, 'leak the key')!1239	offer_now(a, mut backend)!1240	join(b, mut backend)!1241	backend.delete(save_key('proj', first.id))!1242	mut race := StripRace{1243		inner: backend1244		other: &b1245		path:  'secrets.env'1246	}1247	if _ := resend(a, mut race, 'proj', [], [first.id]) {1248		assert false, 'a resend that raced a strip must refuse'1249	} else {1250		assert err.msg().contains('koh steal'), err.msg()1251		// The restored record is still a repair Doctor made.1252		assert (err as Partway).done == 'uploaded 0 objects and 1 save record'1253	}1254	// Head names the restored record, so it stays, less the stripped path.1255	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1256	assert 'secrets.env' !in record.files.map(it.path)1257	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1258}12591260// An object a strip reaches stays while a remote record still lists it under1261// another path, and a strip that landed before an upload failed is still applied.1262fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1263	base := scratch('resend_shared')!1264	defer {1265		os.rmdir_all(base) or {}1266	}1267	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1268	mut a := open_repo(machine('resend_shared_a')!)!1269	defer {1270		a.close()1271	}1272	mut b := open_repo(machine('resend_shared_b')!)!1273	defer {1274		b.close()1275	}1276	put(a.root, 'secrets.env', 'same\n')!1277	put(a.root, 'copy.txt', 'same\n')!1278	first := make_save(a, 'two paths, one object')!1279	offer_now(a, mut backend)!1280	join(b, mut backend)!1281	put(a.root, 'later.txt', 'later\n')!1282	second := make_save(a, 'not offered')!1283	shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1284	backend.delete(save_key('proj', first.id))!1285	backend.delete(shared)!1286	mut race := StripRace{1287		inner:           backend1288		other:           &b1289		path:            'secrets.env'1290		fail_after_race: true1291	}1292	if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1293		assert false, 'the injected failure should have surfaced'1294	} else {1295		assert err.msg().contains('injected'), err.msg()1296	}1297	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1298	paths := record.files.map(it.path)1299	assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1300	assert backend.get(shared)!.found1301}13021303// A note that does not parse, or that no recorded strip of its path backs, is1304// kept and changes nothing remote: sending it would lose the retry or strip1305// where no strip asked.1306fn test_an_untrusted_strip_note_is_kept_and_sends_nothing() {1307	base := scratch('strip_note')!1308	defer {1309		os.rmdir_all(base) or {}1310	}1311	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1312	mut a := open_repo(machine('strip_note_a')!)!1313	defer {1314		a.close()1315	}1316	put(a.root, 'app', 'app\n')!1317	save := make_save(a, 'app')!1318	offer_now(a, mut backend)!1319	events.strip(a, '', 'other')!1320	for note in ['not JSON', '{"saves":[""],"hashes":[]}'] {1321		a.set_state(pending_key('app'), note)!1322		if _ := send_strip(a, mut backend, 'app') {1323			assert false, 'an untrusted note must be refused'1324		} else {1325			assert err.msg().contains('kept for recovery'), err.msg()1326		}1327		assert a.state(pending_key('app')) or { '' } == note1328		assert backend.list(events_prefix('proj'))!.len == 01329		assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)1330	}1331	// Even behind a recorded strip, a note's hashes may name only objects.1332	events.strip(a, '', 'app')!1333	victim := os.join_path(base, 'remote', 'victim', 'head')1334	os.mkdir_all(os.dir(victim))!1335	os.write_file(victim, 'another repo')!1336	a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1337	if _ := send_strip(a, mut backend, 'app') {1338		assert false, 'a note naming a path outside the objects must be refused'1339	}1340	assert os.read_file(victim)! == 'another repo'1341	// Nor may a remote record's path-shaped hash be noted or deleted.1342	a.remove_state(pending_key('app'))!1343	requeue_strip(a, 'app')!1344	backend.delete(save_key('proj', save.id))!1345	backend.put_if_absent(save_key('proj', save.id), encode_save(SaveRecord{1346		id:    save.id1347		files: [FileRecord{1348			path: 'app'1349			hash: '../../victim/head'1350		}]1351	}).bytes())!1352	send_strip(a, mut backend, 'app')!1353	assert os.read_file(victim)! == 'another repo'1354}13551356// A settlement that stops at a record leaves it naming an object the strip1357// deleted. A machine behind must still steal past it, and the note written1358// first finishes the strip once this repo has it.1359fn test_a_settlement_stopped_at_a_record_keeps_history_readable() {1360	base := scratch('settle_record')!1361	defer {1362		os.rmdir_all(base) or {}1363	}1364	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1365	mut a := open_repo(machine('settle_record_a')!)!1366	defer {1367		a.close()1368	}1369	mut b := open_repo(machine('settle_record_b')!)!1370	defer {1371		b.close()1372	}1373	mut c := open_repo(machine('settle_record_c')!)!1374	defer {1375		c.close()1376	}1377	put(a.root, 'readme.txt', 'hello\n')!1378	make_save(a, 'start')!1379	offer_now(a, mut backend)!1380	join(c, mut backend)!1381	put(a.root, 'secrets.env', 'api_key=1\n')!1382	leak := make_save(a, 'leak the key')!1383	offer_now(a, mut backend)!1384	join(b, mut backend)!1385	secret := object_key('proj', storage.hash_hex('api_key=1\n'.bytes()))1386	backend.delete(save_key('proj', leak.id))!1387	backend.delete(secret)!1388	mut race := StripRace{1389		inner:      backend1390		other:      &b1391		path:       'secrets.env'1392		fail_match: true1393	}1394	if _ := resend(a, mut race, 'proj', [], [leak.id]) {1395		assert false, 'the injected rewrite failure should have surfaced'1396	}1397	assert 'secrets.env' in decode_save(backend.get(save_key('proj', leak.id))!.body)!.files.map(it.path)1398	steal_now(c, mut backend)!1399	assert history.head(c) or { history.Save{} }.id == leak.id1400	assert strip_pending(a, 'secrets.env')1401	steal_now(a, mut backend)!1402	send_strip(a, mut backend, 'secrets.env')!1403	cleaned := decode_save(backend.get(save_key('proj', leak.id))!.body)!1404	assert 'secrets.env' !in cleaned.files.map(it.path)1405	assert !backend.get(secret)!.found1406}14071408// With no record to leave behind, the note alone carries a resent object that1409// a strip removed meanwhile, until a send after steal deletes it.1410fn test_an_object_only_resend_leaves_a_note_that_finishes_after_steal() {1411	base := scratch('settle_object')!1412	defer {1413		os.rmdir_all(base) or {}1414	}1415	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1416	mut a := open_repo(machine('settle_object_a')!)!1417	defer {1418		a.close()1419	}1420	mut b := open_repo(machine('settle_object_b')!)!1421	defer {1422		b.close()1423	}1424	put(a.root, 'secrets.env', 'api_key=1\n')!1425	make_save(a, 'leak the key')!1426	offer_now(a, mut backend)!1427	join(b, mut backend)!1428	hash := storage.hash_hex('api_key=1\n'.bytes())1429	backend.delete(object_key('proj', hash))!1430	mut race := StripRace{1431		inner:       backend1432		other:       &b1433		path:        'secrets.env'1434		fail_delete: true1435		on_objects:  true1436	}1437	if _ := resend(a, mut race, 'proj', [hash], []) {1438		assert false, 'the injected delete failure should have surfaced'1439	}1440	assert backend.get(object_key('proj', hash))!.found1441	assert strip_pending(a, 'secrets.env')1442	steal_now(a, mut backend)!1443	send_strip(a, mut backend, 'secrets.env')!1444	assert !backend.get(object_key('proj', hash))!.found1445	assert !strip_pending(a, 'secrets.env')1446}14471448// When the late check itself fails, what the upload sent is kept, and Doctor1449// deletes what the strip removed once a steal brings the strip here.1450fn test_an_unsettled_upload_is_finished_after_steal() {1451	base := scratch('unsettled')!1452	defer {1453		os.rmdir_all(base) or {}1454	}1455	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1456	mut a := open_repo(machine('unsettled_a')!)!1457	defer {1458		a.close()1459	}1460	mut b := open_repo(machine('unsettled_b')!)!1461	defer {1462		b.close()1463	}1464	put(a.root, 'secrets.env', 'api_key=1\n')!1465	make_save(a, 'leak the key')!1466	offer_now(a, mut backend)!1467	join(b, mut backend)!1468	hash := storage.hash_hex('api_key=1\n'.bytes())1469	backend.delete(object_key('proj', hash))!1470	mut race := StripRace{1471		inner:                  backend1472		other:                  &b1473		path:                   'secrets.env'1474		on_objects:             true1475		fail_events_after_race: true1476	}1477	if _ := resend(a, mut race, 'proj', [hash], []) {1478		assert false, 'the injected list failure should have surfaced'1479	}1480	assert unsettled(a)! == [hash]1481	if _ := settle_unsettled(a, mut backend, 'proj') {1482		assert false, 'settling before the strip is here would keep its bytes'1483	}1484	steal_now(a, mut backend)!1485	settle_unsettled(a, mut backend, 'proj')!1486	assert !backend.get(object_key('proj', hash))!.found1487	assert unsettled(a)!.len == 01488}14891490// A strip that sent its event before failing says so.1491fn test_a_strip_that_fails_after_its_event_reports_it() {1492	base := scratch('strip_event_partway')!1493	defer {1494		os.rmdir_all(base) or {}1495	}1496	remote_dir := os.join_path(base, 'remote')1497	mut backend := remote.new_folder(remote_dir)!1498	mut a := open_repo(machine('strip_event_partway_a')!)!1499	defer {1500		a.close()1501	}1502	put(a.root, 'secrets.env', 'api_key=1\n')!1503	make_save(a, 'leak the key')!1504	offer_now(a, mut backend)!1505	strip_local(a, 'secrets.env', '')!1506	mut flaky := FaultyBackend{1507		inner:     remote.new_folder(remote_dir)!1508		fail_list: '/saves'1509	}1510	if _ := send_strip(a, mut flaky, 'secrets.env') {1511		assert false, 'the injected list failure should have surfaced'1512	} else {1513		assert (err as Partway).done.starts_with('sent its event'), (err as Partway).done1514	}1515}15161517// A strip whose deletes fail after its rewrite keeps, in its note, the objects1518// the rewritten record no longer names, so the retry still deletes them.1519fn test_a_strip_retry_deletes_objects_its_rewrite_found() {1520	base := scratch('strip_found')!1521	defer {1522		os.rmdir_all(base) or {}1523	}1524	remote_dir := os.join_path(base, 'remote')1525	mut backend := remote.new_folder(remote_dir)!1526	mut a := open_repo(machine('strip_found_a')!)!1527	defer {1528		a.close()1529	}1530	put(a.root, 'secrets.env', 'api_key=1\n')!1531	make_save(a, 'leak the key')!1532	offer_now(a, mut backend)!1533	strip_local(a, 'secrets.env', '')!1534	// A note rebuilt from the event alone, as Doctor's residue repair writes it.1535	a.remove_state(pending_key('secrets.env'))!1536	requeue_strip(a, 'secrets.env')!1537	mut flaky := FaultyBackend{1538		inner:       remote.new_folder(remote_dir)!1539		fail_delete: true1540	}1541	if _ := send_strip(a, mut flaky, 'secrets.env') {1542		assert false, 'the injected delete failure should have surfaced'1543	}1544	send_strip(a, mut backend, 'secrets.env')!1545	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found1546	assert !strip_pending(a, 'secrets.env')1547}15481549// MovingRecord stands in for another machine rewriting a record between this1550// machine's read and its conditional write: the first conditional write finds1551// `other` already removed from the record.1552struct MovingRecord {1553mut:1554	inner remote.Backend1555	other string1556	moved bool1557}15581559fn (mut m MovingRecord) get(key string) !remote.Object {1560	return m.inner.get(key)!1561}15621563fn (mut m MovingRecord) list(prefix string) ![]string {1564	return m.inner.list(prefix)!1565}15661567fn (mut m MovingRecord) put_if_absent(key string, body []u8) !bool {1568	return m.inner.put_if_absent(key, body)!1569}15701571fn (mut m MovingRecord) put_if_match(key string, body []u8, tag string) !bool {1572	if !m.moved {1573		m.moved = true1574		current := m.inner.get(key)!1575		record := decode_save(current.body)!1576		other := record.files.filter(it.path != m.other)1577		m.inner.put_if_match(key, encode_save(rewrite(record, other)).bytes(), current.tag)!1578	}1579	return m.inner.put_if_match(key, body, tag)!1580}15811582fn (mut m MovingRecord) delete(key string) ! {1583	m.inner.delete(key)!1584}15851586fn test_a_stripped_record_that_moves_is_read_again() {1587	base := scratch('moving_record')!1588	defer {1589		os.rmdir_all(base) or {}1590	}1591	mut folder := remote.new_folder(os.join_path(base, 'remote'))!1592	key := save_key('proj', 'aaaaaaaaaaaa')1593	folder.put_if_absent(key, encode_save(SaveRecord{1594		id:    'aaaaaaaaaaaa'1595		files: [FileRecord{1596			path: 'secret-a'1597		}, FileRecord{1598			path: 'secret-b'1599		}, FileRecord{1600			path: 'keep'1601		}]1602	}).bytes())!1603	mut moving := MovingRecord{1604		inner: folder1605		other: 'secret-b'1606	}1607	strip_record(mut moving, key, 'aaaaaaaaaaaa', [1608		events.Event{1609			kind: events.kind_strip1610			path: 'secret-a'1611		},1612	])!1613	assert decode_save(folder.get(key)!.body)!.files.map(it.path) == ['keep']1614}16151616// An invalid strip event on the remote cannot make an upload take anything back.1617fn test_an_invalid_remote_strip_event_is_not_unapplied() {1618	base := scratch('invalid_remote_strip')!1619	defer {1620		os.rmdir_all(base) or {}1621	}1622	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1623	mut a := open_repo(machine('invalid_remote_strip_a')!)!1624	defer {1625		a.close()1626	}1627	bad := events.Event{1628		id:         'badstripevnt'1629		kind:       events.kind_strip1630		path:       'app'1631		created_at: -11632		machine:    'other'1633	}1634	backend.put_if_absent(event_key('proj', bad.id), encode_event(bad).bytes())!1635	assert unapplied_strips(a, mut backend, 'proj')!.len == 01636}16371638// A resend that fails part way reports what it had already uploaded.1639fn test_a_failed_resend_reports_what_landed() {1640	base := scratch('resend_partway')!1641	defer {1642		os.rmdir_all(base) or {}1643	}1644	remote_dir := os.join_path(base, 'remote')1645	mut a := open_repo(machine('resend_partway_a')!)!1646	defer {1647		a.close()1648	}1649	put(a.root, 'one.txt', 'one\n')!1650	put(a.root, 'two.txt', 'two\n')!1651	make_save(a, 'two objects')!1652	hashes := ['one\n', 'two\n'].map(storage.hash_hex(it.bytes()))1653	mut flaky := FaultyBackend{1654		inner:      remote.new_folder(remote_dir)!1655		fail_write: 21656	}1657	if _ := resend(a, mut flaky, 'proj', hashes, []) {1658		assert false, 'the injected failure should have surfaced'1659	} else {1660		assert err is Partway1661		assert (err as Partway).done == 'uploaded 1 object and 0 save records', (err as Partway).done1662	}1663}16641665// The local half lands before the remote hears anything, so a remote failure1666// leaves nothing to find locally; the pending note is what lets a re-run1667// finish. Objects a remote-only save still needs must survive the strip.1668fn test_an_interrupted_remote_strip_finishes_when_re_run() {1669	base := scratch('strip_resume')!1670	defer {1671		os.rmdir_all(base) or {}1672	}1673	remote_dir := os.join_path(base, 'remote')1674	mut backend := remote.new_folder(remote_dir)!1675	mut a := open_repo(machine('resume_a')!)!1676	defer {1677		a.close()1678	}1679	mut b := open_repo(machine('resume_b')!)!1680	defer {1681		b.close()1682	}1683	put(a.root, 'secrets.env', 'api_key=1\n')!1684	put(a.root, 'shared.env', 'same bytes on B\n')!1685	save := make_save(a, 'add secrets')!1686	offer_now(a, mut backend)!1687	join(b, mut backend)!1688	put(b.root, 'copy.txt', 'same bytes on B\n')!1689	b_save := make_save(b, 'B keeps a copy')!1690	offer_now(b, mut backend)!1691	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1692	secret_hash := storage.hash_hex('api_key=1\n'.bytes())16931694	strip_local(a, 'secrets.env', '')!1695	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1696	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1697	mut flaky := FaultyBackend{1698		inner:      remote.new_folder(remote_dir)!1699		fail_write: 31700	}1701	if _ := send_strip(a, mut flaky, 'secrets.env') {1702		assert false, 'the injected failure should have surfaced'1703	}1704	assert strip_pending(a, 'secrets.env')1705	assert strip_local(a, 'secrets.env', '')!.files_removed == 01706	report := send_strip(a, mut backend, 'secrets.env')!1707	assert report.records == 11708	assert !strip_pending(a, 'secrets.env')1709	assert !backend.get(object_key('proj', secret_hash))!.found1710	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1711	assert 'secrets.env' !in record.files.map(it.path)1712	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1713	assert 'secrets.env' !in b_record.files.map(it.path)17141715	// B's save still names the shared bytes, so stripping A's copy keeps them.1716	strip_local(a, 'shared.env', '')!1717	send_strip(a, mut backend, 'shared.env')!1718	assert backend.get(object_key('proj', shared_hash))!.found1719}17201721// A machine that has not heard of a strip must not offer: its records still1722// list the stripped path, and nothing would rewrite them on the remote again.1723fn test_offer_waits_for_an_unapplied_strip() {1724	base := scratch('strip_first')!1725	defer {1726		os.rmdir_all(base) or {}1727	}1728	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1729	mut a := open_repo(machine('strip_first_a')!)!1730	defer {1731		a.close()1732	}1733	mut b := open_repo(machine('strip_first_b')!)!1734	defer {1735		b.close()1736	}1737	put(a.root, 'secrets.env', 'api_key=1\n')!1738	make_save(a, 'add secrets')!1739	offer_now(a, mut backend)!1740	join(b, mut backend)!1741	strip_local(a, 'secrets.env', '')!1742	send_strip(a, mut backend, 'secrets.env')!1743	put(b.root, 'more.txt', 'B keeps working\n')!1744	later := make_save(b, 'B saves with the secret still in its tree')!1745	if _ := offer_now(b, mut backend) {1746		assert false, 'B offered records that still list the stripped path'1747	}1748	steal_now(b, mut backend)!1749	offer_now(b, mut backend)!1750	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1751	assert 'secrets.env' !in record.files.map(it.path)1752}17531754// A strip whose object deletions failed must still delete them when it is run1755// again, and meanwhile the banned path cannot come back in through a save.1756fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1757	base := scratch('strip_twice')!1758	defer {1759		os.rmdir_all(base) or {}1760	}1761	remote_dir := os.join_path(base, 'remote')1762	mut backend := remote.new_folder(remote_dir)!1763	mut a := open_repo(machine('strip_twice_a')!)!1764	defer {1765		a.close()1766	}1767	put(a.root, 'secrets.env', 'first key\n')!1768	make_save(a, 'first key')!1769	offer_now(a, mut backend)!1770	strip_local(a, 'secrets.env', '')!1771	mut flaky := FaultyBackend{1772		inner:       remote.new_folder(remote_dir)!1773		fail_delete: true1774	}1775	if _ := send_strip(a, mut flaky, 'secrets.env') {1776		assert false, 'the injected delete failure should have surfaced'1777	}1778	put(a.root, 'secrets.env', 'second key\n')!1779	put(a.root, 'other.txt', 'more work\n')!1780	make_save(a, 'more work')!1781	offer_now(a, mut backend)!1782	send_strip(a, mut backend, 'secrets.env')!1783	for key in ['first key\n', 'second key\n'] {1784		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1785	}1786}17871788// Unfinished strips of one path in different scopes finish together.1789fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1790	base := scratch('strip_scopes')!1791	defer {1792		os.rmdir_all(base) or {}1793	}1794	remote_dir := os.join_path(base, 'remote')1795	mut backend := remote.new_folder(remote_dir)!1796	mut a := open_repo(machine('strip_scopes_a')!)!1797	defer {1798		a.close()1799	}1800	put(a.root, 'secrets.env', 'first key\n')!1801	first := make_save(a, 'first key')!1802	put(a.root, 'secrets.env', 'second key\n')!1803	second := make_save(a, 'second key')!1804	offer_now(a, mut backend)!1805	strip_local(a, 'secrets.env', first.id)!1806	// Write 1 uploads the event; write 2, the record rewrite, fails.1807	mut flaky := FaultyBackend{1808		inner:      remote.new_folder(remote_dir)!1809		fail_write: 21810	}1811	if _ := send_strip(a, mut flaky, 'secrets.env') {1812		assert false, 'the injected failure should have surfaced'1813	}1814	strip_local(a, 'secrets.env', second.id)!1815	send_strip(a, mut backend, 'secrets.env')!1816	assert !strip_pending(a, 'secrets.env')1817	for save in [first, second] {1818		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1819		assert 'secrets.env' !in record.files.map(it.path)1820	}1821	for key in ['first key\n', 'second key\n'] {1822		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1823	}1824}18251826// A strip bans its path on every machine that has heard of it: written again,1827// the file makes no save, here or there, so there is never a second copy to strip.1828fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1829	base := scratch('strip_again')!1830	defer {1831		os.rmdir_all(base) or {}1832	}1833	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1834	mut a := open_repo(machine('strip_again_a')!)!1835	defer {1836		a.close()1837	}1838	mut b := open_repo(machine('strip_again_b')!)!1839	defer {1840		b.close()1841	}1842	put(a.root, 'secrets.env', 'first key\n')!1843	make_save(a, 'first key')!1844	offer_now(a, mut backend)!1845	join(b, mut backend)!1846	strip_local(a, 'secrets.env', '')!1847	send_strip(a, mut backend, 'secrets.env')!1848	steal_now(b, mut backend)!1849	for repo in [a, b] {1850		put(repo.root, 'secrets.env', 'second key\n')!1851		if _ := make_save(repo, 'second key') {1852			assert false, 'a banned path must not make a save'1853		}1854	}1855}18561857// A clash on a path the other machine stripped is no clash: the strip removes1858// the path from every save, so there is nothing to choose.1859fn test_a_stripped_path_is_not_asked_about() {1860	base := scratch('clash_strip')!1861	defer {1862		os.rmdir_all(base) or {}1863	}1864	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1865	mut a := open_repo(machine('clash_strip_a')!)!1866	defer {1867		a.close()1868	}1869	mut b := open_repo(machine('clash_strip_b')!)!1870	defer {1871		b.close()1872	}1873	put(a.root, 'secrets.env', 'key one\n')!1874	make_save(a, 'base')!1875	offer_now(a, mut backend)!1876	join(b, mut backend)!1877	put(a.root, 'secrets.env', 'key two\n')!1878	make_save(a, 'A rotates')!1879	offer_now(a, mut backend)!1880	strip_local(a, 'secrets.env', '')!1881	send_strip(a, mut backend, 'secrets.env')!1882	put(b.root, 'secrets.env', 'key three\n')!1883	mine := make_save(b, 'B rotates')!1884	report := steal(b, mut backend, StealOptions{})!1885	assert report.clashes.len == 01886	for save in history.all_saves(b)! {1887		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1888	}1889	assert history.by_id(b, mine.id) != none1890}18911892// A strip scoped to the shared base save changes what the replay sees; the1893// clashes must be found on the same trees, or the replay meets a conflict.1894fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1895	base := scratch('clash_base_strip')!1896	defer {1897		os.rmdir_all(base) or {}1898	}1899	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1900	mut a := open_repo(machine('base_strip_a')!)!1901	defer {1902		a.close()1903	}1904	mut b := open_repo(machine('base_strip_b')!)!1905	defer {1906		b.close()1907	}1908	put(a.root, 'notes.txt', 'shared\n')!1909	common := make_save(a, 'base')!1910	offer_now(a, mut backend)!1911	join(b, mut backend)!1912	put(a.root, 'other.txt', 'A moves on\n')!1913	make_save(a, 'A moves on')!1914	offer_now(a, mut backend)!1915	strip_local(a, 'notes.txt', common.id)!1916	send_strip(a, mut backend, 'notes.txt')!1917	put(b.root, 'notes.txt', 'B edits\n')!1918	make_save(b, 'B edits notes')!1919	if _ := steal(b, mut backend, StealOptions{}) {1920		assert false, 'with the base stripped, notes.txt differs on both sides'1921	} else {1922		assert err.msg().contains('notes.txt changed on both machines')1923	}1924	report := steal(b, mut backend, StealOptions{1925		mine: true1926	})!1927	assert report.clashes == ['notes.txt']1928	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1929}19301931// A folder with files but no saves has nothing to combine with; a steal that1932// fast-forwarded into it would overwrite whatever shares a name.1933fn test_steal_into_a_folder_with_unsaved_files_refuses() {1934	base := scratch('steal_unsaved')!1935	defer {1936		os.rmdir_all(base) or {}1937	}1938	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1939	mut a := open_repo(machine('unsaved_a')!)!1940	defer {1941		a.close()1942	}1943	put(a.root, 'notes.txt', 'from A\n')!1944	make_save(a, 'A writes')!1945	offer_now(a, mut backend)!1946	b_root := machine('unsaved_b')!1947	mut b := storage.create(b_root)!1948	defer {1949		b.close()1950	}1951	put(b_root, 'notes.txt', 'never saved\n')!1952	// Only a repo that already knows its remote name gets this far.1953	b.set_state('remote_repo', 'proj')!1954	if _ := steal(b, mut backend, StealOptions{}) {1955		assert false, 'steal overwrote a folder with unsaved files'1956	} else {1957		assert err.msg() == no_saves_here1958	}1959	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1960	// A fresh `koh init` folder, holding only the default ignore list, is fine.1961	os.rm(os.join_path(b_root, 'notes.txt'))!1962	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1963	steal(b, mut backend, StealOptions{})!1964	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1965}19661967// The document asks steal to warn about every incoming flagged save, so the1968// report names them.1969fn test_steal_reports_incoming_flagged_saves() {1970	base := scratch('flag_warn')!1971	defer {1972		os.rmdir_all(base) or {}1973	}1974	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1975	mut a := open_repo(machine('flag_warn_a')!)!1976	defer {1977		a.close()1978	}1979	mut b := open_repo(machine('flag_warn_b')!)!1980	defer {1981		b.close()1982	}1983	put(a.root, 'a.txt', 'one\n')!1984	make_save(a, 'fine')!1985	offer_now(a, mut backend)!1986	join(b, mut backend)!1987	put(a.root, 'a.txt', 'two\n')!1988	suspect := make_save(a, 'suspect')!1989	put(a.root, 'a.txt', 'three\n')!1990	make_save(a, 'also fine')!1991	events.flag(a, suspect.id, 'broke the build')!1992	offer_now(a, mut backend)!1993	report := steal_now(b, mut backend)!1994	assert report.flagged.map(it.save_id) == [suspect.id]1995	assert report.flagged[0].reason == 'broke the build'1996}19971998// head_of unwraps the head for tests that know the repo has saves.1999fn head_of(store &storage.Store) history.Save {2000	return history.head(store) or { panic('this repo has no head') }2001}20022003// A remote says what it is, so two koh versions never half-understand one2004// bucket. The first offer of a repo records the layout.2005fn test_the_first_offer_records_the_remote_format() {2006	base := scratch('format_write')!2007	defer {2008		os.rmdir_all(base) or {}2009	}2010	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2011	mut a := open_repo(machine('format_write_a')!)!2012	defer {2013		a.close()2014	}2015	put(a.root, 'a.txt', 'one\n')!2016	make_save(a, 'first')!2017	offer_now(a, mut backend)!2018	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2019}20202021// A repo offered before the key existed is version 1: it still syncs, and the2022// next offer records the key then.2023fn test_a_remote_without_a_format_still_syncs() {2024	base := scratch('format_old')!2025	defer {2026		os.rmdir_all(base) or {}2027	}2028	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2029	mut a := open_repo(machine('format_old_a')!)!2030	defer {2031		a.close()2032	}2033	put(a.root, 'a.txt', 'one\n')!2034	make_save(a, 'first')!2035	offer_now(a, mut backend)!2036	// Stand in for a bucket offered before the key existed.2037	backend.delete(format_key('proj'))!2038	mut b := open_repo(machine('format_old_b')!)!2039	defer {2040		b.close()2041	}2042	join(b, mut backend)!2043	assert head_of(b).id == head_of(a).id2044	put(a.root, 'a.txt', 'two\n')!2045	make_save(a, 'second')!2046	offer_now(a, mut backend)!2047	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value2048	report := steal_now(b, mut backend)!2049	assert report.head == head_of(a).id2050}20512052// A remote that speaks another layout is refused loudly, on offer and on2053// steal, rather than half-read.2054fn test_an_unknown_remote_format_is_refused() {2055	base := scratch('format_unknown')!2056	defer {2057		os.rmdir_all(base) or {}2058	}2059	mut backend := remote.new_folder(os.join_path(base, 'remote'))!2060	mut a := open_repo(machine('format_bad_a')!)!2061	defer {2062		a.close()2063	}2064	put(a.root, 'a.txt', 'one\n')!2065	make_save(a, 'first')!2066	offer_now(a, mut backend)!2067	backend.delete(format_key('proj'))!2068	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!2069	if _ := offer_now(a, mut backend) {2070		assert false, 'offer must refuse a format it does not speak'2071	} else {2072		assert err.msg().contains('koh-remote 2')2073	}2074	if _ := steal_now(a, mut backend) {2075		assert false, 'steal must refuse a format it does not speak'2076	} else {2077		assert err.msg().contains('koh-remote 2')2078	}2079	if _ := fetch_repo(mut backend, 'proj') {2080		assert false, 'a steal must refuse a format it does not speak'2081	} else {2082		assert err.msg().contains('koh-remote 2')2083	}2084}20852086// Two machines offering a new repo at once both find no format key; the loser2087// of the conditional write must read back the winner's value and refuse it,2088// not carry on against a remote it cannot read.2089fn test_a_lost_format_write_is_read_back_and_refused() {2090	base := scratch('format_race')!2091	defer {2092		os.rmdir_all(base) or {}2093	}2094	mut inner := remote.new_folder(os.join_path(base, 'remote'))!2095	mut a := open_repo(machine('format_race_a')!)!2096	defer {2097		a.close()2098	}2099	put(a.root, 'a.txt', 'one\n')!2100	make_save(a, 'first')!2101	mut backend := remote.Backend(FaultyBackend{2102		inner:       inner2103		race_format: true2104	})2105	if _ := offer_now(a, mut backend) {2106		assert false, 'offer must refuse the format another machine wrote first'2107	} else {2108		assert err.msg().contains('koh-remote 2')2109	}2110}21112112fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2113	whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2114	for size in [1, 2, 5, whole.len] {2115		mut bundle := Bundle{}2116		for at := 0; at < whole.len; at += size {2117			bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!2118		}2119		bundle.finish()!2120		assert bundle.fetched.repo_objects == {2121			'aa': 'abc'.bytes()2122			'bb': []u8{}2123		}2124	}2125	for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2126		'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2127		mut bundle := Bundle{}2128		bundle.feed(cut.bytes()) or { continue }2129		bundle.finish() or { continue }2130		assert false, cut2131	}2132}21332134fn test_strip_batch_rolls_back_all_paths_and_handles_overlap() {2135	mut store := open_repo(machine('strip_batch')!)!2136	defer { store.close() }2137	put(store.root, 'private/a', 'a')!2138	put(store.root, 'secret', 'secret')!2139	put(store.root, 'keep', 'keep')!2140	save := make_save(store, 'initial')!2141	store.db.exec("CREATE TRIGGER block_secret BEFORE DELETE ON save_files2142		WHEN OLD.path = 'secret' BEGIN SELECT RAISE(ABORT, 'blocked'); END")!2143	if _ := strip_local_batch(store, ['private', 'secret'], '') {2144		assert false, 'batch succeeded despite the second path failing'2145	}2146	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep', 'private/a',2147		'secret']2148	assert events.strip_events(store)!.len == 02149	assert !strip_pending(store, 'private')2150	store.db.exec('DROP TRIGGER block_secret')!2151	reports := strip_local_batch(store, ['private', 'private/a', 'secret'], '')!2152	assert reports.map(it.files_removed) == [1, 0, 1]2153	assert history.files_of(store, save.id)!.map(it.path) == ['.kohignore', 'keep']2154	assert events.strip_events(store)!.len == 22155}21562157fn test_strip_batch_limits_every_path_to_selected_save() {2158	mut store := open_repo(machine('strip_batch_scope')!)!2159	defer { store.close() }2160	put(store.root, 'one', 'one')!2161	put(store.root, 'two', 'two')!2162	first := make_save(store, 'initial')!2163	put(store.root, 'keep', 'keep')!2164	second := make_save(store, 'next')!2165	strip_local_batch(store, ['one', 'two'], first.id)!2166	assert history.files_of(store, first.id)!.map(it.path) == ['.kohignore']2167	assert history.files_of(store, second.id)!.map(it.path) == ['.kohignore', 'keep', 'one', 'two']2168	assert events.strip_events(store)!.all(it.save_id == first.id)2169}21702171fn test_strip_batch_keeps_independent_remote_retry_notes() {2172	mut backend := remote.new_folder(scratch('strip_batch_remote')!)!2173	mut store := open_repo(machine('strip_batch_retry')!)!2174	defer { store.close() }2175	put(store.root, 'one', 'secret')!2176	put(store.root, 'two', 'secret')!2177	save := make_save(store, 'initial')!2178	offer_now(store, mut backend)!2179	strip_local_batch(store, ['one', 'two'], '')!2180	mut flaky := FaultyBackend{ inner: backend, fail_write: 1 }2181	if _ := send_strip(store, mut flaky, 'one') {2182		assert false, 'injected remote failure was not reported'2183	}2184	send_strip(store, mut backend, 'two')!2185	assert strip_pending(store, 'one') && !strip_pending(store, 'two')2186	partial := decode_save(backend.get(save_key('proj', save.id))!.body)!2187	assert partial.files.map(it.path) == ['.kohignore', 'one']2188	assert backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2189	send_strip(store, mut backend, 'one')!2190	assert !strip_pending(store, 'one')2191	finished := decode_save(backend.get(save_key('proj', save.id))!.body)!2192	assert finished.files.map(it.path) == ['.kohignore']2193	assert !backend.get(object_key('proj', storage.hash_hex('secret'.bytes())))!.found2194}