77badda0369b

close the eight review findings on the 3.8.6 Doctor fixes and add the 3.8.6 release notes

time
2026-10-07 16:33 UTC · 5 days ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
resolve doctor safety findings
parent
9519d1769dd2
10 files src/cli/cmd_doctor.v+1 −1src/cli/cmd_doctor_test.v+97 −1src/cli/doctor_remote.v+1 −1src/cli/doctor_repair.v+51 −28src/exchange/exchange.v+84 −29src/exchange/exchange_test.v+63 −1src/exchange/repair.v+11 −7src/exchange/strip.v+7 −3src/storage/objects.v+6 −0tests/cli_test.v+7 −0
src/cli/cmd_doctor.v+1 −1
@@ -90,7 +90,7 @@9090 	} else if report.warnings.len > 0 { 'warning' } else { 'healthy' }9191 	if args.json() {9292 		emit_json(json2.encode(report))93-	} else if repairs.len == 0 || yes || attempted || !interactive() {93+	} else if repairs.len == 0 || yes || attempted || backup_error != '' || !interactive() {9494 		print_doctor(report, true)9595 	}9696 	return if report.problems.len > 0 { 1 } else { 0 }

view file

src/cli/cmd_doctor_test.v+97 −1
@@ -9,6 +9,7 @@99 import src.remote1010 import src.events1111 import src.history12+import src.machine1213 import src.snapshot1314 import src.storage1415 @@ -338,7 +339,11 @@338339 	report.remote_head = ''339340 	report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested', ''}340341 	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'))341344 	report.checks = []345+	assert plan_repairs(store, report).any(it.action.contains('point head'))346+	report.source = ''342347 	repairs := plan_repairs(store, report).filter(it.action.contains('point head'))343348 	assert repairs.len == 1344349 	history.set_head(store, 'cccccccccccc')!@@ -373,7 +378,7 @@373378 		assert false, 'an object with no verified copy must fail its repair'374379 	} else {375380 		assert err is PartialRepair376-		assert (err as PartialRepair).done.contains('1 damaged or missing object')381+		assert (err as PartialRepair).done == 'restored 1 damaged or missing object'377382 		assert err.msg().contains(lost)378383 	}379384 	assert store.get_object(kept)! == 'kept'.bytes()@@ -417,4 +422,95 @@417422 	}418423 	assert exchange.resend(store, mut backend, 'repo', [hash], [])! == 1419424 	assert zlib.decompress(backend.items['repo/objects/${hash}'])!.len == 0425+}426+427+// The remote is asked again before head is guessed: another machine may have428+// offered since the audit.429+fn 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+}457+458+// An error after some objects were restored still reports them.459+fn 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+}488+489+// A strip event Doctor cannot trust removes nothing from a fetched save either.490+fn 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']420516 }

view file

