Skip to main content

gregg_protocol/
validate_v2.rs

1//! Schema-version-2 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::v2::{
12    CommitMetrics, StatusPayloadV2, StatusSnapshotV2, SwapMetrics, MAX_DRIVE_ENTRIES,
13    MAX_DRIVE_NAME_BYTES, SCHEMA_VERSION_V2,
14};
15use crate::{LoadAverage, MemoryMetrics, MAX_SAMPLE_INTERVAL_MS};
16
17/// A single protocol-invariant violation for v2 snapshots.
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[error("{kind}")]
20pub struct ValidationViolationV2 {
21    /// Field-level violation kind.
22    pub kind: ViolationKindV2,
23    /// JSON path to the offending field, in dotted lowercase form.
24    pub field: String,
25}
26
27impl ValidationViolationV2 {
28    fn new(kind: ViolationKindV2, field: impl Into<String>) -> Self {
29        Self {
30            kind,
31            field: field.into(),
32        }
33    }
34}
35
36/// The kind of a single protocol-invariant violation for v2.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ViolationKindV2 {
39    /// `schema_version` did not match the supported version.
40    UnsupportedSchemaVersion {
41        found: u16,
42    },
43    /// An integer count that must be positive was zero.
44    ZeroNotAllowed,
45    /// The sampling cadence exceeded the protocol maximum.
46    SampleIntervalOutOfRange {
47        max_ms: u64,
48    },
49    /// A percentage value was not finite (NaN or infinite).
50    PercentageNotFinite,
51    /// A percentage value was outside the closed `0.0..=100.0` interval.
52    PercentageOutOfRange,
53    /// A load average was non-finite or negative.
54    LoadValueOutOfRange,
55    /// `used_bytes` exceeded `total_bytes` or `limit_bytes`.
56    UsedExceedsTotal,
57    AvailableExceedsTotal,
58    /// `cpu_iowait` capability and `iowait_pct` presence disagreed.
59    IowaitCapabilityMismatch,
60    /// `load_average` capability and `load` presence disagreed.
61    LoadCapabilityMismatch,
62    /// `swap` capability and `swap` presence disagreed.
63    SwapCapabilityMismatch,
64    /// `memory_commit` capability and `commit` presence disagreed.
65    CommitCapabilityMismatch,
66    /// A drive display name was empty.
67    EmptyDriveName,
68    /// A drive display name exceeded the protocol bound.
69    DriveNameTooLong {
70        max_bytes: usize,
71    },
72    /// The drive collection exceeded the protocol bound.
73    TooManyDrives {
74        max_entries: usize,
75    },
76}
77
78impl fmt::Display for ViolationKindV2 {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::UnsupportedSchemaVersion { found } => write!(
82                f,
83                "unsupported schema_version {found} (expected {SCHEMA_VERSION_V2})"
84            ),
85            Self::ZeroNotAllowed => f.write_str("value must be positive"),
86            Self::SampleIntervalOutOfRange { max_ms } => {
87                write!(f, "sample interval must be at most {max_ms} ms")
88            }
89            Self::PercentageNotFinite => f.write_str("percentage must be finite"),
90            Self::PercentageOutOfRange => f.write_str("percentage must be in 0.0..=100.0"),
91            Self::LoadValueOutOfRange => {
92                f.write_str("load average must be finite and non-negative")
93            }
94            Self::UsedExceedsTotal => f.write_str("used exceeds total/limit"),
95            Self::AvailableExceedsTotal => f.write_str("available exceeds total"),
96            Self::IowaitCapabilityMismatch => {
97                f.write_str("iowait_pct must be Some(_) iff cpu_iowait capability is true")
98            }
99            Self::LoadCapabilityMismatch => {
100                f.write_str("load must be Some(_) iff load_average capability is true")
101            }
102            Self::SwapCapabilityMismatch => {
103                f.write_str("swap must be Some(_) iff swap capability is true")
104            }
105            Self::CommitCapabilityMismatch => {
106                f.write_str("commit must be Some(_) iff memory_commit capability is true")
107            }
108            Self::EmptyDriveName => f.write_str("drive name must not be empty"),
109            Self::DriveNameTooLong { max_bytes } => {
110                write!(f, "drive name exceeds maximum length of {max_bytes} bytes")
111            }
112            Self::TooManyDrives { max_entries } => {
113                write!(
114                    f,
115                    "drive list exceeds maximum length of {max_entries} entries"
116                )
117            }
118        }
119    }
120}
121
122/// Validate a v2 snapshot against every version-2 invariant.
123///
124/// Returns `Ok(())` or a list of structured violations.
125pub fn validate_v2(snap: &StatusSnapshotV2) -> Result<(), Vec<ValidationViolationV2>> {
126    let mut violations = Vec::new();
127
128    if snap.schema_version != SCHEMA_VERSION_V2 {
129        violations.push(ValidationViolationV2::new(
130            ViolationKindV2::UnsupportedSchemaVersion {
131                found: snap.schema_version,
132            },
133            "schema_version",
134        ));
135    }
136
137    if snap.observed_at_unix_ms == 0 {
138        violations.push(ValidationViolationV2::new(
139            ViolationKindV2::ZeroNotAllowed,
140            "observed_at_unix_ms",
141        ));
142    }
143    if snap.sample_interval_ms == 0 {
144        violations.push(ValidationViolationV2::new(
145            ViolationKindV2::ZeroNotAllowed,
146            "sample_interval_ms",
147        ));
148    } else if snap.sample_interval_ms > MAX_SAMPLE_INTERVAL_MS {
149        violations.push(ValidationViolationV2::new(
150            ViolationKindV2::SampleIntervalOutOfRange {
151                max_ms: MAX_SAMPLE_INTERVAL_MS,
152            },
153            "sample_interval_ms",
154        ));
155    }
156
157    validate_cpu_v2(&snap.cpu, snap.capabilities.cpu_iowait, &mut violations);
158    validate_load_v2(
159        snap.load.as_ref(),
160        snap.capabilities.load_average,
161        &mut violations,
162    );
163    validate_memory_v2(&snap.memory, &mut violations);
164    validate_swap_v2(snap.swap.as_ref(), snap.capabilities.swap, &mut violations);
165    validate_commit_v2(
166        snap.commit.as_ref(),
167        snap.capabilities.memory_commit,
168        &mut violations,
169    );
170
171    if violations.is_empty() {
172        Ok(())
173    } else {
174        Err(violations)
175    }
176}
177
178/// Validate a flat v2 status payload, including its optional drive data.
179pub fn validate_payload_v2(payload: &StatusPayloadV2) -> Result<(), Vec<ValidationViolationV2>> {
180    let mut violations = match validate_v2(&payload.snapshot) {
181        Ok(()) => Vec::new(),
182        Err(violations) => violations,
183    };
184
185    if let Some(drives) = &payload.drives {
186        if drives.len() > MAX_DRIVE_ENTRIES {
187            violations.push(ValidationViolationV2::new(
188                ViolationKindV2::TooManyDrives {
189                    max_entries: MAX_DRIVE_ENTRIES,
190                },
191                "drives",
192            ));
193        }
194        for (index, drive) in drives.iter().enumerate() {
195            let prefix = format!("drives[{index}]");
196            if drive.name.is_empty() {
197                violations.push(ValidationViolationV2::new(
198                    ViolationKindV2::EmptyDriveName,
199                    format!("{prefix}.name"),
200                ));
201            }
202            if drive.name.len() > MAX_DRIVE_NAME_BYTES {
203                violations.push(ValidationViolationV2::new(
204                    ViolationKindV2::DriveNameTooLong {
205                        max_bytes: MAX_DRIVE_NAME_BYTES,
206                    },
207                    format!("{prefix}.name"),
208                ));
209            }
210            if drive.total_bytes == 0 {
211                violations.push(ValidationViolationV2::new(
212                    ViolationKindV2::ZeroNotAllowed,
213                    format!("{prefix}.total_bytes"),
214                ));
215            }
216            if drive.used_bytes > drive.total_bytes {
217                violations.push(ValidationViolationV2::new(
218                    ViolationKindV2::UsedExceedsTotal,
219                    format!("{prefix}.used_bytes"),
220                ));
221            }
222            if drive
223                .available_bytes
224                .is_some_and(|available| available > drive.total_bytes)
225            {
226                violations.push(ValidationViolationV2::new(
227                    ViolationKindV2::AvailableExceedsTotal,
228                    format!("{prefix}.available_bytes"),
229                ));
230            }
231        }
232    }
233
234    if violations.is_empty() {
235        Ok(())
236    } else {
237        Err(violations)
238    }
239}
240
241fn validate_cpu_v2(
242    cpu: &crate::v2::CpuMetricsV2,
243    cpu_iowait: bool,
244    out: &mut Vec<ValidationViolationV2>,
245) {
246    if cpu.logical_cores == 0 {
247        out.push(ValidationViolationV2::new(
248            ViolationKindV2::ZeroNotAllowed,
249            "cpu.logical_cores",
250        ));
251    }
252    check_percentage_v2(cpu.usage_pct, "cpu.usage_pct", out);
253    match cpu.iowait_pct {
254        None => {
255            if cpu_iowait {
256                out.push(ValidationViolationV2::new(
257                    ViolationKindV2::IowaitCapabilityMismatch,
258                    "cpu.iowait_pct",
259                ));
260            }
261        }
262        Some(value) => {
263            if cpu_iowait {
264                check_percentage_v2(value, "cpu.iowait_pct", out);
265            } else {
266                out.push(ValidationViolationV2::new(
267                    ViolationKindV2::IowaitCapabilityMismatch,
268                    "cpu.iowait_pct",
269                ));
270            }
271        }
272    }
273}
274
275fn validate_load_v2(
276    load: Option<&LoadAverage>,
277    load_average_capable: bool,
278    out: &mut Vec<ValidationViolationV2>,
279) {
280    match load {
281        None => {
282            if load_average_capable {
283                out.push(ValidationViolationV2::new(
284                    ViolationKindV2::LoadCapabilityMismatch,
285                    "load",
286                ));
287            }
288        }
289        Some(l) => {
290            if load_average_capable {
291                check_load_v2(l.one, "load.one", out);
292                check_load_v2(l.five, "load.five", out);
293                check_load_v2(l.fifteen, "load.fifteen", out);
294            } else {
295                out.push(ValidationViolationV2::new(
296                    ViolationKindV2::LoadCapabilityMismatch,
297                    "load",
298                ));
299            }
300        }
301    }
302}
303
304fn check_load_v2(value: f32, field: &str, out: &mut Vec<ValidationViolationV2>) {
305    if !value.is_finite() || value < 0.0 {
306        out.push(ValidationViolationV2::new(
307            ViolationKindV2::LoadValueOutOfRange,
308            field,
309        ));
310    }
311}
312
313fn validate_memory_v2(memory: &MemoryMetrics, out: &mut Vec<ValidationViolationV2>) {
314    check_percentage_v2(memory.usage_pct, "memory.usage_pct", out);
315    if memory.used_bytes > memory.total_bytes {
316        out.push(ValidationViolationV2::new(
317            ViolationKindV2::UsedExceedsTotal,
318            "memory.used_bytes",
319        ));
320    }
321    if memory.total_bytes == 0 && memory.usage_pct != 0.0 {
322        out.push(ValidationViolationV2::new(
323            ViolationKindV2::PercentageOutOfRange,
324            "memory.usage_pct",
325        ));
326    }
327}
328
329fn validate_swap_v2(
330    swap: Option<&SwapMetrics>,
331    swap_capable: bool,
332    out: &mut Vec<ValidationViolationV2>,
333) {
334    match swap {
335        None => {
336            if swap_capable {
337                out.push(ValidationViolationV2::new(
338                    ViolationKindV2::SwapCapabilityMismatch,
339                    "swap",
340                ));
341            }
342        }
343        Some(s) => {
344            if swap_capable {
345                check_percentage_v2(s.usage_pct, "swap.usage_pct", out);
346                if s.used_bytes > s.total_bytes {
347                    out.push(ValidationViolationV2::new(
348                        ViolationKindV2::UsedExceedsTotal,
349                        "swap.used_bytes",
350                    ));
351                }
352                if s.total_bytes == 0 && s.usage_pct != 0.0 {
353                    out.push(ValidationViolationV2::new(
354                        ViolationKindV2::PercentageOutOfRange,
355                        "swap.usage_pct",
356                    ));
357                }
358            } else {
359                out.push(ValidationViolationV2::new(
360                    ViolationKindV2::SwapCapabilityMismatch,
361                    "swap",
362                ));
363            }
364        }
365    }
366}
367
368fn validate_commit_v2(
369    commit: Option<&CommitMetrics>,
370    commit_capable: bool,
371    out: &mut Vec<ValidationViolationV2>,
372) {
373    match commit {
374        None => {
375            if commit_capable {
376                out.push(ValidationViolationV2::new(
377                    ViolationKindV2::CommitCapabilityMismatch,
378                    "commit",
379                ));
380            }
381        }
382        Some(c) => {
383            if commit_capable {
384                check_percentage_v2(c.usage_pct, "commit.usage_pct", out);
385                if c.used_bytes > c.limit_bytes {
386                    out.push(ValidationViolationV2::new(
387                        ViolationKindV2::UsedExceedsTotal,
388                        "commit.used_bytes",
389                    ));
390                }
391                if c.limit_bytes == 0 {
392                    out.push(ValidationViolationV2::new(
393                        ViolationKindV2::ZeroNotAllowed,
394                        "commit.limit_bytes",
395                    ));
396                }
397                if c.limit_bytes == 0 && c.usage_pct != 0.0 {
398                    out.push(ValidationViolationV2::new(
399                        ViolationKindV2::PercentageOutOfRange,
400                        "commit.usage_pct",
401                    ));
402                }
403            } else {
404                out.push(ValidationViolationV2::new(
405                    ViolationKindV2::CommitCapabilityMismatch,
406                    "commit",
407                ));
408            }
409        }
410    }
411}
412
413fn check_percentage_v2(value: f32, field: &str, out: &mut Vec<ValidationViolationV2>) {
414    if !value.is_finite() {
415        out.push(ValidationViolationV2::new(
416            ViolationKindV2::PercentageNotFinite,
417            field,
418        ));
419        return;
420    }
421    if !(0.0..=100.0).contains(&value) {
422        out.push(ValidationViolationV2::new(
423            ViolationKindV2::PercentageOutOfRange,
424            field,
425        ));
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::v2::{
433        CpuMetricsV2, DriveMetrics, MetricCapabilitiesV2, StatusPayloadV2, StatusSnapshotV2,
434        SwapMetrics, MAX_DRIVE_ENTRIES, MAX_DRIVE_NAME_BYTES, SCHEMA_VERSION_V2,
435    };
436    use crate::{LoadAverage, MemoryMetrics, SystemIdentity};
437
438    fn v2_identity() -> SystemIdentity {
439        SystemIdentity {
440            name: "test".into(),
441            hostname: "test.local".into(),
442            os_name: "linux".into(),
443            os_version: "1.0".into(),
444            kernel_name: "Linux".into(),
445            kernel_release: "6.0.0".into(),
446            architecture: "x86_64".into(),
447        }
448    }
449
450    fn valid_linux_v2() -> StatusSnapshotV2 {
451        StatusSnapshotV2 {
452            schema_version: SCHEMA_VERSION_V2,
453            observed_at_unix_ms: 1,
454            sample_interval_ms: 1000,
455            capabilities: MetricCapabilitiesV2 {
456                cpu_iowait: true,
457                load_average: true,
458                swap: true,
459                memory_commit: false,
460            },
461            system: v2_identity(),
462            cpu: CpuMetricsV2 {
463                logical_cores: 8,
464                usage_pct: 25.2,
465                iowait_pct: Some(0.4),
466            },
467            load: Some(LoadAverage {
468                one: 1.32,
469                five: 0.91,
470                fifteen: 0.62,
471            }),
472            memory: MemoryMetrics {
473                used_bytes: 5_900_000_000,
474                total_bytes: 15_600_000_000,
475                usage_pct: 37.8,
476            },
477            swap: Some(SwapMetrics {
478                used_bytes: 0,
479                total_bytes: 4_000_000_000,
480                usage_pct: 0.0,
481            }),
482            commit: None,
483        }
484    }
485
486    fn valid_payload(drives: Option<Vec<DriveMetrics>>) -> StatusPayloadV2 {
487        StatusPayloadV2 {
488            snapshot: valid_linux_v2(),
489            drives,
490        }
491    }
492
493    #[test]
494    fn valid_drive_payloads_include_unavailable_empty_and_populated_states() {
495        assert!(valid_payload(None).validate().is_ok());
496        assert!(valid_payload(Some(Vec::new())).validate().is_ok());
497        assert!(valid_payload(Some(vec![DriveMetrics {
498            name: "C:\\".into(),
499            used_bytes: 1,
500            total_bytes: 2,
501            available_bytes: None,
502        }]))
503        .validate()
504        .is_ok());
505    }
506
507    #[test]
508    fn drive_validation_reports_indexed_fields_and_bounds() {
509        let payload = valid_payload(Some(vec![DriveMetrics {
510            name: String::new(),
511            used_bytes: 3,
512            total_bytes: 2,
513            available_bytes: None,
514        }]));
515        let err = payload.validate().unwrap_err();
516        assert!(err.iter().any(|v| v.field == "drives[0].name"));
517        assert!(err.iter().any(|v| v.field == "drives[0].used_bytes"));
518
519        let too_long = valid_payload(Some(vec![DriveMetrics {
520            name: "x".repeat(MAX_DRIVE_NAME_BYTES + 1),
521            used_bytes: 0,
522            total_bytes: 1,
523            available_bytes: None,
524        }]));
525        assert!(too_long
526            .validate()
527            .unwrap_err()
528            .iter()
529            .any(|v| v.field == "drives[0].name"));
530
531        let too_many = valid_payload(Some(
532            (0..=MAX_DRIVE_ENTRIES)
533                .map(|index| DriveMetrics {
534                    name: format!("/{index}"),
535                    used_bytes: 0,
536                    total_bytes: 1,
537                    available_bytes: None,
538                })
539                .collect(),
540        ));
541        assert!(too_many
542            .validate()
543            .unwrap_err()
544            .iter()
545            .any(|v| v.field == "drives"));
546    }
547
548    #[test]
549    fn drive_names_accept_unicode_and_windows_roots() {
550        let payload = valid_payload(Some(vec![
551            DriveMetrics {
552                name: "データ /home".into(),
553                used_bytes: 1,
554                total_bytes: 2,
555                available_bytes: None,
556            },
557            DriveMetrics {
558                name: "C:\\".into(),
559                used_bytes: 1,
560                total_bytes: 2,
561                available_bytes: None,
562            },
563        ]));
564        payload.validate().unwrap();
565    }
566
567    #[test]
568    fn explicit_availability_is_optional_and_bounded_independently() {
569        let mut payload = valid_payload(Some(vec![DriveMetrics {
570            name: "/".into(),
571            used_bytes: 8,
572            total_bytes: 10,
573            available_bytes: Some(1),
574        }]));
575        payload.validate().unwrap();
576        payload.drives.as_mut().unwrap()[0].available_bytes = Some(11);
577        let error = payload.validate().unwrap_err();
578        assert!(error.iter().any(|violation| {
579            violation.kind == ViolationKindV2::AvailableExceedsTotal
580                && violation.field == "drives[0].available_bytes"
581        }));
582    }
583
584    #[test]
585    fn valid_linux_v2_passes() {
586        let snap = valid_linux_v2();
587        validate_v2(&snap).expect("linux v2 validates");
588    }
589
590    #[test]
591    fn valid_windows_v2_passes() {
592        let snap = StatusSnapshotV2 {
593            schema_version: SCHEMA_VERSION_V2,
594            observed_at_unix_ms: 1,
595            sample_interval_ms: 1000,
596            capabilities: MetricCapabilitiesV2 {
597                cpu_iowait: false,
598                load_average: false,
599                swap: false,
600                memory_commit: true,
601            },
602            system: v2_identity(),
603            cpu: CpuMetricsV2 {
604                logical_cores: 4,
605                usage_pct: 12.5,
606                iowait_pct: None,
607            },
608            load: None,
609            memory: MemoryMetrics {
610                used_bytes: 2_000_000_000,
611                total_bytes: 8_000_000_000,
612                usage_pct: 25.0,
613            },
614            swap: None,
615            commit: Some(crate::v2::CommitMetrics {
616                used_bytes: 3_000_000_000,
617                limit_bytes: 8_000_000_000,
618                usage_pct: 37.5,
619            }),
620        };
621        validate_v2(&snap).expect("windows v2 validates");
622    }
623
624    #[test]
625    fn valid_macos_v2_passes() {
626        let snap = StatusSnapshotV2 {
627            schema_version: SCHEMA_VERSION_V2,
628            observed_at_unix_ms: 1,
629            sample_interval_ms: 1000,
630            capabilities: MetricCapabilitiesV2 {
631                cpu_iowait: false,
632                load_average: true,
633                swap: false,
634                memory_commit: false,
635            },
636            system: v2_identity(),
637            cpu: CpuMetricsV2 {
638                logical_cores: 8,
639                usage_pct: 18.7,
640                iowait_pct: None,
641            },
642            load: Some(LoadAverage {
643                one: 2.10,
644                five: 1.85,
645                fifteen: 1.40,
646            }),
647            memory: MemoryMetrics {
648                used_bytes: 9_000_000_000,
649                total_bytes: 16_000_000_000,
650                usage_pct: 56.25,
651            },
652            swap: None,
653            commit: None,
654        };
655        validate_v2(&snap).expect("macos v2 validates");
656    }
657
658    #[test]
659    fn rejects_wrong_schema_version() {
660        let mut snap = valid_linux_v2();
661        snap.schema_version = 1;
662        let err = validate_v2(&snap).unwrap_err();
663        assert!(err.iter().any(|v| matches!(
664            v.kind,
665            ViolationKindV2::UnsupportedSchemaVersion { found: 1 }
666        )));
667    }
668
669    #[test]
670    fn rejects_zero_observed_at() {
671        let mut snap = valid_linux_v2();
672        snap.observed_at_unix_ms = 0;
673        let err = validate_v2(&snap).unwrap_err();
674        assert!(err.iter().any(|v| v.field == "observed_at_unix_ms"));
675    }
676
677    #[test]
678    fn rejects_zero_sample_interval() {
679        let mut snap = valid_linux_v2();
680        snap.sample_interval_ms = 0;
681        let err = validate_v2(&snap).unwrap_err();
682        assert!(err.iter().any(|v| v.field == "sample_interval_ms"));
683    }
684
685    #[test]
686    fn rejects_sample_interval_above_protocol_limit() {
687        let mut snap = valid_linux_v2();
688        snap.sample_interval_ms = MAX_SAMPLE_INTERVAL_MS + 1;
689        let err = validate_v2(&snap).unwrap_err();
690        assert!(err.iter().any(|violation| {
691            violation.field == "sample_interval_ms"
692                && matches!(
693                    violation.kind,
694                    ViolationKindV2::SampleIntervalOutOfRange { .. }
695                )
696        }));
697    }
698
699    #[test]
700    fn rejects_invalid_load_value_with_load_specific_violation() {
701        let mut snap = valid_linux_v2();
702        snap.load.as_mut().unwrap().one = -1.0;
703        let err = validate_v2(&snap).unwrap_err();
704        assert!(err.iter().any(|violation| {
705            violation.field == "load.one" && violation.kind == ViolationKindV2::LoadValueOutOfRange
706        }));
707    }
708
709    #[test]
710    fn rejects_nonzero_memory_usage_with_zero_total() {
711        let mut snap = valid_linux_v2();
712        snap.memory.total_bytes = 0;
713        snap.memory.usage_pct = 1.0;
714        let err = validate_v2(&snap).unwrap_err();
715        assert!(err.iter().any(|violation| {
716            violation.field == "memory.usage_pct"
717                && violation.kind == ViolationKindV2::PercentageOutOfRange
718        }));
719    }
720
721    #[test]
722    fn rejects_zero_commit_limit_and_nonzero_usage() {
723        let snap = StatusSnapshotV2 {
724            capabilities: MetricCapabilitiesV2 {
725                memory_commit: true,
726                ..valid_linux_v2().capabilities
727            },
728            commit: Some(crate::v2::CommitMetrics {
729                used_bytes: 0,
730                limit_bytes: 0,
731                usage_pct: 1.0,
732            }),
733            ..valid_linux_v2()
734        };
735        let err = validate_v2(&snap).unwrap_err();
736        assert!(err.iter().any(|violation| {
737            violation.field == "commit.limit_bytes"
738                && violation.kind == ViolationKindV2::ZeroNotAllowed
739        }));
740        assert!(err.iter().any(|violation| {
741            violation.field == "commit.usage_pct"
742                && violation.kind == ViolationKindV2::PercentageOutOfRange
743        }));
744    }
745
746    #[test]
747    fn rejects_zero_logical_cores() {
748        let mut snap = valid_linux_v2();
749        snap.cpu.logical_cores = 0;
750        let err = validate_v2(&snap).unwrap_err();
751        assert!(err.iter().any(|v| v.field == "cpu.logical_cores"));
752    }
753
754    #[test]
755    fn rejects_nan_cpu_usage() {
756        let mut snap = valid_linux_v2();
757        snap.cpu.usage_pct = f32::NAN;
758        let err = validate_v2(&snap).unwrap_err();
759        assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
760    }
761
762    #[test]
763    fn rejects_iowait_none_when_capability_true() {
764        let mut snap = valid_linux_v2();
765        snap.cpu.iowait_pct = None;
766        let err = validate_v2(&snap).unwrap_err();
767        assert!(err
768            .iter()
769            .any(|v| matches!(v.kind, ViolationKindV2::IowaitCapabilityMismatch)));
770    }
771
772    #[test]
773    fn rejects_iowait_some_when_capability_false() {
774        let mut snap = valid_linux_v2();
775        snap.capabilities.cpu_iowait = false;
776        snap.cpu.iowait_pct = Some(0.5);
777        let err = validate_v2(&snap).unwrap_err();
778        assert!(err
779            .iter()
780            .any(|v| matches!(v.kind, ViolationKindV2::IowaitCapabilityMismatch)));
781    }
782
783    #[test]
784    fn rejects_load_none_when_capability_true() {
785        let mut snap = valid_linux_v2();
786        snap.load = None;
787        let err = validate_v2(&snap).unwrap_err();
788        assert!(err
789            .iter()
790            .any(|v| matches!(v.kind, ViolationKindV2::LoadCapabilityMismatch)));
791    }
792
793    #[test]
794    fn rejects_load_some_when_capability_false() {
795        let mut snap = valid_linux_v2();
796        snap.capabilities.load_average = false;
797        let err = validate_v2(&snap).unwrap_err();
798        assert!(err
799            .iter()
800            .any(|v| matches!(v.kind, ViolationKindV2::LoadCapabilityMismatch)));
801    }
802
803    #[test]
804    fn rejects_swap_none_when_capability_true() {
805        let mut snap = valid_linux_v2();
806        snap.swap = None;
807        let err = validate_v2(&snap).unwrap_err();
808        assert!(err
809            .iter()
810            .any(|v| matches!(v.kind, ViolationKindV2::SwapCapabilityMismatch)));
811    }
812
813    #[test]
814    fn rejects_swap_some_when_capability_false() {
815        let mut snap = valid_linux_v2();
816        snap.capabilities.swap = false;
817        let err = validate_v2(&snap).unwrap_err();
818        assert!(err
819            .iter()
820            .any(|v| matches!(v.kind, ViolationKindV2::SwapCapabilityMismatch)));
821    }
822
823    #[test]
824    fn rejects_commit_none_when_capability_true() {
825        let mut snap = StatusSnapshotV2 {
826            capabilities: MetricCapabilitiesV2 {
827                memory_commit: true,
828                ..valid_linux_v2().capabilities
829            },
830            ..valid_linux_v2()
831        };
832        snap.commit = None;
833        let err = validate_v2(&snap).unwrap_err();
834        assert!(err
835            .iter()
836            .any(|v| matches!(v.kind, ViolationKindV2::CommitCapabilityMismatch)));
837    }
838
839    #[test]
840    fn rejects_commit_some_when_capability_false() {
841        let mut snap = valid_linux_v2();
842        snap.commit = Some(crate::v2::CommitMetrics {
843            used_bytes: 1_000_000_000,
844            limit_bytes: 4_000_000_000,
845            usage_pct: 25.0,
846        });
847        let err = validate_v2(&snap).unwrap_err();
848        assert!(err
849            .iter()
850            .any(|v| matches!(v.kind, ViolationKindV2::CommitCapabilityMismatch)));
851    }
852
853    #[test]
854    fn rejects_used_exceeds_limit_in_commit() {
855        let snap = StatusSnapshotV2 {
856            capabilities: MetricCapabilitiesV2 {
857                memory_commit: true,
858                ..valid_linux_v2().capabilities
859            },
860            commit: Some(crate::v2::CommitMetrics {
861                used_bytes: 9_000_000_000,
862                limit_bytes: 4_000_000_000,
863                usage_pct: 25.0,
864            }),
865            ..valid_linux_v2()
866        };
867        let err = validate_v2(&snap).unwrap_err();
868        assert!(err.iter().any(|v| v.field == "commit.used_bytes"));
869    }
870
871    #[test]
872    fn rejects_used_exceeds_total_in_swap() {
873        let mut snap = valid_linux_v2();
874        snap.swap = Some(SwapMetrics {
875            used_bytes: 5_000_000_000,
876            total_bytes: 4_000_000_000,
877            usage_pct: 25.0,
878        });
879        let err = validate_v2(&snap).unwrap_err();
880        assert!(err.iter().any(|v| v.field == "swap.used_bytes"));
881    }
882
883    #[test]
884    fn rejects_used_exceeds_total_in_memory() {
885        let mut snap = valid_linux_v2();
886        snap.memory.used_bytes = 20_000_000_000;
887        let err = validate_v2(&snap).unwrap_err();
888        assert!(err.iter().any(|v| v.field == "memory.used_bytes"));
889    }
890
891    #[test]
892    fn rejects_percentage_over_100() {
893        let mut snap = valid_linux_v2();
894        snap.cpu.usage_pct = 101.0;
895        let err = validate_v2(&snap).unwrap_err();
896        assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
897    }
898
899    #[test]
900    fn rejects_infinite_percentage() {
901        let mut snap = valid_linux_v2();
902        snap.cpu.usage_pct = f32::INFINITY;
903        let err = validate_v2(&snap).unwrap_err();
904        assert!(err.iter().any(|v| v.field == "cpu.usage_pct"));
905    }
906
907    #[test]
908    fn multiple_violations_all_reported() {
909        let mut snap = valid_linux_v2();
910        snap.schema_version = 99;
911        snap.observed_at_unix_ms = 0;
912        snap.cpu.logical_cores = 0;
913        let err = validate_v2(&snap).unwrap_err();
914        assert!(err.len() >= 3);
915    }
916}