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 report.remote_strip_residue = remote_report.strip_residue_paths129 for hash in hashes.keys() {130 if !storage.valid_hash(hash) {131 remote_report.problems << 'invalid remote object hash ${hash}'132 continue133 }134 working('checking remote object ${hash[..12]}')135 object := backend.get(exchange.object_key(repo, hash))!136 if !object.found {137 remote_report.problems << 'missing remote object ${hash}'138 report.remote_bad_objects << hash139 continue140 }141 data := zlib.decompress(object.body) or {142 remote_report.problems << 'damaged remote object ${hash}'143 report.remote_bad_objects << hash144 continue145 }146 if storage.hash_hex(data) != hash {147 remote_report.problems << 'remote object ${hash} fails Blake3 verification'148 report.remote_bad_objects << hash149 }150 }151 report.remote_objects = hashes.len152 report.remote_saves = saves.len153 report.check('remote history, events and objects', remote_report.problems)154 report.warnings << remote_report.warnings.map('remote: ${it}')155 remote_report.problems = []string{}156 if mut backend is remote.Web {157 report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings', ''}158 } else {159 latest := backend.get(release.latest_key(repo))!160 mut versions := []string{}161 for key in backend.list('${repo}/releases')! {162 if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {163 return error('unsafe release key')164 }165 if !key.ends_with('/release.json') {166 continue167 }168 object := backend.get(key)!169 if !object.found {170 return error('remote release disappeared: ${key}')171 }172 record := json2.decode[release.Record](object.body.bytestr())!173 if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)174 || !snapshot.legal_path(record.version) || record.version.contains('/') {175 return error('invalid remote release record ${key}')176 }177 if !saves.any(it.id == record.save) {178 remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'179 }180 if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }181 for file in record.files {182 if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)183 || file.platform.contains('/') {184 return error('unsafe release file in ${key}')185 }186 artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!187 if !artifact.found || artifact.body.len != file.size188 || storage.hash_hex(artifact.body) != file.blake3189 || sha256.sum256(artifact.body).hex() != file.sha256 {190 remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'191 }192 }193 versions << record.version194 }195 if latest.found && latest.body.bytestr().trim_space() !in versions {196 remote_report.problems << 'remote latest release names a missing record'197 }198 report.remote_releases = versions.len199 report.check('remote releases', remote_report.problems)200 }201 after := backend.get(exchange.head_key(repo))!202 if after.found != head.found || after.body != head.body || after.tag != head.tag {203 report.check('remote stability', ['remote head changed during audit; retry Doctor'])204 }205 if !head.found {206 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'207 }208 if report.remote_head != '' && report.remote_head != report.head_id {209 report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'210 }211}