Skip to main content

gregg_protocol/
validate.rs

1//! Snapshot validation.
2//!
3//! Validation is deliberately separate from serde deserialization so that
4//! forward-compatible additive changes do not silently change how strict the
5//! crate is about individual fields.
6
7use std::fmt;
8
9use thiserror::Error;
10
11use crate::{
12    snapshot::{CpuMetrics, LoadAverage, MemoryMetrics, StatusSnapshot, SwapMetrics},
13    MAX_SAMPLE_INTERVAL_MS, SCHEMA_VERSION_V1,
14};
15
16/// A single protocol-invariant violation.
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
18#[error("{kind}")]
19pub struct ValidationViolation {
20    /// Field-level violation kind.
21    pub kind: ViolationKind,
22    /// JSON path to the offending field, in dotted lowercase form.
23    pub field: String,
24}
25
26impl ValidationViolation {
27    fn new(kind: ViolationKind, field: impl Into<String>) -> Self {
28        Self {
29            kind,
30            field: field.into(),
31        }
32    }
33}
34
35/// The kind of a single protocol-invariant violation.
36///
37/// Each variant carries enough information for the caller to log a precise
38/// diagnostic without parsing the message string.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ViolationKind {
41    /// `schema_version` did not match the supported version.
42    UnsupportedSchemaVersion { found: u16 },
43    /// An integer count that must be positive was zero.
44    ZeroNotAllowed,
45    /// The sampling cadence exceeded the protocol maximum.
46    SampleIntervalOutOfRange { max_ms: u64 },
47    /// A percentage value was not finite (NaN or infinite).
48    PercentageNotFinite,
49    /// A percentage value was outside the closed `0.0..=100.0` interval.
50    PercentageOutOfRange,
51    /// A load average was non-finite or negative.
52    LoadValueOutOfRange,
53    /// `used_bytes` exceeded `total_bytes`.
54    UsedExceedsTotal,
55    /// `cpu_iowait` capability and `iowait_pct` presence disagreed.
56    IowaitCapabilityMismatch,
57}
58
59impl fmt::Display for ViolationKind {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::UnsupportedSchemaVersion { found } => write!(
63                f,
64                "unsupported schema_version {found} (expected {SCHEMA_VERSION_V1})"
65            ),
66            Self::ZeroNotAllowed => f.write_str("value must be positive"),
67            Self::SampleIntervalOutOfRange { max_ms } => {
68                write!(f, "sample interval must be at most {max_ms} ms")
69            }
70            Self::PercentageNotFinite => f.write_str("percentage must be finite"),
71            Self::PercentageOutOfRange => f.write_str("percentage must be in 0.0..=100.0"),
72            Self::LoadValueOutOfRange => {
73                f.write_str("load average must be finite and non-negative")
74            }
75            Self::UsedExceedsTotal => f.write_str("used_bytes exceeds total_bytes"),
76            Self::IowaitCapabilityMismatch => {
77                f.write_str("iowait_pct must be Some(_) iff cpu_iowait capability is true")
78            }
79        }
80    }
81}
82
83/// Validate a snapshot against every version-1 invariant.
84pub(crate) fn validate(snap: &StatusSnapshot) -> Result<(), Vec<ValidationViolation>> {
85    let mut violations = Vec::new();
86
87    if snap.schema_version != SCHEMA_VERSION_V1 {
88        violations.push(ValidationViolation::new(
89            ViolationKind::UnsupportedSchemaVersion {
90                found: snap.schema_version,
91            },
92            "schema_version",
93        ));
94    }
95
96    if snap.observed_at_unix_ms == 0 {
97        violations.push(ValidationViolation::new(
98            ViolationKind::ZeroNotAllowed,
99            "observed_at_unix_ms",
100        ));
101    }
102    if snap.sample_interval_ms == 0 {
103        violations.push(ValidationViolation::new(
104            ViolationKind::ZeroNotAllowed,
105            "sample_interval_ms",
106        ));
107    } else if snap.sample_interval_ms > MAX_SAMPLE_INTERVAL_MS {
108        violations.push(ValidationViolation::new(
109            ViolationKind::SampleIntervalOutOfRange {
110                max_ms: MAX_SAMPLE_INTERVAL_MS,
111            },
112            "sample_interval_ms",
113        ));
114    }
115
116    validate_cpu(&snap.cpu, snap.capabilities.cpu_iowait, &mut violations);
117    validate_load(&snap.load, &mut violations);
118    validate_memory(&snap.memory, &mut violations);
119    validate_swap(&snap.swap, &mut violations);
120
121    if violations.is_empty() {
122        Ok(())
123    } else {
124        Err(violations)
125    }
126}
127
128fn validate_cpu(cpu: &CpuMetrics, cpu_iowait: bool, out: &mut Vec<ValidationViolation>) {
129    if cpu.logical_cores == 0 {
130        out.push(ValidationViolation::new(
131            ViolationKind::ZeroNotAllowed,
132            "cpu.logical_cores",
133        ));
134    }
135    check_percentage(cpu.usage_pct, "cpu.usage_pct", out);
136    match cpu.iowait_pct {
137        None => {
138            if cpu_iowait {
139                out.push(ValidationViolation::new(
140                    ViolationKind::IowaitCapabilityMismatch,
141                    "cpu.iowait_pct",
142                ));
143            }
144        }
145        Some(value) => {
146            if cpu_iowait {
147                check_percentage(value, "cpu.iowait_pct", out);
148            } else {
149                out.push(ValidationViolation::new(
150                    ViolationKind::IowaitCapabilityMismatch,
151                    "cpu.iowait_pct",
152                ));
153            }
154        }
155    }
156}
157
158fn validate_load(load: &LoadAverage, out: &mut Vec<ValidationViolation>) {
159    check_load(load.one, "load.one", out);
160    check_load(load.five, "load.five", out);
161    check_load(load.fifteen, "load.fifteen", out);
162}
163
164fn check_load(value: f32, field: &str, out: &mut Vec<ValidationViolation>) {
165    if !value.is_finite() || value < 0.0 {
166        out.push(ValidationViolation::new(
167            ViolationKind::LoadValueOutOfRange,
168            field,
169        ));
170    }
171}
172
173fn validate_memory(memory: &MemoryMetrics, out: &mut Vec<ValidationViolation>) {
174    check_percentage(memory.usage_pct, "memory.usage_pct", out);
175    if memory.used_bytes > memory.total_bytes {
176        out.push(ValidationViolation::new(
177            ViolationKind::UsedExceedsTotal,
178            "memory.used_bytes",
179        ));
180    }
181    if memory.total_bytes == 0 && memory.usage_pct != 0.0 {
182        out.push(ValidationViolation::new(
183            ViolationKind::PercentageOutOfRange,
184            "memory.usage_pct",
185        ));
186    }
187}
188
189fn validate_swap(swap: &SwapMetrics, out: &mut Vec<ValidationViolation>) {
190    check_percentage(swap.usage_pct, "swap.usage_pct", out);
191    if swap.used_bytes > swap.total_bytes {
192        out.push(ValidationViolation::new(
193            ViolationKind::UsedExceedsTotal,
194            "swap.used_bytes",
195        ));
196    }
197    if swap.total_bytes == 0 && swap.usage_pct != 0.0 {
198        out.push(ValidationViolation::new(
199            ViolationKind::PercentageOutOfRange,
200            "swap.usage_pct",
201        ));
202    }
203}
204
205fn check_percentage(value: f32, field: &str, out: &mut Vec<ValidationViolation>) {
206    if !value.is_finite() {
207        out.push(ValidationViolation::new(
208            ViolationKind::PercentageNotFinite,
209            field,
210        ));
211        return;
212    }
213    if !(0.0..=100.0).contains(&value) {
214        out.push(ValidationViolation::new(
215            ViolationKind::PercentageOutOfRange,
216            field,
217        ));
218    }
219}