cmd_doctor_test.v

20.1 KB · raw

1module cli23import os4import compress.zlib5import crypto.sha2566import json27import src.exchange8import src.release9import src.remote10import src.events11import src.history12import src.machine13import src.snapshot14import src.storage1516fn testsuite_begin() {17	os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)18}1920fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {21	saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },22		history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]23	assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))24	assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))25	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],26		'aaaaaaaaaaaa').any(it.contains('missing parent'))27	assert doctor_history([]history.Save{}, '').len == 028	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))29}3031fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {32	mut report := DoctorJson{}33	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },34		snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},35		mut report)36	assert report.problems.any(it.contains('unsafe path'))37	assert report.problems.any(it.contains('file and a directory'))38}3940fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {41	dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')42	os.rmdir_all(dir) or {}43	os.mkdir_all(dir)!44	defer {45		os.rmdir_all(dir) or {}46	}47	store := storage.create(dir)!48	defer {49		store.close()50	}51	hash := store.put_object('unsafe'.bytes())!52	store.begin()!53	history.import_save(store, history.Save{54		id: 'badbadbadbad'55	}, [snapshot.FileEntry{56		path: "../June's file"57		hash: hash58	}])!59	history.set_head(store, 'badbadbadbad')!60	store.end()!61	mut report := DoctorJson{}62	doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)63	assert report.problems == [64		"save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",65	]66}6768struct DoctorReadOnly {69mut:70	items map[string][]u871}7273fn (mut backend DoctorReadOnly) get(key string) !remote.Object {74	if body := backend.items[key] {75		return remote.Object{ found: true, body: body }76	}77	return remote.Object{}78}7980fn (mut backend DoctorReadOnly) list(prefix string) ![]string {81	return backend.items.keys().filter(it.starts_with('${prefix}/'))82}8384fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {85	panic('Doctor attempted a remote write')86}8788fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {89	panic('Doctor attempted a remote write')90}9192fn (mut backend DoctorReadOnly) delete(key string) ! {93	panic('Doctor attempted a remote delete')94}9596fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {97	data := 'file'.bytes()98	hash := storage.hash_hex(data)99	mut backend := DoctorReadOnly{100		items: {101			'repo/head':                             'aaaaaaaaaaaa'.bytes()102			'repo/saves/aaaaaaaaaaaa':               json2.encode(exchange.SaveRecord{103				id:    'aaaaaaaaaaaa'104				files: [exchange.FileRecord{ path: 'file', hash: hash }]105			}).bytes()106			'repo/objects/${hash}':                  zlib.compress(data)!107			'repo/releases/latest':                  '1.0.0'.bytes()108			'repo/releases/1.0.0/release.json':      json2.encode(release.Record{109				repo:    'repo'110				version: '1.0.0'111				save:    'aaaaaaaaaaaa'112				files:   [release.File{113					platform: 'linux-aarch64'114					name:     'app'115					size:     data.len116					blake3:   hash117					sha256:   sha256.sum256(data).hex()118				}]119			}).bytes()120			'repo/releases/1.0.0/linux-aarch64/app': data121		}122	}123	mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }124	doctor_remote_content(mut backend, 'repo', mut report)!125	assert report.problems.len == 0126	assert report.remote_saves == 1127	assert report.remote_releases == 1128	backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()129	backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()130	mut damaged := DoctorJson{}131	doctor_remote_content(mut backend, 'repo', mut damaged)!132	assert damaged.problems.any(it.contains('damaged remote object'))133	assert damaged.problems.any(it.contains('checksum verification'))134}135136fn test_doctor_remote_keeps_a_complete_report_for_invalid_records() {137	mut backend := DoctorReadOnly{138		items: {139			'repo/head':               'aaaaaaaaaaaa'.bytes()140			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{141				id:         'aaaaaaaaaaaa'142				created_at: -1143			}).bytes()144			'repo/events/bad':         json2.encode(exchange.EventRecord{145				id:         'bad'146				kind:       'unknown'147				created_at: 1148			}).bytes()149		}150	}151	mut report := DoctorJson{}152	doctor_remote_content(mut backend, 'repo', mut report)!153	assert report.problems.any(it.contains('invalid metadata'))154	assert report.problems.any(it.contains('invalid ID'))155	assert report.problems.any(it.contains('unknown kind'))156}157158fn test_doctor_remote_binds_a_save_to_its_exact_key() {159	mut backend := DoctorReadOnly{160		items: {161			'repo/head':                      'aaaaaaaaaaaa'.bytes()162			'repo/saves/nested/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{163				id: 'aaaaaaaaaaaa'164			}).bytes()165		}166	}167	mut report := DoctorJson{}168	if _ := doctor_remote_content(mut backend, 'repo', mut report) {169		assert false, 'a nested save key must not stand in for the canonical key'170	} else {171		assert err.msg().contains('disagrees with key'), err.msg()172	}173}174175fn test_doctor_reports_event_errors_strip_residue_and_flags() {176	mut report := DoctorJson{}177	saves := [history.Save{ id: 'aaaaaaaaaaaa' }]178	event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },179		events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },180		events.Event{ id: 'bad', kind: 'unknown' }]181	doctor_events(event_list, saves, mut report)182	doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)183	assert report.flagged_saves == 1184	assert report.problems.any(it.contains('invalid ID'))185	assert report.problems.any(it.contains('unknown kind'))186	assert report.problems.any(it.contains('stripped path'))187	assert report.strip_residue_paths == ['private']188}189190struct DoctorMemory {191mut:192	items map[string][]u8193}194195fn (mut backend DoctorMemory) get(key string) !remote.Object {196	if body := backend.items[key] {197		return remote.Object{ found: true, body: body }198	}199	return remote.Object{}200}201202fn (mut backend DoctorMemory) list(prefix string) ![]string {203	return backend.items.keys().filter(it.starts_with('${prefix}/'))204}205206fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {207	if key in backend.items {208		return false209	}210	backend.items[key] = body211	return true212}213214fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {215	return false216}217218fn (mut backend DoctorMemory) delete(key string) ! {219	backend.items.delete(key)220}221222fn doctor_store(name string) !(storage.Store, string) {223	dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')224	os.rmdir_all(dir) or {}225	os.mkdir_all(dir)!226	return storage.create(dir)!, dir227}228229fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {230	store, dir := doctor_store('fetch')!231	defer {232		store.close()233		os.rmdir_all(dir) or {}234	}235	data := 'parent'.bytes()236	hash := storage.hash_hex(data)237	mut backend := DoctorReadOnly{238		items: {239			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{240				id:    'aaaaaaaaaaaa'241				files: [exchange.FileRecord{ path: 'file', hash: hash }]242			}).bytes()243			'repo/objects/${hash}':    zlib.compress(data)!244		}245	}246	store.db.exec('PRAGMA foreign_keys=OFF')!247	store.begin()!248	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },249		[]snapshot.FileEntry{})!250	history.set_head(store, 'bbbbbbbbbbbb')!251	store.end()!252	store.db.exec('PRAGMA foreign_keys=ON')!253	missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')254	assert missing == ['aaaaaaaaaaaa']255	assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1256	assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0257	assert store.get_object(hash)! == data258	assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {259		assert err.msg().contains('does not have save')260		0261	} == 0262}263264fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {265	store, dir := doctor_store('strip')!266	defer {267		store.close()268		os.rmdir_all(dir) or {}269	}270	secret := store.put_object('secret'.bytes())!271	kept := store.put_object('kept'.bytes())!272	store.begin()!273	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [274		snapshot.FileEntry{ path: 'keep', hash: kept },275		snapshot.FileEntry{ path: 'private/key', hash: secret },276	])!277	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },278		[snapshot.FileEntry{ path: 'keep', hash: kept }])!279	events.strip(store, '', 'private')!280	store.end()!281	assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'282	mut report := DoctorJson{}283	doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut284		report)285	assert report.strip_residue286	assert exchange.reapply_strips(store)! == 1287	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']288	assert !store.has_object(secret)!289	assert events.strip_events(store)!.len == 1290}291292fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {293	store, dir := doctor_store('objects')!294	defer {295		store.close()296		os.rmdir_all(dir) or {}297	}298	data := 'content'.bytes()299	hash := store.put_object(data)!300	store.begin()!301	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [302		snapshot.FileEntry{ path: 'app', hash: hash },303	])!304	history.set_head(store, 'aaaaaaaaaaaa')!305	store.end()!306	store.db.exec("UPDATE objects SET data = x'00'")!307	problems := store.check_objects()!308	assert problems.map(it.hash) == [hash]309	assert local_copy(store, hash) == none310	os.write_file(os.join_path(dir, 'app'), 'content')!311	store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!312	assert store.check_objects()!.len == 0313	store.repair_object(hash, 'other'.bytes()) or {314		assert err.msg().contains('does not match')315	}316	mut backend := DoctorMemory{317		items: {318			'repo/objects/${hash}': 'damaged'.bytes()319		}320	}321	assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2322	assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data323	assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash324}325326// An invalid strip event authorizes nothing, and a strip frees only the objects327// it removed: an unrelated unused object may be needed for recovery.328fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {329	store, dir := doctor_store('strip_safety')!330	defer {331		store.close()332		os.rmdir_all(dir) or {}333	}334	app := store.put_object('app'.bytes())!335	secret := store.put_object('secret'.bytes())!336	orphan := store.put_object('orphan'.bytes())!337	store.begin()!338	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [339		snapshot.FileEntry{ path: 'app', hash: app },340		snapshot.FileEntry{ path: 'secret', hash: secret },341	])!342	events.receive(store, events.Event{343		id:         'invalidstrip'344		kind:       events.kind_strip345		path:       'app'346		created_at: -1347		machine:    'test'348	})!349	events.strip(store, '', 'secret')!350	store.end()!351	mut report := DoctorJson{}352	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut353		report)354	assert !report.strip_residue355	assert exchange.reapply_strips(store)! == 1356	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']357	assert store.has_object(app)!358	assert store.has_object(orphan)!359	assert !store.has_object(secret)!360}361362// With a remote, only a full audit that found no other head there lets Doctor363// guess, and the guess is checked again when it is written.364fn test_doctor_guesses_head_only_when_nothing_knows_better() {365	store, dir := doctor_store('only_tip')!366	defer {367		store.close()368		os.rmdir_all(dir) or {}369	}370	store.begin()!371	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!372	store.end()!373	mut report := DoctorJson{374		metadata_available: true375		saves:              1376		source:             'folder/repo'377		remote_head:        'bbbbbbbbbbbb'378	}379	assert !plan_repairs(store, report).any(it.action.contains('point head'))380	report.remote_head = ''381	report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}382	assert !plan_repairs(store, report).any(it.action.contains('point head'))383	report.checks = [DoctorCheck{'remote stability', 'fail', 'remote head changed during audit', ''}]384	assert !plan_repairs(store, report).any(it.action.contains('point head'))385	report.checks = []386	assert plan_repairs(store, report).any(it.action.contains('point head'))387	report.source = ''388	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))389	assert repairs.len == 1390	history.set_head(store, 'cccccccccccc')!391	if _ := repairs[0].run() {392		assert false, 'a head another command set must not be replaced'393	} else {394		assert err.msg().contains('changed'), err.msg()395	}396	assert store.state(storage.head_key) or { '' } == 'cccccccccccc'397	store.remove_state(storage.head_key)!398	repairs[0].run()!399	assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'400}401402fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {403	store, dir := doctor_store('partial')!404	defer {405		store.close()406		os.rmdir_all(dir) or {}407	}408	kept := store.put_object('kept'.bytes())!409	lost := store.put_object('lost'.bytes())!410	store.begin()!411	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [412		snapshot.FileEntry{ path: 'kept', hash: kept },413		snapshot.FileEntry{ path: 'lost', hash: lost },414	])!415	store.end()!416	store.db.exec("UPDATE objects SET data = x'00'")!417	os.write_file(os.join_path(dir, 'kept'), 'kept')!418	if _ := repair_objects(store, [kept, lost], '', '') {419		assert false, 'an object with no verified copy must fail its repair'420	} else {421		assert err is exchange.Partway422		assert (err as exchange.Partway).done == 'restored 1 damaged or missing object'423		assert err.msg().contains(lost)424	}425	assert store.get_object(kept)! == 'kept'.bytes()426}427428fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {429	store, dir := doctor_store('invalid_fetch')!430	defer {431		store.close()432		os.rmdir_all(dir) or {}433	}434	mut backend := DoctorReadOnly{435		items: {436			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{437				id:         'aaaaaaaaaaaa'438				created_at: -1439			}).bytes()440		}441	}442	if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {443		assert false, 'a record with invalid metadata must not be imported'444	} else {445		assert err.msg().contains('invalid metadata')446	}447	assert history.all_saves(store)!.len == 0448}449450// An empty file's Blake3 is the hash of empty bytes, so bytes that fail to451// decompress must not pass for it.452fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {453	store, dir := doctor_store('empty_object')!454	defer {455		store.close()456		os.rmdir_all(dir) or {}457	}458	hash := store.put_object([]u8{})!459	mut backend := DoctorMemory{460		items: {461			'repo/objects/${hash}': 'not zlib'.bytes()462		}463	}464	assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1465	assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0466}467468// The remote is asked again before head is guessed: another machine may have469// offered since the audit.470fn test_doctor_rechecks_the_remote_head_before_guessing() {471	store, dir := doctor_store('remote_tip')!472	remote_dir := os.join_path(dir, 'remote')473	defer {474		store.close()475		machine.forget_remote() or {}476		os.rmdir_all(dir) or {}477	}478	store.begin()!479	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!480	store.end()!481	mut folder := remote.new_folder(remote_dir)!482	machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!483	report := DoctorJson{484		metadata_available: true485		saves:              1486		remote_repo:        'repo'487	}488	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))489	assert repairs.len == 1490	folder.put_if_absent(exchange.head_key('repo'), 'bbbbbbbbbbbb'.bytes())!491	if _ := repairs[0].run() {492		assert false, 'a head offered since the audit must stop the guess'493	} else {494		assert err.msg().contains('another head'), err.msg()495	}496	assert store.state(storage.head_key) == none497}498499// An error after some objects were restored still reports them.500fn test_doctor_reports_restored_objects_when_the_remote_then_fails() {501	store, dir := doctor_store('partial_remote')!502	defer {503		store.close()504		machine.forget_remote() or {}505		os.rmdir_all(dir) or {}506	}507	kept := store.put_object('kept'.bytes())!508	lost := store.put_object('lost'.bytes())!509	store.begin()!510	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [511		snapshot.FileEntry{ path: 'kept', hash: kept },512		snapshot.FileEntry{ path: 'lost', hash: lost },513	])!514	store.end()!515	store.db.exec("UPDATE objects SET data = x'00'")!516	os.write_file(os.join_path(dir, 'kept'), 'kept')!517	machine.save_remote(machine.RemoteSettings{518		kind: machine.kind_folder519		path: os.join_path(dir, 'unmounted')520	})!521	if _ := repair_objects(store, [kept, lost], '', '') {522		assert false, 'an unreachable remote must fail the repair'523	} else {524		assert err is exchange.Partway525		assert (err as exchange.Partway).done == 'restored 1 damaged or missing object'526		assert err.msg().contains('not there'), err.msg()527	}528}529530// A strip event Doctor cannot trust removes nothing from a fetched save either.531fn test_doctor_fetch_ignores_invalid_strip_events() {532	store, dir := doctor_store('fetch_invalid_strip')!533	defer {534		store.close()535		os.rmdir_all(dir) or {}536	}537	data := 'app'.bytes()538	hash := storage.hash_hex(data)539	mut backend := DoctorReadOnly{540		items: {541			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{542				id:    'aaaaaaaaaaaa'543				files: [exchange.FileRecord{ path: 'app', hash: hash }]544			}).bytes()545			'repo/objects/${hash}':    zlib.compress(data)!546		}547	}548	events.receive(store, events.Event{549		id:         'invalidstrip'550		kind:       events.kind_strip551		path:       'app'552		created_at: -1553		machine:    'test'554	})!555	assert exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa'])! == 1556	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']557}558559// A strip whose remote half was lost is sent again from the recorded event560// when the remote still holds the path; a later path that fails still reports561// the strips already sent.562fn test_doctor_sends_a_recorded_strip_again_for_remote_residue() {563	store, dir := doctor_store('remote_residue')!564	remote_dir := os.join_path(dir, 'remote')565	defer {566		store.close()567		machine.forget_remote() or {}568		os.rmdir_all(dir) or {}569	}570	secret := 'secret'.bytes()571	hash := storage.hash_hex(secret)572	mut folder := remote.new_folder(remote_dir)!573	folder.put_if_absent(exchange.save_key('repo', 'aaaaaaaaaaaa'), json2.encode(exchange.SaveRecord{574		id:    'aaaaaaaaaaaa'575		files: [exchange.FileRecord{ path: 'app', hash: hash }]576	}).bytes())!577	folder.put_if_absent(exchange.object_key('repo', hash), zlib.compress(secret)!)!578	machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!579	store.set_state('remote_repo', 'repo')!580	events.strip(store, '', 'app')!581	store.set_state('strip_pending:bad', 'not JSON')!582	report := DoctorJson{583		remote_repo:          'repo'584		remote_strip_residue: ['app', 'bad']585		upload_unsettled:     true586	}587	assert plan_repairs(store, report).any(it.action.contains('interrupted upload'))588	repairs := plan_repairs(store, report).filter(it.action.contains('remote still holds'))589	assert repairs.len == 1590	if _ := repairs[0].run() {591		assert false, 'an untrusted note must stop the second path'592	} else {593		assert err is exchange.Partway594		assert (err as exchange.Partway).done == 'sent 1 strip', (err as exchange.Partway).done595	}596	record := exchange.decode_save(folder.get(exchange.save_key('repo', 'aaaaaaaaaaaa'))!.body)!597	assert record.files.len == 0598	assert !folder.get(exchange.object_key('repo', hash))!.found599	assert !exchange.strip_pending(store, 'app')600}