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
↓ koh-a8e3208a.tar.gzatlas at this save
files changed5 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 −1src/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.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+}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 }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 }