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