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