f55debb9a051

harden steal imports and scope incoming strip cleanup

time
2026-10-09 17:42 UTC · 3 days ago
machine
Junes-MacBook-Pro.local
agent
gpt-5.6-sol
session
resolve steal and strip audit findings
parent
7e9f6e02a31b
8 files src/cli/cmd_doctor_test.v+40 −0src/cli/doctor_checks.v+3 −0src/cli/doctor_remote.v+7 −1src/exchange/exchange.v+30 −6src/exchange/exchange_test.v+231 −5src/exchange/records.v+32 −0src/exchange/repair.v+7 −9src/exchange/steal.v+19 −1
src/cli/cmd_doctor_test.v+40 −0
@@ -133,6 +133,45 @@133133 	assert damaged.problems.any(it.contains('checksum verification'))134134 }135135 136+fn test_doctor_remote_keeps_a_complete_report_for_invalid_records() {137+	mut backend := DoctorReadOnly{138+		items: {139+			'repo/head':               'aaaaaaaaaaaa'.bytes()140+			'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{141+				id:         'aaaaaaaaaaaa'142+				created_at: -1143+			}).bytes()144+			'repo/events/bad':         json2.encode(exchange.EventRecord{145+				id:         'bad'146+				kind:       'unknown'147+				created_at: 1148+			}).bytes()149+		}150+	}151+	mut report := DoctorJson{}152+	doctor_remote_content(mut backend, 'repo', mut report)!153+	assert report.problems.any(it.contains('invalid metadata'))154+	assert report.problems.any(it.contains('invalid ID'))155+	assert report.problems.any(it.contains('unknown kind'))156+}157+158+fn test_doctor_remote_binds_a_save_to_its_exact_key() {159+	mut backend := DoctorReadOnly{160+		items: {161+			'repo/head':                      'aaaaaaaaaaaa'.bytes()162+			'repo/saves/nested/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{163+				id: 'aaaaaaaaaaaa'164+			}).bytes()165+		}166+	}167+	mut report := DoctorJson{}168+	if _ := doctor_remote_content(mut backend, 'repo', mut report) {169+		assert false, 'a nested save key must not stand in for the canonical key'170+	} else {171+		assert err.msg().contains('disagrees with key'), err.msg()172+	}173+}174+136175 fn test_doctor_reports_event_errors_strip_residue_and_flags() {137176 	mut report := DoctorJson{}138177 	saves := [history.Save{ id: 'aaaaaaaaaaaa' }]@@ -142,6 +181,7 @@142181 	doctor_events(event_list, saves, mut report)143182 	doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)144183 	assert report.flagged_saves == 1184+	assert report.problems.any(it.contains('invalid ID'))145185 	assert report.problems.any(it.contains('unknown kind'))146186 	assert report.problems.any(it.contains('stripped path'))147187 	assert report.strip_residue_paths == ['private']

view file

