1module snapshot23import os4import time5import src.storage67fn temp_root(name string) !string {8 dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}')9 os.rmdir_all(dir) or {}10 os.mkdir_all(dir, os.MkdirParams{})!11 return dir12}1314fn put(root string, rel string, text string) ! {15 full := os.join_path(root, rel)16 os.mkdir_all(os.dir(full), os.MkdirParams{})!17 os.write_file(full, text)!18}1920fn test_ignore_matches_the_documented_shapes() {21 ig := parse('22# a comment23*.log24build/25/anchored.txt26out/**27docs/*.md28')29 assert ig.matches('error.log', false)30 assert ig.matches('deep/nested/error.log', false)31 assert !ig.matches('error.txt', false)32 33 34 assert ig.matches('build', true)35 assert ig.matches('build/thing.o', false)36 assert ig.matches('src/build/nested.o', false)37 assert !ig.matches('build', false)38 assert ig.matches('anchored.txt', false)39 assert !ig.matches('sub/anchored.txt', false)40 assert ig.matches('out/anything/at/all', false)41 assert ig.matches('docs/readme.md', false)42 assert !ig.matches('docs/readme.txt', false)43 assert !ig.matches('src/main.v', false)44}45464748fn test_a_stripped_path_with_wildcard_characters_bans_only_itself() {49 root := temp_root('literal_ban')!50 defer {51 os.rmdir_all(root) or {}52 }53 assert add_ignore(root, 'keys/a*b?.env')!54 assert os.read_file(os.join_path(root, ignore_file))! == '/keys/a\\*b\\?.env\n'55 for ig in [load(root), Ignore{}.banning(['keys/a*b?.env'])] {56 assert ig.matches('keys/a*b?.env', false)57 assert !ig.matches('keys/axxbz.env', false)58 assert !ig.matches('keys/a*b?.env.bak', false)59 }60 assert parse('\\\\x').matches('\\x', false)61}6263fn test_hard_ignores_are_not_overridable() {64 for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git',65 '.git/config', 'LOG.md'] {66 assert hard_ignored(path), '${path} should be hard ignored'67 }68 assert !hard_ignored('.kohignore')69 assert !hard_ignored('src/log.md')70 71 72 for path in ['inner/.koh', 'inner/.koh-wal', 'inner/.koh-shm', 'a/b/.git', 'vendored/.git/HEAD',73 'old/.koh-v2/snapshots/x.json', 'koh2repo/.koh/objects/ab/cd'] {74 assert hard_ignored(path), '${path} should be hard ignored'75 }76 assert !hard_ignored('inner/.kohignore')77 assert !hard_ignored('docs/.github/workflow.yml')78 assert !hard_ignored('notes/.koh.md')79 80 ig := parse('')81 assert ig.matches('.koh', false)82 assert !ig.matches('.kohignore', false)83}8485fn test_walk_skips_ignored_files_directories_and_symlinks() {86 mut store := storage.create(temp_root('walk')!)!87 root := store.root88 defer {89 store.close()90 os.rmdir_all(root) or {}91 }92 put(root, 'keep.txt', 'keep\n')!93 put(root, 'nested/deep/file.txt', 'deep\n')!94 put(root, 'skip.log', 'noise\n')!95 put(root, 'build/output.o', 'binary\n')!96 put(root, '.kohignore', '*.log\nbuild/\n')!97 os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {}9899 entries := walk(store, root, load(root))!100 mut paths := []string{}101 for entry in entries {102 paths << entry.path103 }104 assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt']105}106107fn test_walk_records_the_executable_bit() {108 mut store := storage.create(temp_root('exec')!)!109 root := store.root110 defer {111 store.close()112 os.rmdir_all(root) or {}113 }114 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!115 put(root, 'plain.txt', 'hello\n')!116 os.chmod(os.join_path(root, 'run.sh'), 0o755)!117118 mut executable := false119 mut plain_executable := true120 for entry in walk(store, root, Ignore{})! {121 if entry.path == 'run.sh' {122 executable = entry.executable123 }124 if entry.path == 'plain.txt' {125 plain_executable = entry.executable126 }127 }128 assert executable129 assert !plain_executable130}131132fn test_legal_path_is_the_shared_save_path_rule() {133 for path in ['src/main.v', 'café/猫.txt', '.kohignore'] {134 assert legal_path(path), path135 }136 for path in ['', '/etc/passwd', 'a//b', './a', 'a/../b', 'a\\b', 'Icon\r', 'a\u0080b',137 [u8(0xff)].bytestr()] {138 assert !legal_path(path), path139 }140}141142fn test_walk_skips_and_reports_unsafe_names() {143 root := temp_root('unsafe_names')!144 defer {145 os.rmdir_all(root) or {}146 }147 store := storage.create(root)!148 defer {149 store.close()150 }151 put(root, 'safe.txt', 'kept\n')!152 os.write_file('${root}/bad\\name.txt', 'skipped\n')!153 os.write_file('${root}/Icon\r', 'skipped\n')!154 walked := walk_report(store, root, Ignore{})!155 assert walked.files.map(it.path) == ['safe.txt']156 assert walked.unsafe_paths == ['Icon\\x0d', 'bad\\\\name.txt']157}158159fn test_write_tree_refuses_every_path_outside_the_root_before_writing() {160 base := temp_root('restore_containment')!161 defer {162 os.rmdir_all(base) or {}163 }164 root := os.join_path(base, 'repo')165 os.mkdir(root)!166 store := storage.create(root)!167 defer {168 store.close()169 }170 for path in ['../outside.txt', '/etc/koh-test', 'a/../../outside.txt', 'bad\\name', 'control\rname'] {171 if _ := write_tree(store, root, Ignore{}, [FileEntry{172 path: path173 }], []) {174 assert false, '${path} must be refused'175 }176 }177 assert !os.exists(os.join_path(base, 'outside.txt'))178}179180fn test_prune_empty_dirs_never_touches_a_sibling_with_the_roots_prefix() {181 base := temp_root('prune_prefix')!182 defer {183 os.rmdir_all(base) or {}184 }185 root := os.join_path(base, 'repo')186 sibling := os.join_path(base, 'repo2')187 os.mkdir(root)!188 os.mkdir(sibling)!189 prune_empty_dirs(root, sibling)190 assert os.is_dir(sibling)191}192193194195196fn test_cache_does_not_reuse_a_same_size_same_second_edit() {197 mut store := storage.create(temp_root('cache')!)!198 root := store.root199 defer {200 store.close()201 os.rmdir_all(root) or {}202 }203 put(root, 'file.txt', 'aaaa\n')!204 first := walk(store, root, Ignore{})!205 assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())206207 put(root, 'file.txt', 'bbbb\n')!208 second := walk(store, root, Ignore{})!209 assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())210 assert second[0].hash != first[0].hash211}212213214215216217218fn test_cache_skips_a_file_changed_moments_ago() {219 second := i64(1_000_000_000)220 now := 100 * second221 old := now - 10 * second222 assert cacheable(Stat{ mtime_ns: old, ctime_ns: old }, now)223 assert !cacheable(Stat{ mtime_ns: now - second, ctime_ns: now - second }, now)224 assert !cacheable(Stat{ mtime_ns: old, ctime_ns: now - second }, now)225226 mut store := storage.create(temp_root('racy')!)!227 root := store.root228 defer {229 store.close()230 os.rmdir_all(root) or {}231 }232 put(root, 'fresh.txt', 'new\n')!233 put(root, 'copied.txt', 'kept mtime\n')!234 hour_ago := time.now().unix() - 3600235 os.utime(os.join_path(root, 'copied.txt'), hour_ago, hour_ago)!236 walk(store, root, Ignore{})!237 assert store.load_cache()!.len == 0238}239240241242fn test_a_walk_does_not_wait_for_another_writer() {243 mut store := storage.create(temp_root('busy_walk')!)!244 root := store.root245 defer {246 store.close()247 os.rmdir_all(root) or {}248 }249 put(root, 'a.txt', 'a\n')!250 251 252 time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond)253 writer := storage.open(root)!254 defer {255 writer.close()256 }257 writer.begin()!258 started := time.now()259 files := walk(store, root, Ignore{})!260 assert files.len == 1261 assert time.since(started) < time.second262 writer.rollback()263}264265fn test_restore_writes_removes_and_leaves_ignored_files_alone() {266 mut store := storage.create(temp_root('restore')!)!267 root := store.root268 defer {269 store.close()270 os.rmdir_all(root) or {}271 }272 put(root, 'keep.txt', 'recorded\n')!273 put(root, 'gone/inside.txt', 'also recorded\n')!274 put(root, 'noise.log', 'ignored, must survive\n')!275 put(root, '.kohignore', '*.log\n')!276 target := walk(store, root, load(root))!277 store_objects(store, root, target)!278279 280 281 os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!282 os.rm(full_path(root, 'gone/inside.txt'))!283 put(root, 'extra.txt', 'never saved\n')!284285 286 287 report := restore_files(store, root, load(root), target,288 ['extra.txt', '.kohignore', 'noise.log'])!289290 291 292 assert report.written == 2293 assert report.removed == 1294 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'295 assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'296 assert !os.exists(full_path(root, 'extra.txt'))297 assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'298 assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'299}300301fn test_restore_keeps_the_executable_bit() {302 mut store := storage.create(temp_root('restore_exec')!)!303 root := store.root304 defer {305 store.close()306 os.rmdir_all(root) or {}307 }308 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!309 os.chmod(os.join_path(root, 'run.sh'), 0o755)!310 target := walk(store, root, Ignore{})!311 store_objects(store, root, target)!312 os.chmod(os.join_path(root, 'run.sh'), 0o644)!313314 restore_files(store, root, Ignore{}, target, [])!315 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode316 assert is_executable(mode)317}318319fn test_diff_reports_kinds_counts_and_binary_files() {320 mut store := storage.create(temp_root('diff')!)!321 root := store.root322 defer {323 store.close()324 os.rmdir_all(root) or {}325 }326 put(root, 'text.txt', 'one\ntwo\n')!327 put(root, 'same.txt', 'unchanged\n')!328 put(root, 'gone.txt', 'bye\n')!329 before := walk(store, root, Ignore{})!330 store_objects(store, root, before)!331332 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!333 os.rm(full_path(root, 'gone.txt'))!334 os.write_file(os.join_path(root, 'added.txt'), 'new\n')!335 os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!336 after := walk(store, root, Ignore{})!337338 339 340 deltas := diff_trees(store, Tree{341 files: before342 }, Tree{343 root: root344 files: after345 })!346 mut by_path := map[string]Delta{}347 for delta in deltas {348 by_path[delta.path] = delta349 }350 assert deltas.len == 4351 assert by_path['text.txt'].kind == 'modified'352 assert by_path['text.txt'].added == 2353 assert by_path['text.txt'].removed == 1354 assert by_path['added.txt'].kind == 'added'355 assert by_path['gone.txt'].kind == 'removed'356 assert by_path['blob.bin'].binary357 assert by_path['blob.bin'].patch == binary_notice358 assert 'same.txt' !in by_path359}360361fn full_path(root string, rel string) string {362 return os.join_path(root, rel)363}