e86a58245a22
close the update review: a no at the question refuses, the download is checked against its declared size, the swap is a real rename, a document with no version is refused and --json without --yes is a usage error (3.0.89)
- time
- 2026-09-27 14:16 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- deepseek/deepseek-flash
- session
- koh 3 phases 6-8
- parent
- bbf4bfcaed9a
↓ koh-e86a5824.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/cli/cmd_update.v+9 −4src/update/update.v+23 −4tests/cli_test.v+13 −0v.mod+1 −1src/cli/cmd_update.v+9 −4
@@ -18,9 +18,10 @@1818 return usage_error('update takes no arguments, not "${args.positional_at(0)}"')1919 }2020 if args.json() && !args.flag('yes') && !args.flag('check') {21- // The question cannot be asked inside a JSON document, so an install22- // asked for in JSON has to be asked for with --yes as well.23- return refuse(true, 'koh update --json needs --yes')21+ // The question cannot be asked inside a JSON document, so an install asked22+ // for in JSON has to be asked for with --yes as well. That is a mistake in23+ // the command line, not a refusal of the work.24+ return usage_error('--json needs --yes when it would install')2425 }2526 current := version2627 platform := build.this_machine() or { return refuse(args.json(), err.msg()) }@@ -63,7 +64,11 @@6364 note(latest.notes)6465 }6566 }66- confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }67+ // A "no" at the question is a refusal, not a reason to carry on.68+ approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }69+ if !approved {70+ return refuse(args.json(), 'not updating, then')71+ }6772 target := update.install(latest) or { return refuse(args.json(), err.msg()) }6873 if args.json() {6974 emit_json(json2.encode(UpdateJson{src/update/update.v+23 −4
@@ -70,7 +70,12 @@7070 document := json2.decode[Document](response.body) or {7171 return error('${url} is not a release document: ${err.msg()}')7272 }73- if document.version == '' || compare_versions(document.version, now) <= 0 {73+ if document.version == '' {74+ // A document with no version is not a release document, so say that rather75+ // than pretending there is simply nothing to do.76+ return error('${url} has no version — it is not a release document')77+ }78+ if compare_versions(document.version, now) <= 0 {7479 return Checked{}7580 }7681 file := document.platforms[platform] or {@@ -136,7 +141,9 @@136141 return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')137142 }138143 }139- os.mv(staged, target)!144+ // rename, not move: both paths are in one folder, so this is the atomic step145+ // and a failure is a failure, never a half-written binary.146+ os.rename(staged, target)!140147 return target141148 }142149 @@ -146,15 +153,27 @@146153 // tests only), so updating from one is the same code path with the file147154 // read instead of fetched.148155 path := url[file_scheme.len..]149- return os.read_file(path) or {156+ size := os.file_size(path)157+ bytes := os.read_file(path) or {150158 return error('could not read ${url}: ${err.msg()}')151159 }.bytes()160+ // The download is verified against the size it was said to be, and then161+ // against its Blake3, before anything is signed or moved.162+ if size > 0 && bytes.len != size {163+ return error('${url} is ${size} bytes but ${bytes.len} arrived — refusing a partial download')164+ }165+ return bytes152166 }153167 response := http.get(url) or { return error('could not download ${url}: ${err.msg()}') }154168 if response.status_code != 200 {155169 return error('${url} answered ${response.status_code}')156170 }157- return response.body.bytes()171+ bytes := response.body.bytes()172+ declared := response.header.get(.content_length) or { '' }173+ if declared != '' && i64(declared.trim_space().int()) != bytes.len {174+ return error('${url} said ${declared} bytes and sent ${bytes.len} — refusing a partial download')175+ }176+ return bytes158177 }159178 160179 // verify is the check that makes installing anything safe: the bytes must betests/cli_test.v+13 −0
@@ -148,6 +148,19 @@148148 149149 // The item's exit check, as a test: `koh build` builds Koh 3 itself with the150150 // real toolchain, in a copy, and the binary it produces runs.151+// `koh update` refuses a command line that cannot be answered: JSON cannot carry152+// the question, and a stray argument is a mistake. Both are settled before any153+// source is read, so no network is needed here.154+fn test_update_refuses_a_command_line_it_cannot_answer() {155+ dir := temp_repo('update_args')156+ defer { os.rmdir_all(dir) or {} }157+ init_repo(dir)158+ json_without_yes := run(dir, 'update', '--json')159+ assert json_without_yes.code == 2160+ assert json_without_yes.err.contains('needs --yes')161+ assert run(dir, 'update', 'extra').code == 2162+}163+151164 fn test_build_builds_koh_itself() {152165 dir := temp_repo('selfbuild')153166 defer { os.rmdir_all(dir) or {} }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.88'3+ version: '3.0.89'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }