ktstr/scenario/snapshot/
field.rs

1//! [`SnapshotField`] — terminal accessor for a typed read out of a
2//! rendered `crate::monitor::btf_render::RenderedValue` tree, plus
3//! the lazy traversal helpers ([`walk_dotted_path`],
4//! [`lookup_member`], [`peel_pointer`], [`describe_kind`]) and the
5//! [`render_to_u64`] / [`render_to_i64`] coercion paths the
6//! accessors funnel through.
7
8use crate::monitor::btf_render::{RenderedMember, RenderedValue};
9
10use super::{SnapshotError, SnapshotResult};
11
12/// One field's view at the leaf of a dotted-path walk.
13///
14/// Returned by [`super::Snapshot::var`], [`super::SnapshotEntry::get`], and
15/// [`super::SnapshotEntry::key`]. Terminal `as_*` accessors return
16/// [`SnapshotResult`] so a missing or type-mismatched field
17/// surfaces as a recoverable error rather than a panic.
18#[derive(Debug)]
19#[must_use = "SnapshotField is a borrowed view; call as_u64 / as_i64 / etc. to extract"]
20#[non_exhaustive]
21pub enum SnapshotField<'a> {
22    /// Resolved rendered value at the leaf of the path walk.
23    Value(&'a RenderedValue),
24    /// Dedicated per-CPU array key shape (u32, no struct).
25    PercpuKey { key: u32 },
26    /// Path could not be resolved.
27    Missing(SnapshotError),
28}
29
30impl<'a> SnapshotField<'a> {
31    /// Walk into a sub-field. Composable with
32    /// [`super::SnapshotEntry::get`].
33    pub fn get(&self, path: &str) -> SnapshotField<'a> {
34        match self {
35            SnapshotField::Value(v) => walk_dotted_path(v, path),
36            SnapshotField::PercpuKey { .. } => {
37                SnapshotField::Missing(SnapshotError::TypeMismatch {
38                    expected: "Struct".to_string(),
39                    actual:
40                        "Uint(percpu key) — call as_u64/as_i64/as_f64/as_bool for the key value"
41                            .to_string(),
42                    requested: path.to_string(),
43                })
44            }
45            SnapshotField::Missing(err) => SnapshotField::Missing(err.clone()),
46        }
47    }
48
49    /// True when the field resolved successfully.
50    pub fn is_present(&self) -> bool {
51        !matches!(self, SnapshotField::Missing(_))
52    }
53
54    /// Read as `u64`. Accepts [`RenderedValue::Uint`],
55    /// [`RenderedValue::Int`] (errors on negative),
56    /// [`RenderedValue::Bool`] (0/1), [`RenderedValue::Char`]
57    /// (raw byte), [`RenderedValue::Enum`] (raw enum integer),
58    /// [`RenderedValue::Ptr`] (pointer value), and the
59    /// percpu-array u32 key.
60    pub fn as_u64(&self) -> SnapshotResult<u64> {
61        match self {
62            SnapshotField::Value(v) => render_to_u64(v),
63            SnapshotField::PercpuKey { key } => Ok(u64::from(*key)),
64            SnapshotField::Missing(err) => Err(err.clone()),
65        }
66    }
67
68    /// Read as `i64`.
69    pub fn as_i64(&self) -> SnapshotResult<i64> {
70        match self {
71            SnapshotField::Value(v) => render_to_i64(v),
72            SnapshotField::PercpuKey { key } => Ok(i64::from(*key)),
73            SnapshotField::Missing(err) => Err(err.clone()),
74        }
75    }
76
77    /// Read as `bool`. [`RenderedValue::Bool`] direct, ints / enums
78    /// non-zero is true.
79    pub fn as_bool(&self) -> SnapshotResult<bool> {
80        match self {
81            SnapshotField::Value(v) => match v {
82                RenderedValue::Bool { value } => Ok(*value),
83                RenderedValue::Int { value, .. } => Ok(*value != 0),
84                RenderedValue::Uint { value, .. } => Ok(*value != 0),
85                RenderedValue::Char { value } => Ok(*value != 0),
86                RenderedValue::Enum { value, .. } => Ok(*value != 0),
87                RenderedValue::Ptr { value, .. } => Ok(*value != 0),
88                other => Err(SnapshotError::TypeMismatch {
89                    expected: "bool".to_string(),
90                    actual: describe_kind(other),
91                    requested: String::new(),
92                }),
93            },
94            SnapshotField::PercpuKey { key } => Ok(*key != 0),
95            SnapshotField::Missing(err) => Err(err.clone()),
96        }
97    }
98
99    /// Read as `f64`.
100    pub fn as_f64(&self) -> SnapshotResult<f64> {
101        match self {
102            SnapshotField::Value(v) => match v {
103                RenderedValue::Float { value, .. } => Ok(*value),
104                RenderedValue::Int { value, .. } => Ok(*value as f64),
105                RenderedValue::Uint { value, .. } => Ok(*value as f64),
106                RenderedValue::Enum { value, .. } => Ok(*value as f64),
107                other => Err(SnapshotError::TypeMismatch {
108                    expected: "f64".to_string(),
109                    actual: describe_kind(other),
110                    requested: String::new(),
111                }),
112            },
113            SnapshotField::PercpuKey { key } => Ok(f64::from(*key)),
114            SnapshotField::Missing(err) => Err(err.clone()),
115        }
116    }
117
118    /// Read the variant string for an [`RenderedValue::Enum`] with
119    /// a resolved variant name.
120    pub fn as_str(&self) -> SnapshotResult<&'a str> {
121        match self {
122            SnapshotField::Value(v) => match v {
123                RenderedValue::Enum {
124                    variant: Some(name),
125                    ..
126                } => Ok(name.as_str()),
127                other => Err(SnapshotError::TypeMismatch {
128                    expected: "str (enum variant name)".to_string(),
129                    actual: describe_kind(other),
130                    requested: String::new(),
131                }),
132            },
133            SnapshotField::PercpuKey { .. } => Err(SnapshotError::TypeMismatch {
134                expected: "str".to_string(),
135                actual: "Uint(percpu key) — call as_u64/as_i64/as_f64/as_bool for the key value"
136                    .to_string(),
137                requested: String::new(),
138            }),
139            SnapshotField::Missing(err) => Err(err.clone()),
140        }
141    }
142
143    /// Read as `Vec<u64>` from an [`RenderedValue::Array`] whose
144    /// every element coerces via [`Self::as_u64`]'s rules. Errors
145    /// with [`SnapshotError::TypeMismatch`] when self is not an
146    /// array, or when any element fails the coercion (no partial
147    /// results — the caller cannot tell which element silently
148    /// dropped). Mirrors [`RenderedValue::as_u64_array`] but
149    /// propagates the captured [`SnapshotError`] through the
150    /// [`SnapshotField::Missing`] arm.
151    pub fn as_u64_array(&self) -> SnapshotResult<Vec<u64>> {
152        render_to_typed_array(self, RenderedValue::as_u64, "u64")
153    }
154
155    /// Read as `Vec<u32>` from an array. Mirrors
156    /// [`RenderedValue::as_u32_array`]; out-of-range values
157    /// (Uint exceeding `u32::MAX`) error rather than truncate.
158    pub fn as_u32_array(&self) -> SnapshotResult<Vec<u32>> {
159        render_to_typed_array(
160            self,
161            |v| v.as_u64().and_then(|x| u32::try_from(x).ok()),
162            "u32",
163        )
164    }
165
166    /// Read as `Vec<i64>` from an array. Mirrors
167    /// [`RenderedValue::as_i64_array`].
168    pub fn as_i64_array(&self) -> SnapshotResult<Vec<i64>> {
169        render_to_typed_array(self, RenderedValue::as_i64, "i64")
170    }
171
172    /// Read as `Vec<f64>` from an array. Mirrors
173    /// [`RenderedValue::as_f64_array`].
174    pub fn as_f64_array(&self) -> SnapshotResult<Vec<f64>> {
175        render_to_typed_array(self, RenderedValue::as_f64, "f64")
176    }
177
178    /// Read as `Vec<bool>` from an array. Mirrors
179    /// [`RenderedValue::as_bool_array`].
180    pub fn as_bool_array(&self) -> SnapshotResult<Vec<bool>> {
181        render_to_typed_array(self, RenderedValue::as_bool, "bool")
182    }
183
184    /// Drop into the raw [`RenderedValue`] for direct
185    /// [`RenderedValue::member`] / [`RenderedValue::get`] /
186    /// [`RenderedValue::index`] navigation. Use when the
187    /// pattern-matched-into-known-shape access pattern (Option-
188    /// returning terminals, no rich error context) reads more
189    /// naturally than the SnapshotField's Result-propagating
190    /// chain. `None` for [`SnapshotField::PercpuKey`] (no
191    /// underlying tree) and [`SnapshotField::Missing`].
192    pub fn raw(&self) -> Option<&'a RenderedValue> {
193        match self {
194            SnapshotField::Value(v) => Some(v),
195            _ => None,
196        }
197    }
198
199    /// Iterate the elements of an array-shaped field as
200    /// [`SnapshotField`]s so chained navigation composes:
201    /// `field.iter_members().filter_map(|el| el.get("name").as_u64().ok())`.
202    /// Bridges the gap left by the scalar `as_*_array` terminals
203    /// on array-of-struct shapes: those terminals coerce each
204    /// element to a scalar via the shared coercion helper and
205    /// return [`SnapshotError::TypeMismatch`] on the first
206    /// non-scalar element, which is exactly what an array-of-struct
207    /// triggers. `iter_members` instead hands the caller each raw
208    /// element so they can chain `.get(field).as_u64()` per element.
209    /// Peels [`RenderedValue::Ptr`] dereferences and
210    /// [`RenderedValue::Truncated`] partial-array wrappers the
211    /// same way [`Self::as_u64_array`] does.
212    ///
213    /// Yields nothing for non-array shapes, percpu-key fields, or
214    /// missing fields — the empty iterator pattern is the natural
215    /// "no elements to walk" representation when the chain just
216    /// wants to fold over what's there. `iter_members` itself never
217    /// surfaces [`SnapshotError::TypeMismatch`]; callers needing to
218    /// distinguish "absent" from "empty" check [`Self::is_present`]
219    /// or [`Self::error`] explicitly.
220    pub fn iter_members(&self) -> impl Iterator<Item = SnapshotField<'a>> + '_ {
221        let elements = match self {
222            SnapshotField::Value(v) => array_elements_of(v),
223            _ => &[],
224        };
225        elements.iter().map(SnapshotField::Value)
226    }
227
228    /// Error reference when the field is missing; `None`
229    /// otherwise.
230    pub fn error(&self) -> Option<&SnapshotError> {
231        match self {
232            SnapshotField::Missing(err) => Some(err),
233            _ => None,
234        }
235    }
236}
237
238// ---------------------------------------------------------------------------
239// JSON dotted-path accessor (mirrors SnapshotField for stats values)
240// ---------------------------------------------------------------------------
241
242/// Walk a [`RenderedValue`] tree along a dotted path. Each
243/// component matches a [`RenderedMember::name`] inside a
244/// [`RenderedValue::Struct`]; [`RenderedValue::Ptr`] dereferences
245/// are followed transparently. An empty path returns the root.
246pub(crate) fn walk_dotted_path<'a>(root: &'a RenderedValue, path: &str) -> SnapshotField<'a> {
247    if path.is_empty() {
248        return SnapshotField::Value(root);
249    }
250    let mut cursor: &RenderedValue = root;
251    let mut walked = String::new();
252    for component in path.split('.') {
253        if component.is_empty() {
254            return SnapshotField::Missing(SnapshotError::EmptyPathComponent {
255                requested: path.to_string(),
256            });
257        }
258        cursor = peel_pointer(cursor);
259        let RenderedValue::Struct { members, .. } = cursor else {
260            return SnapshotField::Missing(SnapshotError::NotAStruct {
261                requested: path.to_string(),
262                walked: walked.clone(),
263                component: component.to_string(),
264                kind: describe_kind(cursor),
265            });
266        };
267        let next = members.iter().find(|m| m.name == component);
268        let Some(member) = next else {
269            let names: Vec<String> = members.iter().map(|m| m.name.clone()).collect();
270            return SnapshotField::Missing(SnapshotError::FieldNotFound {
271                requested: path.to_string(),
272                walked: walked.clone(),
273                component: component.to_string(),
274                available: names,
275            });
276        };
277        cursor = &member.value;
278        if !walked.is_empty() {
279            walked.push('.');
280        }
281        walked.push_str(component);
282    }
283    SnapshotField::Value(cursor)
284}
285
286/// Look up a single top-level member by exact name. Used by
287/// [`super::Snapshot::var`].
288pub(super) fn lookup_member<'a>(value: &'a RenderedValue, name: &str) -> Option<&'a RenderedValue> {
289    let v = peel_pointer(value);
290    let RenderedValue::Struct { members, .. } = v else {
291        return None;
292    };
293    members
294        .iter()
295        .find(|m: &&RenderedMember| m.name == name)
296        .map(|m| &m.value)
297}
298
299/// Peel through any [`RenderedValue::Ptr`] layers whose `deref`
300/// is `Some`. Stops at the first non-pointer (or a pointer
301/// without a chased deref).
302fn peel_pointer(mut v: &RenderedValue) -> &RenderedValue {
303    let mut steps = 0;
304    while let RenderedValue::Ptr {
305        deref: Some(inner), ..
306    } = v
307    {
308        v = inner.as_ref();
309        steps += 1;
310        if steps > 16 {
311            break;
312        }
313    }
314    v
315}
316
317/// Human-readable variant name used in error messages.
318fn describe_kind(v: &RenderedValue) -> String {
319    match v {
320        RenderedValue::Int { .. } => "Int",
321        RenderedValue::Uint { .. } => "Uint",
322        RenderedValue::Bool { .. } => "Bool",
323        RenderedValue::Char { .. } => "Char",
324        RenderedValue::Float { .. } => "Float",
325        RenderedValue::Enum { .. } => "Enum",
326        RenderedValue::Struct { .. } => "Struct",
327        RenderedValue::Array { .. } => "Array",
328        RenderedValue::CpuList { .. } => "CpuList",
329        RenderedValue::Ptr { .. } => "Ptr",
330        RenderedValue::Bytes { .. } => "Bytes",
331        RenderedValue::Truncated { .. } => "Truncated",
332        RenderedValue::Unsupported { .. } => "Unsupported",
333    }
334    .to_string()
335}
336
337/// Shared array-elements walker: peel [`RenderedValue::Ptr`]'s
338/// deref and [`RenderedValue::Truncated`]'s partial recursively
339/// to reach an [`RenderedValue::Array`], returning the elements
340/// slice on success. On a non-array variant (after peeling),
341/// `Err` carries the unwrapped inner value so callers that want
342/// a typed mismatch diagnostic can name the actual variant via
343/// [`describe_kind`].
344fn array_elements_or_mismatch(v: &RenderedValue) -> Result<&[RenderedValue], &RenderedValue> {
345    match v {
346        RenderedValue::Array { elements, .. } => Ok(elements.as_slice()),
347        RenderedValue::Ptr {
348            deref: Some(inner), ..
349        } => array_elements_or_mismatch(inner.as_ref()),
350        RenderedValue::Truncated { partial, .. } => array_elements_or_mismatch(partial.as_ref()),
351        other => Err(other),
352    }
353}
354
355/// Borrow the elements slice of an [`RenderedValue::Array`],
356/// peeling [`RenderedValue::Ptr`]'s deref and
357/// [`RenderedValue::Truncated`]'s partial. Returns the empty
358/// slice for any non-array variant so the caller's iterator
359/// chain yields no elements cleanly. Thin wrapper over
360/// [`array_elements_or_mismatch`] that swallows the typed
361/// mismatch — appropriate for [`SnapshotField::iter_members`]
362/// whose empty-iterator contract distinguishes absent vs empty
363/// via [`SnapshotField::is_present`] / [`SnapshotField::error`]
364/// rather than via a returned error.
365fn array_elements_of(v: &RenderedValue) -> &[RenderedValue] {
366    array_elements_or_mismatch(v).unwrap_or(&[])
367}
368
369/// Shared typed-array coercion used by [`SnapshotField::as_u64_array`]
370/// and siblings. `coerce` is the per-element scalar extractor that
371/// returns `None` when the element fails the coercion (matches the
372/// [`RenderedValue`] inherent `.as_*` Option-returning shape).
373/// `type_name` names the requested element type for diagnostics.
374fn render_to_typed_array<T, F>(
375    field: &SnapshotField<'_>,
376    coerce: F,
377    type_name: &'static str,
378) -> SnapshotResult<Vec<T>>
379where
380    F: Fn(&RenderedValue) -> Option<T>,
381{
382    let value = match field {
383        SnapshotField::Value(v) => *v,
384        SnapshotField::PercpuKey { .. } => {
385            return Err(SnapshotError::TypeMismatch {
386                expected: format!("[{type_name}]"),
387                actual: "Uint(percpu key) — call as_u64/as_i64/as_f64/as_bool for the key value"
388                    .to_string(),
389                requested: String::new(),
390            });
391        }
392        SnapshotField::Missing(err) => return Err(err.clone()),
393    };
394    let elements = array_elements_or_mismatch(value).map_err(|other| {
395        // Diagnostic wrapping mirrors the operator-facing form the
396        // legacy code emitted for the common one-deep cases:
397        //   - top-level `Truncated{partial: NonArray}` reports
398        //     `Truncated(partial=<inner-kind>)` so the operator
399        //     can tell the partial wrapper hid a non-array shape
400        //     (vs the top level just not being an array).
401        //   - all other paths (top-level non-array, top-level Ptr,
402        //     and any deeper-nested wrapper combination) report
403        //     the unwrapped leaf kind directly.
404        // The shared walker recurses through Ptr+Truncated, so
405        // arbitrary nesting around an Array now succeeds (matches
406        // RenderedValue::array_elements semantics at
407        // src/monitor/btf_render/mod.rs). On failure of a nested
408        // shape (e.g. Ptr→Truncated→NonArray), the diagnostic
409        // collapses to the leaf kind rather than narrating the
410        // wrapper stack — sufficient context for the operator
411        // since the wrapper structure is renderer-internal and
412        // not load-bearing for assertions.
413        let actual = match value {
414            RenderedValue::Truncated { .. } => {
415                format!("Truncated(partial={})", describe_kind(other))
416            }
417            _ => describe_kind(other),
418        };
419        SnapshotError::TypeMismatch {
420            expected: format!("[{type_name}]"),
421            actual,
422            requested: String::new(),
423        }
424    })?;
425    let mut out = Vec::with_capacity(elements.len());
426    for (i, element) in elements.iter().enumerate() {
427        let v = coerce(element).ok_or_else(|| SnapshotError::TypeMismatch {
428            expected: format!("[{type_name}]"),
429            actual: format!("{}[{i}]={}", "Array", describe_kind(element)),
430            requested: String::new(),
431        })?;
432        out.push(v);
433    }
434    Ok(out)
435}
436
437/// Shared u64 coercion used by [`SnapshotField::as_u64`].
438fn render_to_u64(v: &RenderedValue) -> SnapshotResult<u64> {
439    match v {
440        RenderedValue::Uint { value, .. } => Ok(*value),
441        RenderedValue::Int { value, .. } => {
442            if *value < 0 {
443                Err(SnapshotError::TypeMismatch {
444                    expected: "u64".to_string(),
445                    actual: "Int(negative)".to_string(),
446                    requested: String::new(),
447                })
448            } else {
449                Ok(*value as u64)
450            }
451        }
452        RenderedValue::Bool { value } => Ok(u64::from(*value)),
453        RenderedValue::Char { value } => Ok(u64::from(*value)),
454        RenderedValue::Enum {
455            value, is_signed, ..
456        } => {
457            // Mirror RenderedValue::as_u64's enum dispatch so the two
458            // surfaces agree on signedness handling: unsigned enums
459            // reinterpret the bit pattern (the renderer stores i64,
460            // so an unsigned u64 wire value with the high bit set
461            // arrives here as a negative i64 — `as u64` recovers the
462            // bits); signed enums reject negative variants as
463            // out-of-range. Without this branch, an unsigned 64-bit
464            // enum at u64::MAX (stored as i64=-1) returned
465            // TypeMismatch from this path while RenderedValue::as_u64
466            // returned Some(u64::MAX) — same value, two surfaces
467            // disagreed.
468            if *is_signed && *value < 0 {
469                Err(SnapshotError::TypeMismatch {
470                    expected: "u64".to_string(),
471                    actual: "Enum(signed-negative)".to_string(),
472                    requested: String::new(),
473                })
474            } else {
475                Ok(*value as u64)
476            }
477        }
478        RenderedValue::Ptr { value, .. } => Ok(*value),
479        other => Err(SnapshotError::TypeMismatch {
480            expected: "u64".to_string(),
481            actual: describe_kind(other),
482            requested: String::new(),
483        }),
484    }
485}
486
487/// Shared i64 coercion used by [`SnapshotField::as_i64`].
488fn render_to_i64(v: &RenderedValue) -> SnapshotResult<i64> {
489    match v {
490        RenderedValue::Int { value, .. } => Ok(*value),
491        RenderedValue::Uint { value, .. } => {
492            if *value > i64::MAX as u64 {
493                Err(SnapshotError::TypeMismatch {
494                    expected: "i64".to_string(),
495                    actual: "Uint(>i64::MAX)".to_string(),
496                    requested: String::new(),
497                })
498            } else {
499                Ok(*value as i64)
500            }
501        }
502        RenderedValue::Bool { value } => Ok(i64::from(*value)),
503        RenderedValue::Char { value } => Ok(i64::from(*value)),
504        RenderedValue::Enum { value, .. } => Ok(*value),
505        other => Err(SnapshotError::TypeMismatch {
506            expected: "i64".to_string(),
507            actual: describe_kind(other),
508            requested: String::new(),
509        }),
510    }
511}
512
513#[cfg(test)]
514mod tests_coercion {
515    //! Host-pure coverage for the [`SnapshotField`] coercion paths
516    //! ([`render_to_u64`] / [`render_to_i64`], the inline `as_bool` /
517    //! `as_f64` / `as_str` matches, the dotted-path walk, and the
518    //! typed-array terminals). These decide whether a BTF-snapshot
519    //! read SUCCEEDS or surfaces a typed [`SnapshotError`]; a swapped
520    //! arm silently coerces a value the accessor should reject.
521    use super::*;
522
523    fn ptr(value: u64) -> RenderedValue {
524        RenderedValue::Ptr {
525            value,
526            deref: None,
527            deref_skipped_reason: None,
528            cast_annotation: None,
529        }
530    }
531
532    /// REGRESSION: the scalar `SnapshotField::as_bool` and the array
533    /// `as_bool_array` (which coerces each element via
534    /// `RenderedValue::as_bool`) must AGREE on a pointer. Before the
535    /// `Ptr` arm landed on `RenderedValue::as_bool`, the scalar
536    /// accepted a pointer (non-null test) while the array path errored
537    /// on the first pointer element — `field.as_bool()` succeeded and
538    /// `field.as_bool_array()` failed on the same shape.
539    #[test]
540    fn as_bool_scalar_and_array_agree_on_pointer() {
541        let non_null = ptr(0x1000);
542        let null = ptr(0);
543        // Scalar: pointer coerces as a non-null test.
544        assert!(SnapshotField::Value(&non_null).as_bool().unwrap());
545        assert!(!SnapshotField::Value(&null).as_bool().unwrap());
546        // Array: the same per-element coercion, no TypeMismatch.
547        let arr = RenderedValue::Array {
548            len: 2,
549            elements: vec![ptr(0x2000), ptr(0)],
550        };
551        assert_eq!(
552            SnapshotField::Value(&arr).as_bool_array().unwrap(),
553            vec![true, false],
554        );
555    }
556
557    /// `as_u64` accepts the pointer / char / bool scalar variants
558    /// (pointer as its numeric address, char as the raw byte, bool as
559    /// 0/1) — the wider integer-coercion set.
560    #[test]
561    fn as_u64_accepts_ptr_char_bool() {
562        assert_eq!(SnapshotField::Value(&ptr(0xdead)).as_u64().unwrap(), 0xdead);
563        let c = RenderedValue::Char { value: 65 };
564        assert_eq!(SnapshotField::Value(&c).as_u64().unwrap(), 65);
565        let b = RenderedValue::Bool { value: true };
566        assert_eq!(SnapshotField::Value(&b).as_u64().unwrap(), 1);
567    }
568
569    /// `render_to_u64`'s enum arm mirrors `RenderedValue::as_u64`:
570    /// an unsigned 64-bit enum at `u64::MAX` (stored as `i64 = -1`)
571    /// reinterprets the bit pattern, while a signed-negative enum is
572    /// rejected as out-of-range.
573    #[test]
574    fn as_u64_enum_signedness() {
575        let unsigned_max = RenderedValue::Enum {
576            bits: 64,
577            value: -1,
578            variant: None,
579            is_signed: false,
580        };
581        assert_eq!(
582            SnapshotField::Value(&unsigned_max).as_u64().unwrap(),
583            u64::MAX,
584        );
585        let signed_neg = RenderedValue::Enum {
586            bits: 32,
587            value: -5,
588            variant: None,
589            is_signed: true,
590        };
591        assert!(matches!(
592            SnapshotField::Value(&signed_neg).as_u64(),
593            Err(SnapshotError::TypeMismatch { .. })
594        ));
595    }
596
597    /// `as_u64` rejects a negative `Int`; `as_i64` rejects a `Uint`
598    /// above `i64::MAX` — the sign-loss boundaries.
599    #[test]
600    fn integer_sign_boundaries_error() {
601        let neg = RenderedValue::Int {
602            bits: 32,
603            value: -1,
604        };
605        assert!(matches!(
606            SnapshotField::Value(&neg).as_u64(),
607            Err(SnapshotError::TypeMismatch { .. })
608        ));
609        let big = RenderedValue::Uint {
610            bits: 64,
611            value: u64::MAX,
612        };
613        assert!(matches!(
614            SnapshotField::Value(&big).as_i64(),
615            Err(SnapshotError::TypeMismatch { .. })
616        ));
617    }
618
619    /// `as_f64` is narrower than `as_u64`: it accepts Float / Int /
620    /// Uint / Enum but rejects Char, Bool, and Ptr (a float of a
621    /// pointer or a char is not meaningful).
622    #[test]
623    fn as_f64_rejects_char_bool_ptr() {
624        let f = RenderedValue::Float {
625            bits: 64,
626            value: 1.5,
627        };
628        assert_eq!(SnapshotField::Value(&f).as_f64().unwrap(), 1.5);
629        for v in [
630            RenderedValue::Char { value: 1 },
631            RenderedValue::Bool { value: true },
632            ptr(0x10),
633        ] {
634            assert!(
635                matches!(
636                    SnapshotField::Value(&v).as_f64(),
637                    Err(SnapshotError::TypeMismatch { .. })
638                ),
639                "as_f64 must reject {v:?}",
640            );
641        }
642    }
643
644    /// `as_str` reads an enum's resolved variant name and rejects
645    /// non-enum / nameless-enum / percpu-key shapes.
646    #[test]
647    fn as_str_reads_enum_variant_else_errors() {
648        let named = RenderedValue::Enum {
649            bits: 32,
650            value: 2,
651            variant: Some("SCX_OPS_ENABLED".to_string()),
652            is_signed: false,
653        };
654        assert_eq!(
655            SnapshotField::Value(&named).as_str().unwrap(),
656            "SCX_OPS_ENABLED"
657        );
658        let nameless = RenderedValue::Enum {
659            bits: 32,
660            value: 2,
661            variant: None,
662            is_signed: false,
663        };
664        assert!(SnapshotField::Value(&nameless).as_str().is_err());
665        assert!(SnapshotField::PercpuKey { key: 3 }.as_str().is_err());
666    }
667
668    /// The dotted-path walk peels `Ptr{deref: Some}` to the pointed-at
669    /// struct, resolves a member, and surfaces structured errors for a
670    /// non-struct cursor and an empty path component.
671    #[test]
672    fn walk_dotted_path_peels_pointer_and_reports_errors() {
673        let inner = RenderedValue::Struct {
674            type_name: Some("scx_bss".to_string()),
675            members: vec![RenderedMember {
676                name: "stall".to_string(),
677                value: RenderedValue::Uint { bits: 8, value: 1 },
678            }],
679        };
680        let through_ptr = RenderedValue::Ptr {
681            value: 0xffff_0000,
682            deref: Some(Box::new(inner)),
683            deref_skipped_reason: None,
684            cast_annotation: None,
685        };
686        assert_eq!(
687            SnapshotField::Value(&through_ptr)
688                .get("stall")
689                .as_u64()
690                .unwrap(),
691            1,
692        );
693        // Walking into a scalar surfaces NotAStruct.
694        let scalar = RenderedValue::Uint { bits: 8, value: 5 };
695        assert!(matches!(
696            SnapshotField::Value(&scalar).get("x"),
697            SnapshotField::Missing(SnapshotError::NotAStruct { .. })
698        ));
699        // An empty component (`a..b`) surfaces EmptyPathComponent.
700        let s = RenderedValue::Struct {
701            type_name: None,
702            members: vec![RenderedMember {
703                name: "a".to_string(),
704                value: RenderedValue::Uint { bits: 8, value: 0 },
705            }],
706        };
707        assert!(matches!(
708            SnapshotField::Value(&s).get("a..b"),
709            SnapshotField::Missing(SnapshotError::EmptyPathComponent { .. })
710        ));
711    }
712
713    /// REGRESSION: `peel_pointer`'s runaway-depth guard
714    /// (`if steps > 16 { break }`) stops chasing a pointer chain
715    /// deeper than the cap. A chain of 18 nested `Ptr{deref: Some}`
716    /// layers wrapping a final struct never reaches the struct: the
717    /// loop peels 17 layers then breaks with the cursor still a
718    /// `Ptr`, so the dotted-path walk lands on the Ptr (NotAStruct)
719    /// rather than the deeply-nested member. This is the load-bearing
720    /// defense against a malformed / cyclic rendered tree spinning
721    /// forever during host-side traversal of an attacker-influenced
722    /// BTF render. Without the cap, the walk would peel all 18 and
723    /// resolve `x`.
724    #[test]
725    fn peel_pointer_breaks_at_sixteen_step_cap_on_pointer_cycle() {
726        // Innermost: a struct carrying the target member.
727        let mut deep = RenderedValue::Struct {
728            type_name: Some("scx_bss".to_string()),
729            members: vec![RenderedMember {
730                name: "x".to_string(),
731                value: RenderedValue::Uint { bits: 8, value: 1 },
732            }],
733        };
734        // Wrap 18 times in Ptr{deref: Some(prev)} — past the 16-step cap.
735        for _ in 0..18 {
736            deep = RenderedValue::Ptr {
737                value: 0xff,
738                deref: Some(Box::new(deep)),
739                deref_skipped_reason: None,
740                cast_annotation: None,
741            };
742        }
743        let f = SnapshotField::Value(&deep).get("x");
744        // Peeling stopped at the cap with the cursor still a Ptr, so
745        // the struct member is unreachable: NotAStruct, not Value.
746        assert!(
747            matches!(f, SnapshotField::Missing(SnapshotError::NotAStruct { .. })),
748            "16-step cap must leave the cursor on a Ptr (NotAStruct), got {f:?}"
749        );
750    }
751
752    /// `get` on a percpu-key field surfaces a TypeMismatch naming the
753    /// percpu-key shape (no struct to walk into); `is_present`/`raw`/
754    /// `error` reflect each variant.
755    #[test]
756    fn percpu_key_navigation_and_view_helpers() {
757        let pk = SnapshotField::PercpuKey { key: 7 };
758        assert!(matches!(
759            pk.get("x"),
760            SnapshotField::Missing(SnapshotError::TypeMismatch { .. })
761        ));
762        assert!(pk.is_present());
763        assert!(pk.raw().is_none());
764        assert!(pk.error().is_none());
765        assert_eq!(pk.as_u64().unwrap(), 7);
766
767        let missing = SnapshotField::Missing(SnapshotError::EmptyPathComponent {
768            requested: "x".to_string(),
769        });
770        assert!(!missing.is_present());
771        assert!(missing.error().is_some());
772        assert!(matches!(
773            missing.as_u64(),
774            Err(SnapshotError::EmptyPathComponent { .. })
775        ));
776    }
777
778    /// `iter_members` peels `Truncated{partial: Array}` and yields the
779    /// preserved elements; a non-array (after peeling) yields nothing.
780    #[test]
781    fn iter_members_peels_truncated_array_else_empty() {
782        let truncated = RenderedValue::Truncated {
783            needed: 32,
784            had: 16,
785            partial: Box::new(RenderedValue::Array {
786                len: 4,
787                elements: vec![
788                    RenderedValue::Uint { bits: 8, value: 10 },
789                    RenderedValue::Uint { bits: 8, value: 20 },
790                ],
791            }),
792        };
793        let got: Vec<u64> = SnapshotField::Value(&truncated)
794            .iter_members()
795            .map(|el| el.as_u64().unwrap())
796            .collect();
797        assert_eq!(got, vec![10, 20]);
798        // A scalar yields no elements.
799        let scalar = RenderedValue::Uint { bits: 8, value: 1 };
800        assert_eq!(SnapshotField::Value(&scalar).iter_members().count(), 0);
801    }
802}