12module cli34import compress.zlib5import crypto.sha2566import json27import os8import src.events9import src.exchange10import src.history11import src.machine12import src.release13import src.remote14import src.snapshot15import src.storage1617fn doctor_remote_config(mut report DoctorJson) {18 if !machine.has_remote() {19 report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured', ''}20 return21 }22 settings := machine.load_remote() or {23 report.check('machine remote', [err.msg()])24 return25 }26 report.remote_target = describe(settings)27 if settings.kind == machine.kind_s3 {28 machine.load_credentials() or {29 report.check('machine remote', [err.msg()])30 return31 }32 }33 report.check('machine remote configuration', []string{})34}3536fn doctor_remote(store &storage.Store, mut report DoctorJson) ! {37 if report.source == '' && !machine.has_remote() {38 report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured', ''}39 return40 }41 mut backend, repo := doctor_backend(store, report.source, report.remote_repo)!42 doctor_remote_content(mut backend, repo, mut report)!43}444546fn doctor_backend(store &storage.Store, source string, remote_repo string) !(remote.Backend, string) {47 backend := if source != '' {48 exchange.read_only(source)!49 } else {50 this_machine_backend()!51 }52 repo := if source != '' {53 source.all_after_last('/')54 } else if remote_repo != '' {55 remote_repo56 } else {57 os.base(store.root).to_lower()58 }59 if !snapshot.legal_path(repo) || repo.contains('/') {60 return error('unsafe remote repo name')61 }62 return backend, repo63}6465fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {66 format := backend.get(exchange.format_key(repo))!67 if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' {68 return error('remote format is unsupported; update Koh')69 }70 head := backend.get(exchange.head_key(repo))!71 report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }72 mut saves := []history.Save{}73 mut files := map[string][]snapshot.FileEntry{}74 mut hashes := map[string]bool{}75 mut event_list := []events.Event{}76 for key in backend.list('${repo}/saves')! {77 if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {78 return error('unsafe remote key ${key}')79 }80 working('checking remote saves · ${saves.len}')81 object := backend.get(key)!82 if !object.found {83 return error('remote save disappeared: ${key}')84 }85 record := exchange.decode_save(object.body)!86 if key != exchange.save_key(repo, record.id) {87 return error('remote save ID disagrees with key ${key}')88 }89 saves << history.Save{90 id: record.id91 parent: record.parent92 restores: record.restores93 replayed_from: record.replayed_from94 }95 files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })96 for file in record.files {97 hashes[file.hash] = true98 }99 }100 for key in backend.list('${repo}/events')! {101 if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {102 return error('unsafe remote key ${key}')103 }104 working('checking remote events · ${event_list.len}')105 object := backend.get(key)!106 if !object.found {107 return error('remote event disappeared: ${key}')108 }109 event := exchange.decode_event(object.body)!110 if key != exchange.event_key(repo, event.id) {111 return error('remote event ID disagrees with key ${key}')112 }113 event_list << event114 }115 for key in backend.list('${repo}/objects')! {116 if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {117 return error('invalid remote object key ${key}')118 }119 hashes[key.all_after_last('/')] = true120 }121 mut remote_report := DoctorJson{ head_id: report.remote_head }122 remote_report.problems = doctor_history(saves, report.remote_head)123 report.remote_missing_saves = missing_saves(saves, report.remote_head)124 doctor_events(event_list, saves, mut remote_report)125 for save in saves {126 doctor_files(save.id, files[save.id], event_list, mut remote_report)127 }128 for hash in hashes.keys() {129 if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {130 remote_report.problems << 'invalid remote object hash ${hash}'131 continue132 }133 working('checking remote object ${hash[..12]}')134 object := backend.get(exchange.object_key(repo, hash))!135 if !object.found {136 remote_report.problems << 'missing remote object ${hash}'137 report.remote_bad_objects << hash138 continue139 }140 data := zlib.decompress(object.body) or {141 remote_report.problems << 'damaged remote object ${hash}'142 report.remote_bad_objects << hash143 continue144 }145 if storage.hash_hex(data) != hash {146 remote_report.problems << 'remote object ${hash} fails Blake3 verification'147 report.remote_bad_objects << hash148 }149 }150 report.remote_objects = hashes.len151 report.remote_saves = saves.len152 report.check('remote history, events and objects', remote_report.problems)153 report.warnings << remote_report.warnings.map('remote: ${it}')154 remote_report.problems = []string{}155 if mut backend is remote.Web {156 report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings', ''}157 } else {158 latest := backend.get(release.latest_key(repo))!159 mut versions := []string{}160 for key in backend.list('${repo}/releases')! {161 if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {162 return error('unsafe release key')163 }164 if !key.ends_with('/release.json') {165 continue166 }167 object := backend.get(key)!168 if !object.found {169 return error('remote release disappeared: ${key}')170 }171 record := json2.decode[release.Record](object.body.bytestr())!172 if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)173 || !snapshot.legal_path(record.version) || record.version.contains('/') {174 return error('invalid remote release record ${key}')175 }176 if !saves.any(it.id == record.save) {177 remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'178 }179 if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }180 for file in record.files {181 if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)182 || file.platform.contains('/') {183 return error('unsafe release file in ${key}')184 }185 artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!186 if !artifact.found || artifact.body.len != file.size187 || storage.hash_hex(artifact.body) != file.blake3188 || sha256.sum256(artifact.body).hex() != file.sha256 {189 remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'190 }191 }192 versions << record.version193 }194 if latest.found && latest.body.bytestr().trim_space() !in versions {195 remote_report.problems << 'remote latest release names a missing record'196 }197 report.remote_releases = versions.len198 report.check('remote releases', remote_report.problems)199 }200 after := backend.get(exchange.head_key(repo))!201 if after.found != head.found || after.body != head.body || after.tag != head.tag {202 report.check('remote stability', ['remote head changed during audit; retry Doctor'])203 }204 if !head.found {205 report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed, and koh offer sends this line'206 }207 if report.remote_head != '' && report.remote_head != report.head_id {208 report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'209 }210}