//! Writing a tree back onto disk. //! //! A restore writes the target's files and deletes the tracked files the //! target does not have. An ignored file already on disk is never overwritten //! or deleted, and every file lands through a temp file and a rename, so an //! interrupted restore leaves a readable tree rather than half-written files. module snapshot import os import src.storage // Set while a restore writes into the repo. A restore records its save first, // so a mark still here means the files stopped part way: the tree is half one // save and half another, and nobody's work. const restoring_key = 'restoring' pub const restore_unfinished = 'an earlier restore stopped before all its files were written — run koh revert to finish it (it records the tree as it is in a checkpoint first)' pub fn restore_pending(store &storage.Store) bool { return store.state(restoring_key) != none } // mark_restore goes down before a restore records its save, so a kill between // the save and the files still leaves it; restore_files lifts it at the end. pub fn mark_restore(store &storage.Store) ! { store.set_state(restoring_key, 'yes')! } pub fn clear_restore_mark(store &storage.Store) ! { store.remove_state(restoring_key)! } pub struct RestoreReport { pub: written int removed int } // restore_files writes `target` into the working tree and deletes the paths in // `remove` (tracked files the target does not have). An ignored file already // on disk is left as it is, even when the target saved an older copy: it may // hold work no save has. A missing one is written. Every file lands through a // temp file and a rename, so an interrupted restore leaves a readable tree // rather than half-written files. pub fn restore_files(store &storage.Store, root string, ig Ignore, target []FileEntry, remove []string) !RestoreReport { // Peek writes elsewhere; only the repo's own tree carries the mark. in_repo := root == store.root report := write_tree(store, root, ig, target, remove) or { // The save is recorded by now, so the way on is to finish, not retry. if in_repo { return error('${err.msg()} — the restore stopped part way; fix that, then run koh revert to finish it') } return err } if in_repo { clear_restore_mark(store)! } return report } // write_tree checks containment first, before any removal or write. Removals run before the folder check // because they may empty the folder standing where a saved file belongs; one still holding ignored files // refuses the restore before any file is written. A file that already matches is not rewritten. fn write_tree(store &storage.Store, root string, ig Ignore, target []FileEntry, remove []string) !RestoreReport { check_containment(root, target, remove)! mut wanted := map[string]bool{} for entry in target { wanted[entry.path] = true } mut removed := 0 for path in remove { full := os.join_path(root, path) if !os.is_file(full) || ig.matches(path, false) || path in wanted { continue } os.rm(full)! removed++ prune_empty_dirs(root, os.dir(full)) } // A folder where a file belongs: gone if the removals emptied it, else it // holds ignored files, which are never touched. Refuse before writing, as // V's move would otherwise drop the file inside the folder. for entry in target { full := os.join_path(root, entry.path) if os.is_dir(full) { os.rmdir(full) or { return error('${entry.path} is a folder of ignored files where a saved file belongs — move it') } } } mut written := 0 cache := store.load_cache()! for entry in target { if ig.matches(entry.path, false) && os.exists(os.join_path(root, entry.path)) { continue } // Rewriting a file that already matches would only reset its mtime, // and make the next status hash it again. if already_there(root, entry, cache) { continue } data := store.get_object(entry.hash)! write_file(os.join_path(root, entry.path), data, entry.executable)! written++ } return RestoreReport{ written: written removed: removed } } // check_containment proves the whole restore stays below root before its first removal or write. fn check_containment(root string, target []FileEntry, remove []string) ! { base := os.abs_path(root).trim_right(os.path_separator) + os.path_separator mut paths := target.map(it.path) paths << remove for path in paths { full := os.abs_path(os.join_path(root, path)) if !legal_path(path) || !full.starts_with(base) { return error('unsafe path ${escape_path(path)} is outside the repo') } } } // already_there reports whether the file on disk has the target's content and // executable bit. The file cache answers without reading when the file is as // it was when last hashed. fn already_there(root string, entry FileEntry, cache map[string]storage.CacheRow) bool { full := os.join_path(root, entry.path) st := lstat(full) or { return false } if st.kind != .file || st.size != entry.size || is_executable(st.mode) != entry.executable { return false } if row := cache[entry.path] { if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns { return row.hash == entry.hash } } data := os.read_bytes(full) or { return false } return storage.hash_hex(data) == entry.hash } // check_links refuses a restore that would write through a symlinked folder. // Koh never follows links, so the folder it points at is not the repo's, and // writing there would overwrite files outside it. Callers run it before they // record anything. pub fn check_links(root string, files []FileEntry) ! { for file in files { mut folder := '' for part in file.path.split('/')#[..-1] { folder = if folder == '' { part } else { '${folder}/${part}' } st := lstat(os.join_path(root, folder)) or { break } if st.kind == .symlink { return error('${folder} is a symbolic link where a saved folder belongs — koh will not write through it; move it, then run the command again') } } } } fn write_file(path string, data []u8, executable bool) ! { os.mkdir_all(os.dir(path), os.MkdirParams{})! tmp := '${path}.koh-tmp-${os.getpid()}' os.write_file_array(tmp, data)! mode := if executable { 0o755 } else { 0o644 } os.chmod(tmp, mode)! os.rename(tmp, path)! } // prune_empty_dirs clears directories a restore emptied, never the repo root. fn prune_empty_dirs(root string, dir string) { mut current := dir inside := root.trim_right(os.path_separator) + os.path_separator for current.starts_with(inside) { if (os.ls(current) or { return }).len > 0 { return } os.rmdir(current) or { return } current = os.dir(current) } }