src/cli/doctor_remote.v+1 −1
@@ -126,7 +126,7 @@126126 		doctor_files(save.id, files[save.id], event_list, mut remote_report)127127 	}128128 	for hash in hashes.keys() {129-		if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {129+		if !storage.valid_hash(hash) {130130 			remote_report.problems << 'invalid remote object hash ${hash}'131131 			continue132132 		}

view file

src/cli/doctor_repair.v+51 −28
@@ -41,13 +41,21 @@4141 			exchange.fetch_missing(store, mut backend, repo, ids)!4242 		}}4343 	} else if report.metadata_available && report.saves > 0 && report.head.id == '' {44-		// A remote may hold a newer head; only a full audit that found none45-		// other there lets Doctor guess.46-		audited := !report.checks.any(it.name == 'remote audit')44+		// A remote may hold a newer head; only a full, stable audit that found45+		// none other there lets Doctor guess, and the remote is asked again.46+		audited := !report.checks.any(it.name in ['remote audit', 'remote stability']47+			&& it.status != 'pass')4748 		tip := only_tip(store) or { '' }4849 		seen := report.head_id4950 		if tip != '' && (!has_remote || (audited && report.remote_head in ['', tip])) {50-			repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip, seen] () ! {51+			repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip, seen, has_remote, source, remote_repo] () ! {52+				if has_remote {53+					mut backend, repo := doctor_backend(store, source, remote_repo)!54+					theirs := backend.get(exchange.head_key(repo))!55+					if theirs.found && theirs.body.bytestr().trim_space() != tip {56+						return error('the remote now has another head; run koh doctor again')57+					}58+				}5159 				store.begin()!5260 				set_only_tip(store, tip, seen) or {5361 					store.rollback()@@ -71,8 +79,10 @@7179 		paths := report.pending_strips7280 		repairs << DoctorRepair{'send ${fmt.plural(paths.len, 'unfinished strip')} to the remote', fn [store, paths, remote_repo] () ! {7381 			mut backend, _ := doctor_backend(store, '', remote_repo)!74-			for path in paths {75-				exchange.send_strip(store, mut backend, path)!82+			for index, path in paths {83+				exchange.send_strip(store, mut backend, path) or {84+					return partial('sent', index, 'unfinished strip', err)85+				}7686 			}7787 		}}7888 	}@@ -116,34 +126,47 @@116126 // stored bytes (only the size was wrong), then working files, then the remote.117127 fn repair_objects(store &storage.Store, hashes []string, source string, remote_repo string) ! {118128 	mut lost := []string{}119-	for hash in hashes {120-		if data := local_copy(store, hash) {121-			store.repair_object(hash, data)!122-			continue129+	for index, hash in hashes {130+		restored := restore_object(store, hash, source, remote_repo) or {131+			return partial('restored', index - lost.len, 'damaged or missing object', err)123132 		}124-		if source == '' && !machine.has_remote() {133+		if !restored {125134 			lost << hash126-			continue127135 		}128-		mut backend, repo := doctor_backend(store, source, remote_repo)!129-		blob := backend.get(exchange.object_key(repo, hash))!130-		data := zlib.decompress(blob.body) or { []u8{} }131-		if !blob.found || storage.hash_hex(data) != hash {132-			lost << hash133-			continue134-		}135-		store.repair_object(hash, data)!136136 	}137137 	if lost.len > 0 {138-		reason := 'no verified copy here or on the remote of ${lost.join(', ')}'139-		if lost.len < hashes.len {140-			return PartialRepair{141-				done:   'restore ${fmt.plural(hashes.len - lost.len, 'damaged or missing object')} from verified copies'142-				reason: reason143-			}144-		}145-		return error(reason)138+		failure := error('no verified copy here or on the remote of ${lost.join(', ')}')139+		return partial('restored', hashes.len - lost.len, 'damaged or missing object', failure)146140 	}141+}142+143+// partial reports the `done` items a repair finished before `failure`, if any.144+fn partial(verb string, done int, noun string, failure IError) IError {145+	if done == 0 {146+		return failure147+	}148+	return PartialRepair{149+		done:   '${verb} ${fmt.plural(done, noun)}'150+		reason: failure.msg()151+	}152+}153+154+fn restore_object(store &storage.Store, hash string, source string, remote_repo string) !bool {155+	if data := local_copy(store, hash) {156+		store.repair_object(hash, data)!157+		return true158+	}159+	if source == '' && !machine.has_remote() {160+		return false161+	}162+	mut backend, repo := doctor_backend(store, source, remote_repo)!163+	blob := backend.get(exchange.object_key(repo, hash))!164+	data := zlib.decompress(blob.body) or { return false }165+	if !blob.found || storage.hash_hex(data) != hash {166+		return false167+	}168+	store.repair_object(hash, data)!169+	return true147170 }148171 149172 fn stored_intact(store &storage.Store, hash string) bool {

view file

src/exchange/exchange.v+84 −29
@@ -137,27 +137,13 @@137137 		return error(strip_not_applied)138138 	}139139 	mut uploaded := Uploaded{}140-	if remote_head.found {141-		theirs := remote_head.body.bytestr().trim_space()142-		newer := saves_after(store, local.id, theirs) or {143-			return error('remote has new saves — run koh steal')144-		}145-		for save in newer {146-			upload_save(store, mut backend, name, save, mut report, mut uploaded)!147-		}148-	} else {149-		oldest_first := history.line(store)!150-		for index := oldest_first.len - 1; index >= 0; index-- {151-			upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!152-		}140+	upload_line(store, mut backend, name, local.id, remote_head, mut report, mut uploaded) or {141+		return after_failed_upload(store, mut backend, name, uploaded, false, err)153142 	}154-	upload_events(store, mut backend, name, mut report)!155143 	// A strip sends its event before it lists the saves to rewrite, so one156144 	// missed here lists them after these records landed and rewrites them. One157145 	// seen here may have rewritten before they landed: take back what it reaches.158-	late := unapplied_strips(store, mut backend, name)!159-	if late.len > 0 {160-		take_back(store, mut backend, name, uploaded, late)!146+	if undo_late_strips(store, mut backend, name, uploaded, false)! {161147 		return error(strip_not_applied)162148 	}163149 	if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {@@ -187,16 +173,77 @@187173 188174 const strip_not_applied = 'the remote has a strip this repo has not applied — run koh steal'189175 190-// Uploaded is what one offer put on the remote, so a late strip can take it back.176+// Uploaded is what one upload put on the remote, so a late strip can take it back.191177 struct Uploaded {192178 mut:193179 	saves   []string194180 	objects map[string]bool195181 }196182 197-// take_back deletes the records an offer or Doctor's resend put, which head198-// never reached, and every object it put that a strip reaches in a save here.199-fn take_back(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {183+// upload_line sends every save after the remote head, then the events.184+fn upload_line(store &storage.Store, mut backend remote.Backend, name string, local string, remote_head remote.Object, mut report OfferReport, mut uploaded Uploaded) ! {185+	if remote_head.found {186+		theirs := remote_head.body.bytestr().trim_space()187+		newer := saves_after(store, local, theirs) or {188+			return error('remote has new saves — run koh steal')189+		}190+		for save in newer {191+			upload_save(store, mut backend, name, save, mut report, mut uploaded)!192+		}193+	} else {194+		oldest_first := history.line(store)!195+		for index := oldest_first.len - 1; index >= 0; index-- {196+			upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!197+		}198+	}199+	upload_events(store, mut backend, name, mut report)!200+}201+202+// after_failed_upload still applies a strip that landed before the failure203+// to what the upload had put.204+fn after_failed_upload(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, keep_records bool, failure IError) IError {205+	undo_late_strips(store, mut backend, repo, uploaded, keep_records) or {206+		return error('${failure.msg()}; removing what a strip on the remote covers also failed: ${err.msg()}')207+	}208+	return failure209+}210+211+// undo_late_strips applies strips that reached the remote during an upload to212+// what the upload put, and reports whether there were any. An offer's records213+// go, since head never reached them. Doctor's resent records may already be214+// named by head, so they keep everything but the stripped paths; one that215+// moved meanwhile was rewritten by the strip itself.216+fn undo_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, keep_records bool) !bool {217+	strips := unapplied_strips(store, mut backend, repo)!218+	if strips.len == 0 {219+		return false220+	}221+	for id in uploaded.saves {222+		key := save_key(repo, id)223+		if !keep_records {224+			backend.delete(key)!225+			continue226+		}227+		object := backend.get(key)!228+		if !object.found {229+			continue230+		}231+		record := decode_save(object.body)!232+		kept := record.files.filter(fn [strips, id] (file FileRecord) bool {233+			return !strips.any(reaches(it, id) && covers(it.path, file.path))234+		})235+		if kept.len < record.files.len {236+			backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), object.tag)!237+		}238+	}239+	drop_stripped_objects(store, mut backend, repo, uploaded, strips)!240+	return true241+}242+243+// drop_stripped_objects deletes what an upload put that a strip reaches in a244+// save here, unless a remote record still lists it: another machine may hold245+// the same bytes under a path no strip names.246+fn drop_stripped_objects(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {200247 	mut reached := map[string]bool{}201248 	for save in history.all_saves(store)! {202249 		for file in history.files_of(store, save.id)! {@@ -205,9 +252,17 @@205252 			}206253 		}207254 	}208-	for id in uploaded.saves {209-		backend.delete(save_key(repo, id))!255+	if reached.len == 0 {256+		return210257 	}258+	for key in backend.list('${repo}/saves')! {259+		object := backend.get(key)!260+		if object.found {261+			for file in decode_save(object.body)!.files {262+				reached.delete(file.hash)263+			}264+		}265+	}211266 	for hash, _ in reached {212267 		backend.delete(object_key(repo, hash))!213268 	}@@ -215,7 +270,7 @@215270 216271 // upload_save puts a save's new objects, then its record, so a record on the remote always finds its217272 // objects there. Both are put_if_absent, so a retry after a partial offer is safe; `uploaded` keeps218-// what this call created, which is all take_back may delete.273+// what this call created, which is all undo_late_strips may delete.219274 fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport, mut uploaded Uploaded) ! {220275 	files := history.files_of(store, save.id)!221276 	// Saves go up oldest first and head moves only once all of them landed, so@@ -357,7 +412,7 @@357412 		if !events.receive(store, event)! {358413 			continue359414 		}360-		if event.kind == events.kind_strip {415+		if events.valid_strip(event) {361416 			remove_path(store, event.path, event.save_id)!362417 			stripped = true363418 		}@@ -383,12 +438,12 @@383438 	}384439 }385440 386-// known_strips is every strip held here and every one the fetch brings: an387-// arriving save loses what any of them reaches, not only what arrived with it.441+// known_strips is every valid strip held here and every one the fetch brings:442+// an arriving save loses what any of them reaches, not only what arrived with it.388443 fn known_strips(store &storage.Store, fetched Fetched) ![]events.Event {389444 	mut strips := events.strip_events(store)!390-	strips << fetched.events_out.filter(it.kind == events.kind_strip)391-	return strips445+	strips << fetched.events_out446+	return strips.filter(events.valid_strip(it))392447 }393448 394449 // import_arriving lands one save from the remote, unless it is already here, less every path a

view file

src/exchange/exchange_test.v+63 −1
@@ -1120,6 +1120,9 @@11201120 	other &storage.Store11211121 	path  string11221122 	done  bool1123+	// fail every put after the one the strip raced1124+	fail_after_race bool1125+	raced_put       bool11231126 }11241127 11251128 fn (mut r StripRace) get(key string) !remote.Object {@@ -1131,8 +1134,12 @@11311134 }11321135 11331136 fn (mut r StripRace) put_if_absent(key string, body []u8) !bool {1137+	if r.fail_after_race && r.raced_put {1138+		return error('injected write failure')1139+	}11341140 	if !r.done && key.contains('/saves/') {11351141 		r.done = true1142+		r.raced_put = true11361143 		strip_local(r.other, r.path, '')!11371144 		send_strip(r.other, mut r.inner, r.path)!11381145 	}@@ -1220,10 +1227,55 @@12201227 	} else {12211228 		assert err.msg().contains('koh steal'), err.msg()12221229 	}1223-	assert !backend.get(save_key('proj', first.id))!.found1230+	// Head names the restored record, so it stays, less the stripped path.1231+	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1232+	assert 'secrets.env' !in record.files.map(it.path)12241233 	assert !backend.get(object_key('proj', storage.hash_hex('api_key=1\n'.bytes())))!.found12251234 }12261235 1236+// An object a strip reaches stays while a remote record still lists it under1237+// another path, and a strip that landed before an upload failed is still applied.1238+fn test_a_resend_keeps_shared_bytes_and_settles_a_strip_after_a_failure() {1239+	base := scratch('resend_shared')!1240+	defer {1241+		os.rmdir_all(base) or {}1242+	}1243+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1244+	mut a := open_repo(machine('resend_shared_a')!)!1245+	defer {1246+		a.close()1247+	}1248+	mut b := open_repo(machine('resend_shared_b')!)!1249+	defer {1250+		b.close()1251+	}1252+	put(a.root, 'secrets.env', 'same\n')!1253+	put(a.root, 'copy.txt', 'same\n')!1254+	first := make_save(a, 'two paths, one object')!1255+	offer_now(a, mut backend)!1256+	join(b, mut backend)!1257+	put(a.root, 'later.txt', 'later\n')!1258+	second := make_save(a, 'not offered')!1259+	shared := object_key('proj', storage.hash_hex('same\n'.bytes()))1260+	backend.delete(save_key('proj', first.id))!1261+	backend.delete(shared)!1262+	mut race := StripRace{1263+		inner:           backend1264+		other:           &b1265+		path:            'secrets.env'1266+		fail_after_race: true1267+	}1268+	if _ := resend(a, mut race, 'proj', [], [first.id, second.id]) {1269+		assert false, 'the injected failure should have surfaced'1270+	} else {1271+		assert err.msg().contains('injected'), err.msg()1272+	}1273+	record := decode_save(backend.get(save_key('proj', first.id))!.body)!1274+	paths := record.files.map(it.path)1275+	assert 'secrets.env' !in paths && 'copy.txt' in paths, paths.str()1276+	assert backend.get(shared)!.found1277+}1278+12271279 // A note that does not parse, or that no recorded strip of its path backs, is12281280 // kept and changes nothing remote: sending it would lose the retry or strip12291281 // where no strip asked.@@ -1252,6 +1304,16 @@12521304 		assert backend.list(events_prefix('proj'))!.len == 012531305 		assert 'app' in decode_save(backend.get(save_key('proj', save.id))!.body)!.files.map(it.path)12541306 	}1307+	// Even behind a recorded strip, a note's hashes may name only objects.1308+	events.strip(a, '', 'app')!1309+	victim := os.join_path(base, 'remote', 'victim', 'head')1310+	os.mkdir_all(os.dir(victim))!1311+	os.write_file(victim, 'another repo')!1312+	a.set_state(pending_key('app'), '{"saves":[""],"hashes":["../../victim/head"]}')!1313+	if _ := send_strip(a, mut backend, 'app') {1314+		assert false, 'a note naming a path outside the objects must be refused'1315+	}1316+	assert os.read_file(victim)! == 'another repo'12551317 }12561318 12571319 // The local half lands before the remote hears anything, so a remote failure

view file

src/exchange/repair.v+11 −7
@@ -83,6 +83,17 @@8383 		return error(strip_not_applied)8484 	}8585 	mut uploaded := Uploaded{}86+	upload_copies(store, mut backend, repo, hashes, save_ids, mut uploaded) or {87+		return after_failed_upload(store, mut backend, repo, uploaded, true, err)88+	}89+	// As in offer: a strip that landed during the uploads may have missed them.90+	if undo_late_strips(store, mut backend, repo, uploaded, true)! {91+		return error(strip_not_applied)92+	}93+	return uploaded.objects.len + uploaded.saves.len94+}95+96+fn upload_copies(store &storage.Store, mut backend remote.Backend, repo string, hashes []string, save_ids []string, mut uploaded Uploaded) ! {8697 	for hash in hashes {8798 		key := object_key(repo, hash)8899 		existing := backend.get(key)!@@ -113,13 +124,6 @@113124 			uploaded.saves << id114125 		}115126 	}116-	// As in offer: a strip that landed during the uploads may have missed them.117-	late := unapplied_strips(store, mut backend, repo)!118-	if late.len > 0 {119-		take_back(store, mut backend, repo, uploaded, late)!120-		return error(strip_not_applied)121-	}122-	return uploaded.objects.len + uploaded.saves.len123127 }124128 125129 /// Remove what recorded strips cover but a save still holds, without

