exchange_test.v

50.6 KB · raw

1//! Two-machine tests: two repos and one folder remote standing in for the Mac2//! and the PC. This is where offer, steal and combine are actually proven —3//! the folder backend exists so these run offline.45module exchange67import os8import src.events9import src.history10import src.remote11import src.snapshot12import src.storage1314fn test_arriving_record_drops_unsafe_paths_and_reports_them() {15	mut record := SaveRecord{16		files: [17			FileRecord{ path: 'safe.txt' },18			FileRecord{ path: '../outside.txt' },19			FileRecord{ path: 'bad\\name.txt' },20			FileRecord{ path: 'Icon\r' },21		]22	}23	dropped := drop_unsafe_paths(mut record)24	assert record.files.map(it.path) == ['safe.txt']25	assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']26}2728// An unpaired surrogate escape stands for its three bytes, so the record still29// decodes and only the path holding it is dropped; a real pair is kept.30fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {31	body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'32	mut record := decode_save(body.bytes())!33	dropped := drop_unsafe_paths(mut record)34	assert record.files.map(it.path) == ['ok😀']35	assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']36}3738// Machines keep their repo in a folder of the same NAME, because the remote key39// is the folder's name; that is what makes them the same repo to the remote.40fn machine(name string) !string {41	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}', 'proj')42	os.rmdir_all(os.dir(dir)) or {}43	os.mkdir_all(dir, os.MkdirParams{})!44	return dir45}4647fn scratch(name string) !string {48	dir := os.join_path(os.temp_dir(), 'koh3_sync_${os.getpid()}_${name}')49	os.rmdir_all(dir) or {}50	os.mkdir_all(dir, os.MkdirParams{})!51	return dir52}5354fn open_repo(dir string) !storage.Store {55	mut store := storage.create(dir)!56	os.write_file(os.join_path(dir, '.kohignore'), '*.log\n')!57	return store58}5960fn put(root string, rel string, text string) ! {61	full := os.join_path(root, rel)62	os.mkdir_all(os.dir(full), os.MkdirParams{})!63	os.write_file(full, text)!64}6566fn make_save(store &storage.Store, message string) !history.Save {67	return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{68		message: message69	})!70}7172fn offer_now(store &storage.Store, mut backend remote.Backend) !OfferReport {73	return offer(store, mut backend)!74}7576fn steal_now(store &storage.Store, mut backend remote.Backend) !StealReport {77	return steal(store, mut backend, StealOptions{78		theirs: true79	})!80}8182fn test_first_offer_publishes_the_whole_line() {83	base := scratch('first')!84	defer {85		os.rmdir_all(base) or {}86	}87	mut a := open_repo(machine('first_a')!)!88	defer {89		a.close()90	}91	put(a.root, 'a.txt', 'one\n')!92	make_save(a, 'first')!93	put(a.root, 'a.txt', 'two\n')!94	make_save(a, 'second')!95	mut backend := remote.new_folder(os.join_path(base, 'remote'))!9697	report := offer_now(a, mut backend)!98	assert report.repo == 'proj'99	assert report.saves == 2100	assert report.objects >= 2101	assert backend.get(head_key('proj'))!.found102	assert backend.list('proj/saves')!.len == 2103104	// A second offer with nothing new uploads nothing and still succeeds.105	again := offer_now(a, mut backend)!106	assert again.saves == 0107}108109fn test_the_repo_name_is_remembered_and_head_offered() {110	base := scratch('remember')!111	defer {112		os.rmdir_all(base) or {}113	}114	mut a := open_repo(machine('remember_a')!)!115	defer {116		a.close()117	}118	put(a.root, 'a.txt', 'one\n')!119	make_save(a, 'first')!120	mut backend := remote.new_folder(os.join_path(base, 'remote'))!121	offer_now(a, mut backend)!122	assert a.state('remote_repo') or { '' } == 'proj'123	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id124	// Offer's step 3: the new remote head is recorded in state.125	assert a.state('remote_head') or { '' } == head_of(a).id126127	// A second offer, with the remote already holding our head, must not128	// refuse: the name is remembered, so the remote is ours.129	put(a.root, 'a.txt', 'two\n')!130	make_save(a, 'second')!131	offer_now(a, mut backend)!132	assert backend.get(head_key('proj'))!.body.bytestr() == head_of(a).id133}134135fn test_a_repo_name_is_lowercased_at_first_offer() {136	base := scratch('lower')!137	defer {138		os.rmdir_all(base) or {}139	}140	dir := os.join_path(base, 'Haven')141	os.mkdir_all(dir, os.MkdirParams{})!142	mut a := open_repo(dir)!143	defer {144		a.close()145	}146	put(a.root, 'a.txt', 'one\n')!147	make_save(a, 'first')!148	mut backend := remote.new_folder(os.join_path(base, 'remote'))!149	assert offer_now(a, mut backend)!.repo == 'haven'150	assert backend.get(head_key('haven'))!.found151}152153fn test_an_offer_follows_a_rename_kepr_marked_on_the_remote() {154	base := scratch('renamed')!155	defer {156		os.rmdir_all(base) or {}157	}158	mut a := open_repo(machine('renamed_a')!)!159	defer {160		a.close()161	}162	put(a.root, 'a.txt', 'one\n')!163	make_save(a, 'first')!164	remote_dir := os.join_path(base, 'remote')165	mut backend := remote.new_folder(remote_dir)!166	offer_now(a, mut backend)!167	// What Kepr's rename leaves: the history under the new name, a marker, no old name.168	os.mv(os.join_path(remote_dir, 'proj'), os.join_path(remote_dir, 'tool'))!169	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!170	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), 'tool\n')!171172	put(a.root, 'a.txt', 'two\n')!173	make_save(a, 'second')!174	assert offer_now(a, mut backend)!.repo == 'tool'175	assert a.state('remote_repo') or { '' } == 'tool'176	assert backend.get(head_key('tool'))!.body.bytestr() == head_of(a).id177	assert !backend.get(head_key('proj'))!.found178}179180fn test_a_rename_marker_naming_a_path_is_not_followed() {181	base := scratch('badmarker')!182	defer {183		os.rmdir_all(base) or {}184	}185	mut a := open_repo(machine('badmarker_a')!)!186	defer {187		a.close()188	}189	put(a.root, 'a.txt', 'one\n')!190	make_save(a, 'first')!191	remote_dir := os.join_path(base, 'remote')192	mut backend := remote.new_folder(remote_dir)!193	offer_now(a, mut backend)!194	os.rmdir_all(os.join_path(remote_dir, 'proj'))!195	os.mkdir_all(os.join_path(remote_dir, '.kepr', 'renamed'), os.MkdirParams{})!196	os.write_file(os.join_path(remote_dir, '.kepr', 'renamed', 'proj'), '../elsewhere\n')!197	assert offer_now(a, mut backend)!.repo == 'proj'198}199200fn test_steal_fast_forwards_a_machine_that_has_nothing_of_its_own() {201	base := scratch('forward')!202	defer {203		os.rmdir_all(base) or {}204	}205	mut backend := remote.new_folder(os.join_path(base, 'remote'))!206	mut a := open_repo(machine('fwd_a')!)!207	defer {208		a.close()209	}210	put(a.root, 'a.txt', 'from A\n')!211	make_save(a, 'A writes')!212	offer_now(a, mut backend)!213214	// B starts empty and takes the whole repo.215	mut b := open_repo(machine('fwd_b')!)!216	defer {217		b.close()218	}219	fetched := fetch_repo(mut backend, 'proj')!220	b.begin()!221	write_fetched(b, fetched)!222	history.set_head(b, fetched.head)!223	b.end()!224	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!225	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'226	assert head_of(b).id == fetched.head227}228229// A machine that is only ahead has nothing to combine: stealing must leave its230// saves, and their IDs, exactly where they are.231fn test_steal_while_only_ahead_leaves_the_line_alone() {232	base := scratch('ahead')!233	defer {234		os.rmdir_all(base) or {}235	}236	mut backend := remote.new_folder(os.join_path(base, 'remote'))!237	mut a := open_repo(machine('ahead_a')!)!238	defer {239		a.close()240	}241	put(a.root, 'a.txt', 'one\n')!242	make_save(a, 'offered')!243	offer_now(a, mut backend)!244	put(a.root, 'a.txt', 'two\n')!245	ahead := make_save(a, 'not offered yet')!246	report := steal_now(a, mut backend)!247	assert report.replayed == 0248	assert head_of(a).id == ahead.id249	// Nothing was restored, so nothing is left marked as half-restored.250	assert !snapshot.restore_pending(a)251	assert history.all_saves(a)!.len == 2252	offer_now(a, mut backend)!253	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id254}255256// A link is invisible to the walk, so a tree holding one is clean; the files257// a steal brings in must still never land in the folder it points at.258fn test_steal_never_writes_through_a_symlinked_folder() {259	base := scratch('steal_link')!260	defer {261		os.rmdir_all(base) or {}262	}263	mut backend := remote.new_folder(os.join_path(base, 'remote'))!264	mut a := open_repo(machine('steal_link_a')!)!265	defer {266		a.close()267	}268	mut b := open_repo(machine('steal_link_b')!)!269	defer {270		b.close()271	}272	put(a.root, 'a.txt', 'one\n')!273	make_save(a, 'first')!274	offer_now(a, mut backend)!275	join(b, mut backend)!276	outside := os.join_path(base, 'outside')277	put(outside, 'a.md', 'precious\n')!278	os.symlink(outside, os.join_path(b.root, 'docs'))!279	put(a.root, 'docs/a.md', 'from A\n')!280	make_save(a, 'A adds docs')!281	offer_now(a, mut backend)!282	head_before := head_of(b).id283	if _ := steal_now(b, mut backend) {284		assert false, 'a steal writing through a symlinked folder must refuse'285	} else {286		assert err.msg().contains('docs')287	}288	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'289	assert head_of(b).id == head_before290}291292// A steal onto a half-restored tree would replay it as somebody's work.293fn test_steal_refuses_after_a_restore_cut_short() {294	base := scratch('steal_cut')!295	defer {296		os.rmdir_all(base) or {}297	}298	mut backend := remote.new_folder(os.join_path(base, 'remote'))!299	mut a := open_repo(machine('steal_cut_a')!)!300	defer {301		a.close()302	}303	put(a.root, 'a.txt', 'one\n')!304	make_save(a, 'first')!305	offer_now(a, mut backend)!306	a.set_state('restoring', 'yes')!307	if _ := steal_now(a, mut backend) {308		assert false, 'steal must refuse while a restore is unfinished'309	} else {310		assert err.msg().contains('koh revert')311	}312}313314// A replayed save keeps its original time but sits on a newer parent, so a315// steal cannot order saves by time.316fn test_a_steal_imports_parents_before_children() {317	base := scratch('steal_order')!318	defer {319		os.rmdir_all(base) or {}320	}321	mut backend := remote.new_folder(os.join_path(base, 'remote'))!322	mut a := open_repo(machine('order_a')!)!323	defer {324		a.close()325	}326	put(a.root, 'a.txt', 'one\n')!327	parent := make_save(a, 'parent')!328	child := history.save_tree(a, '', history.files_of(a, parent.id)!, history.SaveInput{329		message:    'replayed, older than its parent'330		created_at: parent.created_at - 3600331	})!332	offer_now(a, mut backend)!333	mut b := open_repo(machine('order_b')!)!334	defer {335		b.close()336	}337	join(b, mut backend)!338	assert head_of(b).id == child.id339}340341// A chmod is unsaved work too: a steal would restore the old mode over it.342fn test_steal_refuses_an_unsaved_executable_bit_change() {343	base := scratch('chmod')!344	defer {345		os.rmdir_all(base) or {}346	}347	mut backend := remote.new_folder(os.join_path(base, 'remote'))!348	mut a := open_repo(machine('chmod_a')!)!349	defer {350		a.close()351	}352	put(a.root, 'run.sh', 'echo hi\n')!353	make_save(a, 'script')!354	offer_now(a, mut backend)!355	os.chmod(os.join_path(a.root, 'run.sh'), 0o755)!356	if _ := steal_now(a, mut backend) {357		assert false, 'a chmod-only change should make the tree dirty'358	} else {359		assert err.msg() == dirty_tree360	}361}362363fn test_divergence_without_clashes_keeps_both_machines_work() {364	base := scratch('diverge')!365	defer {366		os.rmdir_all(base) or {}367	}368	mut backend := remote.new_folder(os.join_path(base, 'remote'))!369	mut a := open_repo(machine('div_a')!)!370	defer {371		a.close()372	}373	mut b := open_repo(machine('div_b')!)!374	defer {375		b.close()376	}377	put(a.root, 'shared.txt', 'base\n')!378	make_save(a, 'base')!379	offer_now(a, mut backend)!380381	// B joins, then both machines change a different file.382	join(b, mut backend)!383	put(a.root, 'fromA.txt', 'A\n')!384	make_save(a, 'A adds a file')!385	put(b.root, 'fromB.txt', 'B\n')!386	make_save(b, 'B adds a file')!387	offer_now(a, mut backend)!388389	// B's offer is refused: the remote has moved on.390	if _ := offer_now(b, mut backend) {391		assert false, 'offering on top of unknown remote saves should be refused'392	} else {393		assert err.msg().contains('run koh steal')394	}395396	report := steal_now(b, mut backend)!397	assert !report.forward398	assert report.replayed == 1399	assert report.clashes.len == 0400	// Both files are on B's line now, and on disk.401	assert os.read_file(os.join_path(b.root, 'fromA.txt'))! == 'A\n'402	assert os.read_file(os.join_path(b.root, 'fromB.txt'))! == 'B\n'403	assert os.read_file(os.join_path(b.root, 'shared.txt'))! == 'base\n'404	// B's own save was replayed, not erased.405	mut saw_replay := false406	for save in history.all_saves(b)! {407		if save.replayed_from != '' {408			saw_replay = true409		}410	}411	assert saw_replay412	// And the remote now has B's work too.413	offer_now(b, mut backend)!414	line_head := backend.get(head_key('proj'))!.body.bytestr().trim_space()415	assert line_head == head_of(b).id416}417418// An agent that edits a file and then undoes the edit leaves no net change, so419// the file is not a clash when the other machine changed it. Replaying the420// edit itself must not stop the steal: the remote's version simply stands.421// Likewise a file both machines ended up changing the same way.422fn test_a_local_change_undone_does_not_block_a_combine() {423	base := scratch('undone')!424	defer {425		os.rmdir_all(base) or {}426	}427	mut backend := remote.new_folder(os.join_path(base, 'remote'))!428	mut a := open_repo(machine('undone_a')!)!429	defer {430		a.close()431	}432	mut b := open_repo(machine('undone_b')!)!433	defer {434		b.close()435	}436	put(a.root, 'f.txt', 'base\n')!437	put(a.root, 'g.txt', 'base\n')!438	make_save(a, 'base')!439	offer_now(a, mut backend)!440	join(b, mut backend)!441442	put(a.root, 'f.txt', 'from A\n')!443	put(a.root, 'g.txt', 'same\n')!444	make_save(a, 'A edits both')!445	offer_now(a, mut backend)!446447	put(b.root, 'f.txt', 'tried on B\n')!448	put(b.root, 'g.txt', 'first try\n')!449	tried := make_save(b, 'B tries')!450	put(b.root, 'f.txt', 'base\n')!451	put(b.root, 'g.txt', 'same\n')!452	put(b.root, 'h.txt', 'B\n')!453	undone := make_save(b, 'B undoes f, lands on the same g, adds h')!454455	report := steal_now(b, mut backend)!456	assert report.clashes.len == 0457	assert os.read_file(os.join_path(b.root, 'f.txt'))! == 'from A\n'458	assert os.read_file(os.join_path(b.root, 'g.txt'))! == 'same\n'459	assert os.read_file(os.join_path(b.root, 'h.txt'))! == 'B\n'460	// "B tries" only touched files A changed, so it has nothing left to461	// replay; the second save carries h.txt. Both originals stay in history.462	assert report.replayed == 1463	assert head_of(b).replayed_from == undone.id464	ids := history.all_saves(b)!.map(it.id)465	assert tried.id in ids && undone.id in ids466	assert history.files_of(b, tried.id)!.any(it.path == 'f.txt')467}468469// A person deciding a clash may take minutes; the question must come before470// the write lock, or every agent's save on this repo waits and then fails.471fn test_clash_questions_are_asked_before_the_write_lock() {472	base := scratch('ask_unlocked')!473	defer {474		os.rmdir_all(base) or {}475	}476	mut backend := remote.new_folder(os.join_path(base, 'remote'))!477	mut a := open_repo(machine('ask_a')!)!478	defer {479		a.close()480	}481	mut b := open_repo(machine('ask_b')!)!482	defer {483		b.close()484	}485	put(a.root, 'same.txt', 'base\n')!486	make_save(a, 'base')!487	offer_now(a, mut backend)!488	join(b, mut backend)!489	put(a.root, 'same.txt', 'A version\n')!490	make_save(a, 'A edits same')!491	offer_now(a, mut backend)!492	put(b.root, 'same.txt', 'B version\n')!493	make_save(b, 'B edits same')!494	root := b.root495	// An agent saves while the person is still choosing.496	if _ := steal(b, mut backend, StealOptions{497		guide: fn [root] (preview StealPreview) !map[string]Side {498			other := storage.open(root) or { panic(err) }499			defer {500				other.close()501			}502			os.write_file(os.join_path(root, 'agent.txt'), 'saved meanwhile\n') or { panic(err) }503			history.save_worktree(other, root, snapshot.load(root), history.SaveInput{504				message: 'agent saves during the question'505			}) or { panic('steal held the write lock while asking: ${err}') }506			return {507				preview.clashes[0].path: Side.mine508			}509		}510	})511	{512		assert false, 'a steal whose line moved during the question must refuse'513	} else {514		assert err.msg().contains('changed while')515	}516	// The agent's save is still head, and a second steal combines it too.517	assert head_of(b).message == 'agent saves during the question'518	report := steal(b, mut backend, StealOptions{519		mine: true520	})!521	assert report.kept_mine == 1522	assert os.read_file(os.join_path(b.root, 'agent.txt'))! == 'saved meanwhile\n'523	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'524}525526fn test_clashing_files_are_chosen_one_at_a_time() {527	base := scratch('clash')!528	defer {529		os.rmdir_all(base) or {}530	}531	mut backend := remote.new_folder(os.join_path(base, 'remote'))!532	mut a := open_repo(machine('clash_a')!)!533	defer {534		a.close()535	}536	mut b := open_repo(machine('clash_b')!)!537	defer {538		b.close()539	}540	put(a.root, 'same.txt', 'base\n')!541	put(a.root, 'other.txt', 'quiet\n')!542	make_save(a, 'base')!543	offer_now(a, mut backend)!544	join(b, mut backend)!545546	// Both machines edit the same file, differently.547	put(a.root, 'same.txt', 'A version\n')!548	make_save(a, 'A edits same')!549	offer_now(a, mut backend)!550	put(b.root, 'same.txt', 'B version\n')!551	make_save(b, 'B edits same')!552553	// Without a decision, steal refuses and says which files clash.554	if _ := steal(b, mut backend, StealOptions{}) {555		assert false, 'an undecided clash must be refused'556	} else {557		assert err.msg().contains('--mine') || err.msg().contains('--theirs')558		// `koh offer` has no --mine or --theirs, so it needs the paths, not this sentence.559		if err is ClashError {560			assert err.paths == ['same.txt']561		} else {562			assert false, 'an undecided clash must be a ClashError, not ${err}'563		}564	}565	assert find_clashes(history.files_of(b, head_of(b).id)!, history.files_of(b, head_of(b).id)!, history.files_of(b,566		head_of(b).id)!).len == 0567568	// Choosing the remote's version ends with A's content.569	report := steal_now(b, mut backend)!570	assert report.clashes == ['same.txt']571	assert report.kept_mine == 0572	assert report.kept_versions == ['same.txt:${report.previous_head}']573	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'A version\n'574	assert os.read_file(os.join_path(b.root, 'other.txt'))! == 'quiet\n'575}576577fn test_cancelling_a_guided_steal_keeps_the_line_and_can_retry() {578	base := scratch('guided_cancel')!579	defer { os.rmdir_all(base) or {} }580	mut backend := remote.new_folder(os.join_path(base, 'remote'))!581	mut a := open_repo(machine('guided_cancel_a')!)!582	defer { a.close() }583	mut b := open_repo(machine('guided_cancel_b')!)!584	defer { b.close() }585	put(a.root, 'same.txt', 'base\n')!586	make_save(a, 'base')!587	offer_now(a, mut backend)!588	join(b, mut backend)!589	put(a.root, 'same.txt', 'remote\n')!590	remote_head := make_save(a, 'remote change')!591	offer_now(a, mut backend)!592	put(b.root, 'same.txt', 'mine\n')!593	local_head := make_save(b, 'local change')!594595	if _ := steal(b, mut backend, StealOptions{596		guide: fn (preview StealPreview) !map[string]Side {597			assert preview.clashes.len == 1598			return error('cancelled')599		}600	}) {601		assert false, 'the guide cancelled'602	} else {603		assert err.msg() == 'cancelled'604	}605	assert head_of(b).id == local_head.id606	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'mine\n'607	assert history.by_id(b, remote_head.id) != none608	assert steal(b, mut backend, StealOptions{ mine: true })!.kept_mine == 1609}610611fn test_choosing_mine_keeps_the_local_version() {612	base := scratch('mine')!613	defer {614		os.rmdir_all(base) or {}615	}616	mut backend := remote.new_folder(os.join_path(base, 'remote'))!617	mut a := open_repo(machine('mine_a')!)!618	defer {619		a.close()620	}621	mut b := open_repo(machine('mine_b')!)!622	defer {623		b.close()624	}625	put(a.root, 'same.txt', 'base\n')!626	make_save(a, 'base')!627	offer_now(a, mut backend)!628	join(b, mut backend)!629	put(a.root, 'same.txt', 'A version\n')!630	make_save(a, 'A edits')!631	offer_now(a, mut backend)!632	put(b.root, 'same.txt', 'B version\n')!633	make_save(b, 'B edits')!634635	report := steal(b, mut backend, StealOptions{636		mine: true637	})!638	assert report.kept_mine == 1639	assert os.read_file(os.join_path(b.root, 'same.txt'))! == 'B version\n'640	// The final save says what it kept.641	assert head_of(b).message.contains('combine: kept mine for 1 file')642	// The pre-combine tree is still reachable in history.643	assert head_of(b).restores == ''644	assert history.all_saves(b)!.len >= 4645}646647fn test_flags_travel_with_the_saves() {648	base := scratch('flags')!649	defer {650		os.rmdir_all(base) or {}651	}652	mut backend := remote.new_folder(os.join_path(base, 'remote'))!653	mut a := open_repo(machine('flag_a')!)!654	defer {655		a.close()656	}657	mut b := open_repo(machine('flag_b')!)!658	defer {659		b.close()660	}661	put(a.root, 'a.txt', 'one\n')!662	save := make_save(a, 'suspect save')!663	events.flag(a, save.id, 'look at this')!664	offer_now(a, mut backend)!665666	// B has nothing yet, so a fetch brings the save and its flag across.667	fetched := fetch_back_to_known(b, mut backend, 'proj')!668	assert fetched.events_out.len == 1669	assert fetched.events_out[0].kind == events.kind_flag670	b.begin()!671	write_fetched(b, fetched)!672	history.set_head(b, fetched.head)!673	b.end()!674	state := events.flag_state(b, save.id)!675	assert state.flagged676	assert state.reason == 'look at this'677}678679fn test_a_second_offer_racing_head_is_refused_not_forced() {680	base := scratch('race')!681	defer {682		os.rmdir_all(base) or {}683	}684	mut backend := remote.new_folder(os.join_path(base, 'remote'))!685	mut a := open_repo(machine('race_a')!)!686	defer {687		a.close()688	}689	put(a.root, 'a.txt', 'one\n')!690	make_save(a, 'base')!691	offer_now(a, mut backend)!692693	// Someone else replaces head behind our back.694	assert backend.put_if_match(head_key('proj'), 'a-save-we-do-not-have'.bytes(),695		backend.get(head_key('proj'))!.tag)!696697	put(a.root, 'a.txt', 'two\n')!698	make_save(a, 'second')!699	if _ := offer_now(a, mut backend) {700		assert false, 'offering while the remote head moved must be refused'701	} else {702		assert err.msg().contains('run koh steal')703	}704}705706// join makes a brand new machine share the remote's line.707fn join(store &storage.Store, mut backend remote.Backend) ! {708	fetched := fetch_repo(mut backend, 'proj')!709	store.begin()!710	write_fetched(store, fetched)!711	history.set_head(store, fetched.head)!712	store.end()!713	snapshot.restore_files(store, store.root, snapshot.load(store.root), history.files_of(store,714		fetched.head)!, [])!715	store.set_state('remote_repo', 'proj')!716}717718struct FaultyBackend {719mut:720	inner      remote.Backend721	writes     int722	reads      int723	fail_write int // fail when the write counter reaches this, 0 never724	fail_read  int725	// fail every delete while set726	fail_delete bool727	// race_format stands in for another machine writing the format key first:728	// the first `format` write loses, to a value this koh does not speak.729	race_format bool730	raced       bool731}732733fn (mut f FaultyBackend) get(key string) !remote.Object {734	f.reads++735	if f.fail_read > 0 && f.reads >= f.fail_read {736		return error('injected read failure')737	}738	return f.inner.get(key)!739}740741fn (mut f FaultyBackend) list(prefix string) ![]string {742	return f.inner.list(prefix)!743}744745fn (mut f FaultyBackend) put_if_absent(key string, body []u8) !bool {746	f.writes++747	if f.fail_write > 0 && f.writes >= f.fail_write {748		return error('injected write failure')749	}750	if f.race_format && !f.raced && key.ends_with('/format') {751		f.raced = true752		f.inner.put_if_absent(key, 'koh-remote 2'.bytes())!753		return false754	}755	return f.inner.put_if_absent(key, body)!756}757758fn (mut f FaultyBackend) put_if_match(key string, body []u8, tag string) !bool {759	f.writes++760	if f.fail_write > 0 && f.writes >= f.fail_write {761		return error('injected write failure')762	}763	return f.inner.put_if_match(key, body, tag)!764}765766fn (mut f FaultyBackend) delete(key string) ! {767	if f.fail_delete {768		return error('injected delete failure')769	}770	f.inner.delete(key)!771}772773// Every save offers after it is made, so a one-file change must cost one774// object, not one conditional write per file in the repo: on S3 each of those775// is a round trip.776fn test_an_offer_sends_only_the_objects_a_save_adds() {777	base := scratch('offer_diff')!778	defer {779		os.rmdir_all(base) or {}780	}781	mut a := open_repo(machine('offer_diff_a')!)!782	defer {783		a.close()784	}785	for i in 0 .. 20 {786		put(a.root, 'f${i}.txt', 'file ${i}\n')!787	}788	make_save(a, 'twenty files')!789	mut backend := FaultyBackend{790		inner: remote.new_folder(os.join_path(base, 'remote'))!791	}792	offer(a, mut backend)!793	put(a.root, 'f3.txt', 'changed\n')!794	make_save(a, 'one change')!795	before := backend.writes796	report := offer(a, mut backend)!797	assert report.objects == 1798	// One object, one save record, one head swap.799	assert backend.writes - before == 3800}801802fn test_a_failed_offer_is_safe_to_re_run() {803	base := scratch('fault_offer')!804	defer {805		os.rmdir_all(base) or {}806	}807	remote_dir := os.join_path(base, 'remote')808	mut a := open_repo(machine('fault_a')!)!809	defer {810		a.close()811	}812	put(a.root, 'a.txt', 'one\n')!813	make_save(a, 'first')!814	put(a.root, 'a.txt', 'two\n')!815	make_save(a, 'second')!816817	// The first offer dies after the first write.818	mut flaky := FaultyBackend{819		inner:      remote.new_folder(remote_dir)!820		fail_write: 2821	}822	if _ := offer(a, mut flaky) {823		assert false, 'the injected failure should have surfaced'824	}825826	// Re-running with a healthy remote finishes the job: "already exists" is827	// success, so nothing is lost and nothing is duplicated.828	mut healthy := remote.new_folder(remote_dir)!829	report := offer(a, mut healthy)!830	assert report.saves == 2831	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id832	assert healthy.list('proj/saves')!.len == 2833}834835// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,836// and it must neither remove anything nor hold up an offer.837fn test_a_strip_with_an_illegal_path_is_ignored() {838	base := scratch('illegal_strip')!839	defer {840		os.rmdir_all(base) or {}841	}842	mut backend := remote.new_folder(os.join_path(base, 'remote'))!843	mut a := open_repo(machine('illegal_strip_a')!)!844	defer {845		a.close()846	}847	mut b := open_repo(machine('illegal_strip_b')!)!848	defer {849		b.close()850	}851	put(a.root, 'a/x.txt', 'kept\n')!852	make_save(a, 'add a folder')!853	offer_now(a, mut backend)!854	for id, path in {855		'aaaaaaaaaaaa': 'a//'856		'bbbbbbbbbbbb': 'a\\ud800'857	} {858		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!859	}860	join(b, mut backend)!861	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)862	put(a.root, 'more.txt', 'more\n')!863	make_save(a, 'more')!864	offer_now(a, mut backend)!865}866867fn test_a_failed_steal_leaves_nothing_behind() {868	base := scratch('fault_steal')!869	defer {870		os.rmdir_all(base) or {}871	}872	remote_dir := os.join_path(base, 'remote')873	mut a := open_repo(machine('fault_s_a')!)!874	defer {875		a.close()876	}877	mut b := open_repo(machine('fault_s_b')!)!878	defer {879		b.close()880	}881	put(a.root, 'a.txt', 'from A\n')!882	make_save(a, 'A writes')!883	mut healthy := remote.new_folder(remote_dir)!884	offer(a, mut healthy)!885886	// B's fetch fails half way through reading the remote.887	mut flaky := FaultyBackend{888		inner:     remote.new_folder(remote_dir)!889		fail_read: 2890	}891	if _ := fetch_back_to_known(b, mut flaky, 'proj') {892		assert false, 'the injected read failure should have surfaced'893	}894	// Nothing was written: the store is untouched and still has no saves.895	assert history.head(b) == none896	assert b.object_count()! == 0897898	// A healthy retry works.899	fetched := fetch_back_to_known(b, mut healthy, 'proj')!900	b.begin()!901	write_fetched(b, fetched)!902	history.set_head(b, fetched.head)!903	b.end()!904	snapshot.restore_files(b, b.root, snapshot.load(b.root), history.files_of(b, fetched.head)!, [])!905	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'906}907908fn test_an_interrupted_strip_is_idempotent() {909	// The local half of a strip is one transaction, so an interrupted strip is910	// simply a strip that did not happen; re-running it must land the same911	// result once, not twice.912	base := scratch('fault_strip')!913	defer {914		os.rmdir_all(base) or {}915	}916	mut a := open_repo(machine('fault_strip_a')!)!917	defer {918		a.close()919	}920	put(a.root, 'secret.txt', 'shh\n')!921	put(a.root, 'keep.txt', 'fine\n')!922	save := make_save(a, 'add both')!923924	for _ in 0 .. 2 {925		strip_local(a, 'secret.txt', '')!926	}927	files := history.files_of(a, save.id)!928	// The repo's own `.kohignore` is tracked content, so it stays; only the929	// stripped path goes.930	assert files.map(it.path) == ['.kohignore', 'keep.txt']931	assert events.strip_events(a)!.len == 1932}933934// SQLite's LIKE ignores ASCII case, so a strip of `docs` must not reach into935// `Docs/`, and `a_b` must not reach `aXb`: strip destroys, so it matches exactly.936// The tree is built by hand: on macOS `Docs` and `docs` are one folder on disk,937// but a save made on Linux can hold both.938fn test_a_strip_matches_the_path_exactly() {939	mut a := open_repo(machine('strip_exact')!)!940	defer {941		a.close()942	}943	hash := a.put_object('x\n'.bytes())!944	mut files := []snapshot.FileEntry{}945	for path in ['DOCS.txt', 'Docs/keep.txt', 'a_b/x.txt', 'aXb/y.txt', 'docs/secret.txt'] {946		files << snapshot.FileEntry{947			path: path948			hash: hash949		}950	}951	save := history.save_tree(a, '', files, history.SaveInput{952		message: 'add all'953	})!954	assert strip_scope(a, 'docs', '')!.files == 1955	strip_local(a, 'docs', '')!956	strip_local(a, 'a_b', '')!957	assert history.files_of(a, save.id)!.map(it.path) == ['DOCS.txt', 'Docs/keep.txt', 'aXb/y.txt']958}959960fn test_a_strip_reaches_the_remote_and_other_machines() {961	base := scratch('strip_sync')!962	defer {963		os.rmdir_all(base) or {}964	}965	mut a := open_repo(machine('strip_a')!)!966	defer {967		a.close()968	}969	mut b := open_repo(machine('strip_b')!)!970	defer {971		b.close()972	}973	put(a.root, 'secrets.env', 'api_key=1\n')!974	put(a.root, 'keep.txt', 'fine\n')!975	save := make_save(a, 'add both')!976	mut backend := remote.new_folder(os.join_path(base, 'remote'))!977	offer_now(a, mut backend)!978	join(b, mut backend)!979	assert b.object_count()! > 0980981	// A strips the path locally, the way `koh strip` does, then tells the982	// remote.983	mut secret_hash := ''984	for file in history.files_of(a, save.id)! {985		if file.path == 'secrets.env' {986			secret_hash = file.hash987		}988	}989	assert secret_hash != ''990	strip_local(a, 'secrets.env', '')!991	report := send_strip(a, mut backend, 'secrets.env')!992	assert report.event_sent993	assert report.records == 1994995	// The remote's copy of the save no longer lists the file, and the object is996	// gone from the remote.997	record := decode_save(backend.get(save_key('proj', save.id))!.body)!998	for file in record.files {999		assert file.path != 'secrets.env'1000	}1001	// The stripped object is gone from the remote; the ones still referenced1002	// are not.1003	assert !backend.get(object_key('proj', secret_hash))!.found1004	assert backend.list('proj/objects')!.len == 210051006	// Another machine hears about it on its next steal and applies it.1007	b.begin()!1008	fetched := fetch_back_to_known(b, mut backend, 'proj')!1009	write_fetched(b, fetched)!1010	b.end()!1011	mut paths := []string{}1012	for file in history.files_of(b, save.id)! {1013		paths << file.path1014	}1015	assert 'secrets.env' !in paths1016	assert 'keep.txt' in paths10171018	// The event arrives under its own ID, so it is applied once: B's offer adds1019	// no second copy. The path is banned for good: written again, the file is1020	// neither unsaved work nor ever walked into a save.1021	assert events.strip_events(b)!.map(it.id) == events.strip_events(a)!.map(it.id)1022	put(b.root, 'secrets.env', 'rotated\n')!1023	assert !dirty(b, history.head(b))!1024	put(b.root, 'keep.txt', 'still fine\n')!1025	later := make_save(b, 'edit keep')!1026	offer_now(b, mut backend)!1027	assert backend.list('proj/events')!.len == 11028	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1029}10301031// A strip removes a path from all of history, whenever a save was made. A1032// machine that saved the file after the strip, before it heard of it, loses1033// it from that save too on its next steal, so no copy anywhere keeps it.1034fn test_a_strip_reaches_saves_made_after_it_by_a_machine_that_had_not_heard() {1035	base := scratch('strip_later')!1036	defer {1037		os.rmdir_all(base) or {}1038	}1039	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1040	mut a := open_repo(machine('strip_later_a')!)!1041	defer {1042		a.close()1043	}1044	mut b := open_repo(machine('strip_later_b')!)!1045	defer {1046		b.close()1047	}1048	put(a.root, 'secrets.env', 'api_key=1\n')!1049	old := make_save(a, 'leak the key')!1050	offer_now(a, mut backend)!1051	join(b, mut backend)!1052	strip_local(a, 'secrets.env', '')!1053	send_strip(a, mut backend, 'secrets.env')!1054	// B has not heard, and saves the file again an hour after the strip.1055	put(b.root, 'secrets.env', 'api_key=2\n')!1056	later := history.save_tree(b, b.root, snapshot.walk(b, b.root, snapshot.load(b.root))!, history.SaveInput{1057		message:    'rotate the key'1058		created_at: events.strip_events(a)!.last().created_at + 36001059	})!1060	assert 'secrets.env' in history.files_of(b, later.id)!.map(it.path)1061	b.begin()!1062	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1063	b.end()!1064	assert 'secrets.env' !in history.files_of(b, later.id)!.map(it.path)1065	offer_now(b, mut backend)!10661067	mut c := open_repo(machine('strip_later_c')!)!1068	defer {1069		c.close()1070	}1071	join(c, mut backend)!1072	for save in [old.id, later.id] {1073		assert 'secrets.env' !in history.files_of(c, save)!.map(it.path)1074	}1075	assert !os.exists(os.join_path(c.root, 'secrets.env'))1076}10771078// A strip this machine already holds bans its path from saves that arrive1079// later too, not only from those a fetch brings with the strip.1080fn test_a_held_strip_reaches_saves_arriving_after_it() {1081	base := scratch('strip_held')!1082	defer {1083		os.rmdir_all(base) or {}1084	}1085	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1086	mut a := open_repo(machine('strip_held_a')!)!1087	defer {1088		a.close()1089	}1090	mut b := open_repo(machine('strip_held_b')!)!1091	defer {1092		b.close()1093	}1094	put(a.root, 'secrets.env', 'api_key=1\n')!1095	make_save(a, 'leak the key')!1096	offer_now(a, mut backend)!1097	join(b, mut backend)!1098	strip_local(b, 'secrets.env', '')!1099	put(a.root, 'keep.txt', 'fine\n')!1100	later := make_save(a, 'add keep')!1101	offer_now(a, mut backend)!1102	b.begin()!1103	write_fetched(b, fetch_back_to_known(b, mut backend, 'proj')!)!1104	b.end()!1105	paths := history.files_of(b, later.id)!.map(it.path)1106	assert 'secrets.env' !in paths1107	assert 'keep.txt' in paths1108}11091110// StripRace stands in for another machine that strips `path` completely, event1111// and rewrite, just before this machine's first save record lands.1112struct StripRace {1113mut:1114	inner remote.Backend1115	other &storage.Store1116	path  string1117	done  bool1118}11191120fn (mut r StripRace) get(key string) !remote.Object {1121	return r.inner.get(key)!1122}11231124fn (mut r StripRace) list(prefix string) ![]string {1125	return r.inner.list(prefix)!1126}11271128fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1129	if !r.done && key.contains('/saves/') {1130		r.done = true1131		strip_local(r.other, r.path, '')!1132		send_strip(r.other, mut r.inner, r.path)!1133	}1134	return r.inner.put_if_absent(key, body)!1135}11361137fn (mut r StripRace) put_if_match(key string, body []u8, tag string) !bool {1138	return r.inner.put_if_match(key, body, tag)!1139}11401141fn (mut r StripRace) delete(key string) ! {1142	r.inner.delete(key)!1143}11441145// A strip that finishes on another machine while an offer uploads has already1146// rewritten the remote without the offer's records, so the offer takes back1147// what the strip reaches and leaves head where it was.1148fn test_an_offer_racing_a_strip_leaves_no_stripped_path_behind() {1149	base := scratch('strip_race')!1150	defer {1151		os.rmdir_all(base) or {}1152	}1153	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1154	mut a := open_repo(machine('strip_race_a')!)!1155	defer {1156		a.close()1157	}1158	mut b := open_repo(machine('strip_race_b')!)!1159	defer {1160		b.close()1161	}1162	put(a.root, 'secrets.env', 'api_key=1\n')!1163	first := make_save(a, 'leak the key')!1164	offer_now(a, mut backend)!1165	join(b, mut backend)!1166	put(a.root, 'secrets.env', 'api_key=2\n')!1167	later := make_save(a, 'rotate the key')!1168	secret := history.files_of(a, later.id)!.filter(it.path == 'secrets.env')[0].hash1169	mut race := StripRace{1170		inner: backend1171		other: &b1172		path:  'secrets.env'1173	}1174	if _ := offer(a, mut race) {1175		assert false, 'an offer that raced a strip must refuse'1176	} else {1177		assert err.msg().contains('koh steal'), err.msg()1178	}1179	assert backend.get(head_key('proj'))!.body.bytestr() == first.id1180	assert !backend.get(object_key('proj', secret))!.found1181	for key in backend.list('proj/saves')! {1182		record := decode_save(backend.get(key)!.body)!1183		assert 'secrets.env' !in record.files.map(it.path), record.id1184	}1185}11861187// The local half lands before the remote hears anything, so a remote failure1188// leaves nothing to find locally; the pending note is what lets a re-run1189// finish. Objects a remote-only save still needs must survive the strip.1190fn test_an_interrupted_remote_strip_finishes_when_re_run() {1191	base := scratch('strip_resume')!1192	defer {1193		os.rmdir_all(base) or {}1194	}1195	remote_dir := os.join_path(base, 'remote')1196	mut backend := remote.new_folder(remote_dir)!1197	mut a := open_repo(machine('resume_a')!)!1198	defer {1199		a.close()1200	}1201	mut b := open_repo(machine('resume_b')!)!1202	defer {1203		b.close()1204	}1205	put(a.root, 'secrets.env', 'api_key=1\n')!1206	put(a.root, 'shared.env', 'same bytes on B\n')!1207	save := make_save(a, 'add secrets')!1208	offer_now(a, mut backend)!1209	join(b, mut backend)!1210	put(b.root, 'copy.txt', 'same bytes on B\n')!1211	b_save := make_save(b, 'B keeps a copy')!1212	offer_now(b, mut backend)!1213	shared_hash := storage.hash_hex('same bytes on B\n'.bytes())1214	secret_hash := storage.hash_hex('api_key=1\n'.bytes())12151216	strip_local(a, 'secrets.env', '')!1217	// Write 1 uploads the event and write 2 rewrites the first record; B's save,1218	// made from A's tree, lists the file too, and its rewrite (write 3) fails.1219	mut flaky := FaultyBackend{1220		inner:      remote.new_folder(remote_dir)!1221		fail_write: 31222	}1223	if _ := send_strip(a, mut flaky, 'secrets.env') {1224		assert false, 'the injected failure should have surfaced'1225	}1226	assert strip_pending(a, 'secrets.env')1227	assert strip_local(a, 'secrets.env', '')!.files_removed == 01228	report := send_strip(a, mut backend, 'secrets.env')!1229	assert report.records == 11230	assert !strip_pending(a, 'secrets.env')1231	assert !backend.get(object_key('proj', secret_hash))!.found1232	record := decode_save(backend.get(save_key('proj', save.id))!.body)!1233	assert 'secrets.env' !in record.files.map(it.path)1234	b_record := decode_save(backend.get(save_key('proj', b_save.id))!.body)!1235	assert 'secrets.env' !in b_record.files.map(it.path)12361237	// B's save still names the shared bytes, so stripping A's copy keeps them.1238	strip_local(a, 'shared.env', '')!1239	send_strip(a, mut backend, 'shared.env')!1240	assert backend.get(object_key('proj', shared_hash))!.found1241}12421243// A machine that has not heard of a strip must not offer: its records still1244// list the stripped path, and nothing would rewrite them on the remote again.1245fn test_offer_waits_for_an_unapplied_strip() {1246	base := scratch('strip_first')!1247	defer {1248		os.rmdir_all(base) or {}1249	}1250	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1251	mut a := open_repo(machine('strip_first_a')!)!1252	defer {1253		a.close()1254	}1255	mut b := open_repo(machine('strip_first_b')!)!1256	defer {1257		b.close()1258	}1259	put(a.root, 'secrets.env', 'api_key=1\n')!1260	make_save(a, 'add secrets')!1261	offer_now(a, mut backend)!1262	join(b, mut backend)!1263	strip_local(a, 'secrets.env', '')!1264	send_strip(a, mut backend, 'secrets.env')!1265	put(b.root, 'more.txt', 'B keeps working\n')!1266	later := make_save(b, 'B saves with the secret still in its tree')!1267	if _ := offer_now(b, mut backend) {1268		assert false, 'B offered records that still list the stripped path'1269	}1270	steal_now(b, mut backend)!1271	offer_now(b, mut backend)!1272	record := decode_save(backend.get(save_key('proj', later.id))!.body)!1273	assert 'secrets.env' !in record.files.map(it.path)1274}12751276// A strip whose object deletions failed must still delete them when it is run1277// again, and meanwhile the banned path cannot come back in through a save.1278fn test_a_strip_whose_deletes_failed_finishes_when_run_again() {1279	base := scratch('strip_twice')!1280	defer {1281		os.rmdir_all(base) or {}1282	}1283	remote_dir := os.join_path(base, 'remote')1284	mut backend := remote.new_folder(remote_dir)!1285	mut a := open_repo(machine('strip_twice_a')!)!1286	defer {1287		a.close()1288	}1289	put(a.root, 'secrets.env', 'first key\n')!1290	make_save(a, 'first key')!1291	offer_now(a, mut backend)!1292	strip_local(a, 'secrets.env', '')!1293	mut flaky := FaultyBackend{1294		inner:       remote.new_folder(remote_dir)!1295		fail_delete: true1296	}1297	if _ := send_strip(a, mut flaky, 'secrets.env') {1298		assert false, 'the injected delete failure should have surfaced'1299	}1300	put(a.root, 'secrets.env', 'second key\n')!1301	put(a.root, 'other.txt', 'more work\n')!1302	make_save(a, 'more work')!1303	offer_now(a, mut backend)!1304	send_strip(a, mut backend, 'secrets.env')!1305	for key in ['first key\n', 'second key\n'] {1306		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1307	}1308}13091310// Unfinished strips of one path in different scopes finish together.1311fn test_unfinished_strips_of_one_path_in_different_saves_finish_together() {1312	base := scratch('strip_scopes')!1313	defer {1314		os.rmdir_all(base) or {}1315	}1316	remote_dir := os.join_path(base, 'remote')1317	mut backend := remote.new_folder(remote_dir)!1318	mut a := open_repo(machine('strip_scopes_a')!)!1319	defer {1320		a.close()1321	}1322	put(a.root, 'secrets.env', 'first key\n')!1323	first := make_save(a, 'first key')!1324	put(a.root, 'secrets.env', 'second key\n')!1325	second := make_save(a, 'second key')!1326	offer_now(a, mut backend)!1327	strip_local(a, 'secrets.env', first.id)!1328	// Write 1 uploads the event; write 2, the record rewrite, fails.1329	mut flaky := FaultyBackend{1330		inner:      remote.new_folder(remote_dir)!1331		fail_write: 21332	}1333	if _ := send_strip(a, mut flaky, 'secrets.env') {1334		assert false, 'the injected failure should have surfaced'1335	}1336	strip_local(a, 'secrets.env', second.id)!1337	send_strip(a, mut backend, 'secrets.env')!1338	assert !strip_pending(a, 'secrets.env')1339	for save in [first, second] {1340		record := decode_save(backend.get(save_key('proj', save.id))!.body)!1341		assert 'secrets.env' !in record.files.map(it.path)1342	}1343	for key in ['first key\n', 'second key\n'] {1344		assert !backend.get(object_key('proj', storage.hash_hex(key.bytes())))!.found1345	}1346}13471348// A strip bans its path on every machine that has heard of it: written again,1349// the file makes no save, here or there, so there is never a second copy to strip.1350fn test_a_stripped_path_cannot_be_saved_again_on_any_machine() {1351	base := scratch('strip_again')!1352	defer {1353		os.rmdir_all(base) or {}1354	}1355	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1356	mut a := open_repo(machine('strip_again_a')!)!1357	defer {1358		a.close()1359	}1360	mut b := open_repo(machine('strip_again_b')!)!1361	defer {1362		b.close()1363	}1364	put(a.root, 'secrets.env', 'first key\n')!1365	make_save(a, 'first key')!1366	offer_now(a, mut backend)!1367	join(b, mut backend)!1368	strip_local(a, 'secrets.env', '')!1369	send_strip(a, mut backend, 'secrets.env')!1370	steal_now(b, mut backend)!1371	for repo in [a, b] {1372		put(repo.root, 'secrets.env', 'second key\n')!1373		if _ := make_save(repo, 'second key') {1374			assert false, 'a banned path must not make a save'1375		}1376	}1377}13781379// A clash on a path the other machine stripped is no clash: the strip removes1380// the path from every save, so there is nothing to choose.1381fn test_a_stripped_path_is_not_asked_about() {1382	base := scratch('clash_strip')!1383	defer {1384		os.rmdir_all(base) or {}1385	}1386	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1387	mut a := open_repo(machine('clash_strip_a')!)!1388	defer {1389		a.close()1390	}1391	mut b := open_repo(machine('clash_strip_b')!)!1392	defer {1393		b.close()1394	}1395	put(a.root, 'secrets.env', 'key one\n')!1396	make_save(a, 'base')!1397	offer_now(a, mut backend)!1398	join(b, mut backend)!1399	put(a.root, 'secrets.env', 'key two\n')!1400	make_save(a, 'A rotates')!1401	offer_now(a, mut backend)!1402	strip_local(a, 'secrets.env', '')!1403	send_strip(a, mut backend, 'secrets.env')!1404	put(b.root, 'secrets.env', 'key three\n')!1405	mine := make_save(b, 'B rotates')!1406	report := steal(b, mut backend, StealOptions{})!1407	assert report.clashes.len == 01408	for save in history.all_saves(b)! {1409		assert 'secrets.env' !in history.files_of(b, save.id)!.map(it.path)1410	}1411	assert history.by_id(b, mine.id) != none1412}14131414// A strip scoped to the shared base save changes what the replay sees; the1415// clashes must be found on the same trees, or the replay meets a conflict.1416fn test_a_strip_of_the_shared_base_is_seen_before_asking() {1417	base := scratch('clash_base_strip')!1418	defer {1419		os.rmdir_all(base) or {}1420	}1421	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1422	mut a := open_repo(machine('base_strip_a')!)!1423	defer {1424		a.close()1425	}1426	mut b := open_repo(machine('base_strip_b')!)!1427	defer {1428		b.close()1429	}1430	put(a.root, 'notes.txt', 'shared\n')!1431	common := make_save(a, 'base')!1432	offer_now(a, mut backend)!1433	join(b, mut backend)!1434	put(a.root, 'other.txt', 'A moves on\n')!1435	make_save(a, 'A moves on')!1436	offer_now(a, mut backend)!1437	strip_local(a, 'notes.txt', common.id)!1438	send_strip(a, mut backend, 'notes.txt')!1439	put(b.root, 'notes.txt', 'B edits\n')!1440	make_save(b, 'B edits notes')!1441	if _ := steal(b, mut backend, StealOptions{}) {1442		assert false, 'with the base stripped, notes.txt differs on both sides'1443	} else {1444		assert err.msg().contains('notes.txt changed on both machines')1445	}1446	report := steal(b, mut backend, StealOptions{1447		mine: true1448	})!1449	assert report.clashes == ['notes.txt']1450	assert os.read_file(os.join_path(b.root, 'notes.txt'))! == 'B edits\n'1451}14521453// A folder with files but no saves has nothing to combine with; a steal that1454// fast-forwarded into it would overwrite whatever shares a name.1455fn test_steal_into_a_folder_with_unsaved_files_refuses() {1456	base := scratch('steal_unsaved')!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('unsaved_a')!)!1462	defer {1463		a.close()1464	}1465	put(a.root, 'notes.txt', 'from A\n')!1466	make_save(a, 'A writes')!1467	offer_now(a, mut backend)!1468	b_root := machine('unsaved_b')!1469	mut b := storage.create(b_root)!1470	defer {1471		b.close()1472	}1473	put(b_root, 'notes.txt', 'never saved\n')!1474	// Only a repo that already knows its remote name gets this far.1475	b.set_state('remote_repo', 'proj')!1476	if _ := steal(b, mut backend, StealOptions{}) {1477		assert false, 'steal overwrote a folder with unsaved files'1478	} else {1479		assert err.msg() == no_saves_here1480	}1481	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'never saved\n'1482	// A fresh `koh init` folder, holding only the default ignore list, is fine.1483	os.rm(os.join_path(b_root, 'notes.txt'))!1484	put(b_root, snapshot.ignore_file, snapshot.default_ignore)!1485	steal(b, mut backend, StealOptions{})!1486	assert os.read_file(os.join_path(b_root, 'notes.txt'))! == 'from A\n'1487}14881489// The document asks steal to warn about every incoming flagged save, so the1490// report names them.1491fn test_steal_reports_incoming_flagged_saves() {1492	base := scratch('flag_warn')!1493	defer {1494		os.rmdir_all(base) or {}1495	}1496	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1497	mut a := open_repo(machine('flag_warn_a')!)!1498	defer {1499		a.close()1500	}1501	mut b := open_repo(machine('flag_warn_b')!)!1502	defer {1503		b.close()1504	}1505	put(a.root, 'a.txt', 'one\n')!1506	make_save(a, 'fine')!1507	offer_now(a, mut backend)!1508	join(b, mut backend)!1509	put(a.root, 'a.txt', 'two\n')!1510	suspect := make_save(a, 'suspect')!1511	put(a.root, 'a.txt', 'three\n')!1512	make_save(a, 'also fine')!1513	events.flag(a, suspect.id, 'broke the build')!1514	offer_now(a, mut backend)!1515	report := steal_now(b, mut backend)!1516	assert report.flagged.map(it.save_id) == [suspect.id]1517	assert report.flagged[0].reason == 'broke the build'1518}15191520// head_of unwraps the head for tests that know the repo has saves.1521fn head_of(store &storage.Store) history.Save {1522	return history.head(store) or { panic('this repo has no head') }1523}15241525// A remote says what it is, so two koh versions never half-understand one1526// bucket. The first offer of a repo records the layout.1527fn test_the_first_offer_records_the_remote_format() {1528	base := scratch('format_write')!1529	defer {1530		os.rmdir_all(base) or {}1531	}1532	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1533	mut a := open_repo(machine('format_write_a')!)!1534	defer {1535		a.close()1536	}1537	put(a.root, 'a.txt', 'one\n')!1538	make_save(a, 'first')!1539	offer_now(a, mut backend)!1540	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1541}15421543// A repo offered before the key existed is version 1: it still syncs, and the1544// next offer records the key then.1545fn test_a_remote_without_a_format_still_syncs() {1546	base := scratch('format_old')!1547	defer {1548		os.rmdir_all(base) or {}1549	}1550	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1551	mut a := open_repo(machine('format_old_a')!)!1552	defer {1553		a.close()1554	}1555	put(a.root, 'a.txt', 'one\n')!1556	make_save(a, 'first')!1557	offer_now(a, mut backend)!1558	// Stand in for a bucket offered before the key existed.1559	backend.delete(format_key('proj'))!1560	mut b := open_repo(machine('format_old_b')!)!1561	defer {1562		b.close()1563	}1564	join(b, mut backend)!1565	assert head_of(b).id == head_of(a).id1566	put(a.root, 'a.txt', 'two\n')!1567	make_save(a, 'second')!1568	offer_now(a, mut backend)!1569	assert backend.get(format_key('proj'))!.body.bytestr().trim_space() == format_value1570	report := steal_now(b, mut backend)!1571	assert report.head == head_of(a).id1572}15731574// A remote that speaks another layout is refused loudly, on offer and on1575// steal, rather than half-read.1576fn test_an_unknown_remote_format_is_refused() {1577	base := scratch('format_unknown')!1578	defer {1579		os.rmdir_all(base) or {}1580	}1581	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1582	mut a := open_repo(machine('format_bad_a')!)!1583	defer {1584		a.close()1585	}1586	put(a.root, 'a.txt', 'one\n')!1587	make_save(a, 'first')!1588	offer_now(a, mut backend)!1589	backend.delete(format_key('proj'))!1590	backend.put_if_absent(format_key('proj'), 'koh-remote 2'.bytes())!1591	if _ := offer_now(a, mut backend) {1592		assert false, 'offer must refuse a format it does not speak'1593	} else {1594		assert err.msg().contains('koh-remote 2')1595	}1596	if _ := steal_now(a, mut backend) {1597		assert false, 'steal must refuse a format it does not speak'1598	} else {1599		assert err.msg().contains('koh-remote 2')1600	}1601	if _ := fetch_repo(mut backend, 'proj') {1602		assert false, 'a steal must refuse a format it does not speak'1603	} else {1604		assert err.msg().contains('koh-remote 2')1605	}1606}16071608// Two machines offering a new repo at once both find no format key; the loser1609// of the conditional write must read back the winner's value and refuse it,1610// not carry on against a remote it cannot read.1611fn test_a_lost_format_write_is_read_back_and_refused() {1612	base := scratch('format_race')!1613	defer {1614		os.rmdir_all(base) or {}1615	}1616	mut inner := remote.new_folder(os.join_path(base, 'remote'))!1617	mut a := open_repo(machine('format_race_a')!)!1618	defer {1619		a.close()1620	}1621	put(a.root, 'a.txt', 'one\n')!1622	make_save(a, 'first')!1623	mut backend := remote.Backend(FaultyBackend{1624		inner:       inner1625		race_format: true1626	})1627	if _ := offer_now(a, mut backend) {1628		assert false, 'offer must refuse the format another machine wrote first'1629	} else {1630		assert err.msg().contains('koh-remote 2')1631	}1632}16331634fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {1635	whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'1636	for size in [1, 2, 5, whole.len] {1637		mut bundle := Bundle{}1638		for at := 0; at < whole.len; at += size {1639			bundle.feed(whole[at..int_min(at + size, whole.len)].bytes())!1640		}1641		bundle.finish()!1642		assert bundle.fetched.repo_objects == {1643			'aa': 'abc'.bytes()1644			'bb': []u8{}1645		}1646	}1647	for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',1648		'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {1649		mut bundle := Bundle{}1650		bundle.feed(cut.bytes()) or { continue }1651		bundle.finish() or { continue }1652		assert false, cut1653	}1654}