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 := if report.source != '' {42 exchange.read_only(report.source)!43 } else {44 this_machine_backend()!45 }46 repo := if report.source != '' {47 report.source.all_after_last('/')48 } else if report.remote_repo != '' {49 report.remote_repo50 } else {51 os.base(store.root).to_lower()52 }53 if !snapshot.legal_path(repo) || repo.contains('/') {54 return error('unsafe remote repo name')55 }56 doctor_remote_content(mut backend, repo, mut report)!57}5859fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {60 format := backend.get(exchange.format_key(repo))!61 if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' {62 return error('remote format is unsupported; update Koh')63 }64 head := backend.get(exchange.head_key(repo))!65 report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }66 mut saves := []history.Save{}67 mut files := map[string][]snapshot.FileEntry{}68 mut hashes := map[string]bool{}69 mut event_list := []events.Event{}70 for key in backend.list('${repo}/saves')! {71 if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {72 return error('unsafe remote key ${key}')73 }74 working('checking remote saves · ${saves.len}')75 object := backend.get(key)!76 if !object.found {77 return error('remote save disappeared: ${key}')78 }79 record := exchange.decode_save(object.body)!80 if key != exchange.save_key(repo, record.id) {81 return error('remote save ID disagrees with key ${key}')82 }83 saves << history.Save{84 id: record.id85 parent: record.parent86 restores: record.restores87 replayed_from: record.replayed_from88 }89 files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })90 for file in record.files {91 hashes[file.hash] = true92 }93 }94 for key in backend.list('${repo}/events')! {95 if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {96 return error('unsafe remote key ${key}')97 }98 working('checking remote events · ${event_list.len}')99 object := backend.get(key)!100 if !object.found {101 return error('remote event disappeared: ${key}')102 }103 event := exchange.decode_event(object.body)!104 if key != exchange.event_key(repo, event.id) {105 return error('remote event ID disagrees with key ${key}')106 }107 event_list << event108 }109 for key in backend.list('${repo}/objects')! {110 if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {111 return error('invalid remote object key ${key}')112 }113 hashes[key.all_after_last('/')] = true114 }115 mut remote_report := DoctorJson{ head_id: report.remote_head }116 remote_report.problems = doctor_history(saves, report.remote_head)117 doctor_events(event_list, saves, mut remote_report)118 for save in saves {119 doctor_files(save.id, files[save.id], event_list, mut remote_report)120 }121 for hash in hashes.keys() {122 if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {123 remote_report.problems << 'invalid remote object hash ${hash}'124 continue125 }126 working('checking remote object ${hash[..12]}')127 object := backend.get(exchange.object_key(repo, hash))!128 if !object.found {129 remote_report.problems << 'missing remote object ${hash}'130 continue131 }132 data := zlib.decompress(object.body) or {133 remote_report.problems << 'damaged remote object ${hash}'134 continue135 }136 if storage.hash_hex(data) != hash {137 remote_report.problems << 'remote object ${hash} fails Blake3 verification'138 }139 }140 report.remote_objects = hashes.len141 report.remote_saves = saves.len142 report.check('remote history, events and objects', remote_report.problems)143 report.warnings << remote_report.warnings.map('remote: ${it}')144 remote_report.problems = []string{}145 if mut backend is remote.Web {146 report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'}147 } else {148 latest := backend.get(release.latest_key(repo))!149 mut versions := []string{}150 for key in backend.list('${repo}/releases')! {151 if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {152 return error('unsafe release key')153 }154 if !key.ends_with('/release.json') {155 continue156 }157 object := backend.get(key)!158 if !object.found {159 return error('remote release disappeared: ${key}')160 }161 record := json2.decode[release.Record](object.body.bytestr())!162 if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)163 || !snapshot.legal_path(record.version) || record.version.contains('/') {164 return error('invalid remote release record ${key}')165 }166 if !saves.any(it.id == record.save) {167 remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'168 }169 if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }170 for file in record.files {171 if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)172 || file.platform.contains('/') {173 return error('unsafe release file in ${key}')174 }175 artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!176 if !artifact.found || artifact.body.len != file.size177 || storage.hash_hex(artifact.body) != file.blake3178 || sha256.sum256(artifact.body).hex() != file.sha256 {179 remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'180 }181 }182 versions << record.version183 }184 if latest.found && latest.body.bytestr().trim_space() !in versions {185 remote_report.problems << 'remote latest release names a missing record'186 }187 report.remote_releases = versions.len188 report.check('remote releases', remote_report.problems)189 }190 after := backend.get(exchange.head_key(repo))!191 if after.found != head.found || after.body != head.body || after.tag != head.tag {192 report.check('remote stability', ['remote head changed during audit; retry Doctor'])193 }194 if !head.found {195 report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed'196 }197 if report.remote_head != '' && report.remote_head != report.head_id {198 report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'199 }200}