1module snapshot23import os4import time5import src.storage678fn testsuite_begin() {9 os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true)10}1112fn temp_root(name string) !string {13 dir := os.join_path(os.temp_dir(), 'koh3_snap_${os.getpid()}_${name}')14 os.rmdir_all(dir) or {}15 os.mkdir_all(dir, os.MkdirParams{})!16 return dir17}1819fn put(root string, rel string, text string) ! {20 full := os.join_path(root, rel)21 os.mkdir_all(os.dir(full), os.MkdirParams{})!22 os.write_file(full, text)!23}2425fn test_ignore_matches_the_documented_shapes() {26 ig := parse('27# a comment28*.log29build/30/anchored.txt31out/**32docs/*.md33')34 assert ig.matches('error.log', false)35 assert ig.matches('deep/nested/error.log', false)36 assert !ig.matches('error.txt', false)37 38 39 assert ig.matches('build', true)40 assert ig.matches('build/thing.o', false)41 assert ig.matches('src/build/nested.o', false)42 assert !ig.matches('build', false)43 assert ig.matches('anchored.txt', false)44 assert !ig.matches('sub/anchored.txt', false)45 assert ig.matches('out/anything/at/all', false)46 assert ig.matches('docs/readme.md', false)47 assert !ig.matches('docs/readme.txt', false)48 assert !ig.matches('src/main.v', false)49}50515253fn test_a_stripped_path_with_wildcard_characters_bans_only_itself() {54 root := temp_root('literal_ban')!55 defer {56 os.rmdir_all(root) or {}57 }58 assert add_ignore(root, 'keys/a*b?.env')!59 assert os.read_file(os.join_path(root, ignore_file))! == '/keys/a\\*b\\?.env\n'60 for ig in [load(root), Ignore{}.banning(['keys/a*b?.env'])] {61 assert ig.matches('keys/a*b?.env', false)62 assert !ig.matches('keys/axxbz.env', false)63 assert !ig.matches('keys/a*b?.env.bak', false)64 }65 assert parse('\\\\x').matches('\\x', false)66}6768fn test_hard_ignores_are_not_overridable() {69 for path in ['.koh', '.koh-wal', '.koh-shm', '.koh-v2', '.koh-v2/snapshots/x.json', '.git',70 '.git/config', 'LOG.md'] {71 assert hard_ignored(path), '${path} should be hard ignored'72 }73 assert !hard_ignored('.kohignore')74 assert !hard_ignored('src/log.md')75 76 77 for path in ['inner/.koh', 'inner/.koh-wal', 'inner/.koh-shm', 'a/b/.git', 'vendored/.git/HEAD',78 'old/.koh-v2/snapshots/x.json', 'koh2repo/.koh/objects/ab/cd'] {79 assert hard_ignored(path), '${path} should be hard ignored'80 }81 assert !hard_ignored('inner/.kohignore')82 assert !hard_ignored('docs/.github/workflow.yml')83 assert !hard_ignored('notes/.koh.md')84 85 ig := parse('')86 assert ig.matches('.koh', false)87 assert !ig.matches('.kohignore', false)88}8990fn test_walk_skips_ignored_files_directories_and_symlinks() {91 mut store := storage.create(temp_root('walk')!)!92 root := store.root93 defer {94 store.close()95 os.rmdir_all(root) or {}96 }97 put(root, 'keep.txt', 'keep\n')!98 put(root, 'nested/deep/file.txt', 'deep\n')!99 put(root, 'skip.log', 'noise\n')!100 put(root, 'build/output.o', 'binary\n')!101 put(root, '.kohignore', '*.log\nbuild/\n')!102 os.symlink('keep.txt', os.join_path(root, 'link.txt')) or {}103104 entries := walk(store, root, load(root))!105 mut paths := []string{}106 for entry in entries {107 paths << entry.path108 }109 assert paths == ['.kohignore', 'keep.txt', 'nested/deep/file.txt']110}111112fn test_walk_records_the_executable_bit() {113 mut store := storage.create(temp_root('exec')!)!114 root := store.root115 defer {116 store.close()117 os.rmdir_all(root) or {}118 }119 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!120 put(root, 'plain.txt', 'hello\n')!121 os.chmod(os.join_path(root, 'run.sh'), 0o755)!122123 mut executable := false124 mut plain_executable := true125 for entry in walk(store, root, Ignore{})! {126 if entry.path == 'run.sh' {127 executable = entry.executable128 }129 if entry.path == 'plain.txt' {130 plain_executable = entry.executable131 }132 }133 assert executable134 assert !plain_executable135}136137fn test_legal_path_is_the_shared_save_path_rule() {138 for path in ['src/main.v', 'café/猫.txt', '.kohignore'] {139 assert legal_path(path), path140 }141 for path in ['', '/etc/passwd', 'a//b', './a', 'a/../b', 'a\\b', 'Icon\r', 'a\u0080b',142 [u8(0xff)].bytestr()] {143 assert !legal_path(path), path144 }145}146147148fn test_escape_path_escapes_only_the_invalid_bytes() {149 assert escape_path('é' + [u8(0xff)].bytestr() + 'é') == 'é\\xffé'150 assert escape_path([u8(0xed), 0xa0, 0x80].bytestr()) == '\\xed\\xa0\\x80'151 assert escape_path('a\u0085\\b\r') == 'a\\u0085\\\\b\\x0d'152}153154fn test_walk_skips_and_reports_unsafe_names() {155 root := temp_root('unsafe_names')!156 defer {157 os.rmdir_all(root) or {}158 }159 store := storage.create(root)!160 defer {161 store.close()162 }163 put(root, 'safe.txt', 'kept\n')!164 os.write_file('${root}/bad\\name.txt', 'skipped\n')!165 os.write_file('${root}/Icon\r', 'skipped\n')!166 mut hashing := Hashing{}167 walked := walk_report(store, root, Ignore{}, mut hashing)!168 assert walked.files.map(it.path) == ['safe.txt']169 170 assert hashing.count == 1171 assert walked.unsafe_paths == ['Icon\\x0d', 'bad\\\\name.txt']172}173174fn test_write_tree_refuses_every_path_outside_the_root_before_writing() {175 base := temp_root('restore_containment')!176 defer {177 os.rmdir_all(base) or {}178 }179 root := os.join_path(base, 'repo')180 os.mkdir(root)!181 store := storage.create(root)!182 defer {183 store.close()184 }185 for path in ['../outside.txt', '/etc/koh-test', 'a/../../outside.txt', 'bad\\name', 'control\rname'] {186 if _ := write_tree(store, root, Ignore{}, [FileEntry{187 path: path188 }], []) {189 assert false, '${path} must be refused'190 }191 }192 assert !os.exists(os.join_path(base, 'outside.txt'))193}194195fn test_prune_empty_dirs_never_touches_a_sibling_with_the_roots_prefix() {196 base := temp_root('prune_prefix')!197 defer {198 os.rmdir_all(base) or {}199 }200 root := os.join_path(base, 'repo')201 sibling := os.join_path(base, 'repo2')202 os.mkdir(root)!203 os.mkdir(sibling)!204 prune_empty_dirs(root, sibling)205 assert os.is_dir(sibling)206}207208209210211fn test_cache_does_not_reuse_a_same_size_same_second_edit() {212 mut store := storage.create(temp_root('cache')!)!213 root := store.root214 defer {215 store.close()216 os.rmdir_all(root) or {}217 }218 put(root, 'file.txt', 'aaaa\n')!219 first := walk(store, root, Ignore{})!220 assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())221222 put(root, 'file.txt', 'bbbb\n')!223 second := walk(store, root, Ignore{})!224 assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())225 assert second[0].hash != first[0].hash226}227228229230231232233fn test_cache_skips_a_file_changed_moments_ago() {234 second := i64(1_000_000_000)235 now := 100 * second236 old := now - 10 * second237 assert cacheable(Stat{ mtime_ns: old, ctime_ns: old }, now)238 assert !cacheable(Stat{ mtime_ns: now - second, ctime_ns: now - second }, now)239 assert !cacheable(Stat{ mtime_ns: old, ctime_ns: now - second }, now)240241 mut store := storage.create(temp_root('racy')!)!242 root := store.root243 defer {244 store.close()245 os.rmdir_all(root) or {}246 }247 put(root, 'fresh.txt', 'new\n')!248 put(root, 'copied.txt', 'kept mtime\n')!249 hour_ago := time.now().unix() - 3600250 os.utime(os.join_path(root, 'copied.txt'), hour_ago, hour_ago)!251 walk(store, root, Ignore{})!252 assert store.load_cache()!.len == 0253}254255256257fn test_a_walk_does_not_wait_for_another_writer() {258 mut store := storage.create(temp_root('busy_walk')!)!259 root := store.root260 defer {261 store.close()262 os.rmdir_all(root) or {}263 }264 put(root, 'a.txt', 'a\n')!265 266 267 time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond)268 writer := storage.open(root)!269 defer {270 writer.close()271 }272 writer.begin()!273 started := time.now()274 files := walk(store, root, Ignore{})!275 assert files.len == 1276 assert time.since(started) < time.second277 writer.rollback()278}279280fn test_restore_writes_removes_and_leaves_ignored_files_alone() {281 mut store := storage.create(temp_root('restore')!)!282 root := store.root283 defer {284 store.close()285 os.rmdir_all(root) or {}286 }287 put(root, 'keep.txt', 'recorded\n')!288 put(root, 'gone/inside.txt', 'also recorded\n')!289 put(root, 'noise.log', 'ignored, must survive\n')!290 put(root, '.kohignore', '*.log\n')!291 target := walk(store, root, load(root))!292 store_objects(store, root, target, unsafe { nil })!293294 295 296 os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!297 os.rm(full_path(root, 'gone/inside.txt'))!298 put(root, 'extra.txt', 'never saved\n')!299300 301 302 report := restore_files(store, root, load(root), target,303 ['extra.txt', '.kohignore', 'noise.log'])!304305 306 307 assert report.written == 2308 assert report.removed == 1309 assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'310 assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'311 assert !os.exists(full_path(root, 'extra.txt'))312 assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'313 assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'314}315316fn test_restore_keeps_the_executable_bit() {317 mut store := storage.create(temp_root('restore_exec')!)!318 root := store.root319 defer {320 store.close()321 os.rmdir_all(root) or {}322 }323 put(root, 'run.sh', '#!/bin/sh\necho hi\n')!324 os.chmod(os.join_path(root, 'run.sh'), 0o755)!325 target := walk(store, root, Ignore{})!326 store_objects(store, root, target, unsafe { nil })!327 os.chmod(os.join_path(root, 'run.sh'), 0o644)!328329 restore_files(store, root, Ignore{}, target, [])!330 mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode331 assert is_executable(mode)332}333334fn test_diff_reports_kinds_counts_and_binary_files() {335 mut store := storage.create(temp_root('diff')!)!336 root := store.root337 defer {338 store.close()339 os.rmdir_all(root) or {}340 }341 put(root, 'text.txt', 'one\ntwo\n')!342 put(root, 'same.txt', 'unchanged\n')!343 put(root, 'gone.txt', 'bye\n')!344 before := walk(store, root, Ignore{})!345 store_objects(store, root, before, unsafe { nil })!346347 os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!348 os.rm(full_path(root, 'gone.txt'))!349 os.write_file(os.join_path(root, 'added.txt'), 'new\n')!350 os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!351 after := walk(store, root, Ignore{})!352353 354 355 deltas := diff_trees(store, Tree{356 files: before357 }, Tree{358 root: root359 files: after360 })!361 mut by_path := map[string]Delta{}362 for delta in deltas {363 by_path[delta.path] = delta364 }365 assert deltas.len == 4366 assert by_path['text.txt'].kind == 'modified'367 assert by_path['text.txt'].added == 2368 assert by_path['text.txt'].removed == 1369 assert by_path['added.txt'].kind == 'added'370 assert by_path['gone.txt'].kind == 'removed'371 assert by_path['blob.bin'].binary372 assert by_path['blob.bin'].patch == binary_notice373 assert 'same.txt' !in by_path374}375376fn full_path(root string, rel string) string {377 return os.join_path(root, rel)378}379380fn test_ignore_toggle_rules_follow_directory_walk_and_escape_literals() {381 assert matching_rules('# keep\ncache\n*.log\n', 'nested/cache/a', false) == ['cache']382 assert matching_rules('/cache/\ncache/**\n', 'cache/a', false) == ['/cache/', 'cache/**']383 assert matching_rules('*.log\n', 'notes.txt', false).len == 0384 rule := ignore_rule('#draft*.txt', false, true)385 assert parse(rule).matches('#draft*.txt', false)386 assert !parse(rule).matches('#draftX.txt', false)387 assert ignore_rule('cache', true, false) == '/cache/'388}