//! Walking the working tree: ignore rules, hashing, and the file cache. module snapshot import encoding.utf8 import os import time import src.storage // FAT filesystems keep mtime in 2-second steps; three covers them too. const racy_window_ns = i64(3_000_000_000) // One tracked file in one tree. pub struct FileEntry { pub: path string hash string size i64 executable bool } pub struct WalkReport { pub: files []FileEntry unsafe_paths []string } // legal_path is the one rule for every path Koh saves, receives or restores. pub fn legal_path(path string) bool { if path == '' || path.starts_with('/') || path.contains('\\') || !utf8.validate_str(path) { return false } if path.split('/').any(it in ['', '.', '..']) { return false } bytes := path.bytes() for i, byte in bytes { if byte < 0x20 || byte == 0x7f || (byte == 0xc2 && i + 1 < bytes.len && bytes[i + 1] >= 0x80 && bytes[i + 1] <= 0x9f) { return false } } return true } // escape_path makes unsafe names printable without letting controls affect the terminal. pub fn escape_path(path string) string { mut escaped := '' bytes := path.bytes() mut i := 0 for i < bytes.len { byte := bytes[i] if byte == `\\` { escaped += '\\\\' } else if byte < 0x20 || byte == 0x7f { escaped += '\\x${byte.hex()}' } else if byte == 0xc2 && i + 1 < bytes.len && bytes[i + 1] >= 0x80 && bytes[i + 1] <= 0x9f { escaped += '\\u00${bytes[i + 1].hex()}' i++ } else if byte >= 0x80 { // A whole valid sequence prints as itself; any other byte is escaped alone. n := utf8_char_len(byte) if n > 1 && i + n <= bytes.len && utf8.validate_str(bytes[i..i + n].bytestr()) { escaped += bytes[i..i + n].bytestr() i += n continue } escaped += '\\x${byte.hex()}' } else { escaped += byte.ascii_str() } i++ } return escaped } // Counted hears how far a long step has come; `total` is 0 when it is not known. pub type Counted = fn (done int, total int) // Hashing counts the files a walk has to read and hash (a cached one costs // nothing), and tells `on` each time, when there is an `on`. pub struct Hashing { pub: on Counted = unsafe { nil } mut: count int } // walk hashes every tracked file under `root` and returns them sorted by path. // Symlinks are skipped, as Koh 2 skipped them, so migration stays lossless. pub fn walk(store &storage.Store, root string, ig Ignore) ![]FileEntry { mut quiet := Hashing{} return walk_report(store, root, ig, mut quiet)!.files } pub fn walk_report(store &storage.Store, root string, ig Ignore, mut hashing Hashing) !WalkReport { cache := store.load_cache()! mut entries := []FileEntry{} mut fresh := []storage.CacheRow{} mut unsafe_paths := []string{} walk_dir(root, '', ig.banning(store.stripped_paths()!), cache, mut entries, mut fresh, mut unsafe_paths, mut hashing)! // Skipped while another command writes, or inside a caller's transaction: // the next walk hashes those files again, which is all a missing row costs. store.save_cache_rows(fresh) or {} entries.sort(a.path < b.path) unsafe_paths.sort() return WalkReport{ files: entries unsafe_paths: unsafe_paths } } // walk_raw hashes the same files without a store: no cache, nothing written. // `migrate` uses it to check a working tree against a manifest before it has a // database to check it with. pub fn walk_raw(root string, ig Ignore) ![]FileEntry { mut entries := []FileEntry{} mut unused := []storage.CacheRow{} mut unsafe_paths := []string{} mut quiet := Hashing{} walk_dir(root, '', ig, map[string]storage.CacheRow{}, mut entries, mut unused, mut unsafe_paths, mut quiet)! entries.sort(a.path < b.path) return entries } // walk_dir lists one folder and recurses, adding hashed files to `out`. `cache` holds each path's last // size and stamps, so an unchanged file is not read again; `fresh` gathers rows for files read now, for // walk_report to store. A name breaking spec ยง5.5 is skipped and named in `unsafe_paths`, not an error: // the walk goes on and status and save report it. fn walk_dir(root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry, mut fresh []storage.CacheRow, mut unsafe_paths []string, mut hashing Hashing) ! { dir := if rel == '' { root } else { os.join_path(root, rel) } for name in os.ls(dir)! { child := if rel == '' { name } else { '${rel}/${name}' } if !legal_path(child) { unsafe_paths << escape_path(child) continue } st := lstat(os.join_path(root, child)) or { continue } // The kind is known by now, so a pattern ending in `/` can be applied // to directories only. if ig.matches(child, st.kind == .dir) { continue } match st.kind { .dir { walk_dir(root, child, ig, cache, mut out, mut fresh, mut unsafe_paths, mut hashing)! } .file { if st.size > storage.max_file_size { return error('${child} is ${st.size} bytes; koh stores code and text, not files this large') } hash := hash_file(root, child, st, cache, mut fresh, mut hashing)! out << FileEntry{ path: child hash: hash size: st.size executable: is_executable(st.mode) } } else {} } } } fn hash_file(root string, rel string, st Stat, cache map[string]storage.CacheRow, mut fresh []storage.CacheRow, mut hashing Hashing) !string { if row := cache[rel] { if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns { return row.hash } } hashing.count++ if hashing.on != unsafe { nil } { hashing.on(hashing.count, 0) } hash := storage.hash_hex(os.read_bytes(os.join_path(root, rel))!) if cacheable(st, time.now().unix_nano()) { fresh << storage.CacheRow{ path: rel size: st.size mtime_ns: st.mtime_ns ctime_ns: st.ctime_ns hash: hash } } return hash } // cacheable reports whether a hash may be cached. Linux stamps files from a // clock that ticks every few milliseconds, so a same-size rewrite in the tick // of the hash would look unchanged; a file changed that recently is hashed // again next time instead. `cp -p` and the like keep an old mtime while // rewriting, so the newer of mtime and ctime decides. fn cacheable(st Stat, now_ns i64) bool { changed := if st.ctime_ns > st.mtime_ns { st.ctime_ns } else { st.mtime_ns } return now_ns - changed >= racy_window_ns } // store_objects writes the bytes of every entry the database does not have // yet, so a save only pays for content it has never seen. pub fn store_objects(store &storage.Store, root string, entries []FileEntry, progress Counted) ! { for i, entry in entries { if progress != unsafe { nil } { progress(i + 1, entries.len) } if store.has_object(entry.hash)! { continue } data := os.read_bytes(os.join_path(root, entry.path))! stored := store.put_object(data)! if stored != entry.hash { return error('${entry.path} changed while saving') } } } // same_tree compares two sorted entry lists by path, hash and executable bit. pub fn same_tree(a []FileEntry, b []FileEntry) bool { if a.len != b.len { return false } for i in 0 .. a.len { if a[i].path != b[i].path || a[i].hash != b[i].hash || a[i].executable != b[i].executable { return false } } return true }