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
↓ koh-1d049f5c.tar.gzatlas at this save
files changed8 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 −1src/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] = truesrc/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 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()}'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)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() {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() {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 }