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
5 files VERSION+1 −1src/cli/cmd_update.v+9 −4src/update/update.v+23 −4tests/cli_test.v+13 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.881+3.0.89

view file

src/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{

view file

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 be

view file

tests/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 {} }

view file

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 }

view file