doctor_remote.v

7.7 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 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}