//! Doctor's repairs (spec ยง7.6.2). Each one puts back verified data or //! finishes interrupted work; none changes what a save contains. module cli import compress.zlib import os import src.exchange import src.fmt import src.history import src.machine import src.snapshot import src.storage struct DoctorRepair { action string run fn () ! @[required] } fn plan_repairs(store &storage.Store, report DoctorJson) []DoctorRepair { mut repairs := []DoctorRepair{} has_remote := report.source != '' || machine.has_remote() // A source is read-only, so only this machine's own remote takes uploads. writable_remote := report.source == '' && machine.has_remote() source, remote_repo := report.source, report.remote_repo if report.checks.any(it.name == 'SQLite integrity' && it.status == 'fail') { repairs << DoctorRepair{'rebuild the database indexes', fn [store] () ! { store.db.exec('REINDEX')! }} } if report.damaged_objects.len > 0 { hashes := report.damaged_objects repairs << DoctorRepair{'restore ${fmt.plural(hashes.len, 'damaged or missing object')} from verified copies', fn [store, hashes, source, remote_repo] () ! { repair_objects(store, hashes, source, remote_repo)! }} } if report.missing_saves.len > 0 && has_remote { ids := report.missing_saves repairs << DoctorRepair{'fetch ${fmt.plural(ids.len, 'missing save')} from the remote', fn [store, ids, source, remote_repo] () ! { mut backend, repo := doctor_backend(store, source, remote_repo)! exchange.fetch_missing(store, mut backend, repo, ids)! }} } else if report.metadata_available && report.saves > 0 && report.head.id == '' { // A remote may hold a newer head; only a full, stable audit that found // none other there lets Doctor guess, and the remote is asked again. audited := !report.checks.any(it.name in ['remote audit', 'remote stability'] && it.status != 'pass') tip := only_tip(store) or { '' } seen := report.head_id if tip != '' && (!has_remote || (audited && report.remote_head in ['', tip])) { repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip, seen, has_remote, source, remote_repo] () ! { if has_remote { mut backend, repo := doctor_backend(store, source, remote_repo)! theirs := backend.get(exchange.head_key(repo))! if theirs.found && theirs.body.bytestr().trim_space() != tip { return error('the remote now has another head; run koh doctor again') } } store.begin()! set_only_tip(store, tip, seen) or { store.rollback() return err } store.end()! }} } } if report.strip_residue { repairs << DoctorRepair{'remove stripped paths that saves still hold', fn [store] () ! { exchange.reapply_strips(store)! }} } if report.restore_unfinished { repairs << DoctorRepair{'finish the interrupted restore (koh revert keeps the current files in a checkpoint)', fn [store] () ! { finish_restore(store)! }} } if report.pending_strips.len > 0 && writable_remote { paths := report.pending_strips repairs << DoctorRepair{'send ${fmt.plural(paths.len, 'unfinished strip')} to the remote', fn [store, paths, remote_repo] () ! { send_strips(store, remote_repo, paths, false)! }} } // A strip whose remote half failed after the note was gone, as when an // upload raced it, is sent again from the recorded event. if report.remote_strip_residue.len > 0 && writable_remote { paths := report.remote_strip_residue repairs << DoctorRepair{'remove ${fmt.plural(paths.len, 'stripped path')} the remote still holds', fn [store, paths, remote_repo] () ! { send_strips(store, remote_repo, paths, true)! }} } if report.upload_unsettled && writable_remote { repairs << DoctorRepair{'delete what an interrupted upload sent that a strip removed', fn [store, remote_repo] () ! { mut backend, repo := doctor_backend(store, '', remote_repo)! exchange.settle_unsettled(store, mut backend, repo)! }} } // With damaged rows an unused object may be the only copy of saved content. if report.unreferenced_objects > 0 && report.problems.len == 0 { repairs << DoctorRepair{'delete ${fmt.plural(int(report.unreferenced_objects), 'object')} no save uses', fn [store] () ! { store.begin()! store.drop_unreferenced_objects() or { store.rollback() return err } store.end()! }} } if writable_remote { hashes := report.remote_bad_objects.filter(stored_intact(store, it)) ids := report.remote_missing_saves.filter(history.by_id(store, it) != none) if hashes.len + ids.len > 0 { repairs << DoctorRepair{'upload ${fmt.plural(hashes.len, 'object')} and ${fmt.plural(ids.len, 'save')} the remote lacks or holds damaged', fn [store, hashes, ids, remote_repo] () ! { mut backend, repo := doctor_backend(store, '', remote_repo)! exchange.resend(store, mut backend, repo, hashes, ids)! }} } } return repairs } // Each copy must hash back to its key before it replaces anything: first the // stored bytes (only the size was wrong), then working files, then the remote. fn repair_objects(store &storage.Store, hashes []string, source string, remote_repo string) ! { mut lost := []string{} for index, hash in hashes { restored := restore_object(store, hash, source, remote_repo) or { return exchange.partway(exchange.done_text('restored', index - lost.len, 'damaged or missing object'), err) } if !restored { lost << hash } } if lost.len > 0 { failure := error('no verified copy here or on the remote of ${lost.join(', ')}') return exchange.partway(exchange.done_text('restored', hashes.len - lost.len, 'damaged or missing object'), failure) } } fn send_strips(store &storage.Store, remote_repo string, paths []string, requeue bool) ! { mut backend, _ := doctor_backend(store, '', remote_repo)! for index, path in paths { if requeue { exchange.requeue_strip(store, path) or { return exchange.partway(exchange.done_text('sent', index, 'strip'), err) } } exchange.send_strip(store, mut backend, path) or { return exchange.partway(exchange.done_text('sent', index, 'strip'), err) } } } fn restore_object(store &storage.Store, hash string, source string, remote_repo string) !bool { if data := local_copy(store, hash) { store.repair_object(hash, data)! return true } if source == '' && !machine.has_remote() { return false } mut backend, repo := doctor_backend(store, source, remote_repo)! blob := backend.get(exchange.object_key(repo, hash))! data := zlib.decompress(blob.body) or { return false } if !blob.found || storage.hash_hex(data) != hash { return false } store.repair_object(hash, data)! return true } fn stored_intact(store &storage.Store, hash string) bool { store.get_object(hash) or { return false } return true } fn local_copy(store &storage.Store, hash string) ?[]u8 { if data := store.get_object(hash) { return data } for path in store.paths_of_object(hash) or { return none } { if !snapshot.legal_path(path) { continue } data := os.read_bytes(os.join_path(store.root, path)) or { continue } if storage.hash_hex(data) == hash { return data } } return none } // A head lost with no remote copy can only be guessed safely when exactly one // save has no successor. fn only_tip(store &storage.Store) ?string { saves := history.all_saves(store) or { return none } mut parents := map[string]bool{} for save in saves { parents[save.parent] = true } tips := saves.filter(it.id !in parents) return if tips.len == 1 { tips[0].id } else { none } } // Another koh command may have saved while Doctor asked; the tip seen then // is the head only if nothing has changed since. fn set_only_tip(store &storage.Store, tip string, seen string) ! { if (store.state(storage.head_key) or { '' }) != seen || (only_tip(store) or { '' }) != tip { return error('history changed since Doctor checked it; run koh doctor again') } history.set_head(store, tip)! } // The same steps as koh revert, without its output, which would break --json. fn finish_restore(store &storage.Store) ! { head := history.head(store) or { return error('this repo has no head to restore') } if !compare_with_head(store, head)!.dirty { snapshot.clear_restore_mark(store)! return } plan := plan_restore(store, head)! apply_restore(store, head, 'revert', plan, true)! snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)! }