view file

src/exchange/strip.v+7 −3
@@ -48,13 +48,17 @@4848 	return 'strip_pending:${path}'4949 }5050 51-// read_pending refuses a note it cannot parse, or one claiming a scope no52-// recorded strip of the path has: sending it would lose the retry or remove53-// the path where no strip asked. The note stays for recovery either way.51+// read_pending refuses a note it cannot parse, one naming something other52+// than an object, or one claiming a scope no recorded strip of the path has:53+// sending it would lose the retry, delete outside the repo's objects, or54+// remove the path where no strip asked. The note stays for recovery.5455 pub fn read_pending(store &storage.Store, path string) !Pending {5556 	text := store.state(pending_key(path)) or { return Pending{} }5657 	note := json2.decode[Pending](text) or {5758 		return error('the unfinished strip note for ${path} is unreadable; it is kept for recovery')59+	}60+	if !note.hashes.all(storage.valid_hash(it)) {61+		return error('the unfinished strip note for ${path} names something other than an object; it is kept for recovery')5862 	}5963 	recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)6064 	if note.saves.len == 0 || !note.saves.all(fn [recorded] (scope string) bool {

view file

src/storage/objects.v+6 −0
@@ -134,6 +134,12 @@134134 	return before - after135135 }136136 137+// valid_hash is the shape of an object key: 64 lowercase hex characters, so it138+// can name nothing but an object.139+pub fn valid_hash(hash string) bool {140+	return hash.len == 64 && hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`))141+}142+137143 // drop_unused removes those of `hashes` no save points at any more. Other138144 // unused objects stay: with damaged rows one may be the only copy of saved139145 // content, and Doctor offers their removal separately.

view file

tests/cli_test.v+7 −0
@@ -873,6 +873,13 @@873873 	assert refused.out.contains('"repaired":[]'), refused.out874874 	assert os.read_file(existing)! == 'precious'875875 	assert run(dir, 'doctor', '--json').code == 1876+	// On a terminal the report still appears, naming the failed backup.877+	home := temp_repo('doctor-backup-home')878+	defer { os.rmdir_all(home) or {} }879+	shown := run_pty_home(dir, home, [], 'doctor', '--backup', existing)880+	assert shown.code == 1, shown.out881+	assert shown.out.contains('choose a backup path that does not exist yet'), shown.out882+	assert os.read_file(existing)! == 'precious'876883 }877884 878885 fn test_strip_refuses_without_terminal() {

view file