a8e3208a9603

wip: report unsafe doctor paths and thread flagged build-offer JSON; A3.4 incomplete (3.2.2)

time
2026-09-28 15:05 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
gpt-5
session
harden shared path handling
parent
eff0d4dd1105
5 files VERSION+1 −1src/cli/cmd_doctor.v+19 −0src/cli/cmd_doctor_test.v+32 −0src/cli/cmd_offer.v+4 −2v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.2.11+3.2.2

view file

src/cli/cmd_doctor.v+19 −0
@@ -11,6 +11,8 @@1111 import os1212 import time1313 import src.history14+import src.snapshot15+import src.storage1416 1517 pub fn cmd_doctor(raw []string) int {1618 	args := parse('doctor', raw, Options{@@ -28,6 +30,7 @@2830 	report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }2931 	report.problems << store.check_objects() or { return refuse(args.json(), err.msg()) }3032 	report.problems << store.check_save_files() or { return refuse(args.json(), err.msg()) }33+	report.problems << unsafe_save_paths(store) or { return refuse(args.json(), err.msg()) }3134 3235 	report.objects = int(store.object_count() or { 0 })3336 	report.saves = int(history.save_count(store) or { 0 })@@ -63,6 +66,22 @@6366 		return 16467 	}6568 	return 069+}70+71+fn unsafe_save_paths(store &storage.Store) ![]string {72+	mut problems := []string{}73+	for save in history.line(store)! {74+		for file in history.files_of(store, save.id)! {75+			if !snapshot.legal_path(file.path) {76+				problems << 'save [${save.id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}'77+			}78+		}79+	}80+	return problems81+}82+83+fn shell_quote(text string) string {84+	return "'${text.replace("'", "'\\''")}'"6685 }6786 6887 // peek_age is how old a peek folder must be before doctor clears it.

view file

src/cli/cmd_doctor_test.v+32 −0
@@ -0,0 +1,32 @@1+module cli2+3+import os4+import src.history5+import src.snapshot6+import src.storage7+8+fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {9+	dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')10+	os.rmdir_all(dir) or {}11+	os.mkdir_all(dir)!12+	defer {13+		os.rmdir_all(dir) or {}14+	}15+	store := storage.create(dir)!16+	defer {17+		store.close()18+	}19+	hash := store.put_object('unsafe'.bytes())!20+	store.begin()!21+	history.import_save(store, history.Save{22+		id: 'badbadbadbad'23+	}, [snapshot.FileEntry{24+		path: "../June's file"25+		hash: hash26+	}])!27+	history.set_head(store, 'badbadbadbad')!28+	store.end()!29+	assert unsafe_save_paths(store)! == [30+		"save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",31+	]32+}

view file

src/cli/cmd_offer.v+4 −2
@@ -65,7 +65,7 @@6565 	}6666 	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }6767 	if args.flag('builds') {68-		return offer_builds(args, store, mut backend)68+		return offer_builds(args, store, mut backend, flagged)6969 	}7070 	report := exchange.offer(store, mut backend) or { return refuse(args.json(), err.msg()) }7171 	if args.json() {@@ -91,7 +91,7 @@9191 // offer_builds is `koh offer --builds`: the history goes up first, then a9292 // release built from the working tree. Unsaved changes are refused, so the9393 // published files always belong to a save that is on the remote.94-fn offer_builds(args Args, store &storage.Store, mut backend remote.Backend) int {94+fn offer_builds(args Args, store &storage.Store, mut backend remote.Backend, flagged []string) int {9595 	head := history.head(store) or { return refuse(args.json(), exchange.no_saves) }9696 	if exchange.dirty(store, head) or { true } {9797 		return refuse(args.json(), 'save or checkpoint first — a release is built from a save')@@ -117,6 +117,7 @@117117 				size:     it.size118118 				blake3:   it.blake3119119 			})120+			flagged: flagged120121 		}))121122 		return 0122123 	}@@ -193,4 +194,5 @@193194 	repo    string194195 	version string195196 	files   []PublishedFileJson197+	flagged []string196198 }

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.2.1'3+	version: '3.2.2'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file