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    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    /// A percentage value was not finite (NaN or infinite).
46    PercentageNotFinite,
47    /// A percentage value was outside the closed `0.0..=100.0` interval.
48    PercentageOutOfRange,
49    /// `used_bytes` exceeded `total_bytes`.
50    UsedExceedsTotal,
51    /// `cpu_iowait` capability and `iowait_pct` presence disagreed.
52    IowaitCapabilityMismatch,
53}
54
55impl fmt::Display for ViolationKind {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::UnsupportedSchemaVersion { found } => write!(
59                f,
60                "unsupported schema_version {found} (expected {SCHEMA_VERSION_V1})"
61            ),
62            Self::ZeroNotAllowed => f.write_str("value must be positive"),
63            Self::PercentageNotFinite => f.write_str("percentage must be finite"),
64            Self::PercentageOutOfRange => f.write_str("percentage must be in 0.0..=100.0"),
65            Self::UsedExceedsTotal => f.write_str("used_bytes exceeds total_bytes"),
66            Self::IowaitCapabilityMismatch => {
67                f.write_str("iowait_pct must be Some(_) iff cpu_iowait capability is true")
68            }
69        }
70    }
71}
72
73/// Validate a snapshot against every version-1 invariant.
74pub(crate) fn validate(snap: &StatusSnapshot) -> Result<(), Vec<ValidationViolation>> {
75    let mut violations = Vec::new();
76
77    if snap.schema_version != SCHEMA_VERSION_V1 {
78        violations.push(ValidationViolation::new(
79            ViolationKind::UnsupportedSchemaVersion {
80                found: snap.schema_version,
81            },
82            "schema_version",
83        ));
84    }
85
86    if snap.observed_at_unix_ms == 0 {
87        violations.push(ValidationViolation::new(
88            ViolationKind::ZeroNotAllowed,
89            "observed_at_unix_ms",
90        ));
91    }
92    if snap.sample_interval_ms == 0 {
93        violations.push(ValidationViolation::new(
94            ViolationKind::ZeroNotAllowed,
95            "sample_interval_ms",
96        ));
97    }
98
99    validate_cpu(&snap.cpu, snap.capabilities.cpu_iowait, &mut violations);
100    validate_load(&snap.load, &mut violations);
101    validate_memory(&snap.memory, &mut violations);
102    validate_swap(&snap.swap, &mut violations);
103
104    if violations.is_empty() {
105        Ok(())
106    } else {
107        Err(violations)
108    }
109}
110
111fn validate_cpu(cpu: &CpuMetrics, cpu_iowait: bool, out: &mut Vec<ValidationViolation>) {
112    if cpu.logical_cores == 0 {
113        out.push(ValidationViolation::new(
114            ViolationKind::ZeroNotAllowed,
115            "cpu.logical_cores",
116        ));
117    }
118    check_percentage(cpu.usage_pct, "cpu.usage_pct", out);
119    match cpu.iowait_pct {
120        None => {
121            if cpu_iowait {
122                out.push(ValidationViolation::new(
123                    ViolationKind::IowaitCapabilityMismatch,
124                    "cpu.iowait_pct",
125                ));
126            }
127        }
128        Some(value) => {
129            if cpu_iowait {
130                check_percentage(value, "cpu.iowait_pct", out);
131            } else {
132                out.push(ValidationViolation::new(
133                    ViolationKind::IowaitCapabilityMismatch,
134                    "cpu.iowait_pct",
135                ));
136            }
137        }
138    }
139}
140
141fn validate_load(load: &LoadAverage, out: &mut Vec<ValidationViolation>) {
142    check_load(load.one, "load.one", out);
143    check_load(load.five, "load.five", out);
144    check_load(load.fifteen, "load.fifteen", out);
145}
146
147fn check_load(value: f32, field: &str, out: &mut Vec<ValidationViolation>) {
148    if !value.is_finite() || value < 0.0 {
149        out.push(ValidationViolation::new(
150            ViolationKind::PercentageOutOfRange,
151            field,
152        ));
153    }
154}
155
156fn validate_memory(memory: &MemoryMetrics, out: &mut Vec<ValidationViolation>) {
157    check_percentage(memory.usage_pct, "memory.usage_pct", out);
158    if memory.used_bytes > memory.total_bytes {
159        out.push(ValidationViolation::new(
160            ViolationKind::UsedExceedsTotal,
161            "memory.used_bytes",
162        ));
163    }
164}
165
166fn validate_swap(swap: &SwapMetrics, out: &mut Vec<ValidationViolation>) {
167    check_percentage(swap.usage_pct, "swap.usage_pct", out);
168    if swap.used_bytes > swap.total_bytes {
169        out.push(ValidationViolation::new(
170            ViolationKind::UsedExceedsTotal,
171            "swap.used_bytes",
172        ));
173    }
174    if swap.total_bytes == 0 && swap.usage_pct != 0.0 {
175        out.push(ValidationViolation::new(
176            ViolationKind::PercentageOutOfRange,
177            "swap.usage_pct",
178        ));
179    }
180}
181
182fn check_percentage(value: f32, field: &str, out: &mut Vec<ValidationViolation>) {
183    if !value.is_finite() {
184        out.push(ValidationViolation::new(
185            ViolationKind::PercentageNotFinite,
186            field,
187        ));
188        return;
189    }
190    if !(0.0..=100.0).contains(&value) {
191        out.push(ValidationViolation::new(
192            ViolationKind::PercentageOutOfRange,
193            field,
194        ));
195    }
196}