doctor_remote.v

7.9 KB · raw

1//! Read-only remote inspection; Doctor never calls sync or the write probe.2module 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}4445// The remote Doctor reads: the repo's source when it has one, else this machine's remote.46fn 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 record_problems := []string{}74	mut files := map[string][]snapshot.FileEntry{}75	mut hashes := map[string]bool{}76	mut event_list := []events.Event{}77	for key in backend.list('${repo}/saves')! {78		if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {79			return error('unsafe remote key ${key}')80		}81		working('checking remote saves · ${saves.len}')82		object := backend.get(key)!83		if !object.found {84			return error('remote save disappeared: ${key}')85		}86		record := exchange.decode_save(object.body)!87		if key != exchange.save_key(repo, record.id) {88			return error('remote save ID disagrees with key ${key}')89		}90		exchange.validate_save(record, record.id) or { record_problems << err.msg() }91		saves << history.Save{92			id:            record.id93			parent:        record.parent94			restores:      record.restores95			replayed_from: record.replayed_from96		}97		files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })98		for file in record.files {99			hashes[file.hash] = true100		}101	}102	for key in backend.list('${repo}/events')! {103		if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {104			return error('unsafe remote key ${key}')105		}106		working('checking remote events · ${event_list.len}')107		object := backend.get(key)!108		if !object.found {109			return error('remote event disappeared: ${key}')110		}111		event := exchange.decode_event(object.body)!112		if key != exchange.event_key(repo, event.id) {113			return error('remote event ID disagrees with key ${key}')114		}115		if !exchange.valid_event_id(event.id) {116			record_problems << 'remote event ${event.id} has an invalid ID'117		}118		event_list << event119	}120	for key in backend.list('${repo}/objects')! {121		if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {122			return error('invalid remote object key ${key}')123		}124		hashes[key.all_after_last('/')] = true125	}126	mut remote_report := DoctorJson{ head_id: report.remote_head }127	remote_report.problems = record_problems128	remote_report.problems << doctor_history(saves, report.remote_head)129	report.remote_missing_saves = missing_saves(saves, report.remote_head)130	doctor_events(event_list, saves, mut remote_report)131	for save in saves {132		doctor_files(save.id, files[save.id], event_list, mut remote_report)133	}134	report.remote_strip_residue = remote_report.strip_residue_paths135	for hash in hashes.keys() {136		if !storage.valid_hash(hash) {137			remote_report.problems << 'invalid remote object hash ${hash}'138			continue139		}140		working('checking remote object ${hash[..12]}')141		object := backend.get(exchange.object_key(repo, hash))!142		if !object.found {143			remote_report.problems << 'missing remote object ${hash}'144			report.remote_bad_objects << hash145			continue146		}147		data := zlib.decompress(object.body) or {148			remote_report.problems << 'damaged remote object ${hash}'149			report.remote_bad_objects << hash150			continue151		}152		if storage.hash_hex(data) != hash {153			remote_report.problems << 'remote object ${hash} fails Blake3 verification'154			report.remote_bad_objects << hash155		}156	}157	report.remote_objects = hashes.len158	report.remote_saves = saves.len159	report.check('remote history, events and objects', remote_report.problems)160	report.warnings << remote_report.warnings.map('remote: ${it}')161	remote_report.problems = []string{}162	if mut backend is remote.Web {163		report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings', ''}164	} else {165		latest := backend.get(release.latest_key(repo))!166		mut versions := []string{}167		for key in backend.list('${repo}/releases')! {168			if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {169				return error('unsafe release key')170			}171			if !key.ends_with('/release.json') {172				continue173			}174			object := backend.get(key)!175			if !object.found {176				return error('remote release disappeared: ${key}')177			}178			record := json2.decode[release.Record](object.body.bytestr())!179			if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)180				|| !snapshot.legal_path(record.version) || record.version.contains('/') {181				return error('invalid remote release record ${key}')182			}183			if !saves.any(it.id == record.save) {184				remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'185			}186			if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }187			for file in record.files {188				if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)189					|| file.platform.contains('/') {190					return error('unsafe release file in ${key}')191				}192				artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!193				if !artifact.found || artifact.body.len != file.size194					|| storage.hash_hex(artifact.body) != file.blake3195					|| sha256.sum256(artifact.body).hex() != file.sha256 {196					remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'197				}198			}199			versions << record.version200		}201		if latest.found && latest.body.bytestr().trim_space() !in versions {202			remote_report.problems << 'remote latest release names a missing record'203		}204		report.remote_releases = versions.len205		report.check('remote releases', remote_report.problems)206	}207	after := backend.get(exchange.head_key(repo))!208	if after.found != head.found || after.body != head.body || after.tag != head.tag {209		report.check('remote stability', ['remote head changed during audit; retry Doctor'])210	}211	if !head.found {212		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'213	}214	if report.remote_head != '' && report.remote_head != report.head_id {215		report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'216	}217}