segb/proto.rs
1//! Minimal protobuf field walker — varint + length-delimited types only.
2//!
3//! The App.MenuItem Biome stream carries a protobuf payload whose schema Apple
4//! has not published. Based on the forensicnomicon catalog and the Unit 42
5//! research (<https://unit42.paloaltonetworks.com/new-macos-artifact-discovered/>),
6//! the payload encodes at minimum:
7//!
8//! - **field 1** (wire type 2 = length-delimited): the application name, e.g.
9//! `"Finder"`, `"TextEdit"`.
10//! - **field 2** (wire type 2 = length-delimited): the exact menu-item text
11//! selected by the user, e.g. `"Move to Trash"`, `"Compress \"stolendata\""`.
12//!
13//! # Caveat
14//!
15//! The field numbers above are inferred from the published research output (the
16//! Unit 42 article shows `application` and `menu_item` as the two meaningful
17//! fields) and from the forensicnomicon field schema. No canonical `.proto`
18//! file has been published by Apple. Validation against a real Tahoe 26 sample
19//! is **pending** — see `docs/validation.md`.
20//!
21//! # Design choice — hand-rolled varint walker vs. `prost`
22//!
23//! `prost` requires a `.proto` schema or hand-authored derive macros. Neither
24//! is available here (Apple has not published a schema). Pulling in `prost` to
25//! parse two string fields would add a build-script dependency and a generated
26//! file for no concrete benefit. A tiny varint + wire-type walker is 60 lines,
27//! has no external dependencies, is easier to audit for panic-freedom, and
28//! stays correct by construction. If a full schema is ever published this
29//! module can be replaced with generated `prost` types with no API change.
30
31use crate::error::{Result, SegbError};
32
33/// Wire types as defined in the protobuf encoding spec (§5.3).
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WireType {
36 /// Varint (int32, int64, uint32, uint64, sint32, sint64, bool, enum).
37 Varint = 0,
38 /// 64-bit (fixed64, sfixed64, double).
39 Bit64 = 1,
40 /// Length-delimited (string, bytes, embedded messages, packed repeated).
41 LengthDelimited = 2,
42 /// 32-bit (fixed32, sfixed32, float).
43 Bit32 = 5,
44}
45
46impl WireType {
47 fn from_u64(v: u64) -> Option<Self> {
48 match v {
49 0 => Some(Self::Varint),
50 1 => Some(Self::Bit64),
51 2 => Some(Self::LengthDelimited),
52 5 => Some(Self::Bit32),
53 _ => None,
54 }
55 }
56}
57
58/// A single decoded protobuf field.
59#[derive(Debug, Clone)]
60pub struct Field<'a> {
61 /// Field number (1-based, as in the `.proto` definition).
62 pub field_number: u32,
63 /// Wire type of this field.
64 pub wire_type: WireType,
65 /// The raw bytes of this field's value (slice into the original buffer).
66 pub raw: &'a [u8],
67}
68
69/// Read a protobuf-encoded varint from `data` starting at `*pos`.
70/// Updates `*pos` past the varint. Returns `None` if the buffer is
71/// exhausted before the varint terminates.
72///
73/// Varints are encoded little-endian, 7 bits per byte, MSB = continuation.
74/// The encoding is at most 10 bytes for a 64-bit value.
75fn read_varint(data: &[u8], pos: &mut usize) -> Result<u64> {
76 let start = *pos;
77 let mut result: u64 = 0;
78 let mut shift = 0u32;
79
80 loop {
81 if *pos >= data.len() {
82 return Err(SegbError::MalformedVarint { offset: start });
83 }
84 // Safety: bounds-checked above.
85 let byte = data[*pos];
86 *pos += 1;
87
88 let low_7 = u64::from(byte & 0x7F);
89 // A shift of ≥ 64 would overflow; 10 bytes × 7 bits = 70 bits, but
90 // the 10th byte may only contribute bits 63-56 (7 bits), so shift
91 // reaches at most 63. Guard anyway for malformed >10-byte encodings.
92 if shift < 64 {
93 result |= low_7 << shift;
94 }
95 shift += 7;
96
97 if byte & 0x80 == 0 {
98 // Continuation bit clear — this is the last byte.
99 return Ok(result);
100 }
101 if shift >= 70 {
102 // More than 10 continuation bytes — malformed.
103 return Err(SegbError::MalformedVarint { offset: start });
104 }
105 }
106}
107
108/// Iterate over all protobuf fields in `buf`, yielding each as a [`Field`].
109///
110/// Unknown wire types (groups, deprecated) are skipped; malformed varints or
111/// overflowing length-delimited payloads are returned as errors — never panics.
112pub fn iter_fields(buf: &[u8]) -> impl Iterator<Item = Result<Field<'_>>> {
113 FieldIter { buf, pos: 0 }
114}
115
116struct FieldIter<'a> {
117 buf: &'a [u8],
118 pos: usize,
119}
120
121impl<'a> Iterator for FieldIter<'a> {
122 type Item = Result<Field<'a>>;
123
124 fn next(&mut self) -> Option<Self::Item> {
125 if self.pos >= self.buf.len() {
126 return None;
127 }
128
129 // Decode tag: (field_number << 3) | wire_type
130 let tag = match read_varint(self.buf, &mut self.pos) {
131 Ok(v) => v,
132 Err(e) => return Some(Err(e)),
133 };
134
135 let wire_type_raw = tag & 0x07;
136 let field_number = (tag >> 3) as u32;
137
138 let wire_type = if let Some(wt) = WireType::from_u64(wire_type_raw) {
139 wt
140 } else {
141 // Groups (3/4) and unknown wire types: skip rest of buffer
142 // (we cannot know the length to skip) and stop iteration.
143 self.pos = self.buf.len();
144 return None;
145 };
146
147 let raw: &'a [u8] = match wire_type {
148 WireType::Varint => {
149 let start = self.pos;
150 match read_varint(self.buf, &mut self.pos) {
151 Ok(_) => &self.buf[start..self.pos],
152 Err(e) => return Some(Err(e)),
153 }
154 }
155 WireType::Bit64 => {
156 let start = self.pos;
157 if self.pos + 8 > self.buf.len() {
158 return Some(Err(SegbError::ProtobufOverflow {
159 offset: start,
160 length: 8,
161 remaining: self.buf.len() - self.pos,
162 }));
163 }
164 self.pos += 8;
165 &self.buf[start..self.pos]
166 }
167 WireType::LengthDelimited => {
168 let len_pos = self.pos;
169 let length = match read_varint(self.buf, &mut self.pos) {
170 Ok(v) => v as usize,
171 Err(e) => return Some(Err(e)),
172 };
173 let start = self.pos;
174 let remaining = self.buf.len() - self.pos;
175 if length > remaining {
176 return Some(Err(SegbError::ProtobufOverflow {
177 offset: len_pos,
178 length,
179 remaining,
180 }));
181 }
182 self.pos += length;
183 &self.buf[start..self.pos]
184 }
185 WireType::Bit32 => {
186 let start = self.pos;
187 if self.pos + 4 > self.buf.len() {
188 return Some(Err(SegbError::ProtobufOverflow {
189 offset: start,
190 length: 4,
191 remaining: self.buf.len() - self.pos,
192 }));
193 }
194 self.pos += 4;
195 &self.buf[start..self.pos]
196 }
197 };
198
199 Some(Ok(Field {
200 field_number,
201 wire_type,
202 raw,
203 }))
204 }
205}
206
207/// Decode a length-delimited field value as a UTF-8 string, returning `None`
208/// if the bytes are not valid UTF-8 (lossy conversion is intentionally avoided
209/// — the caller decides how to handle encoding failures).
210#[inline]
211pub fn as_str(raw: &[u8]) -> Option<&str> {
212 std::str::from_utf8(raw).ok()
213}