cmd_doctor_test.v

17.0 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_reports_event_errors_strip_residue_and_flags() {137	mut report := DoctorJson{}138	saves := [history.Save{ id: 'aaaaaaaaaaaa' }]139	event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },140		events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },141		events.Event{ id: 'bad', kind: 'unknown' }]142	doctor_events(event_list, saves, mut report)143	doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)144	assert report.flagged_saves == 1145	assert report.problems.any(it.contains('unknown kind'))146	assert report.problems.any(it.contains('stripped path'))147}148149struct DoctorMemory {150mut:151	items map[string][]u8152}153154fn (mut backend DoctorMemory) get(key string) !remote.Object {155	if body := backend.items[key] {156		return remote.Object{ found: true, body: body }157	}158	return remote.Object{}159}160161fn (mut backend DoctorMemory) list(prefix string) ![]string {162	return backend.items.keys().filter(it.starts_with('${prefix}/'))163}164165fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {166	if key in backend.items {167		return false168	}169	backend.items[key] = body170	return true171}172173fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {174	return false175}176177fn (mut backend DoctorMemory) delete(key string) ! {178	backend.items.delete(key)179}180181fn doctor_store(name string) !(storage.Store, string) {182	dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')183	os.rmdir_all(dir) or {}184	os.mkdir_all(dir)!185	return storage.create(dir)!, dir186}187188fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {189	store, dir := doctor_store('fetch')!190	defer {191		store.close()192		os.rmdir_all(dir) or {}193	}194	data := 'parent'.bytes()195	hash := storage.hash_hex(data)196	mut backend := DoctorReadOnly{197		items: {198			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{199				id:    'aaaaaaaaaaaa'200				files: [exchange.FileRecord{ path: 'file', hash: hash }]201			}).bytes()202			'repo/objects/${hash}':    zlib.compress(data)!203		}204	}205	store.db.exec('PRAGMA foreign_keys=OFF')!206	store.begin()!207	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },208		[]snapshot.FileEntry{})!209	history.set_head(store, 'bbbbbbbbbbbb')!210	store.end()!211	store.db.exec('PRAGMA foreign_keys=ON')!212	missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')213	assert missing == ['aaaaaaaaaaaa']214	assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1215	assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0216	assert store.get_object(hash)! == data217	assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {218		assert err.msg().contains('does not have save')219		0220	} == 0221}222223fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {224	store, dir := doctor_store('strip')!225	defer {226		store.close()227		os.rmdir_all(dir) or {}228	}229	secret := store.put_object('secret'.bytes())!230	kept := store.put_object('kept'.bytes())!231	store.begin()!232	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [233		snapshot.FileEntry{ path: 'keep', hash: kept },234		snapshot.FileEntry{ path: 'private/key', hash: secret },235	])!236	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },237		[snapshot.FileEntry{ path: 'keep', hash: kept }])!238	events.strip(store, '', 'private')!239	store.end()!240	assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'241	mut report := DoctorJson{}242	doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut243		report)244	assert report.strip_residue245	assert exchange.reapply_strips(store)! == 1246	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']247	assert !store.has_object(secret)!248	assert events.strip_events(store)!.len == 1249}250251fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {252	store, dir := doctor_store('objects')!253	defer {254		store.close()255		os.rmdir_all(dir) or {}256	}257	data := 'content'.bytes()258	hash := store.put_object(data)!259	store.begin()!260	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [261		snapshot.FileEntry{ path: 'app', hash: hash },262	])!263	history.set_head(store, 'aaaaaaaaaaaa')!264	store.end()!265	store.db.exec("UPDATE objects SET data = x'00'")!266	problems := store.check_objects()!267	assert problems.map(it.hash) == [hash]268	assert local_copy(store, hash) == none269	os.write_file(os.join_path(dir, 'app'), 'content')!270	store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!271	assert store.check_objects()!.len == 0272	store.repair_object(hash, 'other'.bytes()) or {273		assert err.msg().contains('does not match')274	}275	mut backend := DoctorMemory{276		items: {277			'repo/objects/${hash}': 'damaged'.bytes()278		}279	}280	assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2281	assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data282	assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash283}284285// An invalid strip event authorizes nothing, and a strip frees only the objects286// it removed: an unrelated unused object may be needed for recovery.287fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {288	store, dir := doctor_store('strip_safety')!289	defer {290		store.close()291		os.rmdir_all(dir) or {}292	}293	app := store.put_object('app'.bytes())!294	secret := store.put_object('secret'.bytes())!295	orphan := store.put_object('orphan'.bytes())!296	store.begin()!297	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [298		snapshot.FileEntry{ path: 'app', hash: app },299		snapshot.FileEntry{ path: 'secret', hash: secret },300	])!301	events.receive(store, events.Event{302		id:         'invalidstrip'303		kind:       events.kind_strip304		path:       'app'305		created_at: -1306		machine:    'test'307	})!308	events.strip(store, '', 'secret')!309	store.end()!310	mut report := DoctorJson{}311	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut312		report)313	assert !report.strip_residue314	assert exchange.reapply_strips(store)! == 1315	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']316	assert store.has_object(app)!317	assert store.has_object(orphan)!318	assert !store.has_object(secret)!319}320321// With a remote, only a full audit that found no other head there lets Doctor322// guess, and the guess is checked again when it is written.323fn test_doctor_guesses_head_only_when_nothing_knows_better() {324	store, dir := doctor_store('only_tip')!325	defer {326		store.close()327		os.rmdir_all(dir) or {}328	}329	store.begin()!330	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!331	store.end()!332	mut report := DoctorJson{333		metadata_available: true334		saves:              1335		source:             'folder/repo'336		remote_head:        'bbbbbbbbbbbb'337	}338	assert !plan_repairs(store, report).any(it.action.contains('point head'))339	report.remote_head = ''340	report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}341	assert !plan_repairs(store, report).any(it.action.contains('point head'))342	report.checks = [DoctorCheck{'remote stability', 'fail', 'remote head changed during audit', ''}]343	assert !plan_repairs(store, report).any(it.action.contains('point head'))344	report.checks = []345	assert plan_repairs(store, report).any(it.action.contains('point head'))346	report.source = ''347	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))348	assert repairs.len == 1349	history.set_head(store, 'cccccccccccc')!350	if _ := repairs[0].run() {351		assert false, 'a head another command set must not be replaced'352	} else {353		assert err.msg().contains('changed'), err.msg()354	}355	assert store.state(storage.head_key) or { '' } == 'cccccccccccc'356	store.remove_state(storage.head_key)!357	repairs[0].run()!358	assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'359}360361fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {362	store, dir := doctor_store('partial')!363	defer {364		store.close()365		os.rmdir_all(dir) or {}366	}367	kept := store.put_object('kept'.bytes())!368	lost := store.put_object('lost'.bytes())!369	store.begin()!370	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [371		snapshot.FileEntry{ path: 'kept', hash: kept },372		snapshot.FileEntry{ path: 'lost', hash: lost },373	])!374	store.end()!375	store.db.exec("UPDATE objects SET data = x'00'")!376	os.write_file(os.join_path(dir, 'kept'), 'kept')!377	if _ := repair_objects(store, [kept, lost], '', '') {378		assert false, 'an object with no verified copy must fail its repair'379	} else {380		assert err is PartialRepair381		assert (err as PartialRepair).done == 'restored 1 damaged or missing object'382		assert err.msg().contains(lost)383	}384	assert store.get_object(kept)! == 'kept'.bytes()385}386387fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {388	store, dir := doctor_store('invalid_fetch')!389	defer {390		store.close()391		os.rmdir_all(dir) or {}392	}393	mut backend := DoctorReadOnly{394		items: {395			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{396				id:         'aaaaaaaaaaaa'397				created_at: -1398			}).bytes()399		}400	}401	if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {402		assert false, 'a record with invalid metadata must not be imported'403	} else {404		assert err.msg().contains('invalid metadata')405	}406	assert history.all_saves(store)!.len == 0407}408409// An empty file's Blake3 is the hash of empty bytes, so bytes that fail to410// decompress must not pass for it.411fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {412	store, dir := doctor_store('empty_object')!413	defer {414		store.close()415		os.rmdir_all(dir) or {}416	}417	hash := store.put_object([]u8{})!418	mut backend := DoctorMemory{419		items: {420			'repo/objects/${hash}': 'not zlib'.bytes()421		}422	}423	assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1424	assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0425}426427// The remote is asked again before head is guessed: another machine may have428// offered since the audit.429fn test_doctor_rechecks_the_remote_head_before_guessing() {430	store, dir := doctor_store('remote_tip')!431	remote_dir := os.join_path(dir, 'remote')432	defer {433		store.close()434		machine.forget_remote() or {}435		os.rmdir_all(dir) or {}436	}437	store.begin()!438	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!439	store.end()!440	mut folder := remote.new_folder(remote_dir)!441	machine.save_remote(machine.RemoteSettings{ kind: machine.kind_folder, path: remote_dir })!442	report := DoctorJson{443		metadata_available: true444		saves:              1445		remote_repo:        'repo'446	}447	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))448	assert repairs.len == 1449	folder.put_if_absent(exchange.head_key('repo'), 'bbbbbbbbbbbb'.bytes())!450	if _ := repairs[0].run() {451		assert false, 'a head offered since the audit must stop the guess'452	} else {453		assert err.msg().contains('another head'), err.msg()454	}455	assert store.state(storage.head_key) == none456}457458// An error after some objects were restored still reports them.459fn test_doctor_reports_restored_objects_when_the_remote_then_fails() {460	store, dir := doctor_store('partial_remote')!461	defer {462		store.close()463		machine.forget_remote() or {}464		os.rmdir_all(dir) or {}465	}466	kept := store.put_object('kept'.bytes())!467	lost := store.put_object('lost'.bytes())!468	store.begin()!469	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [470		snapshot.FileEntry{ path: 'kept', hash: kept },471		snapshot.FileEntry{ path: 'lost', hash: lost },472	])!473	store.end()!474	store.db.exec("UPDATE objects SET data = x'00'")!475	os.write_file(os.join_path(dir, 'kept'), 'kept')!476	machine.save_remote(machine.RemoteSettings{477		kind: machine.kind_folder478		path: os.join_path(dir, 'unmounted')479	})!480	if _ := repair_objects(store, [kept, lost], '', '') {481		assert false, 'an unreachable remote must fail the repair'482	} else {483		assert err is PartialRepair484		assert (err as PartialRepair).done == 'restored 1 damaged or missing object'485		assert err.msg().contains('not there'), err.msg()486	}487}488489// A strip event Doctor cannot trust removes nothing from a fetched save either.490fn test_doctor_fetch_ignores_invalid_strip_events() {491	store, dir := doctor_store('fetch_invalid_strip')!492	defer {493		store.close()494		os.rmdir_all(dir) or {}495	}496	data := 'app'.bytes()497	hash := storage.hash_hex(data)498	mut backend := DoctorReadOnly{499		items: {500			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{501				id:    'aaaaaaaaaaaa'502				files: [exchange.FileRecord{ path: 'app', hash: hash }]503			}).bytes()504			'repo/objects/${hash}':    zlib.compress(data)!505		}506	}507	events.receive(store, events.Event{508		id:         'invalidstrip'509		kind:       events.kind_strip510		path:       'app'511		created_at: -1512		machine:    'test'513	})!514	assert exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa'])! == 1515	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']516}