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