fe3253a128c9

add koh update: read a release document from Kepr, install only a newer version, verify its Blake3, sign it ad-hoc and rename it over the running binary (3.0.85)

time
2026-09-27 10:45 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
koh 3 phases 6-8
parent
b760c74f453b
6 files VERSION+1 −1src/cli/cli.v+4 −1src/cli/cmd_update.v+87 −0src/update/update.v+181 −0src/update/update_test.v+87 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.841+3.0.85

view file

src/cli/cli.v+4 −1
@@ -39,7 +39,7 @@3939 			text += '  ${fmt.gold(row[0])}${' '.repeat(12 - row[0].len)}${fmt.muted(row[1])}\n'4040 		}4141 	}42-	text += '\n  ${fmt.dim('also: remote · flag · pluck · peek · build · init · doctor · strip · migrate')}\n'42+	text += '\n  ${fmt.dim('also: remote · flag · pluck · peek · build · update · init · doctor · strip · migrate')}\n'4343 	text += '\n  ${fmt.dim('koh man <command>  for options and examples')}\n'4444 	return text4545 }@@ -119,6 +119,9 @@119119 		}120120 		'build' {121121 			return cmd_build(rest)122+		}123+		'update' {124+			return cmd_update(rest)122125 		}123126 		'migrate' {124127 			return cmd_migrate(rest)

view file

src/cli/cmd_update.v+87 −0
@@ -0,0 +1,87 @@1+//! `koh update` — put a newer published koh in place of this one (§12).2+//!3+//! Nothing is installed without being asked, and nothing is removed: the new4+//! binary replaces the running one by rename, which is the step that makes the5+//! change visible.6+7+module cli8+9+import json210+import src.build11+import src.update12+13+pub fn cmd_update(raw []string) int {14+	args := parse('update', raw, Options{15+		flags: ['check', 'json', 'yes']16+	}) or { return usage_error(err.msg()) }17+	if args.positional.len > 0 {18+		return usage_error('update takes no arguments, not "${args.positional_at(0)}"')19+	}20+	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')24+	}25+	current := version26+	platform := build.this_machine() or { return refuse(args.json(), err.msg()) }27+	checked := update.check(current, platform) or { return refuse(args.json(), err.msg()) }28+	latest := if checked.update {29+		checked.available30+	} else {31+		if args.json() {32+			emit_json(json2.encode(UpdateJson{33+				current:   current34+				latest:    current35+				available: false36+			}))37+		} else {38+			ok('koh ${current} is the newest — nothing to do')39+		}40+		return 041+	}42+	if args.flag('check') {43+		if args.json() {44+			emit_json(json2.encode(UpdateJson{45+				current:   current46+				latest:    latest.version47+				available: true48+				notes:     latest.notes49+			}))50+		} else {51+			ok('koh ${latest.version} is available — you have ${current}')52+			if latest.notes != '' {53+				note(latest.notes)54+			}55+			note('run "koh update" to install it')56+		}57+		// `--check` reports and says so with its exit code, so a script can ask.58+		return 159+	}60+	if !args.json() {61+		note('${current} → ${latest.version}')62+		if latest.notes != '' {63+			note(latest.notes)64+		}65+	}66+	confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }67+	target := update.install(latest) or { return refuse(args.json(), err.msg()) }68+	if args.json() {69+		emit_json(json2.encode(UpdateJson{70+			current:   current71+			latest:    latest.version72+			available: true73+			installed: target74+		}))75+		return 076+	}77+	ok('installed koh ${latest.version} as ${target}')78+	return 079+}80+81+struct UpdateJson {82+	current   string83+	latest    string84+	available bool85+	installed string86+	notes     string87+}

view file

src/update/update.v+181 −0
@@ -0,0 +1,181 @@1+//! `koh update` — put a newer published koh in place of this one (§12).2+//!3+//! The source is a release document: Kepr's `/koh/releases/latest.json` by4+//! default, or whatever `KOH_UPDATE_SOURCE` names. Koh installs only a5+//! **newer** version than the one running, never merely a different one, so a6+//! stale mirror cannot walk the binary backwards. Nothing is ever built here.7+8+module update9+10+import crypto.blake311+import json212+import net.http13+import os14+15+pub const default_source = 'https://kepr.uk/koh/releases/latest.json'16+pub const source_env = 'KOH_UPDATE_SOURCE'17+pub const repo = 'koh'18+19+// One platform's file, in the shape Kepr answers with (§22.4).20+struct PlatformFile {21+	url    string22+	blake3 string23+	sha256 string24+}25+26+// The release document: `notes` is omitted by Kepr when it is empty, so it is27+// optional rather than an empty string.28+struct Document {29+	version   string30+	notes     ?string31+	platforms map[string]PlatformFile32+}33+34+pub struct Available {35+pub:36+	version  string37+	notes    string38+	url      string39+	blake3   string40+	platform string41+}42+43+// source is the document to read: the environment wins, so a self-hosted Kepr44+// can serve its own koh.45+pub fn source() string {46+	named := os.getenv(source_env)47+	if named != '' {48+		return named49+	}50+	return default_source51+}52+53+// Checked is what the source had to say: whether there is anything newer, and54+// what it is when there is.55+pub struct Checked {56+pub:57+	update    bool58+	available Available59+}60+61+// check asks the source what the newest koh is. Errors are errors; "nothing to62+// do" is not one of them, so it is a field, not a failed call.63+pub fn check(now string, platform string) !Checked {64+	url := source()65+	response := http.get(url) or { return error('could not reach ${url}: ${err.msg()}') }66+	if response.status_code != 200 {67+		return error('${url} answered ${response.status_code}')68+	}69+	document := json2.decode[Document](response.body) or {70+		return error('${url} is not a release document: ${err.msg()}')71+	}72+	if document.version == '' || compare_versions(document.version, now) <= 0 {73+		return Checked{}74+	}75+	file := document.platforms[platform] or {76+		return error('koh ${document.version} has no ${platform} file — run it on another machine')77+	}78+	return Checked{79+		update:    true80+		available: Available{81+			version:  document.version82+			notes:    document.notes or { '' }83+			url:      file.url84+			blake3:   file.blake385+			platform: platform86+		}87+	}88+}89+90+// compare_versions orders two versions the way the spec asks: dot-separated91+// components compared as numbers, so 2.10.0 is newer than 2.9.9. A component92+// that is not a number is compared as text, so a strange version can never win93+// by accident. Returns 1, 0 or -1.94+pub fn compare_versions(a string, b string) int {95+	left := a.split('.')96+	right := b.split('.')97+	for index in 0 .. if left.len > right.len { left.len } else { right.len } {98+		one := if index < left.len { left[index] } else { '0' }99+		two := if index < right.len { right[index] } else { '0' }100+		order := compare_component(one, two)101+		if order != 0 {102+			return order103+		}104+	}105+	return 0106+}107+108+fn compare_component(a string, b string) int {109+	// Leading zeros are not part of a number: 01 and 1 are the same version.110+	left := a.trim_left('0')111+	right := b.trim_left('0')112+	if left == right {113+		return 0114+	}115+	if left.len != right.len {116+		return if left.len > right.len { 1 } else { -1 }117+	}118+	return if left > right { 1 } else { -1 }119+}120+121+// install downloads the new binary, checks it, and swaps it in for the one122+// running. The rename is the only step that changes anything, and it is the123+// last one.124+pub fn install(available Available) !string {125+	bytes := download(available.url)!126+	verify(bytes, available.blake3)!127+	target := os.executable()128+	staged := stage(os.dir(target), bytes)!129+	if os.user_os() == 'macos' {130+		// An unsigned binary cannot run on Apple silicon at all, so a refused131+		// signature is a refusal to install, not a warning.132+		signed := os.execute('codesign -f -s - ${sh_quote(staged)}')133+		if signed.exit_code != 0 {134+			os.rm(staged) or {}135+			return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')136+		}137+	}138+	os.mv(staged, target)!139+	return target140+}141+142+fn download(url string) ![]u8 {143+	response := http.get(url) or { return error('could not download ${url}: ${err.msg()}') }144+	if response.status_code != 200 {145+		return error('${url} answered ${response.status_code}')146+	}147+	return response.body.bytes()148+}149+150+// verify is the check that makes installing anything safe: the bytes must be151+// what the document said, or they are thrown away.152+fn verify(bytes []u8, expected string) ! {153+	if bytes.len == 0 {154+		return error('the download was empty')155+	}156+	if expected == '' {157+		return error('the release document carries no Blake3 checksum — refusing to install it')158+	}159+	got := blake3.sum256(bytes).hex()160+	if got != expected {161+		return error('the download is not the published file (Blake3 ${got} instead of ${expected}) — refusing to install it')162+	}163+}164+165+// stage writes the new binary beside the one it will replace, so the rename is166+// within one filesystem and therefore atomic. A folder that cannot be written167+// is refused here, with the write's own error.168+fn stage(dir string, bytes []u8) !string {169+	staged := os.join_path(dir, '.koh-new-${os.getpid()}')170+	os.write_file_array(staged, bytes) or {171+		return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})')172+	}173+	os.chmod(staged, 0o755)!174+	return staged175+}176+177+// sh_quote keeps a path with a space in it one argument, and cannot be broken178+// out of by a quote inside the path.179+fn sh_quote(text string) string {180+	return "'" + text.replace("'", "'\\''") + "'"181+}

view file

src/update/update_test.v+87 −0
@@ -0,0 +1,87 @@1+//! Unit tests for `koh update`: ordering versions, decoding a release2+//! document, and the two checks that stand between a download and the binary.3+4+module update5+6+import crypto.blake37+import json28+import os9+10+fn scratch(name string) !string {11+	dir := os.join_path(os.temp_dir(), 'koh3_update_${os.getpid()}_${name}')12+	os.rmdir_all(dir) or {}13+	os.mkdir_all(dir, os.MkdirParams{})!14+	return dir15+}16+17+fn test_versions_compare_as_numbers_component_by_component() {18+	assert compare_versions('2.10.0', '2.9.9') == 119+	assert compare_versions('2.9.9', '2.10.0') == -120+	assert compare_versions('3.0.82', '3.0.82') == 021+	assert compare_versions('1.0', '1.0.0') == 022+	assert compare_versions('1.01', '1.1') == 023+	assert compare_versions('1.0.1', '1.0') == 124+	assert compare_versions('10.0.0', '9.9.9') == 125+	assert compare_versions('0.9', '1.0') == -126+}27+28+fn test_a_release_document_is_read_with_and_without_notes() {29+	body := '{"version":"3.0.84","platforms":{"macos-aarch64":{"url":"https://x/koh","blake3":"ab"}}}'30+	document := json2.decode[Document](body)!31+	assert document.version == '3.0.84'32+	assert document.notes or { 'absent' } == 'absent'33+	assert document.platforms['macos-aarch64'] or { PlatformFile{} }.url == 'https://x/koh'34+	with_notes := '{"version":"3.0.84","notes":"hello","platforms":{}}'35+	assert json2.decode[Document](with_notes)!.notes or { '' } == 'hello'36+}37+38+fn test_a_download_that_is_not_the_published_file_is_refused() {39+	body := 'the new binary'.bytes()40+	// The Blake3 of this exact text, computed elsewhere: a real check, not the41+	// implementation checking itself.42+	good := '0e86a96ebd4c3d861a2e85c07544478573af7ab3745c1d4e7b5750fdbe7cc397'43+	assert blake3.sum256(body).hex() == good44+	verify(body, good)!45+	if _ := verify(body, 'ff'.repeat(32)) {46+		assert false, 'a download that does not match its checksum must be refused'47+	} else {48+		assert err.msg().contains('not the published file')49+	}50+	if _ := verify([]u8{}, good) {51+		assert false, 'an empty download must be refused'52+	}53+	if _ := verify(body, '') {54+		assert false, 'a document without a checksum must be refused'55+	}56+}57+58+fn test_staging_writes_beside_the_target_and_refuses_a_locked_folder() {59+	dir := scratch('stage')!60+	defer {61+		os.rmdir_all(dir) or {}62+	}63+	staged := stage(dir, 'binary'.bytes())!64+	assert os.dir(staged) == dir65+	assert os.read_file(staged)! == 'binary'66+	// 0755: the file is about to become the koh binary.67+	assert (os.stat(staged) or { panic(err) }).mode & 0o777 == 0o75568+69+	locked := os.join_path(dir, 'locked')70+	os.mkdir_all(locked, os.MkdirParams{})!71+	os.chmod(locked, 0o500)!72+	defer {73+		os.chmod(locked, 0o700) or {}74+	}75+	if _ := stage(locked, 'binary'.bytes()) {76+		assert false, 'a folder that cannot be written must be refused'77+	} else {78+		assert err.msg().contains('cannot write beside')79+	}80+}81+82+fn test_the_source_can_be_named_by_the_environment() {83+	os.setenv(source_env, 'http://127.0.0.1:8080/koh/releases/latest.json', true)84+	assert source() == 'http://127.0.0.1:8080/koh/releases/latest.json'85+	os.unsetenv(source_env)86+	assert source() == default_source87+}

view file

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

view file