Skip to main content

ados_protocol/
logd.rs

1//! Wire contracts for the local logging and telemetry service.
2//!
3//! The agent records logs from every process, telemetry history, discrete
4//! events, and hardware samples into one durable local store. This module
5//! carries the shared types that travel between the producers (Rust services,
6//! the Python agent) and the writer, plus the request/response envelope the
7//! read API and its clients share.
8//!
9//! All ingest frames are msgpack with short field-rename keys and a `v` version
10//! byte, so a producer and a writer on different versions stay forward
11//! compatible: a reader that sees an unknown `v` can skip the frame, and new
12//! fields ride in the open `fields`/`tags`/`extra` maps without a schema
13//! change. The frames travel over the same 4-byte big-endian length framing the
14//! other sockets use ([`crate::frame`]); this module reuses that codec and does
15//! not duplicate it.
16//!
17//! Redaction ([`redact`]) replaces any field whose key looks secret-bearing
18//! with its length only. It
19//! mirrors the agent's structured-logging redaction so no value is ever written
20//! to the store in the clear, regardless of which producer emitted it.
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27use crate::frame;
28
29/// The producer-side `tracing` layer that ships log events to the logging
30/// daemon's ingest socket. Behind the `tracing-layer` feature so consumers that
31/// do not need it (the daemon itself, build tools, the interactive client) stay
32/// lean.
33#[cfg(feature = "tracing-layer")]
34pub mod layer;
35
36/// The reusable event emitter that ships discrete [`EventFrame`] records from a
37/// service to the ingest socket. Unlike the log `layer`, events are a
38/// first-class capture path, so this module is always available (not gated on
39/// `tracing-layer`).
40pub mod emitter;
41
42/// Maximum ingest frame payload. Log records and hardware snapshots are small;
43/// the cap is generous headroom and guards against a runaway producer.
44pub const LOGD_MAX_FRAME: usize = 1024 * 1024;
45
46/// The current wire version stamped on every ingest frame.
47pub const WIRE_VERSION: u8 = 1;
48
49/// The current read-envelope version reported in [`Meta::v`].
50pub const ENVELOPE_VERSION: u8 = 1;
51
52/// Idempotency sentinel: a value already prefixed with this has been redacted
53/// and is passed through unchanged.
54pub const REDACT_PREFIX: &str = "redacted:";
55
56/// Key suffixes (and exact keys, case-insensitive) that mark a field as
57/// secret-bearing. Kept in lockstep with the agent's logging redaction.
58pub const SECRET_SUFFIXES: [&str; 5] = ["key", "code", "token", "password", "secret"];
59
60/// Severity levels, matching the integer encoding stored in the log table.
61/// `Trace`/`Debug` are the high-volume levels dropped first under backpressure;
62/// `Warn` and above are always kept.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum Level {
66    /// 0 — most verbose.
67    Trace,
68    /// 1.
69    Debug,
70    /// 2.
71    Info,
72    /// 3.
73    Warn,
74    /// 4 — most severe.
75    Error,
76}
77
78impl Level {
79    /// The integer encoding stored in the log table.
80    pub fn as_u8(self) -> u8 {
81        match self {
82            Level::Trace => 0,
83            Level::Debug => 1,
84            Level::Info => 2,
85            Level::Warn => 3,
86            Level::Error => 4,
87        }
88    }
89
90    /// Map an integer back to a level, clamping out-of-range values to `Error`.
91    pub fn from_u8(n: u8) -> Level {
92        match n {
93            0 => Level::Trace,
94            1 => Level::Debug,
95            2 => Level::Info,
96            3 => Level::Warn,
97            _ => Level::Error,
98        }
99    }
100}
101
102/// Errors raised encoding or decoding an ingest frame.
103#[derive(Debug, Error)]
104pub enum LogdError {
105    #[error("msgpack encode error: {0}")]
106    Encode(#[from] rmp_serde::encode::Error),
107    #[error("msgpack decode error: {0}")]
108    Decode(#[from] rmp_serde::decode::Error),
109    #[error("framing error: {0}")]
110    Frame(#[from] frame::FrameError),
111    #[error("unsupported wire version {got} (this build speaks {ours})")]
112    Version { got: u8, ours: u8 },
113}
114
115/// The msgpack value type carried in the open field/detail/tag maps. Re-exported
116/// so a downstream producer can build an [`EventFrame`] detail without taking a
117/// direct dependency on `rmpv`.
118pub use rmpv::Value;
119
120/// An open msgpack map of extra fields. Values are arbitrary so new telemetry
121/// round-trips without a schema change (mirrors the open-state pattern).
122pub type Fields = BTreeMap<String, rmpv::Value>;
123
124/// One log record from any producer (Rust service, Python agent, subprocess
125/// tap). The `src` is the emitting process or component; `tgt` is the module or
126/// logger target. Structured fields ride in the open `f` map.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct LogFrame {
129    /// Wire version.
130    #[serde(rename = "v")]
131    pub v: u8,
132    /// Microsecond epoch timestamp.
133    #[serde(rename = "ts")]
134    pub ts_us: i64,
135    /// Emitting process or component (e.g. the daemon name).
136    #[serde(rename = "src")]
137    pub source: String,
138    /// Severity level.
139    #[serde(rename = "lvl")]
140    pub level: Level,
141    /// Module or logger target.
142    #[serde(rename = "tgt", default, skip_serializing_if = "Option::is_none")]
143    pub target: Option<String>,
144    /// The rendered message.
145    #[serde(rename = "msg")]
146    pub msg: String,
147    /// Open structured-fields map.
148    #[serde(rename = "f", default, skip_serializing_if = "BTreeMap::is_empty")]
149    pub fields: Fields,
150}
151
152impl LogFrame {
153    /// Build a record stamped with the current wire version.
154    pub fn new(
155        ts_us: i64,
156        source: impl Into<String>,
157        level: Level,
158        msg: impl Into<String>,
159    ) -> Self {
160        Self {
161            v: WIRE_VERSION,
162            ts_us,
163            source: source.into(),
164            level,
165            target: None,
166            msg: msg.into(),
167            fields: Fields::new(),
168        }
169    }
170
171    /// Redact every secret-bearing field in place (idempotent). Returns `true`
172    /// if any field value was changed.
173    pub fn redact_fields(&mut self) -> bool {
174        redact_map(&mut self.fields)
175    }
176}
177
178/// One telemetry sample: a single dotted metric key with a numeric value and an
179/// open tags map. Replaces the per-field history arrays sampled in the GCS.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub struct TelemetryFrame {
182    /// Wire version.
183    #[serde(rename = "v")]
184    pub v: u8,
185    /// Microsecond epoch timestamp.
186    #[serde(rename = "ts")]
187    pub ts_us: i64,
188    /// Dotted metric key (e.g. `cpu.load`, `link.rssi_dbm`).
189    #[serde(rename = "m")]
190    pub metric: String,
191    /// The numeric value.
192    #[serde(rename = "val")]
193    pub value: f64,
194    /// Open tag map (dimensions such as iface, core index, zone).
195    #[serde(rename = "tg", default, skip_serializing_if = "BTreeMap::is_empty")]
196    pub tags: Fields,
197}
198
199impl TelemetryFrame {
200    /// Build a sample stamped with the current wire version.
201    pub fn new(ts_us: i64, metric: impl Into<String>, value: f64) -> Self {
202        Self {
203            v: WIRE_VERSION,
204            ts_us,
205            metric: metric.into(),
206            value,
207            tags: Fields::new(),
208        }
209    }
210}
211
212/// One discrete event: a state transition, a radio lock change, a sidecar drop,
213/// a pairing change, a transport error. `kind` is a dotted classifier.
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215pub struct EventFrame {
216    /// Wire version.
217    #[serde(rename = "v")]
218    pub v: u8,
219    /// Microsecond epoch timestamp.
220    #[serde(rename = "ts")]
221    pub ts_us: i64,
222    /// Dotted event classifier (e.g. `service.transition`, `radio.lock`).
223    #[serde(rename = "k")]
224    pub kind: String,
225    /// Emitting component.
226    #[serde(rename = "src")]
227    pub source: String,
228    /// Severity level.
229    #[serde(rename = "sev")]
230    pub severity: Level,
231    /// Open detail map.
232    #[serde(rename = "d", default, skip_serializing_if = "BTreeMap::is_empty")]
233    pub detail: Fields,
234}
235
236impl EventFrame {
237    /// Build an event stamped with the current wire version.
238    pub fn new(
239        ts_us: i64,
240        kind: impl Into<String>,
241        source: impl Into<String>,
242        severity: Level,
243    ) -> Self {
244        Self {
245            v: WIRE_VERSION,
246            ts_us,
247            kind: kind.into(),
248            source: source.into(),
249            severity,
250            detail: Fields::new(),
251        }
252    }
253
254    /// Redact every secret-bearing field in the detail map (idempotent). Returns
255    /// `true` if any field value was changed.
256    pub fn redact_detail(&mut self) -> bool {
257        redact_map(&mut self.detail)
258    }
259}
260
261/// A periodic hardware sample: temperatures, per-core frequency, power rails,
262/// pressure-stall, per-iface counters, USB speed, throttle flags. Every reading
263/// rides in the open `s` map so a new signal does not need a schema bump.
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct HwSnapshot {
266    /// Wire version.
267    #[serde(rename = "v")]
268    pub v: u8,
269    /// Microsecond epoch timestamp.
270    #[serde(rename = "ts")]
271    pub ts_us: i64,
272    /// Open map of dotted signal keys to numeric or string readings.
273    #[serde(rename = "s", default, skip_serializing_if = "BTreeMap::is_empty")]
274    pub signals: Fields,
275}
276
277impl HwSnapshot {
278    /// Build an empty snapshot stamped with the current wire version.
279    pub fn new(ts_us: i64) -> Self {
280        Self {
281            v: WIRE_VERSION,
282            ts_us,
283            signals: Fields::new(),
284        }
285    }
286}
287
288/// The kind tag on an ingest frame, so the writer can dispatch one stream of
289/// mixed frames to the right table.
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
291#[serde(tag = "t", rename_all = "lowercase")]
292pub enum IngestFrame {
293    /// A log record.
294    Log(LogFrame),
295    /// A telemetry sample.
296    Telemetry(TelemetryFrame),
297    /// A discrete event.
298    Event(EventFrame),
299    /// A hardware snapshot.
300    Hw(HwSnapshot),
301}
302
303impl IngestFrame {
304    /// Encode this frame as a complete length-prefixed msgpack frame ready to
305    /// write on the ingest socket.
306    pub fn encode(&self) -> Result<Vec<u8>, LogdError> {
307        let body = rmp_serde::to_vec_named(self)?;
308        Ok(frame::encode_frame(&body, LOGD_MAX_FRAME)?)
309    }
310
311    /// Decode one msgpack body (the frame payload, without the length prefix).
312    /// Rejects a frame whose wire version this build does not speak.
313    pub fn decode(body: &[u8]) -> Result<Self, LogdError> {
314        let frame: IngestFrame = rmp_serde::from_slice(body)?;
315        let v = frame.version();
316        if v != WIRE_VERSION {
317            return Err(LogdError::Version {
318                got: v,
319                ours: WIRE_VERSION,
320            });
321        }
322        Ok(frame)
323    }
324
325    /// The wire version carried by the inner frame.
326    pub fn version(&self) -> u8 {
327        match self {
328            IngestFrame::Log(f) => f.v,
329            IngestFrame::Telemetry(f) => f.v,
330            IngestFrame::Event(f) => f.v,
331            IngestFrame::Hw(f) => f.v,
332        }
333    }
334
335    /// Redact secret-bearing fields on the inner frame in place. Returns `true`
336    /// if any field value was actually changed, so a writer can record on the row
337    /// whether redaction touched it.
338    pub fn redact(&mut self) -> bool {
339        match self {
340            IngestFrame::Log(f) => f.redact_fields(),
341            IngestFrame::Event(f) => f.redact_detail(),
342            // Telemetry tags and hardware signals carry no secret material; the
343            // metric/signal keys are fixed dotted names, not free-form fields.
344            IngestFrame::Telemetry(_) | IngestFrame::Hw(_) => false,
345        }
346    }
347}
348
349// --- read API request/response envelope ---------------------------------
350
351/// A read request against the query API. All fields are optional; an empty
352/// request returns the most recent page.
353#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
354pub struct QueryRequest {
355    /// Inclusive lower bound, microsecond epoch.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub from: Option<i64>,
358    /// Exclusive upper bound, microsecond epoch.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub to: Option<i64>,
361    /// Restrict to these dotted metric keys.
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub metric: Vec<String>,
364    /// Restrict to these emitting sources.
365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
366    pub source: Vec<String>,
367    /// Minimum severity level (inclusive).
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub level: Option<Level>,
370    /// Free-text substring match against the message.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub text: Option<String>,
373    /// Restrict to one session id.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub session: Option<i64>,
376    /// Page size.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub limit: Option<u32>,
379    /// Opaque keyset cursor returned by the previous page.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub cursor: Option<String>,
382}
383
384/// Pagination block: the cursor for the next page and the count in this page.
385#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
386pub struct Page {
387    /// Opaque keyset cursor for the next page, absent when exhausted.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub next_cursor: Option<String>,
390    /// Number of rows in this page.
391    pub count: u32,
392}
393
394/// Response metadata: which plane served it, the envelope version, the server
395/// time, and how far the writer is behind real time.
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct Meta {
398    /// The serving plane (e.g. `local` for the unix socket, `lan` for TCP).
399    pub source: String,
400    /// Envelope version.
401    pub v: u8,
402    /// Server time, microsecond epoch.
403    pub ts: i64,
404    /// Writer lag behind real time, milliseconds. Zero when fully caught up.
405    pub db_lag_ms: i64,
406}
407
408/// The success envelope shared by every read endpoint. `data` is the typed
409/// payload (rows, buckets, sessions, stats), `page` carries pagination, `meta`
410/// carries serving metadata.
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
412pub struct QueryResponse<T> {
413    /// The result payload.
414    pub data: T,
415    /// Pagination block.
416    pub page: Page,
417    /// Serving metadata.
418    pub meta: Meta,
419}
420
421/// The error body returned for a failed request.
422#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
423pub struct ApiError {
424    /// Stable machine-readable error code.
425    pub code: String,
426    /// Human-readable message.
427    pub message: String,
428}
429
430/// The error envelope: `{ "error": { "code", "message" } }`.
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct ErrorEnvelope {
433    /// The error body.
434    pub error: ApiError,
435}
436
437impl ErrorEnvelope {
438    /// Build an error envelope.
439    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
440        Self {
441            error: ApiError {
442                code: code.into(),
443                message: message.into(),
444            },
445        }
446    }
447}
448
449// --- redaction ----------------------------------------------------------
450
451/// True when `key` marks a secret-bearing field: a case-insensitive match where
452/// the lower-cased key ends with, or exactly equals, one of [`SECRET_SUFFIXES`].
453pub fn is_secret_key(key: &str) -> bool {
454    let kl = key.to_ascii_lowercase();
455    SECRET_SUFFIXES.iter().any(|s| kl.ends_with(s) || kl == *s)
456}
457
458/// Redact one string value for the given key.
459///
460/// Mirrors the agent's structured-logging redaction byte-for-byte:
461///
462/// - if `key` is not secret-bearing, the value is returned unchanged;
463/// - if the value is empty, it is returned unchanged;
464/// - if the value already carries the [`REDACT_PREFIX`] sentinel, it is returned
465///   unchanged (idempotent — a value that traverses the chain twice is not
466///   redacted again);
467/// - otherwise the value is replaced with `redacted:len=<N>`, `N` its length in
468///   Unicode characters (Python's `len(v)`).
469///
470/// Nothing computed from the content survives. A plaintext head plus an unkeyed
471/// digest, which this used to emit, lets anyone who can read the store recover
472/// a short secret (a six-digit pairing code has two unknown digits left) by
473/// hashing candidates.
474pub fn redact(key: &str, value: &str) -> String {
475    if value.is_empty() || value.starts_with(REDACT_PREFIX) {
476        return value.to_string();
477    }
478    if !is_secret_key(key) {
479        return value.to_string();
480    }
481    format!("{REDACT_PREFIX}len={}", value.chars().count())
482}
483
484/// Redact every secret-bearing string field of an open map in place. Non-string
485/// values, empty strings, and already-redacted values are left untouched.
486/// Returns `true` if at least one field value was changed.
487pub fn redact_map(fields: &mut Fields) -> bool {
488    let mut changed = false;
489    for (k, v) in fields.iter_mut() {
490        if let rmpv::Value::String(s) = v {
491            if let Some(text) = s.as_str() {
492                let redacted = redact(k, text);
493                if redacted != text {
494                    *v = rmpv::Value::from(redacted);
495                    changed = true;
496                }
497            }
498        }
499    }
500    changed
501}
502
503// --- explicit window export / mark-synced types -------------------------
504
505/// One of the four durable tables a window export and its mark-synced selector
506/// can name. Serializes to its lowercase table name so the wire vocabulary
507/// matches the `kind` query parameter the read API already speaks.
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "lowercase")]
510pub enum SyncTable {
511    /// The `logs` table.
512    Logs,
513    /// The `metrics` table.
514    Metrics,
515    /// The `events` table.
516    Events,
517    /// The `hw` table.
518    Hw,
519}
520
521impl SyncTable {
522    /// The SQL/table name this variant maps to.
523    pub fn as_str(self) -> &'static str {
524        match self {
525            Self::Logs => "logs",
526            Self::Metrics => "metrics",
527            Self::Events => "events",
528            Self::Hw => "hw",
529        }
530    }
531
532    /// Every table, in a fixed order. Used to expand an empty selector to all
533    /// four and to report the remaining unsynced count per table.
534    pub const ALL: [SyncTable; 4] = [Self::Logs, Self::Metrics, Self::Events, Self::Hw];
535}
536
537/// The window selector a mark-synced request carries. An exported window is
538/// flipped from unsynced to synced over exactly this selector, so it shares the
539/// vocabulary of the export filter: an optional session, a half-open
540/// `[from_us, to_us)` microsecond window, and an optional table list. An empty
541/// `tables` means all four. All fields absent marks every unsynced row.
542#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
543pub struct SyncRequest {
544    /// Restrict the mark to one session id.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub session: Option<i64>,
547    /// Inclusive lower bound, microsecond epoch.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub from_us: Option<i64>,
550    /// Exclusive upper bound, microsecond epoch.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub to_us: Option<i64>,
553    /// The tables to mark; empty means all four.
554    #[serde(default, skip_serializing_if = "Vec::is_empty")]
555    pub tables: Vec<SyncTable>,
556}
557
558impl SyncRequest {
559    /// The requested tables, expanding an empty selector to all four.
560    pub fn tables_or_all(&self) -> Vec<SyncTable> {
561        if self.tables.is_empty() {
562            SyncTable::ALL.to_vec()
563        } else {
564            self.tables.clone()
565        }
566    }
567}
568
569/// The result of a mark-synced request: the count flipped per requested table,
570/// and the remaining unsynced count per table after the flip (always all four,
571/// so a caller sees the whole store's pending-push watermark in one response).
572#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
573pub struct SyncResponse {
574    /// Rows flipped to synced, keyed by table name.
575    pub marked: BTreeMap<String, i64>,
576    /// Rows still unsynced after the flip, keyed by table name (all four).
577    pub unsynced_after: BTreeMap<String, i64>,
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use rmpv::Value as MpVal;
584
585    fn mk_fields(pairs: &[(&str, &str)]) -> Fields {
586        pairs
587            .iter()
588            .map(|(k, v)| (k.to_string(), MpVal::from(*v)))
589            .collect()
590    }
591
592    /// The hand-written ingest and read-envelope versions must match the
593    /// contract registry entries so a future drift is a build-time test failure.
594    #[test]
595    fn version_matches_registry() {
596        assert_eq!(
597            WIRE_VERSION as u16,
598            crate::contracts::contract_version("logd.ingest").unwrap()
599        );
600        assert_eq!(
601            ENVELOPE_VERSION as u16,
602            crate::contracts::contract_version("logd.read").unwrap()
603        );
604    }
605
606    #[test]
607    fn log_frame_round_trips_through_an_ingest_frame() {
608        let mut log = LogFrame::new(1_700_000_000_000_000, "ados-logd", Level::Warn, "hello");
609        log.target = Some("logd::db".to_string());
610        log.fields.insert("attempt".to_string(), MpVal::from(3u64));
611        let frame = IngestFrame::Log(log.clone());
612
613        let wire = frame.encode().unwrap();
614        // 4-byte big-endian length prefix + body, same framing as the others.
615        assert_eq!(&wire[..4], &(wire.len() as u32 - 4).to_be_bytes());
616
617        let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
618        assert_eq!(back, IngestFrame::Log(log));
619    }
620
621    #[test]
622    fn telemetry_event_and_hw_frames_round_trip() {
623        let tele = IngestFrame::Telemetry(TelemetryFrame::new(1, "cpu.load", 0.42));
624        let evt = IngestFrame::Event(EventFrame::new(2, "radio.lock", "ados-radio", Level::Info));
625        let mut hw = HwSnapshot::new(3);
626        hw.signals
627            .insert("thermal.soc_c".to_string(), MpVal::from(54.5));
628        let hw = IngestFrame::Hw(hw);
629
630        for frame in [tele, evt, hw] {
631            let wire = frame.encode().unwrap();
632            let back = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap();
633            assert_eq!(back, frame);
634        }
635    }
636
637    #[test]
638    fn decode_rejects_an_unknown_wire_version() {
639        let mut log = LogFrame::new(1, "src", Level::Info, "m");
640        log.v = 99;
641        let frame = IngestFrame::Log(log);
642        let wire = frame.encode().unwrap();
643        let err = IngestFrame::decode(&wire[frame::HEADER_SIZE..]).unwrap_err();
644        assert!(matches!(err, LogdError::Version { got: 99, ours: 1 }));
645    }
646
647    #[test]
648    fn level_integer_encoding_round_trips() {
649        for lvl in [
650            Level::Trace,
651            Level::Debug,
652            Level::Info,
653            Level::Warn,
654            Level::Error,
655        ] {
656            assert_eq!(Level::from_u8(lvl.as_u8()), lvl);
657        }
658        // Out-of-range integers clamp to the most severe level.
659        assert_eq!(Level::from_u8(200), Level::Error);
660    }
661
662    #[test]
663    fn query_response_envelope_serializes_with_the_expected_shape() {
664        let resp = QueryResponse {
665            data: vec![1u32, 2, 3],
666            page: Page {
667                next_cursor: Some("abc".to_string()),
668                count: 3,
669            },
670            meta: Meta {
671                source: "local".to_string(),
672                v: ENVELOPE_VERSION,
673                ts: 42,
674                db_lag_ms: 0,
675            },
676        };
677        let json = serde_json::to_value(&resp).unwrap();
678        assert_eq!(json["data"], serde_json::json!([1, 2, 3]));
679        assert_eq!(json["page"]["next_cursor"], "abc");
680        assert_eq!(json["page"]["count"], 3);
681        assert_eq!(json["meta"]["source"], "local");
682        assert_eq!(json["meta"]["v"], 1);
683        assert_eq!(json["meta"]["db_lag_ms"], 0);
684    }
685
686    #[test]
687    fn error_envelope_shape() {
688        let err = ErrorEnvelope::new("bad_request", "unknown filter");
689        let json = serde_json::to_value(&err).unwrap();
690        assert_eq!(json["error"]["code"], "bad_request");
691        assert_eq!(json["error"]["message"], "unknown filter");
692    }
693
694    #[test]
695    fn is_secret_key_matches_suffix_and_exact_case_insensitively() {
696        assert!(is_secret_key("api_key"));
697        assert!(is_secret_key("KEY"));
698        assert!(is_secret_key("pairing_code"));
699        assert!(is_secret_key("token"));
700        assert!(is_secret_key("Password"));
701        assert!(is_secret_key("device_secret"));
702        // Not secret-bearing.
703        assert!(!is_secret_key("device_id"));
704        assert!(!is_secret_key("keyboard")); // "key" is a prefix, not a suffix
705        assert!(!is_secret_key("message"));
706    }
707
708    #[test]
709    fn redact_leaves_non_secret_and_empty_and_sentinel_values_untouched() {
710        // Non-secret key: unchanged.
711        assert_eq!(redact("device_id", "abc123"), "abc123");
712        // Empty value: unchanged even for a secret key.
713        assert_eq!(redact("api_key", ""), "");
714        // Already redacted: idempotent.
715        let once = redact("api_key", "ABCDEFGHIJ1234567890");
716        assert_eq!(redact("api_key", &once), once);
717    }
718
719    /// Byte-for-byte parity with the agent's structured-logging redaction (the
720    /// Python tests carry the same vectors). Nothing of the value survives but
721    /// its length, so none of these can be reversed by hashing candidates.
722    #[test]
723    fn redact_parity_vectors() {
724        let vectors: &[(&str, &str, &str)] = &[
725            ("api_key", "ABCDEFGHIJ1234567890", "redacted:len=20"),
726            ("pairing_code", "999888", "redacted:len=6"),
727            ("token", "tok_supersecretvalue", "redacted:len=20"),
728            ("password", "hunter2", "redacted:len=7"),
729            ("secret", "s", "redacted:len=1"),
730            ("device_secret", "0xDEADBEEFCAFE", "redacted:len=14"),
731        ];
732        for (key, value, expected) in vectors {
733            assert_eq!(&redact(key, value), expected, "key={key} value={value}");
734            // Idempotent: feeding the redacted output back yields the same string.
735            assert_eq!(&redact(key, expected), expected, "idempotent key={key}");
736        }
737    }
738
739    #[test]
740    fn redact_map_redacts_only_secret_string_fields() {
741        let mut fields = mk_fields(&[("api_key", "ABCDEFGHIJ1234567890"), ("device_id", "abc123")]);
742        fields.insert("attempt".to_string(), MpVal::from(7u64)); // non-string, skipped
743        let changed = redact_map(&mut fields);
744        assert!(changed, "redaction changed the secret field");
745        assert_eq!(
746            fields.get("api_key").and_then(|v| v.as_str()),
747            Some("redacted:len=20")
748        );
749        assert_eq!(
750            fields.get("device_id").and_then(|v| v.as_str()),
751            Some("abc123")
752        );
753        assert_eq!(fields.get("attempt").and_then(|v| v.as_u64()), Some(7));
754    }
755
756    #[test]
757    fn redact_map_reports_no_change_when_nothing_is_secret() {
758        // A map with only non-secret and already-redacted values reports no
759        // change, so a caller can record an accurate redaction flag on the row.
760        let mut fields = mk_fields(&[("device_id", "abc123"), ("api_key", "redacted:len=20")]);
761        let changed = redact_map(&mut fields);
762        assert!(!changed, "no value should change");
763        assert_eq!(
764            fields.get("api_key").and_then(|v| v.as_str()),
765            Some("redacted:len=20")
766        );
767    }
768
769    #[test]
770    fn frame_level_redaction_is_applied_through_ingest() {
771        let mut log = LogFrame::new(1, "src", Level::Info, "m");
772        log.fields.insert(
773            "session_token".to_string(),
774            MpVal::from("tok_supersecretvalue"),
775        );
776        let mut frame = IngestFrame::Log(log);
777        frame.redact();
778        if let IngestFrame::Log(l) = &frame {
779            assert_eq!(
780                l.fields.get("session_token").and_then(|v| v.as_str()),
781                Some("redacted:len=20")
782            );
783        } else {
784            panic!("expected log frame");
785        }
786    }
787
788    #[test]
789    fn sync_request_and_response_round_trip() {
790        // A fully-populated request round-trips through JSON unchanged.
791        let req = SyncRequest {
792            session: Some(7),
793            from_us: Some(1_700_000_000_000_000),
794            to_us: Some(1_700_000_600_000_000),
795            tables: vec![SyncTable::Logs, SyncTable::Events],
796        };
797        let json = serde_json::to_value(&req).unwrap();
798        assert_eq!(json["session"], 7);
799        assert_eq!(json["tables"], serde_json::json!(["logs", "events"]));
800        let back: SyncRequest = serde_json::from_value(json).unwrap();
801        assert_eq!(back, req);
802
803        // An empty request omits every optional field, and tables_or_all expands
804        // the absent selector to all four tables.
805        let empty = SyncRequest::default();
806        let ejson = serde_json::to_value(&empty).unwrap();
807        assert_eq!(ejson, serde_json::json!({}));
808        assert_eq!(
809            empty.tables_or_all(),
810            vec![
811                SyncTable::Logs,
812                SyncTable::Metrics,
813                SyncTable::Events,
814                SyncTable::Hw
815            ]
816        );
817        // A populated selector is returned as listed.
818        assert_eq!(
819            req.tables_or_all(),
820            vec![SyncTable::Logs, SyncTable::Events]
821        );
822
823        // The response round-trips with its two per-table maps.
824        let resp = SyncResponse {
825            marked: BTreeMap::from([("logs".to_string(), 142)]),
826            unsynced_after: BTreeMap::from([
827                ("logs".to_string(), 0),
828                ("metrics".to_string(), 318),
829                ("events".to_string(), 12),
830                ("hw".to_string(), 90),
831            ]),
832        };
833        let rjson = serde_json::to_value(&resp).unwrap();
834        assert_eq!(rjson["marked"]["logs"], 142);
835        assert_eq!(rjson["unsynced_after"]["metrics"], 318);
836        let rback: SyncResponse = serde_json::from_value(rjson).unwrap();
837        assert_eq!(rback, resp);
838
839        // Each table variant maps to its lowercase table name.
840        for t in SyncTable::ALL {
841            let s = serde_json::to_value(t).unwrap();
842            assert_eq!(s.as_str().unwrap(), t.as_str());
843        }
844    }
845}