Skip to main content

obs_core/wire/
fields.rs

1//! Buffa wire-format helpers for `#[derive(Event)]` codegen.
2//!
3//! `EventSchema::encode_payload` writes the buffa-encoded payload bytes
4//! that downstream sinks (Parquet, ClickHouse, OTLP) and the runtime
5//! scrubber decode. Spec 12 § 1.2 requires the Rust-first authoring
6//! path (`#[derive(Event)]`) and the proto-first path (`obs-build`) to
7//! produce **byte-identical output** for the same field values.
8//!
9//! `obs-build` delegates to `buffa::Message::write_to` because its
10//! generated structs implement `buffa::Message`. Hand-authored structs
11//! under `#[derive(Event)]` do not — the derive macro therefore emits
12//! per-field calls to [`BuffaEncodeField`], one tag-and-value pair per
13//! field, matching the buffa wire format.
14//!
15//! Spec 12 § 1.2 / spec 14 § 5 / decision D6-1 (format_ver bump to 2).
16
17use buffa::{
18    encoding::{Tag, WireType},
19    types,
20};
21use bytes::BytesMut;
22use secrecy::{ExposeSecret, SecretBox, SecretString};
23
24/// Encode a single struct field as a buffa wire-format `tag + value`
25/// pair, using proto3's "skip default" semantics.
26///
27/// One impl per supported scalar type. The derive macro emits one
28/// `self.field.buffa_encode_field(N, buf)` call per field; the trait
29/// dispatch picks the right wire-format helper at compile time, so
30/// the macro does not need to introspect the field's syntactic type.
31pub trait BuffaEncodeField {
32    /// Encode `self` under proto field number `number`. Implementations
33    /// must skip emitting any bytes when `self` equals the proto3
34    /// default for the type (empty string, zero integer, false, …) so
35    /// the wire shape matches `buffa::Message::write_to`.
36    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut);
37}
38
39// ─── varint scalars ────────────────────────────────────────────────────
40
41impl BuffaEncodeField for u32 {
42    #[inline]
43    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
44        if *self != 0 {
45            Tag::new(number, WireType::Varint).encode(buf);
46            types::encode_uint32(*self, buf);
47        }
48    }
49}
50
51impl BuffaEncodeField for u64 {
52    #[inline]
53    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
54        if *self != 0 {
55            Tag::new(number, WireType::Varint).encode(buf);
56            types::encode_uint64(*self, buf);
57        }
58    }
59}
60
61impl BuffaEncodeField for i32 {
62    #[inline]
63    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
64        if *self != 0 {
65            Tag::new(number, WireType::Varint).encode(buf);
66            types::encode_int32(*self, buf);
67        }
68    }
69}
70
71impl BuffaEncodeField for i64 {
72    #[inline]
73    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
74        if *self != 0 {
75            Tag::new(number, WireType::Varint).encode(buf);
76            types::encode_int64(*self, buf);
77        }
78    }
79}
80
81impl BuffaEncodeField for bool {
82    #[inline]
83    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
84        if *self {
85            Tag::new(number, WireType::Varint).encode(buf);
86            types::encode_bool(*self, buf);
87        }
88    }
89}
90
91// ─── fixed-width scalars ───────────────────────────────────────────────
92
93impl BuffaEncodeField for f32 {
94    #[inline]
95    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
96        if *self != 0.0 {
97            Tag::new(number, WireType::Fixed32).encode(buf);
98            types::encode_float(*self, buf);
99        }
100    }
101}
102
103impl BuffaEncodeField for f64 {
104    #[inline]
105    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
106        if *self != 0.0 {
107            Tag::new(number, WireType::Fixed64).encode(buf);
108            types::encode_double(*self, buf);
109        }
110    }
111}
112
113// ─── length-delimited scalars ──────────────────────────────────────────
114
115impl BuffaEncodeField for String {
116    #[inline]
117    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
118        if !self.is_empty() {
119            Tag::new(number, WireType::LengthDelimited).encode(buf);
120            types::encode_string(self, buf);
121        }
122    }
123}
124
125impl BuffaEncodeField for &str {
126    #[inline]
127    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
128        if !self.is_empty() {
129            Tag::new(number, WireType::LengthDelimited).encode(buf);
130            types::encode_string(self, buf);
131        }
132    }
133}
134
135impl BuffaEncodeField for Vec<u8> {
136    #[inline]
137    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
138        if !self.is_empty() {
139            Tag::new(number, WireType::LengthDelimited).encode(buf);
140            types::encode_bytes(self, buf);
141        }
142    }
143}
144
145impl BuffaEncodeField for &[u8] {
146    #[inline]
147    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
148        if !self.is_empty() {
149            Tag::new(number, WireType::LengthDelimited).encode(buf);
150            types::encode_bytes(self, buf);
151        }
152    }
153}
154
155// ─── secrecy wrappers ──────────────────────────────────────────────────
156//
157// `secrecy::SecretString` and `SecretBox<T>` implement Debug as the
158// constant string `"[REDACTED ...]"`, so a careless `{:?}` or
159// `tracing::error!(?evt)` cannot leak the value. The wire encoder calls
160// `expose_secret()` only at the moment the bytes hit the payload buffer,
161// after which the runtime scrubber (spec 14 § 5) re-encodes the payload
162// with `<redacted-{name}>` markers before any sink sees it.
163//
164// Decision D6-2: SECRET-classified fields should be declared as
165// `SecretString` / `SecretBox<T>` so their in-memory representation is
166// also protected. PII-classified fields stay as their plain typed value
167// since runtime redaction alone is sufficient for that threat model.
168
169impl BuffaEncodeField for SecretString {
170    #[inline]
171    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
172        let exposed = self.expose_secret();
173        if !exposed.is_empty() {
174            Tag::new(number, WireType::LengthDelimited).encode(buf);
175            types::encode_string(exposed, buf);
176        }
177    }
178}
179
180impl BuffaEncodeField for SecretBox<Vec<u8>> {
181    #[inline]
182    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
183        let exposed = self.expose_secret();
184        if !exposed.is_empty() {
185            Tag::new(number, WireType::LengthDelimited).encode(buf);
186            types::encode_bytes(exposed, buf);
187        }
188    }
189}
190
191// ─── Option<T> ─────────────────────────────────────────────────────────
192//
193// proto3 explicit-presence is encoded by `optional` field qualifiers; for
194// the SDK's purposes we treat `None` as "skip" (matching proto3 default
195// elision) and `Some(v)` as the inner value's encoding.
196
197impl<T: BuffaEncodeField> BuffaEncodeField for Option<T> {
198    #[inline]
199    fn buffa_encode_field(&self, number: u32, buf: &mut BytesMut) {
200        if let Some(v) = self {
201            v.buffa_encode_field(number, buf);
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// Verify the trait emits proto3-default elision for zero values.
211    #[test]
212    fn test_should_skip_default_string() {
213        let mut buf = BytesMut::new();
214        String::new().buffa_encode_field(1, &mut buf);
215        assert!(buf.is_empty());
216    }
217
218    #[test]
219    fn test_should_emit_nonempty_string() {
220        let mut buf = BytesMut::new();
221        "hello".to_string().buffa_encode_field(1, &mut buf);
222        // Tag (field=1, wire=2 LD) varint = 0x0A; then length prefix 5; then 'h','e','l','l','o'.
223        assert_eq!(&buf[..], &[0x0A, 0x05, b'h', b'e', b'l', b'l', b'o']);
224    }
225
226    #[test]
227    fn test_should_skip_default_uint64() {
228        let mut buf = BytesMut::new();
229        0u64.buffa_encode_field(2, &mut buf);
230        assert!(buf.is_empty());
231    }
232
233    #[test]
234    fn test_should_emit_nonzero_uint64() {
235        let mut buf = BytesMut::new();
236        42u64.buffa_encode_field(2, &mut buf);
237        // Tag (field=2, wire=0 Varint) = 0x10; then varint 42 = 0x2A.
238        assert_eq!(&buf[..], &[0x10, 0x2A]);
239    }
240
241    #[test]
242    fn test_should_emit_secret_string_via_expose() {
243        let mut buf = BytesMut::new();
244        let secret = SecretString::from("topsecret");
245        secret.buffa_encode_field(3, &mut buf);
246        // Same wire bytes as a plain String would produce — the
247        // scrubber, not the encoder, is responsible for redaction.
248        assert!(buf.starts_with(&[0x1A, 0x09]));
249        assert!(buf.ends_with(b"topsecret"));
250    }
251
252    #[test]
253    fn test_secret_string_debug_should_be_redacted() {
254        let secret = SecretString::from("topsecret");
255        let dbg = format!("{secret:?}");
256        assert!(!dbg.contains("topsecret"));
257        assert!(dbg.to_ascii_lowercase().contains("redacted"));
258    }
259
260    #[test]
261    fn test_should_skip_none_option() {
262        let mut buf = BytesMut::new();
263        let v: Option<String> = None;
264        v.buffa_encode_field(1, &mut buf);
265        assert!(buf.is_empty());
266    }
267}