//! Read-only remote inspection; Doctor never calls sync or the write probe. module cli import compress.zlib import crypto.sha256 import json2 import os import src.events import src.exchange import src.history import src.machine import src.release import src.remote import src.snapshot import src.storage fn doctor_remote_config(mut report DoctorJson) { if !machine.has_remote() { report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured'} return } settings := machine.load_remote() or { report.check('machine remote', [err.msg()]) return } report.remote_target = describe(settings) if settings.kind == machine.kind_s3 { machine.load_credentials() or { report.check('machine remote', [err.msg()]) return } } report.check('machine remote configuration', []string{}) } fn doctor_remote(store &storage.Store, mut report DoctorJson) ! { if report.source == '' && !machine.has_remote() { report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured'} return } mut backend := if report.source != '' { exchange.read_only(report.source)! } else { this_machine_backend()! } repo := if report.source != '' { report.source.all_after_last('/') } else if report.remote_repo != '' { report.remote_repo } else { os.base(store.root).to_lower() } if !snapshot.legal_path(repo) || repo.contains('/') { return error('unsafe remote repo name') } doctor_remote_content(mut backend, repo, mut report)! } fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! { format := backend.get(exchange.format_key(repo))! if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' { return error('remote format is unsupported; update Koh') } head := backend.get(exchange.head_key(repo))! report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' } mut saves := []history.Save{} mut files := map[string][]snapshot.FileEntry{} mut hashes := map[string]bool{} mut event_list := []events.Event{} for key in backend.list('${repo}/saves')! { if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') { return error('unsafe remote key ${key}') } working('checking remote saves · ${saves.len}') object := backend.get(key)! if !object.found { return error('remote save disappeared: ${key}') } record := exchange.decode_save(object.body)! if key != exchange.save_key(repo, record.id) { return error('remote save ID disagrees with key ${key}') } saves << history.Save{ id: record.id parent: record.parent restores: record.restores replayed_from: record.replayed_from } files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash }) for file in record.files { hashes[file.hash] = true } } for key in backend.list('${repo}/events')! { if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') { return error('unsafe remote key ${key}') } working('checking remote events · ${event_list.len}') object := backend.get(key)! if !object.found { return error('remote event disappeared: ${key}') } event := exchange.decode_event(object.body)! if key != exchange.event_key(repo, event.id) { return error('remote event ID disagrees with key ${key}') } event_list << event } for key in backend.list('${repo}/objects')! { if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') { return error('invalid remote object key ${key}') } hashes[key.all_after_last('/')] = true } mut remote_report := DoctorJson{ head_id: report.remote_head } remote_report.problems = doctor_history(saves, report.remote_head) doctor_events(event_list, saves, mut remote_report) for save in saves { doctor_files(save.id, files[save.id], event_list, mut remote_report) } for hash in hashes.keys() { if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) { remote_report.problems << 'invalid remote object hash ${hash}' continue } working('checking remote object ${hash[..12]}') object := backend.get(exchange.object_key(repo, hash))! if !object.found { remote_report.problems << 'missing remote object ${hash}' continue } data := zlib.decompress(object.body) or { remote_report.problems << 'damaged remote object ${hash}' continue } if storage.hash_hex(data) != hash { remote_report.problems << 'remote object ${hash} fails Blake3 verification' } } report.remote_objects = hashes.len report.remote_saves = saves.len report.check('remote history, events and objects', remote_report.problems) report.warnings << remote_report.warnings.map('remote: ${it}') remote_report.problems = []string{} if mut backend is remote.Web { report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'} } else { latest := backend.get(release.latest_key(repo))! mut versions := []string{} for key in backend.list('${repo}/releases')! { if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') { return error('unsafe release key') } if !key.ends_with('/release.json') { continue } object := backend.get(key)! if !object.found { return error('remote release disappeared: ${key}') } record := json2.decode[release.Record](object.body.bytestr())! if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version) || !snapshot.legal_path(record.version) || record.version.contains('/') { return error('invalid remote release record ${key}') } if !saves.any(it.id == record.save) { remote_report.problems << 'release ${record.version} refers to missing save ${record.save}' } if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' } for file in record.files { if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform) || file.platform.contains('/') { return error('unsafe release file in ${key}') } artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))! if !artifact.found || artifact.body.len != file.size || storage.hash_hex(artifact.body) != file.blake3 || sha256.sum256(artifact.body).hex() != file.sha256 { remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification' } } versions << record.version } if latest.found && latest.body.bytestr().trim_space() !in versions { remote_report.problems << 'remote latest release names a missing record' } report.remote_releases = versions.len report.check('remote releases', remote_report.problems) } after := backend.get(exchange.head_key(repo))! if after.found != head.found || after.body != head.body || after.tag != head.tag { report.check('remote stability', ['remote head changed during audit; retry Doctor']) } if !head.found { report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed' } if report.remote_head != '' && report.remote_head != report.head_id { report.warnings << 'local and remote heads differ; inspect koh steal before applying changes' } }