12345678module 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://'192021struct PlatformFile {22 url string23 blake3 string24 sha256 string25}26272829struct 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}43444546pub fn source() string {47 named := os.getenv(source_env)48 if named != '' {49 return named50 }51 return default_source52}53545556pub struct Checked {57pub:58 update bool59 available Available60}61626364pub 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 72 return error('${url} did not answer with a release document — try again later')73 }74 if document.version == '' {75 76 77 return error('${url} has no version — it is not a release document')78 }79 if compare_versions(document.version, now) <= 0 {80 return Checked{}81 }82 file := document.platforms[platform] or {83 return error('koh ${document.version} has no ${platform} file — run it on another machine')84 }85 return Checked{86 update: true87 available: Available{88 version: document.version89 notes: document.notes or { '' }90 url: file.url91 blake3: file.blake392 platform: platform93 }94 }95}96979899100101pub fn compare_versions(a string, b string) int {102 left := a.split('.')103 right := b.split('.')104 for index in 0 .. if left.len > right.len { left.len } else { right.len } {105 one := if index < left.len { left[index] } else { '0' }106 two := if index < right.len { right[index] } else { '0' }107 order := compare_component(one, two)108 if order != 0 {109 return order110 }111 }112 return 0113}114115fn compare_component(a string, b string) int {116 117 left := a.trim_left('0')118 right := b.trim_left('0')119 if left == right {120 return 0121 }122 if left.len != right.len {123 return if left.len > right.len { 1 } else { -1 }124 }125 return if left > right { 1 } else { -1 }126}127128129130131pub fn install(available Available) !string {132 bytes := download(available.url)!133 verify(bytes, available.blake3)!134 target := os.executable()135 fresh := write_beside(os.dir(target), bytes)!136 if os.user_os() == 'macos' {137 138 139 signed := os.execute('codesign -f -s - ${sh_quote(fresh)}')140 if signed.exit_code != 0 {141 os.rm(fresh) or {}142 return error('codesign refused the downloaded binary: ${signed.output.trim_space()}')143 }144 }145 146 147 os.rename(fresh, target)!148 return target149}150151fn download(url string) ![]u8 {152 if url.starts_with(file_scheme) {153 154 155 156 path := url[file_scheme.len..]157 size := os.file_size(path)158 bytes := os.read_file(path) or {159 return error('could not read ${url}: ${err.msg()}')160 }.bytes()161 162 163 if size > 0 && bytes.len != size {164 return error('${url} is ${size} bytes but ${bytes.len} arrived — refusing a partial download')165 }166 return bytes167 }168 response := http.get(url) or { return error('could not download ${url}: ${err.msg()}') }169 if response.status_code != 200 {170 return error('${url} answered ${response.status_code}')171 }172 bytes := response.body.bytes()173 declared := response.header.get(.content_length) or { '' }174 if declared != '' && i64(declared.trim_space().int()) != bytes.len {175 return error('${url} said ${declared} bytes and sent ${bytes.len} — refusing a partial download')176 }177 return bytes178}179180181182fn verify(bytes []u8, expected string) ! {183 if bytes.len == 0 {184 return error('the download was empty')185 }186 if expected == '' {187 return error('the release document carries no Blake3 checksum — refusing to install it')188 }189 got := blake3.sum256(bytes).hex()190 if got != expected {191 return error('the download is not the published file (Blake3 ${got} instead of ${expected}) — refusing to install it')192 }193}194195196197198fn write_beside(dir string, bytes []u8) !string {199 fresh := os.join_path(dir, '.koh-new-${os.getpid()}')200 os.write_file_array(fresh, bytes) or {201 return error('cannot write beside ${dir} — put koh where this user can replace it (${err.msg()})')202 }203 os.chmod(fresh, 0o755)!204 return fresh205}206207208209fn sh_quote(text string) string {210 return "'" + text.replace("'", "'\\''") + "'"211}