update.v

7.1 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 os14import src.remote1516pub const default_source = 'https://kepr.uk/koh/releases/latest.json'17pub const source_env = 'KOH_UPDATE_SOURCE'18pub const repo = 'koh'19const file_scheme = 'file://'2021// One platform's file, in the shape Kepr answers with (§22.4).22struct PlatformFile {23	url    string24	blake3 string25	sha256 string26}2728// The release document: `notes` is omitted by Kepr when it is empty, so it is29// optional rather than an empty string.30struct Document {31	version   string32	notes     ?string33	platforms map[string]PlatformFile34}3536pub struct Available {37pub:38	version  string39	notes    string40	url      string41	blake3   string42	platform string43}4445// source is the document to read: the environment wins, so a self-hosted Kepr46// can serve its own koh.47pub fn source() string {48	named := os.getenv(source_env)49	if named != '' {50		return named51	}52	return default_source53}5455// Checked is what the source had to say: whether there is anything newer, and56// what it is when there is.57pub struct Checked {58pub:59	update    bool60	available Available61}6263// check asks the source what the newest koh is. Errors are errors; "nothing to64// do" is not one of them, so it is a field, not a failed call.65pub fn check(now string, platform string) !Checked {66	url := source()67	response := http.fetch(68		url:          url69		validate:     true70		verify:       remote.tls_roots(url)!71		enable_http2: false72	) or { return error('could not reach ${url}: ${err.msg()}') }73	if response.status_code != 200 {74		return error('${url} answered ${response.status_code}')75	}76	document := json2.decode[Document](response.body) or {77		// The parser's own message, carets and colour included, means nothing here.78		return error('${url} did not answer with a release document — try again later')79	}80	if document.version == '' {81		// A document with no version is not a release document, so say that rather82		// than pretending there is simply nothing to do.83		return error('${url} has no version — it is not a release document')84	}85	if compare_versions(document.version, now) <= 0 {86		return Checked{}87	}88	file := document.platforms[platform] or {89		return error('koh ${document.version} has no ${platform} file — run it on another machine')90	}91	return Checked{92		update:    true93		available: Available{94			version:  document.version95			notes:    document.notes or { '' }96			url:      file.url97			blake3:   file.blake398			platform: platform99		}100	}101}102103// compare_versions orders two versions the way the spec asks: dot-separated104// components compared as numbers, so 2.10.0 is newer than 2.9.9. A component105// that is not a number is compared as text, so a strange version can never win106// by accident. Returns 1, 0 or -1.107pub fn compare_versions(a string, b string) int {108	left := a.split('.')109	right := b.split('.')110	for index in 0 .. if left.len > right.len { left.len } else { right.len } {111		one := if index < left.len { left[index] } else { '0' }112		two := if index < right.len { right[index] } else { '0' }113		order := compare_component(one, two)114		if order != 0 {115			return order116		}117	}118	return 0119}120121fn compare_component(a string, b string) int {122	// Leading zeros are not part of a number: 01 and 1 are the same version.123	left := a.trim_left('0')124	right := b.trim_left('0')125	if left == right {126		return 0127	}128	if left.len != right.len {129		return if left.len > right.len { 1 } else { -1 }130	}131	return if left > right { 1 } else { -1 }132}133134// install downloads the new binary, checks it, and swaps it in for the one135// running. The rename is the only step that changes anything, and it is the136// last one.137pub fn install(available Available) !string {138	bytes := download(available.url)!139	verify(bytes, available.blake3)!140	target := os.executable()141	fresh := write_beside(os.dir(target), bytes)!142	if os.user_os() == 'macos' {143		// An unsigned binary cannot run on Apple silicon at all, so a refused144		// signature is a refusal to install, not a warning.145		signed := os.execute('codesign -f -s - ${sh_quote(fresh)}')146		if signed.exit_code != 0 {147			os.rm(fresh) or {}148			return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')149		}150	}151	// rename, not move: both paths are in one folder, so this is the atomic step152	// and a failure is a failure, never a half-written binary.153	os.rename(fresh, target)!154	return target155}156157fn download(url string) ![]u8 {158	if url.starts_with(file_scheme) {159		// A Kepr with a folder bucket hands out `file://` download URLs (dev and160		// tests only), so updating from one is the same code path with the file161		// read instead of fetched.162		path := url[file_scheme.len..]163		size := os.file_size(path)164		bytes := os.read_file(path) or {165			return error('could not read ${url}: ${err.msg()}')166		}.bytes()167		// The download is verified against the size it was said to be, and then168		// against its Blake3, before anything is signed or moved.169		if size > 0 && bytes.len != size {170			return error('${url} is ${size} bytes but ${bytes.len} arrived — refusing a partial download')171		}172		return bytes173	}174	response := http.fetch(175		url:          url176		validate:     true177		verify:       remote.tls_roots(url)!178		enable_http2: false179	) or { return error('could not download ${url}: ${err.msg()}') }180	if response.status_code != 200 {181		return error('${url} answered ${response.status_code}')182	}183	bytes := response.body.bytes()184	declared := response.header.get(.content_length) or { '' }185	if declared != '' && i64(declared.trim_space().int()) != bytes.len {186		return error('${url} said ${declared} bytes and sent ${bytes.len} — refusing a partial download')187	}188	return bytes189}190191// verify is the check that makes installing anything safe: the bytes must be192// what the document said, or they are thrown away.193fn verify(bytes []u8, expected string) ! {194	if bytes.len == 0 {195		return error('the download was empty')196	}197	if expected == '' {198		return error('the release document carries no Blake3 checksum — refusing to install it')199	}200	got := blake3.sum256(bytes).hex()201	if got != expected {202		return error('the download is not the published file (Blake3 ${got} instead of ${expected}) — refusing to install it')203	}204}205206// write_beside writes the new binary beside the one it will replace, so the rename is207// within one filesystem and therefore atomic. A folder that cannot be written208// is refused here, with the write's own error.209fn write_beside(dir string, bytes []u8) !string {210	fresh := os.join_path(dir, '.koh-new-${os.getpid()}')211	os.write_file_array(fresh, bytes) or {212		return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})')213	}214	os.chmod(fresh, 0o755)!215	return fresh216}217218// sh_quote keeps a path with a space in it one argument, and cannot be broken219// out of by a quote inside the path.220fn sh_quote(text string) string {221	return "'" + text.replace("'", "'\\''") + "'"222}