module snapshot import os import time import src.storage fn temp_root(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn put(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } fn test_ignore_matches_the_documented_shapes() { ig := parse(' # a comment *.log build/ /anchored.txt out/** docs/*.md ') assert ig.matches('error.log', false) assert ig.matches('deep/nested/error.log', false) assert !ig.matches('error.txt', false) // A trailing slash names a directory: the directory itself and everything // below it, but not a file that shares the name. assert ig.matches('build', true) assert ig.matches('build/thing.o', false) assert ig.matches('src/build/nested.o', false) assert !ig.matches('build', false) assert ig.matches('anchored.txt', false) assert !ig.matches('sub/anchored.txt', false) assert ig.matches('out/anything/at/all', false) assert ig.matches('docs/readme.md', false) assert !ig.matches('docs/readme.txt', false) assert !ig.matches('src/main.v', false) } // A stripped path is banned as itself: its `*` and `?` must not become // wildcards, in `.kohignore` or in the walk's ban. fn test_a_stripped_path_with_wildcard_characters_bans_only_itself() { root := temp_root('literal_ban')! defer { os.rmdir_all(root) or {} } assert add_ignore(root, 'keys/a*b?.env')! assert os.read_file(os.join_path(root, ignore_file))! == '/keys/a\\*b\\?.env\n' for ig in [load(root), Ignore{}.banning(['keys/a*b?.env'])] { assert ig.matches('keys/a*b?.env', false) assert !ig.matches('keys/axxbz.env', false) assert !ig.matches('keys/a*b?.env.bak', false) } assert parse('\\\\x').matches('\\x', false) } fn test_hard_ignores_are_not_overridable() { for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git', '.git/config', 'LOG.md'] { assert hard_ignored(path), '${path} should be hard ignored' } assert !hard_ignored('.kohignore') assert !hard_ignored('src/log.md') // A repo inside the repo keeps its own history: restoring an old copy of // its .koh or .git would roll that history back. for path in ['inner/.koh', 'inner/.koh-wal', 'inner/.koh-shm', 'a/b/.git', 'vendored/.git/HEAD', 'old/.koh-v2/snapshots/x.json', 'koh2repo/.koh/objects/ab/cd'] { assert hard_ignored(path), '${path} should be hard ignored' } assert !hard_ignored('inner/.kohignore') assert !hard_ignored('docs/.github/workflow.yml') assert !hard_ignored('notes/.koh.md') // A pattern list cannot un-ignore them, and `.kohignore` itself is content. ig := parse('') assert ig.matches('.koh', false) assert !ig.matches('.kohignore', false) } fn test_walk_skips_ignored_files_directories_and_symlinks() { mut store := storage.create(temp_root('walk')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'keep.txt', 'keep\n')! put(root, 'nested/deep/file.txt', 'deep\n')! put(root, 'skip.log', 'noise\n')! put(root, 'build/output.o', 'binary\n')! put(root, '.kohignore', '*.log\nbuild/\n')! os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {} entries := walk(store, root, load(root))! mut paths := []string{} for entry in entries { paths << entry.path } assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt'] } fn test_walk_records_the_executable_bit() { mut store := storage.create(temp_root('exec')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'run.sh', '#!/bin/sh\necho hi\n')! put(root, 'plain.txt', 'hello\n')! os.chmod(os.join_path(root, 'run.sh'), 0o755)! mut executable := false mut plain_executable := true for entry in walk(store, root, Ignore{})! { if entry.path == 'run.sh' { executable = entry.executable } if entry.path == 'plain.txt' { plain_executable = entry.executable } } assert executable assert !plain_executable } fn test_legal_path_is_the_shared_save_path_rule() { for path in ['src/main.v', 'café/猫.txt', '.kohignore'] { assert legal_path(path), path } for path in ['', '/etc/passwd', 'a//b', './a', 'a/../b', 'a\\b', 'Icon\r', 'a\u0080b', [u8(0xff)].bytestr()] { assert !legal_path(path), path } } // Only bytes outside a valid UTF-8 sequence become \xNN; Kepr's escapePath prints the same. fn test_escape_path_escapes_only_the_invalid_bytes() { assert escape_path('é' + [u8(0xff)].bytestr() + 'é') == 'é\\xffé' assert escape_path([u8(0xed), 0xa0, 0x80].bytestr()) == '\\xed\\xa0\\x80' assert escape_path('a\u0085\\b\r') == 'a\\u0085\\\\b\\x0d' } fn test_walk_skips_and_reports_unsafe_names() { root := temp_root('unsafe_names')! defer { os.rmdir_all(root) or {} } store := storage.create(root)! defer { store.close() } put(root, 'safe.txt', 'kept\n')! os.write_file('${root}/bad\\name.txt', 'skipped\n')! os.write_file('${root}/Icon\r', 'skipped\n')! mut hashing := Hashing{} walked := walk_report(store, root, Ignore{}, mut hashing)! assert walked.files.map(it.path) == ['safe.txt'] // Only a file that had to be read counts; unsafe names are never read. assert hashing.count == 1 assert walked.unsafe_paths == ['Icon\\x0d', 'bad\\\\name.txt'] } fn test_write_tree_refuses_every_path_outside_the_root_before_writing() { base := temp_root('restore_containment')! defer { os.rmdir_all(base) or {} } root := os.join_path(base, 'repo') os.mkdir(root)! store := storage.create(root)! defer { store.close() } for path in ['../outside.txt', '/etc/koh-test', 'a/../../outside.txt', 'bad\\name', 'control\rname'] { if _ := write_tree(store, root, Ignore{}, [FileEntry{ path: path }], []) { assert false, '${path} must be refused' } } assert !os.exists(os.join_path(base, 'outside.txt')) } fn test_prune_empty_dirs_never_touches_a_sibling_with_the_roots_prefix() { base := temp_root('prune_prefix')! defer { os.rmdir_all(base) or {} } root := os.join_path(base, 'repo') sibling := os.join_path(base, 'repo2') os.mkdir(root)! os.mkdir(sibling)! prune_empty_dirs(root, sibling) assert os.is_dir(sibling) } // The point of nanosecond timestamps: a file rewritten inside the same second // with the same size must not reuse the cached hash, or a save would record // content that is no longer on disk. fn test_cache_does_not_reuse_a_same_size_same_second_edit() { mut store := storage.create(temp_root('cache')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'file.txt', 'aaaa\n')! first := walk(store, root, Ignore{})! assert first[0].hash == storage.hash_hex('aaaa\n'.bytes()) put(root, 'file.txt', 'bbbb\n')! second := walk(store, root, Ignore{})! assert second[0].hash == storage.hash_hex('bbbb\n'.bytes()) assert second[0].hash != first[0].hash } // Linux stamps files from a clock that ticks every few milliseconds, so a // same-size rewrite in the tick the file was hashed in leaves size, mtime and // ctime unchanged. A hash taken that close to the last change is never cached; // the next walk hashes again. Tools such as `cp -p` keep an old mtime while // rewriting, so the newer of the two times decides. fn test_cache_skips_a_file_changed_moments_ago() { second := i64(1_000_000_000) now := 100 * second old := now - 10 * second assert cacheable(Stat{ mtime_ns: old, ctime_ns: old }, now) assert !cacheable(Stat{ mtime_ns: now - second, ctime_ns: now - second }, now) assert !cacheable(Stat{ mtime_ns: old, ctime_ns: now - second }, now) mut store := storage.create(temp_root('racy')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'fresh.txt', 'new\n')! put(root, 'copied.txt', 'kept mtime\n')! hour_ago := time.now().unix() - 3600 os.utime(os.join_path(root, 'copied.txt'), hour_ago, hour_ago)! walk(store, root, Ignore{})! assert store.load_cache()!.len == 0 } // `status` must work while another agent's save holds the write lock: the // cache only saves time, so a busy repo hashes without caching, at once. fn test_a_walk_does_not_wait_for_another_writer() { mut store := storage.create(temp_root('busy_walk')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'a.txt', 'a\n')! // Only a file whose last change is older than the racy window is cached, // and ctime cannot be set back, so the test waits it out. time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond) writer := storage.open(root)! defer { writer.close() } writer.begin()! started := time.now() files := walk(store, root, Ignore{})! assert files.len == 1 assert time.since(started) < time.second writer.rollback() } fn test_restore_writes_removes_and_leaves_ignored_files_alone() { mut store := storage.create(temp_root('restore')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'keep.txt', 'recorded\n')! put(root, 'gone/inside.txt', 'also recorded\n')! put(root, 'noise.log', 'ignored, must survive\n')! put(root, '.kohignore', '*.log\n')! target := walk(store, root, load(root))! store_objects(store, root, target, unsafe { nil })! // Drift the working tree: clobber one file, delete another, and add a file // the recorded tree has never seen. os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')! os.rm(full_path(root, 'gone/inside.txt'))! put(root, 'extra.txt', 'never saved\n')! // The removal list names an ignored file (which survives), a file the // target still has (rewritten, not removed), and one genuinely extra file. report := restore_files(store, root, load(root), target, ['extra.txt', '.kohignore', 'noise.log'])! // The clobbered and the deleted file are written; the unchanged // .kohignore is left as it is. assert report.written == 2 assert report.removed == 1 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n' assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n' assert !os.exists(full_path(root, 'extra.txt')) assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n' assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n' } fn test_restore_keeps_the_executable_bit() { mut store := storage.create(temp_root('restore_exec')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'run.sh', '#!/bin/sh\necho hi\n')! os.chmod(os.join_path(root, 'run.sh'), 0o755)! target := walk(store, root, Ignore{})! store_objects(store, root, target, unsafe { nil })! os.chmod(os.join_path(root, 'run.sh'), 0o644)! restore_files(store, root, Ignore{}, target, [])! mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode assert is_executable(mode) } fn test_diff_reports_kinds_counts_and_binary_files() { mut store := storage.create(temp_root('diff')!)! root := store.root defer { store.close() os.rmdir_all(root) or {} } put(root, 'text.txt', 'one\ntwo\n')! put(root, 'same.txt', 'unchanged\n')! put(root, 'gone.txt', 'bye\n')! before := walk(store, root, Ignore{})! store_objects(store, root, before, unsafe { nil })! os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')! os.rm(full_path(root, 'gone.txt'))! os.write_file(os.join_path(root, 'added.txt'), 'new\n')! os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])! after := walk(store, root, Ignore{})! // A save's side of a diff is bytes from the object store; only the side // that is the working tree reads from disk. deltas := diff_trees(store, Tree{ files: before }, Tree{ root: root files: after })! mut by_path := map[string]Delta{} for delta in deltas { by_path[delta.path] = delta } assert deltas.len == 4 assert by_path['text.txt'].kind == 'modified' assert by_path['text.txt'].added == 2 assert by_path['text.txt'].removed == 1 assert by_path['added.txt'].kind == 'added' assert by_path['gone.txt'].kind == 'removed' assert by_path['blob.bin'].binary assert by_path['blob.bin'].patch == binary_notice assert 'same.txt' !in by_path } fn full_path(root string, rel string) string { return os.join_path(root, rel) }