1d049f5cd490

refuse to restore through a symlinked folder, so undo, load, pluck and steal never overwrite files outside the repo (3.0.30, codex review pending)

time
2026-09-23 22:47 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh 3 reliability campaign
parent
7b2e6110eed5
8 files VERSION+1 −1src/cli/cmd_back.v+1 −0src/cli/cmd_pluck.v+1 −0src/snapshot/restore.v+17 −0src/sync/steal.v+8 −2src/sync/sync_test.v+36 −0tests/cli_test.v+25 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.291+3.0.30

view file

src/cli/cmd_back.v+1 −0
@@ -196,6 +196,7 @@196196 // tree, so those are tracked files, and revert has recorded the rest.197197 fn plan_restore(store &storage.Store, target history.Save) !RestorePlan {198198 	files := history.files_of(store, target.id)!199+	snapshot.check_links(store.root, files)!199200 	mut wanted := map[string]bool{}200201 	for file in files {201202 		wanted[file.path] = true

view file

src/cli/cmd_pluck.v+1 −0
@@ -122,6 +122,7 @@122122 	if apply.conflicts(plan) {123123 		return error('${plan.conflict} has changed since [${source.id}]; koh does not merge lines — resolve it by hand, or load the save and re-edit')124124 	}125+	snapshot.check_links(store.root, plan.files)!125126 	return plan, current.files126127 }127128 

view file

src/snapshot/restore.v+17 −0
@@ -87,6 +87,23 @@8787 	return storage.hash_hex(data) == entry.hash8888 }8989 90+// check_links refuses a restore that would write through a symlinked folder.91+// Koh never follows links, so the folder it points at is not the repo's, and92+// writing there would overwrite files outside it. Callers run it before they93+// record anything.94+pub fn check_links(root string, files []FileEntry) ! {95+	for file in files {96+		mut folder := ''97+		for part in file.path.split('/')#[..-1] {98+			folder = if folder == '' { part } else { '${folder}/${part}' }99+			st := lstat(os.join_path(root, folder)) or { break }100+			if st.kind == .symlink {101+				return error('${folder} is a symbolic link where a saved folder belongs — koh will not write through it; move it, then run the command again')102+			}103+		}104+	}105+}106+90107 fn write_file(path string, data []u8, executable bool) ! {91108 	os.mkdir_all(os.dir(path), os.MkdirParams{})!92109 	tmp := '${path}.koh-tmp-${os.getpid()}'

view file

src/sync/steal.v+8 −2
@@ -119,6 +119,14 @@119119 		store.rollback()120120 		return err121121 	})122+	files := history.files_of(store, final.id) or {123+		store.rollback()124+		return err125+	}126+	snapshot.check_links(store.root, files) or {127+		store.rollback()128+		return err129+	}122130 	store.commit() or { return err }123131 	report.head = final.id124132 	flags := events.latest_flags(store)!@@ -135,7 +143,6 @@135143 			// The line did not move, so neither does the working tree.136144 			return report137145 		}138-		files := history.files_of(store, final.id)!139146 		mut wanted := map[string]bool{}140147 		for file in files {141148 			wanted[file.path] = true@@ -150,7 +157,6 @@150157 			remove)!151158 		report.removed = result.removed152159 	} else {153-		files := history.files_of(store, final.id)!154160 		snapshot.restore_files(store, store.root, snapshot.load(store.root), files, [])!155161 	}156162 	history.refresh_log_md(store)

view file

src/sync/sync_test.v+36 −0
@@ -160,6 +160,42 @@160160 	assert backend.get(head_key('proj'))!.body.bytestr() == ahead.id161161 }162162 163+// A link is invisible to the walk, so a tree holding one is clean; the files164+// a steal brings in must still never land in the folder it points at.165+fn test_steal_never_writes_through_a_symlinked_folder() {166+	base := scratch('steal_link')!167+	defer {168+		os.rmdir_all(base) or {}169+	}170+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!171+	mut a := open_repo(machine('steal_link_a')!)!172+	defer {173+		a.close()174+	}175+	mut b := open_repo(machine('steal_link_b')!)!176+	defer {177+		b.close()178+	}179+	put(a.root, 'a.txt', 'one\n')!180+	commit(a, 'first')!181+	offer_now(a, mut backend)!182+	join(b, mut backend)!183+	outside := os.join_path(base, 'outside')184+	put(outside, 'a.md', 'precious\n')!185+	os.symlink(outside, os.join_path(b.root, 'docs'))!186+	put(a.root, 'docs/a.md', 'from A\n')!187+	commit(a, 'A adds docs')!188+	offer_now(a, mut backend)!189+	head_before := history.head(b)!.id190+	if _ := steal_now(b, mut backend) {191+		assert false, 'a steal writing through a symlinked folder must refuse'192+	} else {193+		assert err.msg().contains('docs')194+	}195+	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'196+	assert history.head(b)!.id == head_before197+}198+163199 // A replayed save keeps its original time but sits on a newer parent, so a164200 // clone cannot order saves by time.165201 fn test_a_clone_imports_parents_before_children() {

view file

tests/cli_test.v+25 −0
@@ -189,6 +189,31 @@189189 	assert os.ls(os.join_path(dir, 'out'))! == ['build.log']190190 }191191 192+// Koh never follows a symlink, so a folder replaced by a link to somewhere193+// else is not the repo's to write into: restoring through it would overwrite194+// files outside the repo. The refusal comes before anything is recorded.195+fn test_a_restore_never_writes_through_a_symlinked_folder() {196+	dir := temp_repo('link-in-way')197+	outside := temp_repo('link-target')198+	defer {199+		os.rmdir_all(dir) or {}200+		os.rmdir_all(outside) or {}201+	}202+	init_repo(dir)203+	write(dir, 'docs/a.md', 'saved\n')204+	first := run(dir, 'save', 'docs', '--json').out.all_after('"id":"')[..12]205+	os.rmdir_all(os.join_path(dir, 'docs')) or { panic(err) }206+	write(outside, 'a.md', 'precious\n')207+	os.symlink(outside, os.join_path(dir, 'docs')) or { panic(err) }208+	assert run(dir, 'save', 'docs is a link now').code == 0209+	head := run(dir, 'status', '--json').out210+	r := run(dir, 'load', first, '--yes')211+	assert r.code == 1212+	assert r.err.contains('docs')213+	assert os.read_file(os.join_path(outside, 'a.md'))! == 'precious\n'214+	assert run(dir, 'status', '--json').out == head215+}216+192217 // Doctor clears peek folders left from earlier days, never one somebody may193218 // be reading right now.194219 fn test_doctor_sweeps_only_old_peek_folders() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.29'3+	version: '3.0.30'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file