//! `koh update` — put a newer published koh in place of this one (§12). //! //! The source is a release document: Kepr's `/koh/releases/latest.json` by //! default, or whatever `KOH_UPDATE_SOURCE` names. Koh installs only a //! **newer** version than the one running, never merely a different one, so a //! stale mirror cannot walk the binary backwards. Nothing is ever built here. module update import crypto.blake3 import json2 import net.http import os import src.remote pub const default_source = 'https://kepr.uk/koh/releases/latest.json' pub const source_env = 'KOH_UPDATE_SOURCE' pub const repo = 'koh' const file_scheme = 'file://' // One platform's file, in the shape Kepr answers with (§22.4). struct PlatformFile { url string blake3 string sha256 string } // The release document: `notes` is omitted by Kepr when it is empty, so it is // optional rather than an empty string. struct Document { version string notes ?string platforms map[string]PlatformFile } pub struct Available { pub: version string notes string url string blake3 string platform string } // source is the document to read: the environment wins, so a self-hosted Kepr // can serve its own koh. pub fn source() string { named := os.getenv(source_env) if named != '' { return named } return default_source } // Checked is what the source had to say: whether there is anything newer, and // what it is when there is. pub struct Checked { pub: update bool available Available } // check asks the source what the newest koh is. Errors are errors; "nothing to // do" is not one of them, so it is a field, not a failed call. pub fn check(now string, platform string) !Checked { url := source() response := http.fetch( url: url validate: true verify: remote.tls_roots(url)! enable_http2: false ) or { return error('could not reach ${url}: ${err.msg()}') } if response.status_code != 200 { return error('${url} answered ${response.status_code}') } document := json2.decode[Document](response.body) or { // The parser's own message, carets and colour included, means nothing here. return error('${url} did not answer with a release document — try again later') } if document.version == '' { // A document with no version is not a release document, so say that rather // than pretending there is simply nothing to do. return error('${url} has no version — it is not a release document') } if compare_versions(document.version, now) <= 0 { return Checked{} } file := document.platforms[platform] or { return error('koh ${document.version} has no ${platform} file — run it on another machine') } return Checked{ update: true available: Available{ version: document.version notes: document.notes or { '' } url: file.url blake3: file.blake3 platform: platform } } } // compare_versions orders two versions the way the spec asks: dot-separated // components compared as numbers, so 2.10.0 is newer than 2.9.9. A component // that is not a number is compared as text, so a strange version can never win // by accident. Returns 1, 0 or -1. pub fn compare_versions(a string, b string) int { left := a.split('.') right := b.split('.') for index in 0 .. if left.len > right.len { left.len } else { right.len } { one := if index < left.len { left[index] } else { '0' } two := if index < right.len { right[index] } else { '0' } order := compare_component(one, two) if order != 0 { return order } } return 0 } fn compare_component(a string, b string) int { // Leading zeros are not part of a number: 01 and 1 are the same version. left := a.trim_left('0') right := b.trim_left('0') if left == right { return 0 } if left.len != right.len { return if left.len > right.len { 1 } else { -1 } } return if left > right { 1 } else { -1 } } // install downloads the new binary, checks it, and swaps it in for the one // running. The rename is the only step that changes anything, and it is the // last one. pub fn install(available Available) !string { bytes := download(available.url)! verify(bytes, available.blake3)! target := os.executable() fresh := write_beside(os.dir(target), bytes)! if os.user_os() == 'macos' { // An unsigned binary cannot run on Apple silicon at all, so a refused // signature is a refusal to install, not a warning. signed := os.execute('codesign -f -s - ${sh_quote(fresh)}') if signed.exit_code != 0 { os.rm(fresh) or {} return error('codesign refused the downloaded binary: ${signed.output.trim_space()}') } } // rename, not move: both paths are in one folder, so this is the atomic step // and a failure is a failure, never a half-written binary. os.rename(fresh, target)! return target } fn download(url string) ![]u8 { if url.starts_with(file_scheme) { // A Kepr with a folder bucket hands out `file://` download URLs (dev and // tests only), so updating from one is the same code path with the file // read instead of fetched. path := url[file_scheme.len..] size := os.file_size(path) bytes := os.read_file(path) or { return error('could not read ${url}: ${err.msg()}') }.bytes() // The download is verified against the size it was said to be, and then // against its Blake3, before anything is signed or moved. if size > 0 && bytes.len != size { return error('${url} is ${size} bytes but ${bytes.len} arrived — refusing a partial download') } return bytes } response := http.fetch( url: url validate: true verify: remote.tls_roots(url)! enable_http2: false ) or { return error('could not download ${url}: ${err.msg()}') } if response.status_code != 200 { return error('${url} answered ${response.status_code}') } bytes := response.body.bytes() declared := response.header.get(.content_length) or { '' } if declared != '' && i64(declared.trim_space().int()) != bytes.len { return error('${url} said ${declared} bytes and sent ${bytes.len} — refusing a partial download') } return bytes } // verify is the check that makes installing anything safe: the bytes must be // what the document said, or they are thrown away. fn verify(bytes []u8, expected string) ! { if bytes.len == 0 { return error('the download was empty') } if expected == '' { return error('the release document carries no Blake3 checksum — refusing to install it') } got := blake3.sum256(bytes).hex() if got != expected { return error('the download is not the published file (Blake3 ${got} instead of ${expected}) — refusing to install it') } } // write_beside writes the new binary beside the one it will replace, so the rename is // within one filesystem and therefore atomic. A folder that cannot be written // is refused here, with the write's own error. fn write_beside(dir string, bytes []u8) !string { fresh := os.join_path(dir, '.koh-new-${os.getpid()}') os.write_file_array(fresh, bytes) or { return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})') } os.chmod(fresh, 0o755)! return fresh } // sh_quote keeps a path with a space in it one argument, and cannot be broken // out of by a quote inside the path. fn sh_quote(text string) string { return "'" + text.replace("'", "'\\''") + "'" }