cmd_doctor_test.v

13.8 KB · raw

1module cli23import os4import compress.zlib5import crypto.sha2566import json27import src.exchange8import src.release9import src.remote10import src.events11import src.history12import src.snapshot13import src.storage1415fn testsuite_begin() {16	os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)17}1819fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {20	saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },21		history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]22	assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))23	assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))24	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],25		'aaaaaaaaaaaa').any(it.contains('missing parent'))26	assert doctor_history([]history.Save{}, '').len == 027	assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))28}2930fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {31	mut report := DoctorJson{}32	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },33		snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},34		mut report)35	assert report.problems.any(it.contains('unsafe path'))36	assert report.problems.any(it.contains('file and a directory'))37}3839fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {40	dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')41	os.rmdir_all(dir) or {}42	os.mkdir_all(dir)!43	defer {44		os.rmdir_all(dir) or {}45	}46	store := storage.create(dir)!47	defer {48		store.close()49	}50	hash := store.put_object('unsafe'.bytes())!51	store.begin()!52	history.import_save(store, history.Save{53		id: 'badbadbadbad'54	}, [snapshot.FileEntry{55		path: "../June's file"56		hash: hash57	}])!58	history.set_head(store, 'badbadbadbad')!59	store.end()!60	mut report := DoctorJson{}61	doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)62	assert report.problems == [63		"save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",64	]65}6667struct DoctorReadOnly {68mut:69	items map[string][]u870}7172fn (mut backend DoctorReadOnly) get(key string) !remote.Object {73	if body := backend.items[key] {74		return remote.Object{ found: true, body: body }75	}76	return remote.Object{}77}7879fn (mut backend DoctorReadOnly) list(prefix string) ![]string {80	return backend.items.keys().filter(it.starts_with('${prefix}/'))81}8283fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {84	panic('Doctor attempted a remote write')85}8687fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {88	panic('Doctor attempted a remote write')89}9091fn (mut backend DoctorReadOnly) delete(key string) ! {92	panic('Doctor attempted a remote delete')93}9495fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {96	data := 'file'.bytes()97	hash := storage.hash_hex(data)98	mut backend := DoctorReadOnly{99		items: {100			'repo/head':                             'aaaaaaaaaaaa'.bytes()101			'repo/saves/aaaaaaaaaaaa':               json2.encode(exchange.SaveRecord{102				id:    'aaaaaaaaaaaa'103				files: [exchange.FileRecord{ path: 'file', hash: hash }]104			}).bytes()105			'repo/objects/${hash}':                  zlib.compress(data)!106			'repo/releases/latest':                  '1.0.0'.bytes()107			'repo/releases/1.0.0/release.json':      json2.encode(release.Record{108				repo:    'repo'109				version: '1.0.0'110				save:    'aaaaaaaaaaaa'111				files:   [release.File{112					platform: 'linux-aarch64'113					name:     'app'114					size:     data.len115					blake3:   hash116					sha256:   sha256.sum256(data).hex()117				}]118			}).bytes()119			'repo/releases/1.0.0/linux-aarch64/app': data120		}121	}122	mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }123	doctor_remote_content(mut backend, 'repo', mut report)!124	assert report.problems.len == 0125	assert report.remote_saves == 1126	assert report.remote_releases == 1127	backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()128	backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()129	mut damaged := DoctorJson{}130	doctor_remote_content(mut backend, 'repo', mut damaged)!131	assert damaged.problems.any(it.contains('damaged remote object'))132	assert damaged.problems.any(it.contains('checksum verification'))133}134135fn test_doctor_reports_event_errors_strip_residue_and_flags() {136	mut report := DoctorJson{}137	saves := [history.Save{ id: 'aaaaaaaaaaaa' }]138	event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },139		events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },140		events.Event{ id: 'bad', kind: 'unknown' }]141	doctor_events(event_list, saves, mut report)142	doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)143	assert report.flagged_saves == 1144	assert report.problems.any(it.contains('unknown kind'))145	assert report.problems.any(it.contains('stripped path'))146}147148struct DoctorMemory {149mut:150	items map[string][]u8151}152153fn (mut backend DoctorMemory) get(key string) !remote.Object {154	if body := backend.items[key] {155		return remote.Object{ found: true, body: body }156	}157	return remote.Object{}158}159160fn (mut backend DoctorMemory) list(prefix string) ![]string {161	return backend.items.keys().filter(it.starts_with('${prefix}/'))162}163164fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {165	if key in backend.items {166		return false167	}168	backend.items[key] = body169	return true170}171172fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {173	return false174}175176fn (mut backend DoctorMemory) delete(key string) ! {177	backend.items.delete(key)178}179180fn doctor_store(name string) !(storage.Store, string) {181	dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')182	os.rmdir_all(dir) or {}183	os.mkdir_all(dir)!184	return storage.create(dir)!, dir185}186187fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {188	store, dir := doctor_store('fetch')!189	defer {190		store.close()191		os.rmdir_all(dir) or {}192	}193	data := 'parent'.bytes()194	hash := storage.hash_hex(data)195	mut backend := DoctorReadOnly{196		items: {197			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{198				id:    'aaaaaaaaaaaa'199				files: [exchange.FileRecord{ path: 'file', hash: hash }]200			}).bytes()201			'repo/objects/${hash}':    zlib.compress(data)!202		}203	}204	store.db.exec('PRAGMA foreign_keys=OFF')!205	store.begin()!206	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },207		[]snapshot.FileEntry{})!208	history.set_head(store, 'bbbbbbbbbbbb')!209	store.end()!210	store.db.exec('PRAGMA foreign_keys=ON')!211	missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')212	assert missing == ['aaaaaaaaaaaa']213	assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1214	assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0215	assert store.get_object(hash)! == data216	assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {217		assert err.msg().contains('does not have save')218		0219	} == 0220}221222fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {223	store, dir := doctor_store('strip')!224	defer {225		store.close()226		os.rmdir_all(dir) or {}227	}228	secret := store.put_object('secret'.bytes())!229	kept := store.put_object('kept'.bytes())!230	store.begin()!231	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [232		snapshot.FileEntry{ path: 'keep', hash: kept },233		snapshot.FileEntry{ path: 'private/key', hash: secret },234	])!235	history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },236		[snapshot.FileEntry{ path: 'keep', hash: kept }])!237	events.strip(store, '', 'private')!238	store.end()!239	assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'240	mut report := DoctorJson{}241	doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut242		report)243	assert report.strip_residue244	assert exchange.reapply_strips(store)! == 1245	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']246	assert !store.has_object(secret)!247	assert events.strip_events(store)!.len == 1248}249250fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {251	store, dir := doctor_store('objects')!252	defer {253		store.close()254		os.rmdir_all(dir) or {}255	}256	data := 'content'.bytes()257	hash := store.put_object(data)!258	store.begin()!259	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [260		snapshot.FileEntry{ path: 'app', hash: hash },261	])!262	history.set_head(store, 'aaaaaaaaaaaa')!263	store.end()!264	store.db.exec("UPDATE objects SET data = x'00'")!265	problems := store.check_objects()!266	assert problems.map(it.hash) == [hash]267	assert local_copy(store, hash) == none268	os.write_file(os.join_path(dir, 'app'), 'content')!269	store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!270	assert store.check_objects()!.len == 0271	store.repair_object(hash, 'other'.bytes()) or {272		assert err.msg().contains('does not match')273	}274	mut backend := DoctorMemory{275		items: {276			'repo/objects/${hash}': 'damaged'.bytes()277		}278	}279	assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2280	assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data281	assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash282}283284// An invalid strip event authorizes nothing, and a strip frees only the objects285// it removed: an unrelated unused object may be needed for recovery.286fn test_doctor_strip_repair_ignores_invalid_events_and_keeps_unrelated_objects() {287	store, dir := doctor_store('strip_safety')!288	defer {289		store.close()290		os.rmdir_all(dir) or {}291	}292	app := store.put_object('app'.bytes())!293	secret := store.put_object('secret'.bytes())!294	orphan := store.put_object('orphan'.bytes())!295	store.begin()!296	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [297		snapshot.FileEntry{ path: 'app', hash: app },298		snapshot.FileEntry{ path: 'secret', hash: secret },299	])!300	events.receive(store, events.Event{301		id:         'invalidstrip'302		kind:       events.kind_strip303		path:       'app'304		created_at: -1305		machine:    'test'306	})!307	events.strip(store, '', 'secret')!308	store.end()!309	mut report := DoctorJson{}310	doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: 'app' }], events.all(store)!, mut311		report)312	assert !report.strip_residue313	assert exchange.reapply_strips(store)! == 1314	assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['app']315	assert store.has_object(app)!316	assert store.has_object(orphan)!317	assert !store.has_object(secret)!318}319320// With a remote, only a full audit that found no other head there lets Doctor321// guess, and the guess is checked again when it is written.322fn test_doctor_guesses_head_only_when_nothing_knows_better() {323	store, dir := doctor_store('only_tip')!324	defer {325		store.close()326		os.rmdir_all(dir) or {}327	}328	store.begin()!329	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, []snapshot.FileEntry{})!330	store.end()!331	mut report := DoctorJson{332		metadata_available: true333		saves:              1334		source:             'folder/repo'335		remote_head:        'bbbbbbbbbbbb'336	}337	assert !plan_repairs(store, report).any(it.action.contains('point head'))338	report.remote_head = ''339	report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}340	assert !plan_repairs(store, report).any(it.action.contains('point head'))341	report.checks = []342	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))343	assert repairs.len == 1344	history.set_head(store, 'cccccccccccc')!345	if _ := repairs[0].run() {346		assert false, 'a head another command set must not be replaced'347	} else {348		assert err.msg().contains('changed'), err.msg()349	}350	assert store.state(storage.head_key) or { '' } == 'cccccccccccc'351	store.remove_state(storage.head_key)!352	repairs[0].run()!353	assert store.state(storage.head_key) or { '' } == 'aaaaaaaaaaaa'354}355356fn test_doctor_reports_objects_it_restored_before_one_it_could_not() {357	store, dir := doctor_store('partial')!358	defer {359		store.close()360		os.rmdir_all(dir) or {}361	}362	kept := store.put_object('kept'.bytes())!363	lost := store.put_object('lost'.bytes())!364	store.begin()!365	history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [366		snapshot.FileEntry{ path: 'kept', hash: kept },367		snapshot.FileEntry{ path: 'lost', hash: lost },368	])!369	store.end()!370	store.db.exec("UPDATE objects SET data = x'00'")!371	os.write_file(os.join_path(dir, 'kept'), 'kept')!372	if _ := repair_objects(store, [kept, lost], '', '') {373		assert false, 'an object with no verified copy must fail its repair'374	} else {375		assert err is PartialRepair376		assert (err as PartialRepair).done.contains('1 damaged or missing object')377		assert err.msg().contains(lost)378	}379	assert store.get_object(kept)! == 'kept'.bytes()380}381382fn test_doctor_refuses_a_remote_save_with_invalid_metadata() {383	store, dir := doctor_store('invalid_fetch')!384	defer {385		store.close()386		os.rmdir_all(dir) or {}387	}388	mut backend := DoctorReadOnly{389		items: {390			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{391				id:         'aaaaaaaaaaaa'392				created_at: -1393			}).bytes()394		}395	}396	if _ := exchange.fetch_missing(store, mut backend, 'repo', ['aaaaaaaaaaaa']) {397		assert false, 'a record with invalid metadata must not be imported'398	} else {399		assert err.msg().contains('invalid metadata')400	}401	assert history.all_saves(store)!.len == 0402}403404// An empty file's Blake3 is the hash of empty bytes, so bytes that fail to405// decompress must not pass for it.406fn test_doctor_replaces_a_damaged_remote_copy_of_an_empty_file() {407	store, dir := doctor_store('empty_object')!408	defer {409		store.close()410		os.rmdir_all(dir) or {}411	}412	hash := store.put_object([]u8{})!413	mut backend := DoctorMemory{414		items: {415			'repo/objects/${hash}': 'not zlib'.bytes()416		}417	}418	assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1419	assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0420}