doctor_remote.v

7.2 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 := if report.source != '' {42		exchange.read_only(report.source)!43	} else {44		this_machine_backend()!45	}46	repo := if report.source != '' {47		report.source.all_after_last('/')48	} else if report.remote_repo != '' {49		report.remote_repo50	} else {51		os.base(store.root).to_lower()52	}53	if !snapshot.legal_path(repo) || repo.contains('/') {54		return error('unsafe remote repo name')55	}56	doctor_remote_content(mut backend, repo, mut report)!57}5859fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {60	format := backend.get(exchange.format_key(repo))!61	if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' {62		return error('remote format is unsupported; update Koh')63	}64	head := backend.get(exchange.head_key(repo))!65	report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }66	mut saves := []history.Save{}67	mut files := map[string][]snapshot.FileEntry{}68	mut hashes := map[string]bool{}69	mut event_list := []events.Event{}70	for key in backend.list('${repo}/saves')! {71		if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {72			return error('unsafe remote key ${key}')73		}74		working('checking remote saves · ${saves.len}')75		object := backend.get(key)!76		if !object.found {77			return error('remote save disappeared: ${key}')78		}79		record := exchange.decode_save(object.body)!80		if key != exchange.save_key(repo, record.id) {81			return error('remote save ID disagrees with key ${key}')82		}83		saves << history.Save{84			id:            record.id85			parent:        record.parent86			restores:      record.restores87			replayed_from: record.replayed_from88		}89		files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })90		for file in record.files {91			hashes[file.hash] = true92		}93	}94	for key in backend.list('${repo}/events')! {95		if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {96			return error('unsafe remote key ${key}')97		}98		working('checking remote events · ${event_list.len}')99		object := backend.get(key)!100		if !object.found {101			return error('remote event disappeared: ${key}')102		}103		event := exchange.decode_event(object.body)!104		if key != exchange.event_key(repo, event.id) {105			return error('remote event ID disagrees with key ${key}')106		}107		event_list << event108	}109	for key in backend.list('${repo}/objects')! {110		if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {111			return error('invalid remote object key ${key}')112		}113		hashes[key.all_after_last('/')] = true114	}115	mut remote_report := DoctorJson{ head_id: report.remote_head }116	remote_report.problems = doctor_history(saves, report.remote_head)117	doctor_events(event_list, saves, mut remote_report)118	for save in saves {119		doctor_files(save.id, files[save.id], event_list, mut remote_report)120	}121	for hash in hashes.keys() {122		if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {123			remote_report.problems << 'invalid remote object hash ${hash}'124			continue125		}126		working('checking remote object ${hash[..12]}')127		object := backend.get(exchange.object_key(repo, hash))!128		if !object.found {129			remote_report.problems << 'missing remote object ${hash}'130			continue131		}132		data := zlib.decompress(object.body) or {133			remote_report.problems << 'damaged remote object ${hash}'134			continue135		}136		if storage.hash_hex(data) != hash {137			remote_report.problems << 'remote object ${hash} fails Blake3 verification'138		}139	}140	report.remote_objects = hashes.len141	report.remote_saves = saves.len142	report.check('remote history, events and objects', remote_report.problems)143	report.warnings << remote_report.warnings.map('remote: ${it}')144	remote_report.problems = []string{}145	if mut backend is remote.Web {146		report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'}147	} else {148		latest := backend.get(release.latest_key(repo))!149		mut versions := []string{}150		for key in backend.list('${repo}/releases')! {151			if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {152				return error('unsafe release key')153			}154			if !key.ends_with('/release.json') {155				continue156			}157			object := backend.get(key)!158			if !object.found {159				return error('remote release disappeared: ${key}')160			}161			record := json2.decode[release.Record](object.body.bytestr())!162			if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)163				|| !snapshot.legal_path(record.version) || record.version.contains('/') {164				return error('invalid remote release record ${key}')165			}166			if !saves.any(it.id == record.save) {167				remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'168			}169			if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }170			for file in record.files {171				if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)172					|| file.platform.contains('/') {173					return error('unsafe release file in ${key}')174				}175				artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!176				if !artifact.found || artifact.body.len != file.size177					|| storage.hash_hex(artifact.body) != file.blake3178					|| sha256.sum256(artifact.body).hex() != file.sha256 {179					remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'180				}181			}182			versions << record.version183		}184		if latest.found && latest.body.bytestr().trim_space() !in versions {185			remote_report.problems << 'remote latest release names a missing record'186		}187		report.remote_releases = versions.len188		report.check('remote releases', remote_report.problems)189	}190	after := backend.get(exchange.head_key(repo))!191	if after.found != head.found || after.body != head.body || after.tag != head.tag {192		report.check('remote stability', ['remote head changed during audit; retry Doctor'])193	}194	if !head.found {195		report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed'196	}197	if report.remote_head != '' && report.remote_head != report.head_id {198		report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'199	}200}