snapshot_test.v

12.0 KB · raw

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	// A trailing slash names a directory: the directory itself and everything33	// below it, but not a file that shares the name.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}4546// A stripped path is banned as itself: its `*` and `?` must not become47// wildcards, in `.kohignore` or in the walk's ban.48fn 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	// A repo inside the repo keeps its own history: restoring an old copy of71	// its .koh or .git would roll that history back.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	// A pattern list cannot un-ignore them, and `.kohignore` itself is content.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}141142// Only bytes outside a valid UTF-8 sequence become \xNN; Kepr's escapePath prints the same.143fn test_escape_path_escapes_only_the_invalid_bytes() {144	assert escape_path('é' + [u8(0xff)].bytestr() + 'é') == 'é\\xffé'145	assert escape_path([u8(0xed), 0xa0, 0x80].bytestr()) == '\\xed\\xa0\\x80'146	assert escape_path('a\u0085\\b\r') == 'a\\u0085\\\\b\\x0d'147}148149fn test_walk_skips_and_reports_unsafe_names() {150	root := temp_root('unsafe_names')!151	defer {152		os.rmdir_all(root) or {}153	}154	store := storage.create(root)!155	defer {156		store.close()157	}158	put(root, 'safe.txt', 'kept\n')!159	os.write_file('${root}/bad\\name.txt', 'skipped\n')!160	os.write_file('${root}/Icon\r', 'skipped\n')!161	mut hashing := Hashing{}162	walked := walk_report(store, root, Ignore{}, mut hashing)!163	assert walked.files.map(it.path) == ['safe.txt']164	// Only a file that had to be read counts; unsafe names are never read.165	assert hashing.count == 1166	assert walked.unsafe_paths == ['Icon\\x0d', 'bad\\\\name.txt']167}168169fn test_write_tree_refuses_every_path_outside_the_root_before_writing() {170	base := temp_root('restore_containment')!171	defer {172		os.rmdir_all(base) or {}173	}174	root := os.join_path(base, 'repo')175	os.mkdir(root)!176	store := storage.create(root)!177	defer {178		store.close()179	}180	for path in ['../outside.txt', '/etc/koh-test', 'a/../../outside.txt', 'bad\\name', 'control\rname'] {181		if _ := write_tree(store, root, Ignore{}, [FileEntry{182			path: path183		}], []) {184			assert false, '${path} must be refused'185		}186	}187	assert !os.exists(os.join_path(base, 'outside.txt'))188}189190fn test_prune_empty_dirs_never_touches_a_sibling_with_the_roots_prefix() {191	base := temp_root('prune_prefix')!192	defer {193		os.rmdir_all(base) or {}194	}195	root := os.join_path(base, 'repo')196	sibling := os.join_path(base, 'repo2')197	os.mkdir(root)!198	os.mkdir(sibling)!199	prune_empty_dirs(root, sibling)200	assert os.is_dir(sibling)201}202203// The point of nanosecond timestamps: a file rewritten inside the same second204// with the same size must not reuse the cached hash, or a save would record205// content that is no longer on disk.206fn test_cache_does_not_reuse_a_same_size_same_second_edit() {207	mut store := storage.create(temp_root('cache')!)!208	root := store.root209	defer {210		store.close()211		os.rmdir_all(root) or {}212	}213	put(root, 'file.txt', 'aaaa\n')!214	first := walk(store, root, Ignore{})!215	assert first[0].hash == storage.hash_hex('aaaa\n'.bytes())216217	put(root, 'file.txt', 'bbbb\n')!218	second := walk(store, root, Ignore{})!219	assert second[0].hash == storage.hash_hex('bbbb\n'.bytes())220	assert second[0].hash != first[0].hash221}222223// Linux stamps files from a clock that ticks every few milliseconds, so a224// same-size rewrite in the tick the file was hashed in leaves size, mtime and225// ctime unchanged. A hash taken that close to the last change is never cached;226// the next walk hashes again. Tools such as `cp -p` keep an old mtime while227// rewriting, so the newer of the two times decides.228fn test_cache_skips_a_file_changed_moments_ago() {229	second := i64(1_000_000_000)230	now := 100 * second231	old := now - 10 * second232	assert cacheable(Stat{ mtime_ns: old, ctime_ns: old }, now)233	assert !cacheable(Stat{ mtime_ns: now - second, ctime_ns: now - second }, now)234	assert !cacheable(Stat{ mtime_ns: old, ctime_ns: now - second }, now)235236	mut store := storage.create(temp_root('racy')!)!237	root := store.root238	defer {239		store.close()240		os.rmdir_all(root) or {}241	}242	put(root, 'fresh.txt', 'new\n')!243	put(root, 'copied.txt', 'kept mtime\n')!244	hour_ago := time.now().unix() - 3600245	os.utime(os.join_path(root, 'copied.txt'), hour_ago, hour_ago)!246	walk(store, root, Ignore{})!247	assert store.load_cache()!.len == 0248}249250// `status` must work while another agent's save holds the write lock: the251// cache only saves time, so a busy repo hashes without caching, at once.252fn test_a_walk_does_not_wait_for_another_writer() {253	mut store := storage.create(temp_root('busy_walk')!)!254	root := store.root255	defer {256		store.close()257		os.rmdir_all(root) or {}258	}259	put(root, 'a.txt', 'a\n')!260	// Only a file whose last change is older than the racy window is cached,261	// and ctime cannot be set back, so the test waits it out.262	time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond)263	writer := storage.open(root)!264	defer {265		writer.close()266	}267	writer.begin()!268	started := time.now()269	files := walk(store, root, Ignore{})!270	assert files.len == 1271	assert time.since(started) < time.second272	writer.rollback()273}274275fn test_restore_writes_removes_and_leaves_ignored_files_alone() {276	mut store := storage.create(temp_root('restore')!)!277	root := store.root278	defer {279		store.close()280		os.rmdir_all(root) or {}281	}282	put(root, 'keep.txt', 'recorded\n')!283	put(root, 'gone/inside.txt', 'also recorded\n')!284	put(root, 'noise.log', 'ignored, must survive\n')!285	put(root, '.kohignore', '*.log\n')!286	target := walk(store, root, load(root))!287	store_objects(store, root, target, unsafe { nil })!288289	// Drift the working tree: clobber one file, delete another, and add a file290	// the recorded tree has never seen.291	os.write_file(os.join_path(root, 'keep.txt'), 'clobbered\n')!292	os.rm(full_path(root, 'gone/inside.txt'))!293	put(root, 'extra.txt', 'never saved\n')!294295	// The removal list names an ignored file (which survives), a file the296	// target still has (rewritten, not removed), and one genuinely extra file.297	report := restore_files(store, root, load(root), target,298		['extra.txt', '.kohignore', 'noise.log'])!299300	// The clobbered and the deleted file are written; the unchanged301	// .kohignore is left as it is.302	assert report.written == 2303	assert report.removed == 1304	assert os.read_file(full_path(root, 'keep.txt'))! == 'recorded\n'305	assert os.read_file(full_path(root, 'gone/inside.txt'))! == 'also recorded\n'306	assert !os.exists(full_path(root, 'extra.txt'))307	assert os.read_file(full_path(root, 'noise.log'))! == 'ignored, must survive\n'308	assert os.read_file(full_path(root, '.kohignore'))! == '*.log\n'309}310311fn test_restore_keeps_the_executable_bit() {312	mut store := storage.create(temp_root('restore_exec')!)!313	root := store.root314	defer {315		store.close()316		os.rmdir_all(root) or {}317	}318	put(root, 'run.sh', '#!/bin/sh\necho hi\n')!319	os.chmod(os.join_path(root, 'run.sh'), 0o755)!320	target := walk(store, root, Ignore{})!321	store_objects(store, root, target, unsafe { nil })!322	os.chmod(os.join_path(root, 'run.sh'), 0o644)!323324	restore_files(store, root, Ignore{}, target, [])!325	mode := (lstat(os.join_path(root, 'run.sh')) or { panic('missing') }).mode326	assert is_executable(mode)327}328329fn test_diff_reports_kinds_counts_and_binary_files() {330	mut store := storage.create(temp_root('diff')!)!331	root := store.root332	defer {333		store.close()334		os.rmdir_all(root) or {}335	}336	put(root, 'text.txt', 'one\ntwo\n')!337	put(root, 'same.txt', 'unchanged\n')!338	put(root, 'gone.txt', 'bye\n')!339	before := walk(store, root, Ignore{})!340	store_objects(store, root, before, unsafe { nil })!341342	os.write_file(os.join_path(root, 'text.txt'), 'one\nthree\nfour\n')!343	os.rm(full_path(root, 'gone.txt'))!344	os.write_file(os.join_path(root, 'added.txt'), 'new\n')!345	os.write_bytes(os.join_path(root, 'blob.bin'), [u8(0), 0x01, 0x02])!346	after := walk(store, root, Ignore{})!347348	// A save's side of a diff is bytes from the object store; only the side349	// that is the working tree reads from disk.350	deltas := diff_trees(store, Tree{351		files: before352	}, Tree{353		root:  root354		files: after355	})!356	mut by_path := map[string]Delta{}357	for delta in deltas {358		by_path[delta.path] = delta359	}360	assert deltas.len == 4361	assert by_path['text.txt'].kind == 'modified'362	assert by_path['text.txt'].added == 2363	assert by_path['text.txt'].removed == 1364	assert by_path['added.txt'].kind == 'added'365	assert by_path['gone.txt'].kind == 'removed'366	assert by_path['blob.bin'].binary367	assert by_path['blob.bin'].patch == binary_notice368	assert 'same.txt' !in by_path369}370371fn full_path(root string, rel string) string {372	return os.join_path(root, rel)373}