update.v

6.8 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'18const file_scheme = 'file://'1920// One platform's file, in the shape Kepr answers with (§22.4).21struct PlatformFile {22	url    string23	blake3 string24	sha256 string25}2627// The release document: `notes` is omitted by Kepr when it is empty, so it is28// optional rather than an empty string.29struct Document {30	version   string31	notes     ?string32	platforms map[string]PlatformFile33}3435pub struct Available {36pub:37	version  string38	notes    string39	url      string40	blake3   string41	platform string42}4344// source is the document to read: the environment wins, so a self-hosted Kepr45// can serve its own koh.46pub fn source() string {47	named := os.getenv(source_env)48	if named != '' {49		return named50	}51	return default_source52}5354// Checked is what the source had to say: whether there is anything newer, and55// what it is when there is.56pub struct Checked {57pub:58	update    bool59	available Available60}6162// check asks the source what the newest koh is. Errors are errors; "nothing to63// do" is not one of them, so it is a field, not a failed call.64pub fn check(now string, platform string) !Checked {65	url := source()66	response := http.get(url) or { return error('could not reach ${url}: ${err.msg()}') }67	if response.status_code != 200 {68		return error('${url} answered ${response.status_code}')69	}70	document := json2.decode[Document](response.body) or {71		return error('${url} is not a release document: ${err.msg()}')72	}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 {79		return Checked{}80	}81	file := document.platforms[platform] or {82		return error('koh ${document.version} has no ${platform} file — run it on another machine')83	}84	return Checked{85		update:    true86		available: Available{87			version:  document.version88			notes:    document.notes or { '' }89			url:      file.url90			blake3:   file.blake391			platform: platform92		}93	}94}9596// compare_versions orders two versions the way the spec asks: dot-separated97// components compared as numbers, so 2.10.0 is newer than 2.9.9. A component98// that is not a number is compared as text, so a strange version can never win99// by accident. Returns 1, 0 or -1.100pub fn compare_versions(a string, b string) int {101	left := a.split('.')102	right := b.split('.')103	for index in 0 .. if left.len > right.len { left.len } else { right.len } {104		one := if index < left.len { left[index] } else { '0' }105		two := if index < right.len { right[index] } else { '0' }106		order := compare_component(one, two)107		if order != 0 {108			return order109		}110	}111	return 0112}113114fn compare_component(a string, b string) int {115	// Leading zeros are not part of a number: 01 and 1 are the same version.116	left := a.trim_left('0')117	right := b.trim_left('0')118	if left == right {119		return 0120	}121	if left.len != right.len {122		return if left.len > right.len { 1 } else { -1 }123	}124	return if left > right { 1 } else { -1 }125}126127// install downloads the new binary, checks it, and swaps it in for the one128// running. The rename is the only step that changes anything, and it is the129// last one.130pub fn install(available Available) !string {131	bytes := download(available.url)!132	verify(bytes, available.blake3)!133	target := os.executable()134	fresh := write_beside(os.dir(target), bytes)!135	if os.user_os() == 'macos' {136		// An unsigned binary cannot run on Apple silicon at all, so a refused137		// signature is a refusal to install, not a warning.138		signed := os.execute('codesign -f -s - ${sh_quote(fresh)}')139		if signed.exit_code != 0 {140			os.rm(fresh) or {}141			return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')142		}143	}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(fresh, target)!147	return target148}149150fn download(url string) ![]u8 {151	if url.starts_with(file_scheme) {152		// A Kepr with a folder bucket hands out `file://` download URLs (dev and153		// tests only), so updating from one is the same code path with the file154		// read instead of fetched.155		path := url[file_scheme.len..]156		size := os.file_size(path)157		bytes := os.read_file(path) or {158			return error('could not read ${url}: ${err.msg()}')159		}.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 bytes166	}167	response := http.get(url) or { return error('could not download ${url}: ${err.msg()}') }168	if response.status_code != 200 {169		return error('${url} answered ${response.status_code}')170	}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 bytes177}178179// verify is the check that makes installing anything safe: the bytes must be180// what the document said, or they are thrown away.181fn verify(bytes []u8, expected string) ! {182	if bytes.len == 0 {183		return error('the download was empty')184	}185	if expected == '' {186		return error('the release document carries no Blake3 checksum — refusing to install it')187	}188	got := blake3.sum256(bytes).hex()189	if got != expected {190		return error('the download is not the published file (Blake3 ${got} instead of ${expected}) — refusing to install it')191	}192}193194// write_beside writes the new binary beside the one it will replace, so the rename is195// within one filesystem and therefore atomic. A folder that cannot be written196// is refused here, with the write's own error.197fn write_beside(dir string, bytes []u8) !string {198	fresh := os.join_path(dir, '.koh-new-${os.getpid()}')199	os.write_file_array(fresh, bytes) or {200		return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})')201	}202	os.chmod(fresh, 0o755)!203	return fresh204}205206// sh_quote keeps a path with a space in it one argument, and cannot be broken207// out of by a quote inside the path.208fn sh_quote(text string) string {209	return "'" + text.replace("'", "'\\''") + "'"210}