Skip to main content

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}