c626bc7e3f0b

read unpaired surrogate escapes as bytes so one bad name never refuses a whole record, escape only the bytes outside valid UTF-8, and ignore strips with illegal paths, as Kepr does (3.2.4)

time
2026-09-28 16:56 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
harden shared path handling
parent
f916e638e588
9 files VERSION+1 −1src/exchange/exchange.v+4 −1src/exchange/exchange_test.v+42 −0src/exchange/records.v+46 −2src/exchange/steal.v+4 −1src/exchange/strip.v+1 −1src/snapshot/snapshot_test.v+7 −0src/snapshot/tree.v+8 −1v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.2.31+3.2.4

view file

src/exchange/exchange.v+4 −1
@@ -299,7 +299,10 @@299299 		if !body.found {300300 			continue301301 		}302-		incoming << decode_event(body.body)!302+		event := decode_event(body.body)!303+		if !ignored_strip(event) {304+			incoming << event305+		}303306 	}304307 	return incoming305308 }

view file

src/exchange/exchange_test.v+42 −0
@@ -25,6 +25,16 @@2525 	assert dropped == ['../outside.txt', 'Icon\\x0d', 'bad\\\\name.txt']2626 }2727 28+// An unpaired surrogate escape stands for its three bytes, so the record still29+// decodes and only the path holding it is dropped; a real pair is kept.30+fn test_a_record_with_unpaired_surrogate_escapes_drops_only_those_paths() {31+	body := '{"id":"abcdefabcdef","files":[{"path":"ok\\ud83d\\ude00"},{"path":"lead\\ud800"},{"path":"trail\\udc00x"},{"path":"back\\\\ud800"}]}'32+	mut record := decode_save(body.bytes())!33+	dropped := drop_unsafe_paths(mut record)34+	assert record.files.map(it.path) == ['ok😀']35+	assert dropped == ['back\\\\ud800', 'lead\\xed\\xa0\\x80', 'trail\\xed\\xb0\\x80x']36+}37+2838 // Machines keep their repo in a folder of the same NAME, because the remote key2939 // is the folder's name; that is what makes them the same repo to the remote.3040 fn machine(name string) !string {@@ -755,6 +765,38 @@755765 	assert report.saves == 2756766 	assert healthy.get(head_key('proj'))!.body.bytestr().trim_space() == head_of(a).id757767 	assert healthy.list('proj/saves')!.len == 2768+}769+770+// A strip whose path breaks §5.5 names nothing a save can hold; Koh and Kepr both leave it out,771+// and it must neither remove anything nor hold up an offer.772+fn test_a_strip_with_an_illegal_path_is_ignored() {773+	base := scratch('illegal_strip')!774+	defer {775+		os.rmdir_all(base) or {}776+	}777+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!778+	mut a := open_repo(machine('illegal_strip_a')!)!779+	defer {780+		a.close()781+	}782+	mut b := open_repo(machine('illegal_strip_b')!)!783+	defer {784+		b.close()785+	}786+	put(a.root, 'a/x.txt', 'kept\n')!787+	make_save(a, 'add a folder')!788+	offer_now(a, mut backend)!789+	for id, path in {790+		'aaaaaaaaaaaa': 'a//'791+		'bbbbbbbbbbbb': 'a\\ud800'792+	} {793+		backend.put_if_absent(event_key('proj', id), '{"id":"${id}","kind":"strip","save_id":"","path":"${path}","reason":"","created_at":1,"machine":"crafted"}'.bytes())!794+	}795+	join(b, mut backend)!796+	assert 'a/x.txt' in history.files_of(b, history.head(b)!.id)!.map(it.path)797+	put(a.root, 'more.txt', 'more\n')!798+	make_save(a, 'more')!799+	offer_now(a, mut backend)!758800 }759801 760802 fn test_a_failed_steal_leaves_nothing_behind() {

view file

