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