d5d9ef7e460f

report unsafe paths in doctor, drop them in migrate by the shared rule, and keep flagged names in offer --builds --json (3.2.2)

time
2026-09-28 15:21 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
harden shared path handling
parent
a8e3208a9603
3 files src/cli/cmd_migrate.v+3 −0src/migrate/migrate.v+29 −8src/migrate/migrate_test.v+37 −0
src/cli/cmd_migrate.v+3 −0
@@ -34,11 +34,13 @@3434 			head:         report.head3535 			ignore_moved: report.ignore_carried3636 			build_file:   report.build_renamed37+			unsafe_paths: report.unsafe_paths3738 		}))3839 		return 03940 	}4041 	ok('migrated ${fmt.plural(report.saves, 'save')} from Koh 2 (lane ${report.lane})')4142 	note('${report.checkpoints} checkpoints · ${report.flags} flags · ${report.objects} objects')43+	warn_unsafe_paths(report.unsafe_paths)4244 	note('the Koh 2 data is untouched in ${migrate.archive_dir}/ — move it back to go back')4345 	if report.ignore_carried {4446 		note('carried the ignore rules to ${snapshot.ignore_file}')@@ -66,4 +68,5 @@6668 	head         string6769 	ignore_moved bool6870 	build_file   bool71+	unsafe_paths []string6972 }

view file

src/migrate/migrate.v+29 −8
@@ -50,7 +50,8 @@5050 	is_checkpoint bool5151 	flagged       bool5252 	flag_reason   ?string53-	files         []ManifestFile53+mut:54+	files []ManifestFile5455 }5556 5657 pub struct Report {@@ -66,6 +67,7 @@6667 	ignore_carried     bool6768 	log_md_was_tracked bool6869 	build_renamed      bool70+	unsafe_paths       []string6971 }7072 7173 // run converts the repo at `root`. `lane` picks which line to import: `dev`@@ -89,14 +91,16 @@8991 		chosen = other_lane(manifests) or { return err }9092 		lane_head(root, manifests, chosen)!9193 	}92-	line := ancestry(manifests, head)!93-	check_clean_tree(root, head)!94+	mut line := ancestry(manifests, head)!95+	unsafe_paths := drop_unsafe_paths(mut line)96+	check_clean_tree(root, line[line.len - 1])!9497 	check_build_files(root)!9598 9699 	mut report := Report{97-		root: root98-		lane: chosen99-		head: head.id100+		root:         root101+		lane:         chosen102+		head:         head.id103+		unsafe_paths: unsafe_paths100104 	}101105 	// Import into a draft file first: nothing is touched until it verifies.102106 	draft_path := os.join_path(root, draft_file)@@ -280,8 +284,8 @@280284 // disk_hash hashes a regular file inside the repo. Koh 2 never followed links,281285 // nor does Koh 3, so a link on the way to it means the saved file is missing.282286 fn disk_hash(root string, path string) ?string {283-	// Only a plain path inside the repo; a damaged manifest cannot point out.284-	if path.starts_with('/') || path.split('/').any(it in ['', '.', '..']) {287+	// Only a legal path inside the repo; a damaged manifest cannot point out.288+	if !snapshot.legal_path(path) {285289 		return none286290 	}287291 	full := os.join_path(root, path)@@ -301,6 +305,23 @@301305 	parts := path.split('/')302306 	return parts[0] == '.kohconfig' || parts[0].starts_with('.kohignore.')303307 		|| parts.any(it.ends_with('.bundle'))308+}309+310+// drop_unsafe_paths removes, by the shared path rule, every file Koh 3 would311+// refuse to restore, and names each one once for the report.312+fn drop_unsafe_paths(mut line []Manifest) []string {313+	mut dropped := map[string]bool{}314+	for mut manifest in line {315+		for file in manifest.files {316+			if !snapshot.legal_path(file.path) {317+				dropped[snapshot.escape_path(file.path)] = true318+			}319+		}320+		manifest.files = manifest.files.filter(snapshot.legal_path(it.path))321+	}322+	mut names := dropped.keys()323+	names.sort()324+	return names304325 }305326 306327 fn import_and_verify(store &storage.Store, root string, line []Manifest, mut report Report) ! {

view file

src/migrate/migrate_test.v+37 −0
@@ -277,3 +277,40 @@277277 		assert disk_hash(root, path) == none, path278278 	}279279 }280+281+fn test_disk_hash_uses_the_shared_path_rule() {282+	root := os.join_path(os.temp_dir(), 'koh3_migrate_rule_${os.getpid()}')283+	os.mkdir_all(root)!284+	defer {285+		os.rmdir_all(root) or {}286+	}287+	for name in ['back\\slash', 'Icon\r'] {288+		// Joined by hand: os.join_path turns a backslash into a separator.289+		os.write_file('${root}/${name}', 'odd\n')!290+		assert disk_hash(root, name) == none, name291+	}292+}293+294+fn test_unsafe_manifest_paths_are_dropped_and_named_once() {295+	mut line := [296+		Manifest{297+			id:    'a'298+			files: [ManifestFile{299+				path: 'ok.txt'300+			}, ManifestFile{301+				path: 'Icon\r'302+			}]303+		},304+		Manifest{305+			id:    'b'306+			files: [ManifestFile{307+				path: 'Icon\r'308+			}, ManifestFile{309+				path: '../x'310+			}]311+		},312+	]313+	assert drop_unsafe_paths(mut line) == ['../x', 'Icon\\x0d']314+	assert line[0].files.map(it.path) == ['ok.txt']315+	assert line[1].files.len == 0316+}

view file