snapshot_test.v

12.7 KB · raw

1module snapshot23import os4import time5import src.storage67// Global rules must not make source fixtures depend on the developer’s home.8fn 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	// A trailing slash names a directory: the directory itself and everything38	// below it, but not a file that shares the name.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}5051// A stripped path is banned as itself: its `*` and `?` must not become52// wildcards, in `.kohignore` or in the walk's ban.53fn 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	// A repo inside the repo keeps its own history: restoring an old copy of76	// its .koh or .git would roll that history back.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	// A pattern list cannot un-ignore them, and `.kohignore` itself is content.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}146147// Only bytes outside a valid UTF-8 sequence become \xNN; Kepr's escapePath prints the same.148fn 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	// Only a file that had to be read counts; unsafe names are never read.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}207208// The point of nanosecond timestamps: a file rewritten inside the same second209// with the same size must not reuse the cached hash, or a save would record210// content that is no longer on disk.211fn 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}227228// Linux stamps files from a clock that ticks every few milliseconds, so a229// same-size rewrite in the tick the file was hashed in leaves size, mtime and230// ctime unchanged. A hash taken that close to the last change is never cached;231// the next walk hashes again. Tools such as `cp -p` keep an old mtime while232// rewriting, so the newer of the two times decides.233fn 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}254255// `status` must work while another agent's save holds the write lock: the256// cache only saves time, so a busy repo hashes without caching, at once.257fn 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	// Only a file whose last change is older than the racy window is cached,266	// and ctime cannot be set back, so the test waits it out.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	// Drift the working tree: clobber one file, delete another, and add a file295	// the recorded tree has never seen.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	// The removal list names an ignored file (which survives), a file the301	// target still has (rewritten, not removed), and one genuinely extra file.302	report := restore_files(store, root, load(root), target,303		['extra.txt', '.kohignore', 'noise.log'])!304305	// The clobbered and the deleted file are written; the unchanged306	// .kohignore is left as it is.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	// A save's side of a diff is bytes from the object store; only the side354	// that is the working tree reads from disk.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}