update.v

5.5 KB · raw

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.78module update910import crypto.blake311import json212import net.http13import os1415pub const default_source = 'https://kepr.uk/koh/releases/latest.json'16pub const source_env = 'KOH_UPDATE_SOURCE'17pub const repo = 'koh'1819// One platform's file, in the shape Kepr answers with (§22.4).20struct PlatformFile {21	url    string22	blake3 string23	sha256 string24}2526// The release document: `notes` is omitted by Kepr when it is empty, so it is27// optional rather than an empty string.28struct Document {29	version   string30	notes     ?string31	platforms map[string]PlatformFile32}3334pub struct Available {35pub:36	version  string37	notes    string38	url      string39	blake3   string40	platform string41}4243// source is the document to read: the environment wins, so a self-hosted Kepr44// can serve its own koh.45pub fn source() string {46	named := os.getenv(source_env)47	if named != '' {48		return named49	}50	return default_source51}5253// Checked is what the source had to say: whether there is anything newer, and54// what it is when there is.55pub struct Checked {56pub:57	update    bool58	available Available59}6061// 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.63pub 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}8990// 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.94pub 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}107108fn 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}120121// 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.124pub 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}141142fn 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}149150// verify is the check that makes installing anything safe: the bytes must be151// what the document said, or they are thrown away.152fn 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}164165// 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.168fn 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}176177// sh_quote keeps a path with a space in it one argument, and cannot be broken178// out of by a quote inside the path.179fn sh_quote(text string) string {180	return "'" + text.replace("'", "'\\''") + "'"181}