12345678module 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://'202122struct PlatformFile {23 url string24 blake3 string25 sha256 string26}27282930struct 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}44454647pub fn source() string {48 named := os.getenv(source_env)49 if named != '' {50 return named51 }52 return default_source53}54555657pub struct Checked {58pub:59 update bool60 available Available61}62636465pub 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 78 return error('${url} did not answer with a release document — try again later')79 }80 if document.version == '' {81 82 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}102103104105106107pub 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 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}133134135136137pub 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 144 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 152 153 os.rename(fresh, target)!154 return target155}156157fn download(url string) ![]u8 {158 if url.starts_with(file_scheme) {159 160 161 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 168 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}190191192193fn 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}205206207208209fn 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}217218219220fn sh_quote(text string) string {221 return "'" + text.replace("'", "'\\''") + "'"222}