src/cli/doctor_checks.v+3 −0
@@ -98,6 +98,9 @@9898 	ids := saves.map(it.id)9999 	mut flags := map[string]events.Event{}100100 	for event in event_list {101+		if !exchange.valid_event_id(event.id) {102+			report.problems << 'event ${event.id} has an invalid ID'103+		}101104 		if event.created_at < 0 { report.problems << 'event ${event.id} has a negative timestamp' }102105 		if event.kind !in [events.kind_flag, events.kind_clear, events.kind_strip] {103106 			report.problems << 'event ${event.id} has unknown kind ${event.kind}'

view file

src/cli/doctor_remote.v+7 −1
@@ -70,6 +70,7 @@7070 	head := backend.get(exchange.head_key(repo))!7171 	report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }7272 	mut saves := []history.Save{}73+	mut record_problems := []string{}7374 	mut files := map[string][]snapshot.FileEntry{}7475 	mut hashes := map[string]bool{}7576 	mut event_list := []events.Event{}@@ -86,6 +87,7 @@8687 		if key != exchange.save_key(repo, record.id) {8788 			return error('remote save ID disagrees with key ${key}')8889 		}90+		exchange.validate_save(record, record.id) or { record_problems << err.msg() }8991 		saves << history.Save{9092 			id:            record.id9193 			parent:        record.parent@@ -110,6 +112,9 @@110112 		if key != exchange.event_key(repo, event.id) {111113 			return error('remote event ID disagrees with key ${key}')112114 		}115+		if !exchange.valid_event_id(event.id) {116+			record_problems << 'remote event ${event.id} has an invalid ID'117+		}113118 		event_list << event114119 	}115120 	for key in backend.list('${repo}/objects')! {@@ -119,7 +124,8 @@119124 		hashes[key.all_after_last('/')] = true120125 	}121126 	mut remote_report := DoctorJson{ head_id: report.remote_head }122-	remote_report.problems = doctor_history(saves, report.remote_head)127+	remote_report.problems = record_problems128+	remote_report.problems << doctor_history(saves, report.remote_head)123129 	report.remote_missing_saves = missing_saves(saves, report.remote_head)124130 	doctor_events(event_list, saves, mut remote_report)125131 	for save in saves {

view file

src/exchange/exchange.v+30 −6
@@ -427,6 +427,9 @@427427 	mut fetched := Fetched{428428 		head: remote_head.body.bytestr().trim_space()429429 	}430+	if !valid_save_id(fetched.head) {431+		return error('the remote head has invalid save ID ${fetched.head}')432+	}430433 	mut on_line := map[string]bool{}431434 	for save in history.line(store)! {432435 		on_line[save.id] = true@@ -435,7 +438,12 @@435438 	fetched.events_out = fetch_events(store, mut backend, repo)!436439 	strips := known_strips(store, fetched)!437440 	mut cursor := fetched.head441+	mut seen := map[string]bool{}438442 	for cursor != '' && cursor !in on_line {443+		if cursor in seen {444+			return error('the remote history has a parent cycle at ${cursor}')445+		}446+		seen[cursor] = true439447 		mut record := if saved := history.by_id(store, cursor) {440448 			save_record(store, saved)!441449 		} else {@@ -443,9 +451,12 @@443451 			if !record_object.found {444452 				return error('the remote is missing save ${cursor} — run koh offer there')445453 			}446-			decode_save(record_object.body)!454+			decoded := decode_save(record_object.body)!455+			validate_save(decoded, cursor)!456+			decoded447457 		}448458 		fetched.unsafe_paths << drop_unsafe_paths(mut record)459+		validate_hashes(record.id, record.files)!449460 		for file in record.files {450461 			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects451462 				&& !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {@@ -473,6 +484,9 @@473484 	mut incoming := []events.Event{}474485 	for key in backend.list(events_prefix(repo))! {475486 		id := os.base(key)487+		if !valid_event_id(id) {488+			return error('the remote has invalid event ID ${id}')489+		}476490 		if id in known {477491 			continue478492 		}@@ -481,6 +495,9 @@481495 			continue482496 		}483497 		event := decode_event(body.body)!498+		if event.id != id {499+			return error('remote event record ID ${event.id} disagrees with key ${id}')500+		}484501 		if !ignored_strip(event) {485502 			incoming << event486503 		}@@ -495,23 +512,27 @@495512 	for hash, blob in fetched.repo_objects {496513 		store.put_object_blob(hash, blob)!497514 	}498-	mut stripped := false515+	mut freed := []string{}499516 	for event in fetched.events_out {500517 		if !events.receive(store, event)! {501518 			continue502519 		}503520 		if events.valid_strip(event) {521+			freed << stripped_hashes(store, event.path, event.save_id)!504522 			remove_path(store, event.path, event.save_id)!505-			stripped = true506523 		}507524 	}508525 	strips := known_strips(store, fetched)!509526 	for record in fetched.saves {527+		for file in record.files {528+			if file.hash in fetched.repo_objects529+				&& strips.any(reaches(it, record.id) && covers(it.path, file.path)) {530+				freed << file.hash531+			}532+		}510533 		import_arriving(store, record, strips)!511534 	}512-	if stripped {513-		store.drop_unreferenced_objects()!514-	}535+	store.drop_unused(freed)!515536 }516537 517538 // write_fetched_records makes network work durable without changing the line.@@ -540,6 +561,9 @@540561 	if history.by_id(store, record.id) != none {541562 		return542563 	}564+	validate_save(record, record.id)!565+	safe := record.files.filter(snapshot.legal_path(it.path))566+	validate_hashes(record.id, safe)!543567 	files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {544568 		return snapshot.legal_path(file.path)545569 			&& !strips.any(reaches(it, record.id) && covers(it.path, file.path))

view file

src/exchange/exchange_test.v+231 −5
@@ -920,6 +920,230 @@920920 	assert os.read_file(os.join_path(b.root, 'a.txt'))! == 'from A\n'921921 }922922 923+fn test_steal_refuses_invalid_remote_save_metadata() {924+	base := scratch('invalid_steal_metadata')!925+	defer { os.rmdir_all(base) or {} }926+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!927+	mut receiver := open_repo(machine('invalid_steal_metadata_receiver')!)!928+	defer { receiver.close() }929+	for repo, record in {930+		'negative-time': SaveRecord{ id: 'aaaaaaaaaaaa', created_at: -1 }931+		'negative-size': SaveRecord{ id: 'bbbbbbbbbbbb', size_bytes: -1 }932+	} {933+		backend.put_if_absent(head_key(repo), record.id.bytes())!934+		backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!935+		if _ := fetch_back_to_known(receiver, mut backend, repo) {936+			assert false, 'invalid remote save metadata must be refused'937+		} else {938+			assert err.msg().contains('invalid metadata'), err.msg()939+		}940+	}941+}942+943+fn test_steal_binds_remote_records_to_their_keys_and_head() {944+	base := scratch('steal_record_identity')!945+	defer { os.rmdir_all(base) or {} }946+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!947+	mut receiver := open_repo(machine('steal_record_identity_receiver')!)!948+	defer { receiver.close() }949+	backend.put_if_absent(head_key('mismatch'), 'aaaaaaaaaaaa'.bytes())!950+	backend.put_if_absent(save_key('mismatch', 'aaaaaaaaaaaa'), encode_save(SaveRecord{951+		id: 'bbbbbbbbbbbb'952+	}).bytes())!953+	if _ := fetch_back_to_known(receiver, mut backend, 'mismatch') {954+		assert false, 'a save record under another ID must be refused'955+	} else {956+		assert err.msg().contains('disagrees with'), err.msg()957+	}958+	backend.put_if_absent(head_key('missing-head'), 'cccccccccccc'.bytes())!959+	backend.put_if_absent(save_key('missing-head', 'dddddddddddd'), encode_save(SaveRecord{960+		id: 'dddddddddddd'961+	}).bytes())!962+	if _ := fetch_repo(mut backend, 'missing-head') {963+		assert false, 'a whole-repo steal without its declared head must be refused'964+	} else {965+		assert err.msg().contains('head'), err.msg()966+	}967+}968+969+fn test_steal_refuses_invalid_object_hashes_before_reading_their_keys() {970+	base := scratch('steal_bad_hash')!971+	defer { os.rmdir_all(base) or {} }972+	remote_dir := os.join_path(base, 'remote')973+	mut folder := remote.new_folder(remote_dir)!974+	mut receiver := open_repo(machine('steal_bad_hash_receiver')!)!975+	defer { receiver.close() }976+	folder.put_if_absent(head_key('bad-hash'), 'aaaaaaaaaaaa'.bytes())!977+	folder.put_if_absent(save_key('bad-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{978+		id:    'aaaaaaaaaaaa'979+		files: [FileRecord{ path: 'file', hash: '../../outside' }]980+	}).bytes())!981+	mut guarded := FaultyBackend{982+		inner:     remote.new_folder(remote_dir)!983+		fail_read: 4984+	}985+	if _ := fetch_back_to_known(receiver, mut guarded, 'bad-hash') {986+		assert false, 'an invalid object hash must be refused'987+	} else {988+		assert err.msg().contains('invalid object hash'), err.msg()989+	}990+}991+992+fn test_steal_drops_an_unsafe_path_before_reading_its_hash() {993+	base := scratch('steal_unsafe_hash')!994+	defer { os.rmdir_all(base) or {} }995+	remote_dir := os.join_path(base, 'remote')996+	mut folder := remote.new_folder(remote_dir)!997+	mut receiver := open_repo(machine('steal_unsafe_hash_receiver')!)!998+	defer { receiver.close() }999+	folder.put_if_absent(head_key('unsafe-hash'), 'aaaaaaaaaaaa'.bytes())!1000+	folder.put_if_absent(save_key('unsafe-hash', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1001+		id:    'aaaaaaaaaaaa'1002+		files: [FileRecord{ path: '../outside', hash: '../../outside' }]1003+	}).bytes())!1004+	mut guarded := FaultyBackend{1005+		inner:     remote.new_folder(remote_dir)!1006+		fail_read: 41007+	}1008+	fetched := fetch_back_to_known(receiver, mut guarded, 'unsafe-hash')!1009+	assert fetched.saves[0].files.len == 01010+	assert fetched.unsafe_paths == ['../outside']1011+}1012+1013+fn test_steal_refuses_invalid_save_references() {1014+	base := scratch('steal_bad_references')!1015+	defer { os.rmdir_all(base) or {} }1016+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1017+	mut receiver := open_repo(machine('steal_bad_references_receiver')!)!1018+	defer { receiver.close() }1019+	for repo, record in {1020+		'bad-parent':  SaveRecord{ id: 'aaaaaaaaaaaa', parent: '../parent' }1021+		'bad-restore': SaveRecord{ id: 'bbbbbbbbbbbb', restores: '../restore' }1022+		'bad-replay':  SaveRecord{ id: 'cccccccccccc', replayed_from: '../replay' }1023+	} {1024+		backend.put_if_absent(head_key(repo), record.id.bytes())!1025+		backend.put_if_absent(save_key(repo, record.id), encode_save(record).bytes())!1026+		if _ := fetch_back_to_known(receiver, mut backend, repo) {1027+			assert false, 'invalid remote save references must be refused'1028+		} else {1029+			assert err.msg().contains('invalid save ID'), err.msg()1030+		}1031+	}1032+}1033+1034+fn test_steal_accepts_migrated_six_character_save_ids() {1035+	base := scratch('steal_six_ids')!1036+	defer { os.rmdir_all(base) or {} }1037+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1038+	backend.put_if_absent(head_key('six'), 'bbbbbb'.bytes())!1039+	backend.put_if_absent(save_key('six', 'aaaaaa'), encode_save(SaveRecord{1040+		id: 'aaaaaa'1041+	}).bytes())!1042+	backend.put_if_absent(save_key('six', 'bbbbbb'), encode_save(SaveRecord{1043+		id:     'bbbbbb'1044+		parent: 'aaaaaa'1045+	}).bytes())!1046+	whole := fetch_repo(mut backend, 'six')!1047+	assert whole.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1048+	mut receiver := open_repo(machine('steal_six_ids_receiver')!)!1049+	defer { receiver.close() }1050+	incremental := fetch_back_to_known(receiver, mut backend, 'six')!1051+	assert incremental.saves.map(it.id) == ['aaaaaa', 'bbbbbb']1052+}1053+1054+fn test_steal_refuses_a_remote_parent_cycle() {1055+	base := scratch('steal_parent_cycle')!1056+	defer { os.rmdir_all(base) or {} }1057+	remote_dir := os.join_path(base, 'remote')1058+	mut folder := remote.new_folder(remote_dir)!1059+	mut receiver := open_repo(machine('steal_parent_cycle_receiver')!)!1060+	defer { receiver.close() }1061+	folder.put_if_absent(head_key('cycle'), 'aaaaaaaaaaaa'.bytes())!1062+	folder.put_if_absent(save_key('cycle', 'aaaaaaaaaaaa'), encode_save(SaveRecord{1063+		id:     'aaaaaaaaaaaa'1064+		parent: 'bbbbbbbbbbbb'1065+	}).bytes())!1066+	folder.put_if_absent(save_key('cycle', 'bbbbbbbbbbbb'), encode_save(SaveRecord{1067+		id:     'bbbbbbbbbbbb'1068+		parent: 'aaaaaaaaaaaa'1069+	}).bytes())!1070+	mut guarded := FaultyBackend{1071+		inner:     remote.new_folder(remote_dir)!1072+		fail_read: 51073+	}1074+	if _ := fetch_back_to_known(receiver, mut guarded, 'cycle') {1075+		assert false, 'a remote parent cycle must be refused'1076+	} else {1077+		assert err.msg().contains('parent cycle'), err.msg()1078+	}1079+}1080+1081+fn test_steal_binds_remote_events_to_their_keys() {1082+	base := scratch('steal_event_identity')!1083+	defer { os.rmdir_all(base) or {} }1084+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!1085+	mut receiver := open_repo(machine('steal_event_identity_receiver')!)!1086+	defer { receiver.close() }1087+	backend.put_if_absent(event_key('repo', 'aaaaaaaaaaaa'), encode_event(events.Event{1088+		id:         'bbbbbbbbbbbb'1089+		kind:       events.kind_flag1090+		save_id:    'cccccccccccc'1091+		created_at: 11092+		machine:    'other'1093+	}).bytes())!1094+	if _ := fetch_events(receiver, mut backend, 'repo') {1095+		assert false, 'an event record under another ID must be refused'1096+	} else {1097+		assert err.msg().contains('disagrees with'), err.msg()1098+	}1099+}1100+1101+fn test_an_incoming_strip_drops_only_objects_it_makes_unused() {1102+	mut receiver := open_repo(machine('incoming_strip_objects')!)!1103+	defer { receiver.close() }1104+	recovery := receiver.put_object('recovery'.bytes())!1105+	secret := receiver.put_object('secret'.bytes())!1106+	blob := receiver.object_blob(secret)!1107+	write_fetched(receiver, Fetched{1108+		repo_objects: {1109+			secret: blob1110+		}1111+		saves:        [SaveRecord{1112+			id:    'aaaaaaaaaaaa'1113+			files: [FileRecord{ path: 'secret', hash: secret }]1114+		}]1115+		events_out:   [events.Event{1116+			id:         'bbbbbbbbbbbb'1117+			kind:       events.kind_strip1118+			path:       'secret'1119+			created_at: 11120+			machine:    'other'1121+		}]1122+	})!1123+	assert !receiver.has_object(secret)!1124+	assert receiver.has_object(recovery)!1125+}1126+1127+fn test_an_incoming_strip_keeps_a_matching_preexisting_orphan() {1128+	mut receiver := open_repo(machine('incoming_strip_matching_orphan')!)!1129+	defer { receiver.close() }1130+	orphan := receiver.put_object('same bytes'.bytes())!1131+	write_fetched(receiver, Fetched{1132+		saves:      [SaveRecord{1133+			id:    'aaaaaaaaaaaa'1134+			files: [FileRecord{ path: 'secret', hash: orphan }]1135+		}]1136+		events_out: [events.Event{1137+			id:         'bbbbbbbbbbbb'1138+			kind:       events.kind_strip1139+			path:       'secret'1140+			created_at: 11141+			machine:    'other'1142+		}]1143+	})!1144+	assert receiver.has_object(orphan)!1145+}1146+9231147 fn test_an_interrupted_strip_is_idempotent() {9241148 	// The local half of a strip is one transaction, so an interrupted strip is9251149 	// simply a strip that did not happen; re-running it must land the same@@ -2180,7 +2404,9 @@21802404 }21812405 21822406 fn test_a_bundle_lands_each_entry_however_it_is_split_and_refuses_a_cut_or_foreign_one() {2183-	whole := 'objects/aa 3\nabcobjects/bb 0\nend 0\n'2407+	hash_a := 'a'.repeat(64)2408+	hash_b := 'b'.repeat(64)2409+	whole := 'objects/${hash_a} 3\nabcobjects/${hash_b} 0\nend 0\n'21842410 	for size in [1, 2, 5, whole.len] {21852411 		mut bundle := Bundle{}21862412 		for at := 0; at < whole.len; at += size {@@ -2188,12 +2414,12 @@21882414 		}21892415 		bundle.finish()!21902416 		assert bundle.fetched.repo_objects == {2191-			'aa': 'abc'.bytes()2192-			'bb': []u8{}2417+			hash_a: 'abc'.bytes()2418+			hash_b: []u8{}21932419 		}21942420 	}2195-	for cut in ['objects/aa 3\nabc', 'objects/aa 4\nabc', 'objects/aa 3', 'objects/aa x\nabc',2196-		'releases/v1 1\nx', 'end 0\nobjects/aa 3\nabc'] {2421+	for cut in ['objects/${hash_a} 3\nabc', 'objects/${hash_a} 4\nabc', 'objects/${hash_a} 3',2422+		'objects/${hash_a} x\nabc', 'releases/v1 1\nx', 'end 0\nobjects/${hash_a} 3\nabc'] {21972423 		mut bundle := Bundle{}21982424 		bundle.feed(cut.bytes()) or { continue }21992425 		bundle.finish() or { continue }

view file

src/exchange/records.v+32 −0
@@ -105,6 +105,38 @@105105 	return json2.encode(record)106106 }107107 108+pub fn valid_save_id(id string) bool {109+	return id.len in [6, 12]110+		&& id.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`))111+}112+113+pub fn valid_event_id(id string) bool {114+	return id.len == 12 && valid_save_id(id)115+}116+117+pub fn validate_save(record SaveRecord, expected string) ! {118+	if !valid_save_id(expected) {119+		return error('the remote has invalid save ID ${expected}')120+	}121+	if record.id != expected {122+		return error('remote save record ID ${record.id} disagrees with key ${expected}')123+	}124+	if ![record.parent, record.restores, record.replayed_from].all(it == '' || valid_save_id(it)) {125+		return error('remote save ${record.id} has an invalid save ID')126+	}127+	if record.created_at < 0 || record.size_bytes < 0 {128+		return error('remote save ${record.id} has invalid metadata')129+	}130+}131+132+pub fn validate_hashes(id string, files []FileRecord) ! {133+	for file in files {134+		if !storage.valid_hash(file.hash) {135+			return error('remote save ${id} has invalid object hash ${file.hash}')136+		}137+	}138+}139+108140 // decode_save reads a record the remote holds. A lone surrogate escape fails only its path,109141 // not the whole record (spec §5.5).110142 pub fn decode_save(body []u8) !SaveRecord {

view file

src/exchange/repair.v+7 −9
@@ -23,20 +23,18 @@2323 		if id == '' || id in seen || history.by_id(store, id) != none {2424 			continue2525 		}26+		if !valid_save_id(id) {27+			return error('remote history names invalid save ID ${id}')28+		}2629 		seen[id] = true2730 		object := backend.get(save_key(repo, id))!2831 		if !object.found {2932 			return error('the remote does not have save ${id} either')3033 		}31-		record := decode_save(object.body)!32-		if record.id != id {33-			return error('remote save ${id} holds the record of ${record.id}')34-		}35-		// The same scalar rules Doctor checks locally: an invalid record is36-		// refused here, not imported for Doctor to report afterwards.37-		if record.created_at < 0 || record.size_bytes < 0 {38-			return error('remote save ${id} has invalid metadata')39-		}34+		mut record := decode_save(object.body)!35+		validate_save(record, id)!36+		drop_unsafe_paths(mut record)37+		validate_hashes(record.id, record.files)!4038 		for file in record.files {4139 			if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects4240 				&& !strips.any(reaches(it, id) && covers(it.path, file.path)) {

view file

src/exchange/steal.v+19 −1
@@ -573,6 +573,9 @@573573 	mut fetched := Fetched{574574 		head: remote_head.body.bytestr().trim_space()575575 	}576+	if !valid_save_id(fetched.head) {577+		return error('the remote head has invalid save ID ${fetched.head}')578+	}576579 	mut bundle := &Bundle{577580 		fetched: fetched578581 	}@@ -589,6 +592,9 @@589592 		}590593 	}591594 	fetched.saves = parent_first(fetched.saves)!595+	if !fetched.saves.any(it.id == fetched.head) {596+		return error('the remote is missing its head save ${fetched.head}')597+	}592598 	fetched.events_out.sort(a.id < b.id)593599 	return fetched594600 }@@ -669,17 +675,29 @@669675 670676 // take files one fetched key, relative to the repo: an object, a save or an event.671677 fn take(mut fetched Fetched, key string, body []u8) ! {678+	id := key.all_after('/')672679 	match key.all_before('/') {673680 		'objects' {674-			fetched.repo_objects[key.all_after('/')] = body681+			if !storage.valid_hash(id) {682+				return error('the remote has invalid object hash ${id}')683+			}684+			fetched.repo_objects[id] = body675685 		}676686 		'saves' {677687 			mut record := decode_save(body)!688+			validate_save(record, id)!678689 			fetched.unsafe_paths << drop_unsafe_paths(mut record)690+			validate_hashes(record.id, record.files)!679691 			fetched.saves << record680692 		}681693 		'events' {694+			if !valid_event_id(id) {695+				return error('the remote has invalid event ID ${id}')696+			}682697 			event := decode_event(body)!698+			if event.id != id {699+				return error('remote event record ID ${event.id} disagrees with key ${id}')700+			}683701 			if !ignored_strip(event) {684702 				fetched.events_out << event685703 			}

view file