//! 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, repo := doctor_backend(store, report.source, report.remote_repo)! doctor_remote_content(mut backend, repo, mut report)! } // The remote Doctor reads: the repo's source when it has one, else this machine's remote. fn doctor_backend(store &storage.Store, source string, remote_repo string) !(remote.Backend, string) { backend := if source != '' { exchange.read_only(source)! } else { this_machine_backend()! } repo := if source != '' { source.all_after_last('/') } else if remote_repo != '' { remote_repo } else { os.base(store.root).to_lower() } if !snapshot.legal_path(repo) || repo.contains('/') { return error('unsafe remote repo name') } return backend, repo } 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) report.remote_missing_saves = missing_saves(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) } report.remote_strip_residue = remote_report.strip_residue_paths for hash in hashes.keys() { if !storage.valid_hash(hash) { 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}' report.remote_bad_objects << hash continue } data := zlib.decompress(object.body) or { remote_report.problems << 'damaged remote object ${hash}' report.remote_bad_objects << hash continue } if storage.hash_hex(data) != hash { remote_report.problems << 'remote object ${hash} fails Blake3 verification' report.remote_bad_objects << hash } } 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, and koh offer sends this line' } if report.remote_head != '' && report.remote_head != report.head_id { report.warnings << 'local and remote heads differ; inspect koh steal before applying changes' } }