src/exchange/records.v+46 −2
@@ -18,6 +18,7 @@1818 import src.history1919 import src.snapshot2020 import src.storage21+import strconv2122 2223 pub fn format_key(repo string) string {2324 	return '${repo}/format'@@ -100,9 +101,52 @@100101 }101102 102103 pub fn decode_save(body []u8) !SaveRecord {103-	return json2.decode[SaveRecord](body.bytestr())!104+	return json2.decode[SaveRecord](lone_surrogates_as_bytes(body).bytestr())!104105 }105106 107+// ignored_strip is a strip whose path breaks §5.5. No save can hold such a path,108+// so Koh and Kepr both leave the event out rather than guess what it meant.109+fn ignored_strip(event events.Event) bool {110+	return event.kind == events.kind_strip && !snapshot.legal_path(event.path)111+}112+113+// lone_surrogates_as_bytes writes a `\\uXXXX` surrogate escape with no partner as its114+// three bytes. json2 refuses such an escape outright, which would refuse the whole115+// save; as bytes it decodes, and the one path holding it fails legal_path (§5.5).116+fn lone_surrogates_as_bytes(body []u8) []u8 {117+	mut out := []u8{cap: body.len}118+	mut i := 0119+	for i < body.len {120+		if body[i] != `\\` || i + 1 >= body.len {121+			out << body[i]122+			i++123+			continue124+		}125+		unit := escaped_surrogate(body, i)126+		if unit >= 0xd800 && unit <= 0xdbff && escaped_surrogate(body, i + 6) >= 0xdc00 {127+			out << body[i..i + 12]128+			i += 12129+		} else if unit >= 0 {130+			out << [u8(0xe0 | (unit >> 12)), u8(0x80 | ((unit >> 6) & 0x3f)), u8(0x80 | (unit & 0x3f))]131+			i += 6132+		} else {133+			// Any other escape, `\\\\` included, passes through whole.134+			out << body[i..i + 2]135+			i += 2136+		}137+	}138+	return out139+}140+141+// escaped_surrogate reads a `\\uD800`–`\\uDFFF` escape at `at`, or returns -1.142+fn escaped_surrogate(body []u8, at int) int {143+	if at + 6 > body.len || body[at] != `\\` || body[at + 1] != `u` {144+		return -1145+	}146+	unit := strconv.parse_int(body[at + 2..at + 6].bytestr(), 16, 32) or { return -1 }147+	return if unit >= 0xd800 && unit <= 0xdfff { int(unit) } else { -1 }148+}149+106150 // drop_unsafe_paths applies the shared path contract before an arriving record is used.107151 pub fn drop_unsafe_paths(mut record SaveRecord) []string {108152 	mut kept := []FileRecord{}@@ -175,7 +219,7 @@175219 }176220 177221 pub fn decode_event(body []u8) !events.Event {178-	record := json2.decode[EventRecord](body.bytestr())!222+	record := json2.decode[EventRecord](lone_surrogates_as_bytes(body).bytestr())!179223 	return events.Event{180224 		id:         record.id181225 		kind:       record.kind

view file

src/exchange/steal.v+4 −1
@@ -586,7 +586,10 @@586586 	for key in event_keys {587587 		body := backend.get(key)!588588 		if body.found {589-			fetched.events_out << decode_event(body.body)!589+			event := decode_event(body.body)!590+			if !ignored_strip(event) {591+				fetched.events_out << event592+			}590593 		}591594 	}592595 	return fetched

view file

src/exchange/strip.v+1 −1
@@ -191,7 +191,7 @@191191 			continue192192 		}193193 		event := decode_event(body.body)!194-		if event.kind == events.kind_strip {194+		if event.kind == events.kind_strip && !ignored_strip(event) {195195 			strips << event196196 		}197197 	}

view file

src/snapshot/snapshot_test.v+7 −0
@@ -139,6 +139,13 @@139139 	}140140 }141141 142+// Only bytes outside a valid UTF-8 sequence become \xNN; Kepr's escapePath prints the same.143+fn 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+}148+142149 fn test_walk_skips_and_reports_unsafe_names() {143150 	root := temp_root('unsafe_names')!144151 	defer {

view file

src/snapshot/tree.v+8 −1
@@ -58,7 +58,14 @@5858 			&& bytes[i + 1] <= 0x9f {5959 			escaped += '\\u00${bytes[i + 1].hex()}'6060 			i++61-		} else if byte >= 0x80 && !utf8.validate_str(bytes[i..].bytestr()) {61+		} else if byte >= 0x80 {62+			// A whole valid sequence prints as itself; any other byte is escaped alone.63+			n := utf8_char_len(byte)64+			if n > 1 && i + n <= bytes.len && utf8.validate_str(bytes[i..i + n].bytestr()) {65+				escaped += bytes[i..i + n].bytestr()66+				i += n67+				continue68+			}6269 			escaped += '\\x${byte.hex()}'6370 		} else {6471 			escaped += byte.ascii_str()

view file

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

view file