ktstr/monitor/btf_render/mod.rs
1//! BTF-driven rendering of raw value bytes into structured output.
2//!
3//! [`render_value`] takes a BTF type id and a byte slice and produces
4//! a [`RenderedValue`] tree that mirrors the type's structure: ints,
5//! floats, enums, structs, arrays, pointers. Modifier qualifiers
6//! ([`btf_rs::Type::Volatile`], [`btf_rs::Type::Const`],
7//! [`btf_rs::Type::Restrict`], [`btf_rs::Type::Typedef`],
8//! [`btf_rs::Type::TypeTag`], [`btf_rs::Type::DeclTag`]) are peeled
9//! before dispatch. [`render_value_with_mem`] is the production
10//! entry point that additionally accepts a [`MemReader`] so the
11//! [`btf_rs::Type::Ptr`] arm and the cast-intercept path
12//! (consulting [`MemReader::cast_lookup`]) chase pointers through
13//! arena snapshots, slab/vmalloc reads, the sdt_alloc bridge, and
14//! the cross-BTF Fwd resolution index.
15//!
16//! The renderer is total: any type kind it cannot decode (Func, FuncProto,
17//! Fwd, Void, or a bytes slice shorter than the type's declared size)
18//! yields a [`RenderedValue::Unsupported`] or [`RenderedValue::Truncated`]
19//! node so the caller always gets a well-formed tree it can serialize.
20//!
21//! `BTF_KIND_DATASEC` (the type libbpf assigns as the value type of a
22//! global-section ARRAY map like `.bss` / `.data` / `.rodata`) is
23//! rendered by walking its `VarSecinfo` entries. Each VarSecinfo points
24//! at a `BTF_KIND_VAR`, which in turn references the variable's actual
25//! type. The renderer slices the section bytes at
26//! `[var_secinfo.offset()..var_secinfo.offset() + var_secinfo.size()]`
27//! and recursively renders the variable's type into that slice. The
28//! result is a [`RenderedValue::Struct`] whose `type_name` is the
29//! section name and whose `members` enumerate the section's variables
30//! by their declared names, so a failure dump's `.bss` map shows
31//! `stall=1, crash=0, ...` instead of an opaque hex dump.
32//!
33//! Bitfield handling: when [`btf_rs::Member::bitfield_size`] is `Some(w)`,
34//! the renderer reads enough bytes to cover the bitfield's bit range,
35//! shifts and masks, and applies sign extension when the underlying
36//! type is a signed Int, signed Enum, or signed Enum64 — BTF bitfields
37//! can carry any of those bases (e.g. `enum scx_exit_kind` declared
38//! with negative members).
39//!
40//! # Panics on malformed BTF
41//!
42//! [`btf_rs::Int::size`], [`btf_rs::Struct::size`] (and
43//! [`btf_rs::Union::size`] via `pub type Union = Struct;`),
44//! [`btf_rs::Enum::size`], [`btf_rs::Enum64::size`],
45//! [`btf_rs::Float::size`], and [`btf_rs::Datasec::size`] each panic
46//! when the underlying BTF type record has a kind discriminant
47//! claiming "Int/Struct/Union/Enum/etc." but a size field that the
48//! kernel's btf-type union decoded as the type-id alternative — a
49//! structural-validity violation that is not a recoverable parse
50//! error in `btf_rs` 2.0. This renderer calls those `size()`
51//! accessors after `Type::X(...)` discriminant match, which guards
52//! against wrong-kind dispatch but does NOT guard against the
53//! kind-vs-size-field skew inside a single record.
54//!
55//! Malformed BTF is treated as a producer bug (kernel BTF blob, BPF
56//! object's program BTF, or operator-supplied `--vmlinux` override).
57//! The renderer surfaces the panic loudly rather than silently
58//! rendering nonsense — aligned with the "no silent data drops"
59//! invariant. A panic here points the operator at the BTF producer,
60//! not the dump pipeline.
61
62use std::borrow::Cow;
63use std::collections::HashSet;
64
65use serde::{Deserialize, Serialize};
66
67use btf_rs::{Btf, BtfType, Member, Struct, Type};
68
69use super::dump::hex_dump;
70
71/// Maximum number of qualifier-peel iterations before [`peel_modifiers`]
72/// gives up. BTF chains are bounded in practice (struct → typedef →
73/// const → volatile is already 4); the cap protects the renderer from
74/// a malformed BTF input that introduces a self-referential cycle.
75const MAX_MODIFIER_DEPTH: u32 = 32;
76
77/// Maximum array length the renderer expands element-by-element. Larger
78/// arrays are truncated with the original length recorded so the caller
79/// can serialize a partial view rather than allocating a million
80/// [`RenderedValue`] nodes.
81const MAX_ARRAY_ELEMS: usize = 4096;
82
83/// Recursion depth limit for nested struct / array rendering. Same
84/// motivation as [`MAX_MODIFIER_DEPTH`]: bound output size on
85/// pathological BTF.
86const MAX_RENDER_DEPTH: u32 = 32;
87
88/// Structured rendering of one BTF-typed value.
89///
90/// The `kind` tag identifies the variant; field order matches the
91/// rendering pipeline (Int / Uint / Bool / Char before Float / Enum /
92/// Struct / Array / CpuList / Ptr, with Bytes / Truncated / Unsupported
93/// as the recovery path).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96#[allow(dead_code)]
97pub enum RenderedValue {
98 /// Signed integer. `bits` is the BTF-declared width.
99 ///
100 /// **Always signed by construction.** The internal `render_int`
101 /// routes `BTF_KIND_INT` with `is_signed() = false` to
102 /// [`Self::Uint`]; `is_signed() = true` to [`Self::Int`]. The
103 /// variant choice IS the signedness tag — no equivalent of
104 /// `Enum::is_signed` is needed because unsigned ints never land
105 /// here.
106 Int { bits: u32, value: i64 },
107 /// Unsigned integer. `bits` is the BTF-declared width. See the
108 /// signedness-by-variant note on [`Self::Int`].
109 Uint { bits: u32, value: u64 },
110 /// Boolean (BTF int with `is_bool()`).
111 Bool { value: bool },
112 /// Character (BTF int with `is_char()`). `value` holds the raw
113 /// byte for round-tripping; non-printable values are preserved.
114 /// Field name matches the other scalar variants (Int / Uint / Bool /
115 /// Float / Enum / Ptr) so any field-driven serializer treats them
116 /// uniformly.
117 Char { value: u8 },
118 /// IEEE-754 float (BTF_KIND_FLOAT).
119 Float { bits: u32, value: f64 },
120 /// Enum value with optional resolved variant name.
121 ///
122 /// `is_signed` tracks the BTF type's signedness so accessors
123 /// downstream can decide whether to reinterpret the bit
124 /// pattern (unsigned: `value as u64` gives the wire bits back
125 /// even when the storage width forces a negative `i64`) or
126 /// treat negative values as out-of-range (signed: a true
127 /// negative enum variant is not coercible to `u64`).
128 ///
129 /// Constructed by the BTF renderer; downstream consumers read
130 /// `is_signed` (or trust the typed accessors like
131 /// [`RenderedValue::as_u64`] to dispatch correctly) rather than
132 /// building `Enum` literals directly. `#[serde(default)]` lets
133 /// pre-`is_signed` archives (test sidecars, captured failure
134 /// dumps) deserialize as `is_signed: false`, the default. Note
135 /// that this changes behavior for old archives carrying a
136 /// negative `value`: pre-field [`RenderedValue::as_u64`] returned
137 /// `None` for any negative stored value (whether the BTF type
138 /// was signed-negative OR unsigned-with-high-bit-set, since the
139 /// renderer never tracked the distinction); post-field, a
140 /// deserialize-as-default `is_signed: false` reinterprets the
141 /// bit pattern via `as u64`. That shift is intentional — old
142 /// archives could not encode the signedness, and the conservative
143 /// "treat as unsigned bits" recovers more cases than the old
144 /// "any negative is rejected" behavior.
145 Enum {
146 bits: u32,
147 value: i64,
148 variant: Option<String>,
149 #[serde(default)]
150 is_signed: bool,
151 },
152 /// Aggregate (struct or union). For unions, only the first member
153 /// is meaningful — the renderer emits all members each backed by
154 /// the same byte range so the caller can pick.
155 Struct {
156 type_name: Option<String>,
157 members: Vec<RenderedMember>,
158 },
159 /// Array. `elements` is truncated to `MAX_ARRAY_ELEMS`.
160 Array {
161 len: usize,
162 elements: Vec<RenderedValue>,
163 },
164 /// CPU bitmask rendered as a range-collapsed list. Produced when
165 /// the renderer detects a `cpumask`, `bpf_cpumask`, or `scx_bitmap`
166 /// struct by BTF type name.
167 ///
168 /// Field-name exception: scalar variants (Int / Uint / Bool /
169 /// Char / Float / Enum / Ptr) name the inner field `value` for
170 /// uniform serialization; CpuList breaks the convention by
171 /// using `cpus` because the rendered text is type-specific
172 /// (e.g. `"0-2,5"`) and reads more naturally as `cpus` in
173 /// JSON consumers — `value` would be misleading for a non-
174 /// scalar payload. Char keeps `value` since its payload is a
175 /// single byte (the BTF int type is the underlying scalar).
176 CpuList { cpus: String },
177 /// Pointer value with optional dereferenced content. When `deref`
178 /// is Some, the pointer was chased at dump time and the target
179 /// struct is rendered inline. `deref_skipped_reason` carries the
180 /// cause when the chase was attempted but did not produce a
181 /// deref — `None` means no chase was attempted (e.g. null
182 /// pointer or no `MemReader` supplied), and a non-`None`
183 /// reason with `deref: None` means the chase was attempted but
184 /// could not complete (cross-page boundary, BTF-size truncated
185 /// against the read cap, kernel kptr that failed plausibility
186 /// gating, etc.). The reason field enables the consumer to
187 /// distinguish "we didn't try" from "we tried and failed for
188 /// reason X" without a separate flag.
189 ///
190 /// `cast_annotation` distinguishes cast-recovered pointers
191 /// (set by `render_cast_pointer` to `"cast→arena"` /
192 /// `"cast→kernel"`) from BTF-typed pointers (the
193 /// [`Type::Ptr`] arm normally leaves it `None`). Display
194 /// surfaces it as a parenthesised tag so operators can tell
195 /// at a glance whether the pointer came from native BTF
196 /// typing or the cast analyzer's recovery path.
197 ///
198 /// One [`Type::Ptr`] exception: when the renderer recovers a
199 /// `BTF_KIND_FWD` pointee's real struct id via the sdt_alloc
200 /// bridge (`MemReader::resolve_arena_type`), the arena
201 /// branch sets this field to `"sdt_alloc"` so the rendered
202 /// subtree is flagged as a recovered chase rather than a
203 /// native BTF resolve. Cast-recovered pointers that cleared
204 /// the same bridge extend the annotation to
205 /// `"cast→{addr_space} (sdt_alloc)"`.
206 ///
207 /// Storage is [`Cow<'static, str>`] so the renderer's emit
208 /// sites — every value the renderer itself produces is one
209 /// of a five-element closed set
210 /// (`"sdt_alloc"`, `"cast→arena"`, `"cast→kernel"`,
211 /// `"cast→arena (sdt_alloc)"`, `"cast→kernel (sdt_alloc)"`)
212 /// — borrow `&'static str` literals via [`Cow::Borrowed`]
213 /// without per-chase heap allocations. JSON deserialization
214 /// produces [`Cow::Owned`] (serde's [`Cow`] impl forwards
215 /// to `String`'s deserializer), so existing serialized
216 /// snapshots round-trip unchanged.
217 Ptr {
218 value: u64,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 deref: Option<Box<RenderedValue>>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 deref_skipped_reason: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 cast_annotation: Option<Cow<'static, str>>,
225 },
226 /// Fallback hex dump for types the renderer can decode the size of
227 /// but not the structure (e.g. `BTF_KIND_FWD`). Hex is lowercase,
228 /// space-separated.
229 Bytes { hex: String },
230 /// The byte slice ended before the type's declared size. `needed`
231 /// is the required byte count; `had` is what was supplied.
232 /// `partial` carries whatever decoded successfully before the
233 /// truncation: a `Struct` with the members that fit (further
234 /// truncated members nest as their own `Truncated`), an
235 /// `Array` with the elements that fit, or a `Bytes` hex dump of
236 /// the raw bytes that were available when no structured partial
237 /// applied (e.g. a 2-byte slice for a 4-byte int).
238 Truncated {
239 needed: usize,
240 had: usize,
241 partial: Box<RenderedValue>,
242 },
243 /// BTF type kind the renderer does not handle (Func, FuncProto,
244 /// Fwd, Void, or a kind beyond the qualifier-peel cap).
245 /// `reason` carries the human-readable cause.
246 Unsupported { reason: String },
247}
248
249/// One member of a [`RenderedValue::Struct`]. `name` is the BTF name;
250/// for anonymous union members it is empty. `value` is the recursive
251/// rendering of the member's bytes.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct RenderedMember {
254 pub name: String,
255 pub value: RenderedValue,
256}
257
258impl RenderedValue {
259 /// Single-step struct-member walk. Returns the named member of a
260 /// `Struct` (or `Truncated{partial: Struct}`), peeling through
261 /// `Ptr{deref: Some}` transparently. `None` for any other variant
262 /// or when the member name is absent.
263 pub fn member(&self, name: &str) -> Option<&RenderedValue> {
264 match self {
265 RenderedValue::Struct { members, .. } => {
266 members.iter().find(|m| m.name == name).map(|m| &m.value)
267 }
268 RenderedValue::Ptr {
269 deref: Some(inner), ..
270 } => inner.member(name),
271 RenderedValue::Truncated { partial, .. } => partial.member(name),
272 _ => None,
273 }
274 }
275
276 /// Array-element walk by 0-indexed position. Returns the i-th
277 /// element of an `Array` (or `Truncated{partial: Array}` —
278 /// `None` if `i` is past what the truncation preserved), peeling
279 /// through `Ptr{deref: Some}`. `None` for any other variant or
280 /// out-of-range index.
281 pub fn index(&self, i: usize) -> Option<&RenderedValue> {
282 match self {
283 RenderedValue::Array { elements, .. } => elements.get(i),
284 RenderedValue::Ptr {
285 deref: Some(inner), ..
286 } => inner.index(i),
287 RenderedValue::Truncated { partial, .. } => partial.index(i),
288 _ => None,
289 }
290 }
291
292 /// Dotted-path walk equivalent to a chain of [`Self::member`]
293 /// calls split on `.`. Empty path returns `Some(self)`; any
294 /// component that fails to resolve returns `None`. Peels through
295 /// `Ptr{deref: Some}` and `Truncated{partial: Struct}` at every
296 /// step.
297 pub fn get(&self, path: &str) -> Option<&RenderedValue> {
298 if path.is_empty() {
299 return Some(self);
300 }
301 let mut current = self;
302 for component in path.split('.') {
303 if component.is_empty() {
304 return None;
305 }
306 current = current.member(component)?;
307 }
308 Some(current)
309 }
310
311 /// Coerce a scalar variant to `u64`. Accepts `Uint`, `Bool`
312 /// (`true=1`, `false=0`), `Char` (raw byte), and `Ptr` (numeric
313 /// address). `Int<0`, `Enum<0`, `Float`, `Struct`, `Array`,
314 /// `Bytes`, `Truncated`, and `Unsupported` return `None`. Peels
315 /// `Ptr{deref: Some}` transparently — the unsigned-pointer
316 /// numeric value still wins.
317 pub fn as_u64(&self) -> Option<u64> {
318 match self {
319 RenderedValue::Uint { value, .. } => Some(*value),
320 RenderedValue::Int { value, .. } if *value >= 0 => Some(*value as u64),
321 RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
322 RenderedValue::Char { value } => Some(*value as u64),
323 // Signed enums: reject negative values (sign loss).
324 // Unsigned enums: reinterpret the bit pattern via
325 // `as u64` so e.g. an unsigned 64-bit enum variant
326 // with value `0xFFFF_FFFF_FFFF_FFFF` (stored as `i64 = -1`
327 // by the renderer) round-trips back to its true
328 // unsigned wire value.
329 RenderedValue::Enum {
330 value,
331 is_signed: true,
332 ..
333 } if *value >= 0 => Some(*value as u64),
334 RenderedValue::Enum {
335 value,
336 is_signed: false,
337 ..
338 } => Some(*value as u64),
339 RenderedValue::Ptr { value, .. } => Some(*value),
340 _ => None,
341 }
342 }
343
344 /// Coerce a scalar variant to `i64`. Accepts `Int`, `Uint<=i64::MAX`,
345 /// `Bool`, `Char`, and `Enum`. `Uint>i64::MAX`, `Float`,
346 /// aggregate, and recovery variants return `None`.
347 pub fn as_i64(&self) -> Option<i64> {
348 match self {
349 RenderedValue::Int { value, .. } => Some(*value),
350 RenderedValue::Uint { value, .. } if *value <= i64::MAX as u64 => Some(*value as i64),
351 RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
352 RenderedValue::Char { value } => Some(*value as i64),
353 RenderedValue::Enum { value, .. } => Some(*value),
354 _ => None,
355 }
356 }
357
358 /// Coerce a scalar variant to `f64`. `Float` is the only direct
359 /// source; integer variants widen via `as f64` (with the usual
360 /// precision loss above 2^53).
361 pub fn as_f64(&self) -> Option<f64> {
362 match self {
363 RenderedValue::Float { value, .. } => Some(*value),
364 RenderedValue::Int { value, .. } => Some(*value as f64),
365 RenderedValue::Uint { value, .. } => Some(*value as f64),
366 RenderedValue::Enum { value, .. } => Some(*value as f64),
367 _ => None,
368 }
369 }
370
371 /// Coerce a scalar variant to `bool`. Direct from `Bool`; from
372 /// `Uint`/`Int`/`Char`/`Enum`/`Ptr` returns `Some(value != 0)` —
373 /// a `Ptr` coerces as a non-null test, matching `as_u64`'s
374 /// treatment of a pointer as its numeric address. `Float` and
375 /// aggregate variants return `None`.
376 ///
377 /// The `Ptr` arm keeps this in lockstep with the scalar
378 /// `SnapshotField::as_bool` accessor (scenario/snapshot/field.rs),
379 /// which already coerces `Ptr` to a non-null bool. Without it the
380 /// array path (`as_bool_array` → this method per element) rejected
381 /// a pointer element that the scalar accessor accepted, so
382 /// `field.as_bool()` and `field.as_bool_array()` disagreed on a
383 /// pointer — the same scalar-vs-array divergence the `Enum`
384 /// signedness handling in `as_u64` was added to eliminate.
385 pub fn as_bool(&self) -> Option<bool> {
386 match self {
387 RenderedValue::Bool { value } => Some(*value),
388 RenderedValue::Uint { value, .. } => Some(*value != 0),
389 RenderedValue::Int { value, .. } => Some(*value != 0),
390 RenderedValue::Char { value } => Some(*value != 0),
391 RenderedValue::Enum { value, .. } => Some(*value != 0),
392 RenderedValue::Ptr { value, .. } => Some(*value != 0),
393 _ => None,
394 }
395 }
396
397 /// Extract a homogeneous `Vec<u64>` from an `Array` whose every
398 /// element coerces via [`Self::as_u64`]. Returns `None` if self
399 /// is not an `Array` (or `Truncated{partial: Array}`), or if any
400 /// element fails the coercion — the caller cannot rely on a
401 /// partial result. Peels `Ptr{deref: Some}`.
402 pub fn as_u64_array(&self) -> Option<Vec<u64>> {
403 let elements = self.array_elements()?;
404 elements.iter().map(RenderedValue::as_u64).collect()
405 }
406
407 /// Extract a homogeneous `Vec<u32>` from an `Array` whose every
408 /// element coerces via [`Self::as_u64`] and fits in `u32`.
409 /// Out-of-range values return `None` (no silent truncation).
410 pub fn as_u32_array(&self) -> Option<Vec<u32>> {
411 let elements = self.array_elements()?;
412 elements
413 .iter()
414 .map(|e| e.as_u64().and_then(|v| u32::try_from(v).ok()))
415 .collect()
416 }
417
418 /// Extract a homogeneous `Vec<i64>` from an `Array` whose every
419 /// element coerces via [`Self::as_i64`].
420 pub fn as_i64_array(&self) -> Option<Vec<i64>> {
421 let elements = self.array_elements()?;
422 elements.iter().map(RenderedValue::as_i64).collect()
423 }
424
425 /// Extract a homogeneous `Vec<f64>` from an `Array` whose every
426 /// element coerces via [`Self::as_f64`].
427 pub fn as_f64_array(&self) -> Option<Vec<f64>> {
428 let elements = self.array_elements()?;
429 elements.iter().map(RenderedValue::as_f64).collect()
430 }
431
432 /// Extract a homogeneous `Vec<bool>` from an `Array` whose every
433 /// element coerces via [`Self::as_bool`].
434 pub fn as_bool_array(&self) -> Option<Vec<bool>> {
435 let elements = self.array_elements()?;
436 elements.iter().map(RenderedValue::as_bool).collect()
437 }
438
439 /// Internal: borrow the elements slice of an `Array` variant,
440 /// peeling `Ptr{deref: Some}` and `Truncated{partial: Array}`.
441 /// The peel matches [`Self::index`] so consumers see consistent
442 /// behavior between random-access and full-iteration paths.
443 fn array_elements(&self) -> Option<&[RenderedValue]> {
444 match self {
445 RenderedValue::Array { elements, .. } => Some(elements.as_slice()),
446 RenderedValue::Ptr {
447 deref: Some(inner), ..
448 } => inner.array_elements(),
449 RenderedValue::Truncated { partial, .. } => partial.array_elements(),
450 _ => None,
451 }
452 }
453}
454
455impl std::fmt::Display for RenderedValue {
456 /// Human-readable rendering for test-failure output. JSON remains
457 /// the programmatic form (via `serde_json`); this Display emits
458 /// pretty-printed text suitable for assertion failure messages,
459 /// e.g.
460 ///
461 /// ```text
462 /// task_ctx{weight=1024, last_runnable_at=12345678901234}
463 /// ```
464 ///
465 /// Structs that fit within the inline width budget pack onto one
466 /// line as `TypeName{field=value, field=value}`; wider structs
467 /// break to a multi-line `TypeName:` breadcrumb form with
468 /// indented `field=value` rows. Nested structs and arrays indent
469 /// by two spaces per level. Scalar-only arrays render inline
470 /// (`[1, 2, 3]`); arrays containing structs / nested arrays
471 /// render block-style with one element per line.
472 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473 write_rendered_value(f, self, 0)
474 }
475}
476
477/// Indentation prefix. Two-space steps match the example in the
478/// module-level Display doc.
479const INDENT: &str = " ";
480
481/// Render a [`RenderedValue`] with a caller-supplied starting
482/// indentation depth. Wrapper modules (e.g.
483/// `crate::monitor::dump::display`) use this to nest a renderer
484/// output inside their own indented context — passing
485/// `depth = 1` produces output indented one level deeper than the
486/// default `Display::fmt` path (which always starts at `depth = 0`).
487pub(crate) fn write_value_at_depth(
488 f: &mut std::fmt::Formatter<'_>,
489 v: &RenderedValue,
490 depth: usize,
491) -> std::fmt::Result {
492 write_rendered_value(f, v, depth)
493}
494
495/// Recursive Display helper. Tracks current indentation `depth` so
496/// nested structs / block-style arrays line up correctly. Direct
497/// `Display::fmt` cannot pass extra state, so this is the entry
498/// point for every recursive call.
499fn write_rendered_value(
500 f: &mut std::fmt::Formatter<'_>,
501 v: &RenderedValue,
502 depth: usize,
503) -> std::fmt::Result {
504 match v {
505 RenderedValue::Int { value, .. } => {
506 let mut buf = itoa::Buffer::new();
507 f.write_str(buf.format(*value))
508 }
509 RenderedValue::Uint { value, .. } => {
510 // Genuine unsigned integers render as decimal regardless
511 // of magnitude. Pointer-typed values are handled by the
512 // [`RenderedValue::Ptr`] arm — the BTF type drives the
513 // format, not the value. A `u64` counter that happens
514 // to land in the kernel-pointer numeric range still
515 // renders as decimal because that is what the BPF
516 // programmer declared. Pointer-shaped values declared
517 // as pointers (Type::Ptr in BTF, possibly through
518 // typedefs which `peel_modifiers` collapses) reach the
519 // Ptr Display arm below and emit `0x<hex>` there.
520 let mut buf = itoa::Buffer::new();
521 f.write_str(buf.format(*value))
522 }
523 RenderedValue::Bool { value } => f.write_str(if *value { "true" } else { "false" }),
524 RenderedValue::Char { value } => {
525 if (0x20..=0x7e).contains(value) {
526 f.write_str("'")?;
527 f.write_str(core::str::from_utf8(&[*value]).unwrap_or("?"))?;
528 f.write_str("'")
529 } else {
530 write!(f, "0x{value:02x}")
531 }
532 }
533 RenderedValue::Float { value, .. } => write!(f, "{value}"),
534 RenderedValue::Enum {
535 value,
536 variant,
537 is_signed,
538 ..
539 } => {
540 // Mirror as_u64's signedness dispatch: unsigned enums
541 // render the bit-pattern u64 value (so an unsigned 64-bit
542 // enum stored as i64=-1 displays as the u64::MAX wire
543 // value, not "-1"); signed enums render the i64
544 // directly. Without this branch, the renderer printed
545 // "-1" for an unsigned u64::MAX, which is exactly the
546 // sign-loss the is_signed field exists to prevent.
547 let mut buf = itoa::Buffer::new();
548 let rendered_value: &str = if *is_signed {
549 buf.format(*value)
550 } else {
551 buf.format(*value as u64)
552 };
553 match variant {
554 Some(name) => {
555 f.write_str(name)?;
556 f.write_str(" (")?;
557 f.write_str(rendered_value)?;
558 f.write_str(")")
559 }
560 None => f.write_str(rendered_value),
561 }
562 }
563 RenderedValue::CpuList { cpus } => write!(f, "cpus={{{cpus}}}"),
564 RenderedValue::Ptr {
565 value,
566 deref,
567 deref_skipped_reason,
568 cast_annotation,
569 ..
570 } => {
571 write!(f, "0x{value:x}")?;
572 if let Some(tag) = cast_annotation {
573 write!(f, " ({tag})")?;
574 }
575 if let Some(inner) = deref {
576 f.write_str(" → ")?;
577 write_rendered_value(f, inner, depth)?;
578 } else if let Some(reason) = deref_skipped_reason {
579 // Surface the chase-skip cause inline. Cycle markers
580 // collapse to the dense `[cycle]` form — operators
581 // recognise it without needing the address repeated
582 // (the address is already in the pointer's hex
583 // value preceding this marker). Other skip reasons
584 // (cross-page failure, plausibility gate, etc.)
585 // keep the verbose `[chase: ...]` form so the
586 // specific cause is visible.
587 if reason.starts_with("cycle ") {
588 f.write_str(" [cycle]")?;
589 } else {
590 write!(f, " [chase: {reason}]")?;
591 }
592 }
593 Ok(())
594 }
595 RenderedValue::Bytes { hex } => f.write_str(hex),
596 RenderedValue::Truncated {
597 needed,
598 had,
599 partial,
600 } => {
601 if *had == 0 {
602 return Ok(());
603 }
604 write!(f, "<truncated needed={needed} had={had}> ")?;
605 write_rendered_value(f, partial, depth)
606 }
607 RenderedValue::Unsupported { reason } => write!(f, "<unsupported: {reason}>"),
608 RenderedValue::Array { len, elements } => {
609 if elements.is_empty() {
610 return write!(f, "[]");
611 }
612 // Detect i8/u8 arrays that are C strings: all elements
613 // are 8-bit Int/Uint, mostly printable ASCII or NUL.
614 // Detect non-empty C strings: 8-bit arrays starting with
615 // a non-NUL printable byte. All-zero arrays and arrays
616 // starting with NUL are NOT strings (they're zero data).
617 let first_byte = match &elements[0] {
618 RenderedValue::Int { bits: 8, value } => Some(*value as u8),
619 RenderedValue::Uint { bits: 8, value } => Some(*value as u8),
620 RenderedValue::Char { value } => Some(*value),
621 _ => None,
622 };
623 let is_string = first_byte.is_some_and(|b| b != 0 && is_text_byte(b))
624 && elements.len() >= 2
625 && elements.iter().all(|e| match e {
626 RenderedValue::Int { bits: 8, value } => is_text_byte(*value as u8),
627 RenderedValue::Uint { bits: 8, value } => is_text_byte(*value as u8),
628 RenderedValue::Char { value } => is_text_byte(*value),
629 _ => false,
630 });
631 if is_string {
632 // Build the string to check if it's multi-line.
633 let mut s = String::new();
634 for e in elements {
635 let ch = match e {
636 RenderedValue::Int { value, .. } => *value as u8,
637 RenderedValue::Uint { value, .. } => *value as u8,
638 RenderedValue::Char { value } => *value,
639 _ => 0,
640 };
641 if ch == 0 {
642 break;
643 }
644 s.push(ch as char);
645 }
646 if s.contains('\n') {
647 // Multi-line: render with actual newlines, indented.
648 f.write_str("|\n")?;
649 for line in s.split('\n') {
650 if line.is_empty() {
651 continue;
652 }
653 write_indent(f, depth + 1)?;
654 f.write_str(line)?;
655 f.write_str("\n")?;
656 }
657 write_indent(f, depth)?;
658 } else {
659 write!(f, "\"{s}\"")?;
660 }
661 return Ok(());
662 }
663 let inline = elements.iter().all(is_inline_scalar);
664 if inline {
665 // Build contiguous runs of non-zero elements. Each
666 // run carries (start_idx, end_idx_inclusive,
667 // [&values]). Zero elements break a run; the gaps
668 // between runs surface implicitly (no `(N zero)`
669 // count needed when run brackets carry the index).
670 let mut runs: Vec<(usize, usize, Vec<&RenderedValue>)> = Vec::new();
671 for (i, e) in elements.iter().enumerate() {
672 if is_zero(e) {
673 continue;
674 }
675 if let Some(last) = runs.last_mut()
676 && last.1 + 1 == i
677 {
678 last.1 = i;
679 last.2.push(e);
680 } else {
681 runs.push((i, i, vec![e]));
682 }
683 }
684
685 // All-zero short-circuit. The `[all N zero]` glyph
686 // makes "every slot is zero" obvious without
687 // listing every index.
688 if runs.is_empty() {
689 return write!(f, "[all {len} zero]");
690 }
691
692 // Pre-render every element's text form so we can
693 // measure widths and pack rows with element-
694 // boundary wrapping (no mid-value breaks).
695 let render_elem = |e: &RenderedValue| -> String {
696 use std::fmt::Write;
697 let mut s = String::new();
698 match e {
699 RenderedValue::Uint { value, bits } if *bits >= 32 => {
700 let _ = write!(s, "{value:#x}");
701 }
702 _ => {
703 let _ = write!(s, "{e}");
704 }
705 }
706 s
707 };
708
709 // Special case: a single run covering the whole
710 // array starting at 0 means there are no gaps —
711 // emit a plain `[v1, v2, ...]` without index
712 // brackets. Long lists wrap at element boundaries
713 // with continuation indented to align with the
714 // first element after `[`.
715 if runs.len() == 1
716 && runs[0].0 == 0
717 && runs[0].1 + 1 == elements.len()
718 && elements.len() == *len
719 {
720 let strs: Vec<String> = runs[0].2.iter().map(|e| render_elem(e)).collect();
721 write_inline_list_wrapped(f, "[", "]", &strs, ", ", depth)?;
722 return Ok(());
723 }
724
725 // Sparse render: each run as `[start..end]={v, v, ...}`
726 // (or `[idx]=v` for single-element runs). Multiple
727 // runs separate with two spaces and wrap to a new
728 // line at run boundaries when the line would
729 // exceed the inline budget.
730 let run_strs: Vec<String> = runs
731 .iter()
732 .map(|(start, end, vals)| {
733 if start == end {
734 format!("[{start}]={}", render_elem(vals[0]))
735 } else {
736 let inner: Vec<String> = vals.iter().map(|v| render_elem(v)).collect();
737 format!("[{start}..{end}]={{{}}}", inner.join(", "))
738 }
739 })
740 .collect();
741 write_inline_list_wrapped(f, "[", "]", &run_strs, " ", depth)?;
742 if elements.len() < *len {
743 write!(f, " /* {} of {len} shown */", elements.len())?;
744 }
745 Ok(())
746 } else {
747 // Group identical elements by content. Show each
748 // unique value once with its index range.
749 f.write_str("[")?;
750 let mut groups: Vec<(usize, usize, &RenderedValue)> = Vec::new();
751 for (i, e) in elements.iter().enumerate() {
752 if is_zero(e) {
753 continue;
754 }
755 if let Some(g) = groups.last_mut()
756 && g.2 == e
757 {
758 g.1 = i;
759 continue;
760 }
761 groups.push((i, i, e));
762 }
763 // All-zero short-circuit: every element was zero-
764 // suppressed, no groups recorded. The full rendering
765 // would emit an empty bracket pair, so collapse to
766 // the dense "all N zero]" form instead.
767 if groups.is_empty() {
768 return write!(f, "all {len} zero]");
769 }
770 // Render groups, merging consecutive similar structs
771 // (differ by <8 fields) into a single template with
772 // a per-index table for the varying fields.
773 let mut i = 0;
774 while i < groups.len() {
775 let (start, end, val) = &groups[i];
776 // Try to extend a run of similar singletons.
777 if start == end
778 && let RenderedValue::Struct {
779 members: first_m, ..
780 } = val
781 {
782 let mut run_end = i;
783 'scan: while run_end + 1 < groups.len() {
784 let (ns, ne, nv) = &groups[run_end + 1];
785 if ns != ne {
786 break;
787 }
788 if let RenderedValue::Struct {
789 members: next_m, ..
790 } = nv
791 {
792 if next_m.len() != first_m.len() {
793 break;
794 }
795 let diffs = first_m
796 .iter()
797 .zip(next_m.iter())
798 .filter(|(a, b)| a.value != b.value)
799 .count();
800 if diffs >= 8 {
801 break 'scan;
802 }
803 } else {
804 break;
805 }
806 run_end += 1;
807 }
808 if run_end > i {
809 // Try to merge [i..=run_end] into a template.
810 let run = &groups[i..=run_end];
811 if try_write_struct_template(f, run, depth + 1)? {
812 i = run_end + 1;
813 continue;
814 }
815 // try_write_struct_template returned false
816 // (run too short, no varying fields, or > 3
817 // varying). Wrote nothing. Fall through to
818 // the per-element block below for groups[i];
819 // the loop renders the rest of the run one
820 // group at a time.
821 }
822 }
823 f.write_str("\n")?;
824 write_indent(f, depth + 1)?;
825 if start == end {
826 write!(f, "[{start}] ")?;
827 } else {
828 write!(f, "[{start}-{end}] ")?;
829 }
830 write_rendered_value(f, val, depth + 1)?;
831 i += 1;
832 }
833 // Zero elements are suppressed silently — the gaps
834 // between rendered groups speak for themselves; an
835 // explicit count line adds no information the
836 // operator needs.
837 f.write_str("\n")?;
838 write_indent(f, depth)?;
839 f.write_str("]")?;
840 if elements.len() < *len {
841 write!(f, " /* {} of {len} shown */", elements.len())?;
842 }
843 Ok(())
844 }
845 }
846 RenderedValue::Struct { type_name, members } => {
847 write_struct(f, type_name.as_deref(), members, depth)
848 }
849 }
850}
851
852/// Render a Struct (or Union, which shares the same wire shape)
853/// using the column-aligned multi-line / inline-with-braces
854/// format. Inline takes priority when the rendered form fits on a
855/// single line under [`STRUCT_INLINE_WIDTH_BUDGET`]; otherwise the
856/// breadcrumb form `TypeName:` is emitted, followed by indented
857/// rows of column-aligned scalar fields and per-field lines for
858/// compound members.
859fn write_struct(
860 f: &mut std::fmt::Formatter<'_>,
861 type_name: Option<&str>,
862 members: &[RenderedMember],
863 depth: usize,
864) -> std::fmt::Result {
865 // Flatten anonymous struct members: C anonymous structs expose
866 // their inner fields directly on the parent. Without flattening,
867 // the Display code would see empty-name members wrapping inner
868 // structs and suppress them (producing `Type{}`).
869 let mut flat_members;
870 let members = if members
871 .iter()
872 .any(|m| m.name.is_empty() && matches!(m.value, RenderedValue::Struct { .. }))
873 {
874 // Pool the NAMED siblings' scalar values BEFORE flatten
875 // consumes the anonymous overlays, so a union overlay that
876 // only re-views named fields is suppressed rather than
877 // flattened into duplicate sibling columns. (Building the pool
878 // after flatten — the previous order — left it unable to see
879 // the empty-name Structs at all, so the dedup never fired.)
880 let pool = build_sibling_scalar_pool(members);
881 flat_members = Vec::with_capacity(members.len());
882 for m in members {
883 if m.name.is_empty()
884 && let RenderedValue::Struct {
885 members: ref inner, ..
886 } = m.value
887 {
888 // Suppress an anonymous union overlay that merely
889 // duplicates named-sibling scalars; otherwise flatten
890 // its inner fields onto the parent.
891 if !pool.is_empty() && anon_duplicates_pool(&m.value, &pool) {
892 continue;
893 }
894 flat_members.extend_from_slice(inner);
895 continue;
896 }
897 flat_members.push(m.clone());
898 }
899 flat_members.as_slice()
900 } else {
901 members
902 };
903
904 // Single-pass filter + pre-render. `visible_rendered` carries
905 // tuples of (member, rendered single-line string). The render
906 // happens exactly once per visible member; both the inline-fit
907 // probe and the final emit reuse the same string. Members
908 // whose value renders to multi-line text (an embedded `\n`
909 // signals a Struct that broke to its own breadcrumb form) get
910 // a `None` rendered string and feed the compound-line path
911 // directly — they can't pack into a column row.
912 let mut visible_rendered: Vec<(&RenderedMember, Option<String>)> =
913 Vec::with_capacity(members.len());
914 for m in members {
915 if is_deeply_zero(&m.value) {
916 continue;
917 }
918 if (m.name.contains("___fmt") || m.name.contains("____fmt")) && is_string_value(&m.value) {
919 continue;
920 }
921 // Pre-render the value to a single-line string for flat
922 // scalars and for nested Struct values whose own Display
923 // happens to fit inline (no embedded `\n`). Other compound
924 // members (Array, Ptr-with-deref, Truncated, CpuList,
925 // Unsupported) always produce multi-line output OR carry
926 // their own internal layout that the breadcrumb path
927 // re-renders directly via `write_rendered_value`, so they
928 // get `None` here. A `None` rendering causes
929 // `try_inline_from_rendered` to bail to the multi-line
930 // path. Allowing nested Structs to participate in the
931 // outer's inline form lets `outer{child=inner{a=1}}` pack
932 // onto one line when both are small enough — without it,
933 // any nested Struct would force the breadcrumb form even
934 // for trivial two-level cases.
935 let single_line = if is_flat_scalar(&m.value) {
936 Some(format!("{}", m.value))
937 } else if matches!(m.value, RenderedValue::Struct { .. }) {
938 let s = format!("{}", m.value);
939 if s.contains('\n') { None } else { Some(s) }
940 } else {
941 None
942 };
943 visible_rendered.push((m, single_line));
944 }
945
946 // Inline-fit probe: assemble `TypeName{name=val, name=val}`
947 // by joining the pre-rendered values. Bail to multi-line if
948 // any value lacks a single-line render OR the total width
949 // exceeds the budget.
950 if let Some(inline) = try_inline_from_rendered(type_name, &visible_rendered) {
951 return f.write_str(&inline);
952 }
953
954 // Multi-line breadcrumb form. Layout when type_name is
955 // present:
956 // TypeName:
957 // scalar1=v1 scalar2=v2 scalar3=v3
958 // scalar4=v4
959 // compound1 InnerType:
960 // inner_field=...
961 //
962 // Anonymous structs (no type_name) drop the breadcrumb name
963 // and just emit `:` followed by the indented body — the
964 // visual hierarchy is preserved by the indent depth alone.
965 if let Some(name) = type_name {
966 f.write_str(name)?;
967 }
968 if visible_rendered.is_empty() {
969 // Truly empty struct (no visible fields after suppression):
970 // emit `Type{}` (or `{}` for anon). Suppressed zero /
971 // fmt-string fields produce no visible artifact, so an
972 // all-zero struct lands here as if it had no members at
973 // all.
974 f.write_str("{}")?;
975 return Ok(());
976 }
977 f.write_str(":")?;
978
979 // Partition visible into flat-scalar cells (Int/Uint/Bool/
980 // Char/Float/Enum/Ptr-without-deref) and compound members.
981 // Only flat scalars participate in column packing — values
982 // with their own internal structure (inline struct braces,
983 // pointer chases, arrays, truncated wrappers, cpu lists) get
984 // their own full-width lines so the column grid stays
985 // visually homogeneous.
986 //
987 // Reuse the pre-rendered string from `visible_rendered` for
988 // each scalar cell. The compound-member path doesn't need
989 // the rendered string — it recurses into
990 // `write_rendered_value` which will render at the deeper
991 // depth. This avoids the third-format pass.
992 let mut scalar_cells: Vec<(String, String)> = Vec::new();
993 let mut compound_members: Vec<&RenderedMember> = Vec::new();
994 for (m, rendered) in &visible_rendered {
995 if is_flat_scalar(&m.value) {
996 // Flat scalars always have a single-line rendering;
997 // unwrap the Option that was Some(_) at filter time.
998 let value_str = rendered.clone().expect(
999 "is_flat_scalar guarantees a single-line rendering; \
1000 visible_rendered must carry Some(string) for flat scalars",
1001 );
1002 let name = if m.name.is_empty() {
1003 "<anon>".to_string()
1004 } else {
1005 m.name.clone()
1006 };
1007 scalar_cells.push((name, value_str));
1008 } else {
1009 compound_members.push(m);
1010 }
1011 }
1012
1013 // Emit scalar rows: 3 per row. Column alignment kicks in
1014 // only when there are >= 3 rows AND the field-name length
1015 // variation in a column is significant (>= 4 chars). Below
1016 // those thresholds, columns just separate with a 3-space
1017 // gap and `name=value` (no padding, no `=` alignment).
1018 if !scalar_cells.is_empty() {
1019 let cells_per_row = 3;
1020 let n = scalar_cells.len();
1021 let n_rows = n.div_ceil(cells_per_row);
1022 // Per-column max / min field-name length. `=` alignment
1023 // requires both >= 3 rows AND `max - min >= 4`. When
1024 // name lengths cluster within 3 chars of each other,
1025 // padding is overhead — the columns read fine without
1026 // it.
1027 let mut name_max = vec![0usize; cells_per_row];
1028 let mut name_min = vec![usize::MAX; cells_per_row];
1029 for row in 0..n_rows {
1030 for col in 0..cells_per_row {
1031 let idx = row * cells_per_row + col;
1032 if idx >= n {
1033 break;
1034 }
1035 let nl = scalar_cells[idx].0.len();
1036 if nl > name_max[col] {
1037 name_max[col] = nl;
1038 }
1039 if nl < name_min[col] {
1040 name_min[col] = nl;
1041 }
1042 }
1043 }
1044 // Decide per-column whether to pad. Threshold: 3+ rows
1045 // AND >= 4 char variation. Below either bar, col is
1046 // unpadded.
1047 let pad_eq: Vec<bool> = (0..cells_per_row)
1048 .map(|col| {
1049 if n_rows < 3 {
1050 return false;
1051 }
1052 let max = name_max[col];
1053 let min = name_min[col];
1054 if min == usize::MAX {
1055 return false;
1056 }
1057 max.saturating_sub(min) >= 4
1058 })
1059 .collect();
1060 // Per-column cell-width (full `padded_name + sep + value`
1061 // length) for column-to-column alignment. Built after
1062 // pad_eq is final.
1063 let mut cell_widths = vec![0usize; cells_per_row];
1064 for row in 0..n_rows {
1065 for col in 0..cells_per_row {
1066 let idx = row * cells_per_row + col;
1067 if idx >= n {
1068 break;
1069 }
1070 let (name, value) = &scalar_cells[idx];
1071 let cl = if pad_eq[col] {
1072 name_max[col] + 3 + value.len() // "name = value"
1073 } else {
1074 name.len() + 1 + value.len() // "name=value"
1075 };
1076 if cl > cell_widths[col] {
1077 cell_widths[col] = cl;
1078 }
1079 }
1080 }
1081 for row in 0..n_rows {
1082 f.write_str("\n")?;
1083 write_indent(f, depth + 1)?;
1084 for col in 0..cells_per_row {
1085 let idx = row * cells_per_row + col;
1086 if idx >= n {
1087 break;
1088 }
1089 let (name, value) = &scalar_cells[idx];
1090 f.write_str(name)?;
1091 if pad_eq[col] {
1092 // Pad name to column's max so equals signs
1093 // line up. Use ` = ` (space-equals-space) for
1094 // visual breathing room around the operator.
1095 for _ in 0..name_max[col].saturating_sub(name.len()) {
1096 f.write_str(" ")?;
1097 }
1098 f.write_str(" = ")?;
1099 } else {
1100 // Compact form: bare `name=value`, no padding.
1101 f.write_str("=")?;
1102 }
1103 f.write_str(value)?;
1104 // Trailing pad to align next column (3-space
1105 // minimum gap). The last cell on a row needs no
1106 // trailing pad — the line ends after the value.
1107 if col + 1 < cells_per_row && (row * cells_per_row + col + 1) < n {
1108 let cell_len = if pad_eq[col] {
1109 name_max[col] + 3 + value.len()
1110 } else {
1111 name.len() + 1 + value.len()
1112 };
1113 let pad = cell_widths[col].saturating_sub(cell_len) + 3;
1114 for _ in 0..pad {
1115 f.write_str(" ")?;
1116 }
1117 }
1118 }
1119 }
1120 }
1121
1122 // Emit compound members: each on its own line at depth+1,
1123 // recursing into write_rendered_value at depth+1 so the
1124 // nested render picks up correct indentation.
1125 for m in compound_members {
1126 f.write_str("\n")?;
1127 write_indent(f, depth + 1)?;
1128 if m.name.is_empty() {
1129 f.write_str("<anon> ")?;
1130 } else {
1131 write!(f, "{} ", m.name)?;
1132 }
1133 write_rendered_value(f, &m.value, depth + 1)?;
1134 }
1135
1136 // Zero fields and bpf_printk format strings are suppressed
1137 // silently above; nothing further to emit at this level.
1138 Ok(())
1139}
1140
1141/// Width budget for the inline struct form. A struct whose
1142/// rendered single-line form exceeds this falls through to
1143/// multi-line. Matches the soft per-line cap in the dump-display
1144/// layer (so an inline struct that fits here also fits in the
1145/// failure-dump output column width).
1146const STRUCT_INLINE_WIDTH_BUDGET: usize = 120;
1147
1148/// Soft per-line budget for inline list rendering (arrays,
1149/// sparse runs). When the joined form exceeds this, the helper
1150/// wraps at element boundaries with continuation indented to the
1151/// caller's `depth + 1` so the next row aligns under the bracket
1152/// open. Picked to match the struct inline budget — the two
1153/// paths share a visual line-width target.
1154const INLINE_LIST_WRAP_BUDGET: usize = 120;
1155
1156/// Render `parts` as `<open>part0, part1, ...<close>` joined by
1157/// `sep`. Single-line form is preferred; when the joined width
1158/// exceeds [`INLINE_LIST_WRAP_BUDGET`] the helper wraps at
1159/// element boundaries — never mid-element. Continuation rows
1160/// indent to `depth + 1` so wrapping aligns with the caller's
1161/// column.
1162///
1163/// Each `parts` entry is a fully-rendered single-line string
1164/// already (no embedded `\n`). The helper does not re-render or
1165/// truncate; it only inserts line breaks at part boundaries.
1166fn write_inline_list_wrapped(
1167 f: &mut std::fmt::Formatter<'_>,
1168 open: &str,
1169 close: &str,
1170 parts: &[String],
1171 sep: &str,
1172 depth: usize,
1173) -> std::fmt::Result {
1174 if parts.is_empty() {
1175 f.write_str(open)?;
1176 return f.write_str(close);
1177 }
1178 // Probe single-line form: open + parts.join(sep) + close.
1179 let sep_len = sep.len();
1180 let mut total = open.len() + close.len();
1181 for (i, p) in parts.iter().enumerate() {
1182 if i > 0 {
1183 total += sep_len;
1184 }
1185 total += p.len();
1186 }
1187 f.write_str(open)?;
1188 if total <= INLINE_LIST_WRAP_BUDGET {
1189 // Fits on one line.
1190 for (i, p) in parts.iter().enumerate() {
1191 if i > 0 {
1192 f.write_str(sep)?;
1193 }
1194 f.write_str(p)?;
1195 }
1196 return f.write_str(close);
1197 }
1198 // Wrap mode: emit parts greedily, breaking to a new line
1199 // whenever the next part would push the current line past
1200 // budget. The first part stays on the open-bracket line so
1201 // the reader's eye doesn't have to track an empty `[`.
1202 // Continuation lines indent to depth + 1; that's deeper than
1203 // the bracket itself but matches the multi-line struct's
1204 // scalar-row indent, keeping the visual hierarchy consistent.
1205 let indent = INDENT.repeat(depth + 1);
1206 // Track current-line cursor: starts at length of `open`.
1207 let mut cursor = open.len();
1208 for (i, p) in parts.iter().enumerate() {
1209 if i == 0 {
1210 f.write_str(p)?;
1211 cursor += p.len();
1212 continue;
1213 }
1214 // Check whether adding this part (with sep prefix) would
1215 // exceed the line budget.
1216 let next_len = sep_len + p.len();
1217 if cursor + next_len > INLINE_LIST_WRAP_BUDGET {
1218 // Wrap before this part. The separator's leading
1219 // characters (e.g. `, ` → `,`) stay on the previous
1220 // line so the comma reads naturally; here we elide
1221 // the leading whitespace by starting the new line
1222 // with just indent + part.
1223 f.write_str(sep.trim_end())?;
1224 f.write_str("\n")?;
1225 f.write_str(&indent)?;
1226 f.write_str(p)?;
1227 cursor = indent.len() + p.len();
1228 } else {
1229 f.write_str(sep)?;
1230 f.write_str(p)?;
1231 cursor += next_len;
1232 }
1233 }
1234 f.write_str(close)?;
1235 Ok(())
1236}
1237
1238/// Inline-fit probe over pre-rendered member values. Returns
1239/// `Some(joined_string)` when the struct's `TypeName{f=v, f=v}`
1240/// form fits within [`STRUCT_INLINE_WIDTH_BUDGET`]; `None`
1241/// otherwise (multi-line path takes over).
1242///
1243/// Each `(member, rendered)` pair carries the value's single-line
1244/// rendering — `None` rendering means the value is multi-line and
1245/// disqualifies the struct from inline form. Zero / format-string
1246/// / overlay-dup members are already filtered out by the caller.
1247///
1248/// The render-once invariant: this probe builds the inline string
1249/// by JOINING the pre-rendered values without re-formatting them.
1250/// The final write either commits the same string (inline path)
1251/// or discards it and the multi-line path reuses the same Vec
1252/// (no second render).
1253fn try_inline_from_rendered(
1254 type_name: Option<&str>,
1255 visible_rendered: &[(&RenderedMember, Option<String>)],
1256) -> Option<String> {
1257 if visible_rendered.is_empty() {
1258 // Empty visible set: emit `Type{}` (or `{}` for anon).
1259 let s = match type_name {
1260 Some(n) => format!("{n}{{}}"),
1261 None => "{}".to_string(),
1262 };
1263 return if s.len() <= STRUCT_INLINE_WIDTH_BUDGET {
1264 Some(s)
1265 } else {
1266 None
1267 };
1268 }
1269 // Bail to multi-line if any member's value renders multi-line.
1270 let mut field_strs = Vec::with_capacity(visible_rendered.len());
1271 for (m, value_str) in visible_rendered {
1272 let v = value_str.as_deref()?;
1273 let name = if m.name.is_empty() {
1274 "<anon>"
1275 } else {
1276 m.name.as_str()
1277 };
1278 field_strs.push(format!("{name}={v}"));
1279 }
1280 let body = field_strs.join(", ");
1281 let s = match type_name {
1282 Some(n) => format!("{n}{{{body}}}"),
1283 None => format!("{{{body}}}"),
1284 };
1285 if s.len() <= STRUCT_INLINE_WIDTH_BUDGET {
1286 Some(s)
1287 } else {
1288 None
1289 }
1290}
1291
1292pub fn is_zero(v: &RenderedValue) -> bool {
1293 match v {
1294 RenderedValue::Int { value, .. } => *value == 0,
1295 RenderedValue::Uint { value, .. } => *value == 0,
1296 RenderedValue::Bool { value } => !*value,
1297 RenderedValue::Char { value } => *value == 0,
1298 RenderedValue::Float { value, .. } => *value == 0.0,
1299 RenderedValue::Enum { value, .. } => *value == 0,
1300 RenderedValue::CpuList { cpus } => cpus.is_empty(),
1301 // Ptr zero-detection: only the numeric value matters. A
1302 // null pointer with a `deref_skipped_reason` (rare but
1303 // possible if a future code path attaches a reason without
1304 // a chase) is still zero — the reason is diagnostic, not
1305 // a value carrier.
1306 RenderedValue::Ptr { value, .. } => *value == 0,
1307 // Skip recursive is_zero on compounds — the subtree traversal
1308 // is O(leaves) and doubles the total rendering cost. Compound
1309 // types are always rendered; only scalars get zero-suppressed.
1310 // Use [`is_deeply_zero`] when an all-zero compound should be
1311 // suppressed alongside scalars (e.g. struct Display arm
1312 // collapsing all-zero nested aggregates into the "(N fields
1313 // zero)" summary).
1314 _ => false,
1315 }
1316}
1317
1318/// Numeric scalar value of a [`RenderedValue`] for cross-member
1319/// dedup. Returns `Some(u64)` for the scalar variants that carry
1320/// a numeric value (Int, Uint, Bool, Char, Enum, Ptr); `None` for
1321/// the rest. Signed Int values are reinterpreted as `u64` bit
1322/// patterns to allow comparison against unsigned siblings — the
1323/// dedup heuristic compares wire bit patterns, not arithmetic
1324/// values.
1325fn scalar_numeric_value(v: &RenderedValue) -> Option<u64> {
1326 match v {
1327 RenderedValue::Int { value, .. } => Some(*value as u64),
1328 RenderedValue::Uint { value, .. } => Some(*value),
1329 RenderedValue::Bool { value } => Some(if *value { 1 } else { 0 }),
1330 RenderedValue::Char { value } => Some(*value as u64),
1331 RenderedValue::Enum { value, .. } => Some(*value as u64),
1332 RenderedValue::Ptr { value, .. } => Some(*value),
1333 _ => None,
1334 }
1335}
1336
1337/// Build the sibling-scalar-value pool used by anonymous-overlay
1338/// dedup. Walks `members` once, collecting non-zero scalar values
1339/// from each named sibling AND descending one level into a sibling
1340/// Struct (the common single-field-struct case
1341/// e.g. `tid: struct sdt_id { val: u64 }`). Returning the set
1342/// once lets the caller pass it into [`anon_duplicates_pool`] for
1343/// each anonymous member without re-walking the sibling list.
1344fn build_sibling_scalar_pool(members: &[RenderedMember]) -> std::collections::HashSet<u64> {
1345 let mut sibling_values: std::collections::HashSet<u64> = std::collections::HashSet::new();
1346 for s in members {
1347 // Pool only NAMED siblings: the anonymous overlays are the
1348 // values being deduped against this pool, so including their
1349 // own scalars would make the dedup self-referential.
1350 if s.name.is_empty() {
1351 continue;
1352 }
1353 if let Some(n) = scalar_numeric_value(&s.value) {
1354 if n != 0 {
1355 sibling_values.insert(n);
1356 }
1357 } else if let RenderedValue::Struct { members: sm, .. } = &s.value {
1358 for sub in sm {
1359 if let Some(n) = scalar_numeric_value(&sub.value)
1360 && n != 0
1361 {
1362 sibling_values.insert(n);
1363 }
1364 }
1365 }
1366 }
1367 sibling_values
1368}
1369
1370/// True when the anonymous member `anon` (a Struct overlay from a
1371/// BTF union) duplicates content already in the sibling-scalar
1372/// pool — every non-zero scalar leaf in `anon` has a value
1373/// already present in `pool`. Companion to
1374/// [`build_sibling_scalar_pool`]: caller builds the pool once,
1375/// queries it once per anonymous member.
1376///
1377/// Heuristic bound: the match is by scalar VALUE, not by field
1378/// offset, so an overlay whose every non-zero leaf coincidentally
1379/// equals an unrelated named sibling's value is also suppressed. The
1380/// effect is display-only (a hidden render row — never a
1381/// counter/verdict effect) and grows vanishingly unlikely as the leaf
1382/// count rises; offset-aware dedup would be the precise fix but is
1383/// unwarranted for a renderer.
1384fn anon_duplicates_pool(anon: &RenderedValue, pool: &std::collections::HashSet<u64>) -> bool {
1385 let RenderedValue::Struct { members, .. } = anon else {
1386 return false;
1387 };
1388 if members.is_empty() || pool.is_empty() {
1389 return false;
1390 }
1391 for m in members {
1392 match scalar_numeric_value(&m.value) {
1393 Some(0) => continue, // zero half of a wider scalar
1394 Some(n) => {
1395 if !pool.contains(&n) {
1396 return false;
1397 }
1398 }
1399 None => return false, // compound sub-member; can't dedup
1400 }
1401 }
1402 true
1403}
1404
1405/// Recursive variant of [`is_zero`] that descends into compound
1406/// types: a `Struct` is deeply zero iff every member's value is
1407/// deeply zero (an empty member list also qualifies); an `Array`
1408/// is deeply zero iff every element is deeply zero (an empty
1409/// elements vec also qualifies). For scalars the result matches
1410/// [`is_zero`].
1411///
1412/// `Bytes`, `Truncated`, and `Unsupported` are treated as NOT
1413/// deeply zero — they carry diagnostic content (hex bytes, decoded
1414/// partial, error reason) the consumer needs to see even when the
1415/// numeric content happens to be all zeros.
1416///
1417/// Recursion is capped at depth 16 to bound pathological BTF where
1418/// rendered nesting exceeds the renderer's own
1419/// [`MAX_RENDER_DEPTH`] cap. The cap is defense-in-depth: a
1420/// well-formed render produced by [`render_value_inner`] cannot
1421/// nest deeper than [`MAX_RENDER_DEPTH`] (32), but the cap here
1422/// stops the helper from hanging on a malformed externally-supplied
1423/// `RenderedValue` tree.
1424pub(crate) fn is_deeply_zero(v: &RenderedValue) -> bool {
1425 /// Recursion cap. 16 is comfortably below
1426 /// [`MAX_RENDER_DEPTH`] (32) so a render that came from
1427 /// [`render_value_inner`] always terminates well within the
1428 /// cap; the cap protects helper callers that may construct a
1429 /// `RenderedValue` tree from sources outside the renderer's
1430 /// depth-limited path.
1431 const MAX_DEPTH: u32 = 16;
1432 fn inner(v: &RenderedValue, depth: u32) -> bool {
1433 if depth >= MAX_DEPTH {
1434 // Past the cap: refuse to commit. Returning `false`
1435 // means the caller treats the value as non-zero,
1436 // surfacing it in Display rather than silently
1437 // suppressing a deeply nested subtree we couldn't
1438 // fully verify.
1439 return false;
1440 }
1441 match v {
1442 RenderedValue::Struct { members, .. } => {
1443 members.iter().all(|m| inner(&m.value, depth + 1))
1444 }
1445 RenderedValue::Array { elements, .. } => elements.iter().all(|e| inner(e, depth + 1)),
1446 // Bytes carries diagnostic hex; Truncated carries a
1447 // partial render the operator must see; Unsupported
1448 // carries an error reason. None of these are
1449 // suppressible, regardless of the numeric content
1450 // they may or may not encode.
1451 RenderedValue::Bytes { .. }
1452 | RenderedValue::Truncated { .. }
1453 | RenderedValue::Unsupported { .. } => false,
1454 // Scalars: match the canonical is_zero.
1455 _ => is_zero(v),
1456 }
1457 }
1458 inner(v, 0)
1459}
1460
1461/// Try to render array-of-structs groups as a template: show the
1462/// struct once with per-index values for fields that vary. Returns
1463/// true if template rendering was used.
1464fn try_write_struct_template(
1465 f: &mut std::fmt::Formatter<'_>,
1466 groups: &[(usize, usize, &RenderedValue)],
1467 depth: usize,
1468) -> Result<bool, std::fmt::Error> {
1469 // All groups must be single-element Structs with the same member count.
1470 let structs: Vec<(usize, &[RenderedMember])> = groups
1471 .iter()
1472 .filter_map(|(start, end, val)| {
1473 if start != end {
1474 return None;
1475 }
1476 match val {
1477 RenderedValue::Struct { members, .. } => Some((*start, members.as_slice())),
1478 _ => None,
1479 }
1480 })
1481 .collect();
1482 if structs.len() != groups.len() || structs.len() < 3 {
1483 return Ok(false);
1484 }
1485 let member_count = structs[0].1.len();
1486 if structs.iter().any(|(_, m)| m.len() != member_count) {
1487 return Ok(false);
1488 }
1489
1490 // Find which fields vary.
1491 let first = structs[0].1;
1492 let mut varying: Vec<usize> = Vec::new();
1493 for i in 0..member_count {
1494 if structs[1..]
1495 .iter()
1496 .any(|(_, m)| m[i].value != first[i].value)
1497 {
1498 varying.push(i);
1499 }
1500 }
1501 if varying.is_empty() || varying.len() > 3 {
1502 return Ok(false);
1503 }
1504
1505 // Validate that template indices are strictly contiguous
1506 // before emitting `[start-end]` range. Zero-suppression in the
1507 // caller can drop intermediate indices (e.g. groups [0,2,4] —
1508 // 0,1,2,3,4 with zeros at 1,3 dropped to keep the rendering
1509 // compact). The `[0-4]` header would then misleadingly imply
1510 // every index 0..=4 is in the template. Bail to fallback
1511 // per-element rendering when indices aren't consecutive.
1512 if !structs.windows(2).all(|pair| pair[1].0 == pair[0].0 + 1) {
1513 return Ok(false);
1514 }
1515
1516 // Emit template: struct with common fields shown, varying
1517 // fields as a per-index value table. Header uses the
1518 // breadcrumb form `[idx-range] TypeName:`. Common fields
1519 // render as `name=value` rows; varying fields render as
1520 // `name: [idx]=val [idx]=val ...` (the `:` introduces the
1521 // per-index list, not a field assignment).
1522 let type_name = match groups[0].2 {
1523 RenderedValue::Struct { type_name, .. } => type_name.as_deref(),
1524 _ => None,
1525 };
1526 let idx_range = format!("[{}-{}]", structs[0].0, structs.last().unwrap().0);
1527 f.write_str("\n")?;
1528 write_indent(f, depth)?;
1529 match type_name {
1530 Some(name) => write!(f, "{idx_range} {name}:")?,
1531 None => write!(f, "{idx_range}:")?,
1532 }
1533
1534 for (i, m) in first.iter().enumerate() {
1535 if varying.contains(&i) {
1536 continue;
1537 }
1538 // is_deeply_zero so all-zero compound members (e.g. an
1539 // empty inner struct) suppress alongside scalars in the
1540 // template's common-fields section. Matches the main
1541 // `write_struct` filter — without this, a template would
1542 // render an `inner={}` line for the same value that the
1543 // non-template path collapses silently, producing
1544 // inconsistent output for callers that flip between
1545 // template and per-element rendering.
1546 if is_deeply_zero(&m.value) {
1547 continue;
1548 }
1549 f.write_str("\n")?;
1550 write_indent(f, depth + 1)?;
1551 write!(f, "{}=", m.name)?;
1552 write_rendered_value(f, &m.value, depth + 1)?;
1553 }
1554
1555 // Varying fields as compact per-index lines. The label form
1556 // `name:` introduces the per-index list — distinct from
1557 // `name=value` because each row carries multiple values, one
1558 // per index.
1559 for &vi in &varying {
1560 f.write_str("\n")?;
1561 write_indent(f, depth + 1)?;
1562 write!(f, "{}: ", first[vi].name)?;
1563 for (idx, members) in &structs {
1564 write!(f, "[{idx}]=")?;
1565 write_rendered_value(f, &members[vi].value, depth + 1)?;
1566 f.write_str(" ")?;
1567 }
1568 }
1569
1570 // Zero fields are suppressed silently — no count line.
1571 Ok(true)
1572}
1573
1574/// Try to render bytes as a cpumask cpu-list. Reads u64 words from
1575/// the start of `bytes`, extracts set bits, and formats as
1576/// `cpus={0,2,5-7}`. Returns None if bytes are too short.
1577///
1578/// `max_cpus` caps the highest CPU id walked: bits at positions >=
1579/// `max_cpus` are treated as out-of-range (slab padding / freelist
1580/// garbage) and stop the walk. The kernel sizes `struct cpumask`'s
1581/// `bits` array to `BITS_TO_LONGS(NR_CPUS)` words but only the first
1582/// `nr_cpu_ids` bits are meaningful — the bytes between
1583/// `nr_cpu_ids` and the slab allocation size are uninitialized or
1584/// recycled freelist data. Callers that don't have `nr_cpu_ids`
1585/// available pass `u32::MAX` (no cap).
1586fn try_render_cpumask_bits(bytes: &[u8], max_cpus: u32) -> Option<RenderedValue> {
1587 if bytes.len() < 8 {
1588 return None;
1589 }
1590 let n_words = bytes.len() / 8;
1591 let mut set_cpus: Vec<u32> = Vec::new();
1592 for word_idx in 0..n_words {
1593 let off = word_idx * 8;
1594 if off + 8 > bytes.len() {
1595 break;
1596 }
1597 let word_first_cpu = (word_idx * 64) as u64;
1598 // Once the first cpu id covered by this word is at or
1599 // beyond `max_cpus`, no further bits in this or later
1600 // words are meaningful — stop walking.
1601 if word_first_cpu >= max_cpus as u64 {
1602 break;
1603 }
1604 let word = u64::from_le_bytes(bytes[off..off + 8].try_into().unwrap());
1605 if word == 0 {
1606 continue;
1607 }
1608 // Pointer-shape gate (mirrors the kptr-cpumask path below):
1609 // slab garbage in trailing words can appear as a high-bit-set
1610 // u64 that would enumerate phantom CPU IDs; bail when a later
1611 // word looks like a kernel address (top byte 0xff). An all-ones
1612 // word is a fully-online 64-CPU chunk, NOT a pointer, so it is
1613 // decoded. The previous `word > 0xFFFF_FFFF && set_cpus.len() >
1614 // 64` gate wrongly bailed on those all-ones words, silently
1615 // dropping CPUs 128+ on a fully-online >128-CPU host. Apply
1616 // BEFORE pushing this word's bits so a suspect word never
1617 // contaminates `set_cpus`.
1618 if word != u64::MAX && word >> 56 == 0xff {
1619 break;
1620 }
1621 for bit in 0..64 {
1622 let cpu = (word_idx * 64 + bit) as u32;
1623 // Per-bit cap: skip bits at or above max_cpus. The
1624 // outer `word_first_cpu` gate handles whole-word
1625 // bailout; this catches the partial-word case where
1626 // max_cpus falls inside the current word (e.g.
1627 // max_cpus=8 with first word at cpu 0 — bits 8..63
1628 // are slab padding).
1629 if cpu >= max_cpus {
1630 break;
1631 }
1632 if word & (1u64 << bit) != 0 {
1633 set_cpus.push(cpu);
1634 }
1635 }
1636 }
1637 Some(RenderedValue::CpuList {
1638 cpus: format_cpu_list(&set_cpus),
1639 })
1640}
1641
1642/// Format a sorted list of CPU IDs as a range-collapsed string.
1643/// e.g. `[0,1,2,5,7,8,9]` → "0-2,5,7-9"
1644///
1645/// Writes to a single `String` with `fmt::Write` so each range
1646/// emits at most two integer formats and one comma — half the
1647/// allocations of the prior `Vec<String>` + `join(",")` approach.
1648fn format_cpu_list(cpus: &[u32]) -> String {
1649 use std::fmt::Write;
1650 if cpus.is_empty() {
1651 return String::new();
1652 }
1653 let mut out = String::new();
1654 let mut start = cpus[0];
1655 let mut end = cpus[0];
1656 let flush = |out: &mut String, start: u32, end: u32| {
1657 if !out.is_empty() {
1658 out.push(',');
1659 }
1660 if start == end {
1661 // unwrap is safe: write! to String never fails.
1662 let _ = write!(out, "{start}");
1663 } else {
1664 let _ = write!(out, "{start}-{end}");
1665 }
1666 };
1667 for &cpu in &cpus[1..] {
1668 if cpu == end + 1 {
1669 end = cpu;
1670 } else {
1671 flush(&mut out, start, end);
1672 start = cpu;
1673 end = cpu;
1674 }
1675 }
1676 flush(&mut out, start, end);
1677 out
1678}
1679
1680fn is_text_byte(b: u8) -> bool {
1681 // Conservative: only NUL (C string terminator), \n, and printable
1682 // ASCII. \t and \r are excluded — binary BPF arrays starting with
1683 // those bytes were misclassified as strings.
1684 b == 0 || b == b'\n' || (0x20..=0x7e).contains(&b)
1685}
1686
1687fn is_string_value(v: &RenderedValue) -> bool {
1688 match v {
1689 RenderedValue::Array { elements, .. } => {
1690 elements.len() >= 2
1691 && elements.iter().all(|e| match e {
1692 RenderedValue::Int { bits: 8, value } => is_text_byte(*value as u8),
1693 RenderedValue::Uint { bits: 8, value } => is_text_byte(*value as u8),
1694 RenderedValue::Char { value } => is_text_byte(*value),
1695 _ => false,
1696 })
1697 }
1698 _ => false,
1699 }
1700}
1701
1702/// Whether a value renders as a single line under Display. Used by
1703/// the array case to pick inline vs block layout, and by
1704/// `super::dump::display` to decide whether a struct's fields
1705/// qualify for inline-entry rendering in the FailureDumpEntry /
1706/// FailureDumpMap table layouts.
1707pub(crate) fn is_inline_scalar(v: &RenderedValue) -> bool {
1708 match v {
1709 RenderedValue::Int { .. }
1710 | RenderedValue::Uint { .. }
1711 | RenderedValue::Bool { .. }
1712 | RenderedValue::Char { .. }
1713 | RenderedValue::Float { .. }
1714 | RenderedValue::Enum { .. }
1715 | RenderedValue::Bytes { .. }
1716 | RenderedValue::Unsupported { .. } => true,
1717 RenderedValue::Ptr { deref, .. } => deref.is_none(),
1718 _ => false,
1719 }
1720}
1721
1722/// Whether a value is a "flat scalar" — a primitive carrying a
1723/// single rendered token without any internal structure. Used by
1724/// the multi-line struct path to decide which fields participate
1725/// in column packing: only flat scalars can pack 3-per-row, since
1726/// compound forms (inline struct braces, pointer derefs, arrays,
1727/// truncation wrappers, cpu lists) would mismatch the column
1728/// grid.
1729///
1730/// Stricter than [`is_inline_scalar`]: a `Ptr` with `deref:
1731/// Some(...)` is NOT flat (it carries an arrow plus a nested
1732/// render), and `Bytes` / `Unsupported` carry diagnostic content
1733/// that prefers its own line for readability. `CpuList` reads
1734/// like a structure (`cpus={0-3}`) so it stays out of the column
1735/// grid too.
1736pub(crate) fn is_flat_scalar(v: &RenderedValue) -> bool {
1737 match v {
1738 RenderedValue::Int { .. }
1739 | RenderedValue::Uint { .. }
1740 | RenderedValue::Bool { .. }
1741 | RenderedValue::Char { .. }
1742 | RenderedValue::Float { .. }
1743 | RenderedValue::Enum { .. } => true,
1744 // Ptr is flat only when there's no deref payload. A
1745 // pointer rendered as `0xADDR → ...` doesn't fit a
1746 // narrow column.
1747 RenderedValue::Ptr {
1748 deref: None,
1749 deref_skipped_reason: None,
1750 ..
1751 } => true,
1752 _ => false,
1753 }
1754}
1755
1756fn write_indent(f: &mut std::fmt::Formatter<'_>, depth: usize) -> std::fmt::Result {
1757 for _ in 0..depth {
1758 f.write_str(INDENT)?;
1759 }
1760 Ok(())
1761}
1762
1763// Re-export [`CastHit`] so the renderer's [`MemReader::cast_lookup`]
1764// trait method can name the return type without forcing every
1765// caller to import the cast_analysis module path. [`AddrSpace`] is
1766// no longer re-exported — it lives at its canonical home in
1767// [`super::cast_analysis::AddrSpace`] and the renderer treats the
1768// hint as runtime-secondary, so callers that need the variant
1769// import it directly from cast_analysis.
1770pub use super::cast_analysis::CastHit;
1771
1772/// Outcome of [`MemReader::resolve_arena_type`]: the BTF type id the
1773/// chase should render against, paired with the byte count the
1774/// chase must skip past the chased address before the payload
1775/// struct begins.
1776///
1777/// The production
1778/// `super::dump::render_map::AccessorMemReader::resolve_arena_type`
1779/// emits exactly two `header_skip` shapes — mid-slot pointers
1780/// (header-region or mid-payload offsets) return `None`:
1781///
1782/// - `header_skip == 0` (payload-start chase): the chased address
1783/// already lands at the slot's payload start, e.g. the return of
1784/// `scx_task_data(p)` cached in `cached_taskc_raw`. The renderer
1785/// reads `btf_size` bytes from the chased address and renders
1786/// directly against `target_type_id`.
1787/// - `header_skip == slot.header_size` (slot-start chase): the
1788/// chased address lands at the slot's first byte, e.g. the
1789/// `data` field of `scx_task_map_val` storing the raw return of
1790/// `sdt_alloc()`. The renderer reads `header_skip + btf_size`
1791/// bytes from the chased address and slices off the leading
1792/// `header_skip` bytes (the `union sdt_id` header) before
1793/// rendering the payload struct against `target_type_id`.
1794///
1795/// Field name `target_type_id` matches
1796/// [`CastHit::target_type_id`]'s precedent so the two
1797/// chase-routing return shapes use the same vocabulary.
1798#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1799pub struct ArenaResolveHit {
1800 /// BTF type id (in the entry BTF's id space) of the payload
1801 /// struct the chase should render against. Resolved from the
1802 /// allocator slot's `target_type_id` populated by the
1803 /// sdt_alloc pre-pass.
1804 pub target_type_id: u32,
1805 /// Byte count the chase must skip past the chased address
1806 /// before the payload struct begins. `0` for a payload-start
1807 /// chase (the chased address already lands at the payload);
1808 /// the slot's `header_size` for a slot-start chase (the chase
1809 /// must skip the leading `union sdt_id` header before
1810 /// rendering the payload).
1811 pub header_skip: usize,
1812}
1813
1814/// Aggregate kind of a `BTF_KIND_FWD` terminal: `struct foo;` vs
1815/// `union foo;`. Threaded into [`MemReader::cross_btf_resolve_fwd`]
1816/// so the resolver only matches a same-name complete body whose
1817/// aggregate kind agrees — a `Fwd` declared as `struct foo` must
1818/// NOT resolve to a `union foo` in another BTF (the wire format
1819/// permits same-name struct + union declarations, rare but legal).
1820/// Mirrors the gate [`peel_modifiers_resolving_fwd`] applies for
1821/// in-BTF resolution.
1822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1823pub enum FwdKind {
1824 /// `BTF_KIND_FWD` declared as `struct foo;`. Matches the
1825 /// `true` branch of [`btf_rs::Fwd::is_struct`].
1826 Struct,
1827 /// `BTF_KIND_FWD` declared as `union foo;`. Matches the
1828 /// `false` branch of [`btf_rs::Fwd::is_struct`].
1829 Union,
1830}
1831
1832impl FwdKind {
1833 /// Build a [`FwdKind`] from a [`btf_rs::Fwd`]'s aggregate kind
1834 /// flag. The two callers
1835 /// ([`try_cross_btf_fwd_resolve`] and
1836 /// [`peel_modifiers_resolving_fwd`]) reach the flag via
1837 /// [`btf_rs::Fwd::is_struct`].
1838 pub fn from_is_struct(is_struct: bool) -> Self {
1839 if is_struct {
1840 FwdKind::Struct
1841 } else {
1842 FwdKind::Union
1843 }
1844 }
1845}
1846
1847/// Reference to a complete struct/union definition in a BTF other
1848/// than the one the chase entered with. Returned by
1849/// [`MemReader::cross_btf_resolve_fwd`] when a `BTF_KIND_FWD`
1850/// terminal in the entry BTF resolves to a body in a sibling
1851/// object's BTF (the multi-`.bpf.objs` shape: one object declares
1852/// `struct foo;` (forward), another defines `struct foo { ... }`
1853/// (full body)).
1854///
1855/// Borrowed: the BTF reference is tied to the [`MemReader`]
1856/// implementation's owned BTF storage (typically `Arc<Btf>` retained
1857/// across the dump pass). Render code that recurses into `btf`
1858/// must thread the same [`MemReader`] through so further chases
1859/// from inside the cross-BTF subtree can also resolve cross-BTF
1860/// (same-name structs in either direction).
1861///
1862/// `type_id` is the resolved struct/union type id WITHIN `btf`'s
1863/// own id space — distinct from the entry BTF's id space. The
1864/// chase code switches the rendering BTF to `btf` for the
1865/// recursion against `type_id`.
1866///
1867/// `Copy + Clone`: a borrowed reference plus a `u32` is bitwise
1868/// copyable. The pair lets the chase paths pass the value by
1869/// `Copy` rather than by `&` and sidesteps the move-after-borrow
1870/// snags in any match that would otherwise consume the hit
1871/// twice. [`Debug`] / [`Hash`] / [`Eq`] are blocked by [`Btf`]
1872/// in `btf-rs` — it does not derive any of them — so the
1873/// minimal `Copy + Clone` set is the most we can offer until
1874/// upstream changes.
1875#[derive(Copy, Clone)]
1876pub struct CrossBtfRef<'a> {
1877 /// Sibling BTF that carries the resolved struct/union body.
1878 /// Borrowed from the [`MemReader`] implementation's owned BTF
1879 /// storage (typically `Arc<Btf>` retained across the dump
1880 /// pass) — the renderer must thread the same [`MemReader`]
1881 /// through any recursion into this BTF so further chases
1882 /// from inside the cross-BTF subtree can also resolve
1883 /// cross-BTF.
1884 pub btf: &'a Btf,
1885 /// Resolved struct/union type id WITHIN [`Self::btf`]'s own
1886 /// id space — distinct from the entry BTF's id space. The
1887 /// chase code switches the rendering BTF to [`Self::btf`]
1888 /// and recurses against this id.
1889 pub type_id: u32,
1890}
1891
1892/// # CrossBtfMemReader contract
1893///
1894/// [`CrossBtfMemReader`] wraps a `&dyn MemReader` and selectively
1895/// suppresses id-keyed lookups (cast_lookup, resolve_arena_type)
1896/// at cross-BTF boundaries. When adding a new method to this trait:
1897/// check whether the method is id-keyed (operates on BTF type IDs
1898/// from the entry BTF). If yes, CrossBtfMemReader MUST override it
1899/// to return `None`/default. If no (raw addresses, string names),
1900/// CrossBtfMemReader should delegate to inner. Failing to audit
1901/// causes silent wrong-renders in cross-BTF chase paths.
1902pub trait MemReader {
1903 fn read_kva(&self, kva: u64, len: usize) -> Option<Vec<u8>>;
1904 /// Check if an address is in the arena range. Arena pointers
1905 /// resolve into `ArenaSnapshot`'s captured page set, so the
1906 /// reader has a frozen byte view — chasing them is well-defined.
1907 /// Kernel kptrs (slab/vmalloc allocations outside the arena
1908 /// window) MAY be stale references to objects already freed by
1909 /// the time the freeze captured them; the renderer applies a
1910 /// best-effort plausibility heuristic (top-byte check on the
1911 /// first qword to reject obvious freelist next-pointer
1912 /// patterns — see [`render_cast_pointer`] and the cpumask kptr
1913 /// branch in the [`btf_rs::Type::Ptr`] arm) but cannot verify
1914 /// liveness. Cast-recovered kernel kptrs ARE chased through
1915 /// [`MemReader::read_kva`] when [`MemReader::cast_lookup`]
1916 /// returns an `AddrSpace::Kernel` hit, even though slab
1917 /// liveness is not guaranteed; the heuristic gates and the
1918 /// `deref_skipped_reason` field on [`RenderedValue::Ptr`]
1919 /// surface uncertainty without dropping the hit. Default
1920 /// returns false — pointer chasing skips arena resolution
1921 /// silently.
1922 fn is_arena_addr(&self, _addr: u64) -> bool {
1923 false
1924 }
1925 /// Read bytes from the captured arena at a user-space arena
1926 /// address. Default returns None — the Ptr deref path emits the
1927 /// raw pointer hex without chasing.
1928 ///
1929 /// Returns None if the address is unmapped or the full requested
1930 /// length cannot be read.
1931 fn read_arena(&self, _addr: u64, _len: usize) -> Option<Vec<u8>> {
1932 None
1933 }
1934 /// Guest's `nr_cpu_ids` — the number of possible CPUs the
1935 /// kernel exposes to userspace allocators (`cpumask_size()`,
1936 /// percpu arrays, etc.). The cpumask renderer caps the bit
1937 /// walk at this value: the kernel's `struct cpumask` `bits`
1938 /// slab allocation is sized to `BITS_TO_LONGS(NR_CPUS)`, but
1939 /// only the first `nr_cpu_ids` bits are meaningful — bits
1940 /// beyond that are slab-internal padding or freelist garbage
1941 /// that `SLAB_FREELIST_HARDENED` XOR-encoding can mask the
1942 /// top-byte heuristic from rejecting. Default returns
1943 /// `u32::MAX` (no cap) so callers without the value still
1944 /// produce a render.
1945 fn nr_cpu_ids(&self) -> u32 {
1946 u32::MAX
1947 }
1948 /// Look up a cast finding for `(parent_type_id, member_byte_offset)`.
1949 /// `parent_type_id` is the BTF type id of the *struct/union* that
1950 /// owns the member (already peeled through Typedef / Const /
1951 /// Volatile / Restrict / TypeTag / DeclTag — the cast analyzer
1952 /// keys on the underlying aggregate, not the modifier-wrapped
1953 /// surface type).
1954 /// `member_byte_offset` is the byte offset of the `u64` member
1955 /// inside that struct.
1956 ///
1957 /// Returning `Some(hit)` lets the renderer interpret a `u64`
1958 /// member as `Ptr(hit.target_type_id)` and chase it through the
1959 /// reader corresponding to `hit.addr_space`. Returning `None`
1960 /// (the default) leaves the renderer's existing behavior intact:
1961 /// the field renders as a plain unsigned integer. Default-`None`
1962 /// keeps every existing [`MemReader`] impl correct without an
1963 /// explicit override.
1964 fn cast_lookup(&self, _parent_type_id: u32, _member_byte_offset: u32) -> Option<CastHit> {
1965 None
1966 }
1967 /// Resolve a chased arena pointer value to the BTF type id of
1968 /// the payload it points at, plus a `header_skip` byte count
1969 /// that tells the chase how to land on the payload struct from
1970 /// the chased address.
1971 ///
1972 /// The intended trigger: a [`Type::Ptr`] (or cast-recovered
1973 /// pointer) whose declared pointee is a [`Type::Fwd`] whose body
1974 /// lives in a separate BTF object. The scheduler's program BTF
1975 /// carries only the `BTF_KIND_FWD` forward declaration — there is
1976 /// no struct body to size against, so the renderer's
1977 /// [`chase_arena_pointer`] / [`render_cast_pointer`] paths skip
1978 /// with an "unsizable" reason. The
1979 /// [`super::sdt_alloc::SdtAllocatorSnapshot`] pre-pass already
1980 /// resolves the real payload BTF type id via
1981 /// [`super::sdt_alloc::discover_payload_btf_id`] for every live
1982 /// allocator. The dump path threads a per-pass index of every
1983 /// live allocator slot into the renderer's [`MemReader`] so the
1984 /// chase can recover the real type id and the slot shape when
1985 /// the BTF-only resolve fails.
1986 ///
1987 /// `addr` is the chased pointer value as it appears in guest
1988 /// memory (i.e. the same value the renderer just read from a
1989 /// `u64` field or a [`Type::Ptr`] field). Implementations
1990 /// transform it into the index key shape they store. The
1991 /// sdt_alloc bridge stores one entry per slot keyed on
1992 /// `slot_start & 0xFFFF_FFFF`; the production
1993 /// `super::dump::render_map::AccessorMemReader` impl uses a
1994 /// range lookup to find the slot whose
1995 /// `[slot_start, slot_start + elem_size)` range contains the
1996 /// chased address.
1997 ///
1998 /// Returns `Some(ArenaResolveHit { target_type_id, header_skip })`
1999 /// when the address falls inside a known allocator slot at a
2000 /// position the renderer can chase:
2001 ///
2002 /// - **Slot-start pointer** (the chased address equals the slot
2003 /// start, e.g. the `data` field of `scx_task_map_val` storing
2004 /// the raw return of `sdt_alloc()`): `header_skip` is the
2005 /// slot's header size (typically 8 — the size of `union
2006 /// sdt_id`). The renderer reads `header_skip + btf_size`
2007 /// bytes from `addr`, slices off the first `header_skip`
2008 /// bytes (the sdt_id header), and renders the payload struct
2009 /// against `target_type_id`.
2010 /// - **Payload-start pointer** (the chased address equals
2011 /// `slot_start + header_size`, e.g. the return of
2012 /// `scx_task_data(p)` cached in `cached_taskc_raw`):
2013 /// `header_skip == 0`. The renderer reads `btf_size` bytes
2014 /// from `addr` and renders directly.
2015 ///
2016 /// Returns `None` (the default) when the reader has no index,
2017 /// the address is outside every known allocator's slot range,
2018 /// the addressed slot has no resolved payload type, or the
2019 /// chased pointer landed at an interior slot position
2020 /// (mid-header or mid-payload) the renderer cannot translate
2021 /// into a struct render. Default-`None` keeps existing
2022 /// [`MemReader`] impls correct without an explicit override.
2023 fn resolve_arena_type(&self, _addr: u64) -> Option<ArenaResolveHit> {
2024 None
2025 }
2026 /// Resolve a `BTF_KIND_FWD` terminal by struct/union name to a
2027 /// complete definition in a sibling BTF.
2028 ///
2029 /// The intended trigger: the renderer's chase paths
2030 /// ([`chase_arena_pointer`] and [`render_cast_pointer`]) just
2031 /// peeled the chase target through
2032 /// [`peel_modifiers_resolving_fwd`] but the local same-BTF
2033 /// sibling search came up empty. The terminal is still a
2034 /// `Type::Fwd`, so the BTF-only chase would skip with
2035 /// "forward declaration; body not in this BTF". This method
2036 /// asks the reader whether any sibling BTF (built once per
2037 /// scheduler binary by the cast-analysis pre-pass — see
2038 /// [`crate::vmm::cast_analysis_load::CastAnalysisOutput::fwd_index`])
2039 /// carries a complete body for `name` matching the `kind`
2040 /// aggregate kind ([`FwdKind::Struct`] or [`FwdKind::Union`]).
2041 ///
2042 /// Returning `Some(CrossBtfRef { btf, type_id })` lets the
2043 /// chase switch to `btf` and recurse into `type_id` for the
2044 /// pointee render. The same [`MemReader`] is threaded into
2045 /// the inner recursion so chases originating inside the
2046 /// cross-BTF subtree can also bridge — typical for a struct
2047 /// whose members are themselves `Fwd` to another BTF.
2048 ///
2049 /// Returning `None` (the default) preserves the historical
2050 /// "forward declaration; body not in this BTF" skip path —
2051 /// keeps every existing [`MemReader`] impl correct without
2052 /// an explicit override.
2053 fn cross_btf_resolve_fwd(&self, _name: &str, _kind: FwdKind) -> Option<CrossBtfRef<'_>> {
2054 None
2055 }
2056
2057 /// Type-gated meta fallback for sdt_alloc bridge. Only called
2058 /// when `resolve_arena_type` returned None AND the chase target
2059 /// is any Fwd-typed pointee (the [`try_sdt_alloc_bridge`] gate
2060 /// matches `Type::Fwd(_)`, not a specific `Fwd` name). Returns
2061 /// the first sdt_alloc meta with a resolved payload type if the
2062 /// address is in the arena window.
2063 fn resolve_arena_type_meta_fallback(&self, _addr: u64) -> Option<ArenaResolveHit> {
2064 None
2065 }
2066
2067 /// True when `addr` points at an allocator slot the dump pre-pass
2068 /// has rendered with a resolved payload type into
2069 /// `report.sdt_allocations`. The arena chase short-circuits on
2070 /// `true` so the per-map renderer doesn't re-render the same
2071 /// allocation a second time when it follows an entry pointer
2072 /// through a TASK_STORAGE / HASH map into the same arena slot.
2073 /// Implementations restrict the underlying set to slots from
2074 /// typed allocators (resolved `target_type_id != 0`); untyped
2075 /// pre-pass renders (hex fallback) must NOT suppress per-map
2076 /// chases because the cast analyzer's shape inference may resolve
2077 /// a target type the heuristic missed. Default returns `false` —
2078 /// readers without a rendered-slot index proceed with the chase.
2079 fn is_already_rendered(&self, _addr: u64) -> bool {
2080 false
2081 }
2082
2083 /// All unique alloc_sizes captured across the CastMap's Arena
2084 /// findings. Used as a last-resort fallback when a deferred-
2085 /// resolve chase has alloc_size=None: try each captured size
2086 /// with discover_payload_btf_id and use it if exactly one
2087 /// produces a unique BTF match. Default returns empty.
2088 fn alloc_size_types(&self) -> &[(u64, String)] {
2089 &[]
2090 }
2091}
2092
2093/// Render a BTF type's bytes into a [`RenderedValue`] without an
2094/// associated guest-memory reader. Pointer dereferences degrade
2095/// gracefully — the raw pointer hex is emitted without chasing.
2096#[allow(dead_code)]
2097pub fn render_value(btf: &Btf, type_id: u32, bytes: &[u8]) -> RenderedValue {
2098 let mut visited: HashSet<u64> = HashSet::new();
2099 render_value_inner(btf, type_id, bytes, 0, None::<&dyn MemReader>, &mut visited)
2100}
2101
2102/// Render a BTF type's bytes into a [`RenderedValue`] with an
2103/// associated guest-memory reader for pointer chasing. Identical to
2104/// [`render_value`] except the supplied [`MemReader`] is threaded
2105/// through the [`btf_rs::Type::Ptr`] arm and the cast-intercept path
2106/// in `render_member`, so:
2107///
2108/// - BTF-typed pointers ([`btf_rs::Type::Ptr`]) are dereferenced via
2109/// [`MemReader::read_arena`] (when [`MemReader::is_arena_addr`]
2110/// matches) or the cpumask kptr chase via [`MemReader::read_kva`].
2111/// - `u64` fields the cast analyzer flagged via
2112/// [`MemReader::cast_lookup`] are interpreted as typed pointers and
2113/// chased through [`render_cast_pointer`] — the same chase path,
2114/// producing the same [`RenderedValue::Ptr`] shape.
2115///
2116/// Total in the same sense as [`render_value`]: any failure (unmapped
2117/// page, plausibility-gate rejection, cycle, depth cap) surfaces as a
2118/// `Ptr` with `deref: None` and a populated `deref_skipped_reason`,
2119/// never an error return.
2120pub fn render_value_with_mem(
2121 btf: &Btf,
2122 type_id: u32,
2123 bytes: &[u8],
2124 mem: &dyn MemReader,
2125) -> RenderedValue {
2126 let mut visited: HashSet<u64> = HashSet::new();
2127 render_value_inner(btf, type_id, bytes, 0, Some(mem), &mut visited)
2128}
2129
2130/// `visited` carries the set of pointer addresses already chased on
2131/// the current traversal path. The `Type::Ptr` arm checks this set
2132/// before descending: a pointer whose target address is already in
2133/// `visited` is a cycle (most commonly a linked-list `next` pointer
2134/// pointing back to a node earlier on the chain). Without the
2135/// check, a cycle recurses until [`MAX_RENDER_DEPTH`] fires,
2136/// producing a 32-deep wall of identical nested structs in the
2137/// failure dump. With the check, the renderer surfaces a `[cycle]`
2138/// marker after the pointer's hex value and stops.
2139///
2140/// `MAX_RENDER_DEPTH` remains as a backstop for non-cycle
2141/// pathological BTF (deeply nested types without an actual cycle).
2142fn render_value_inner(
2143 btf: &Btf,
2144 type_id: u32,
2145 bytes: &[u8],
2146 depth: u32,
2147 mem: Option<&dyn MemReader>,
2148 visited: &mut HashSet<u64>,
2149) -> RenderedValue {
2150 if depth >= MAX_RENDER_DEPTH {
2151 return RenderedValue::Unsupported {
2152 reason: format!("render depth {MAX_RENDER_DEPTH} exceeded"),
2153 };
2154 }
2155
2156 let Some((ty, peeled_type_id)) = peel_modifiers_with_id(btf, type_id) else {
2157 return RenderedValue::Unsupported {
2158 reason: format!("could not peel modifiers from type id {type_id}"),
2159 };
2160 };
2161
2162 match ty {
2163 Type::Int(int) => render_int(&int, bytes),
2164 Type::Float(float) => render_float(float.size(), bytes),
2165 Type::Enum(e) => {
2166 // Enum values are 32-bit on the wire (vlen entries each
2167 // holding `val: u32`), but the underlying storage size
2168 // comes from `Enum::size()`.
2169 let needed = e.size();
2170 if bytes.len() < needed {
2171 return RenderedValue::Truncated {
2172 needed,
2173 had: bytes.len(),
2174 partial: Box::new(RenderedValue::Bytes {
2175 hex: hex_dump(bytes),
2176 }),
2177 };
2178 }
2179 let raw = read_uint_le(&bytes[..needed]);
2180 let signed = e.is_signed();
2181 let value = if signed {
2182 sign_extend(raw, needed * 8) as i64
2183 } else {
2184 raw as i64
2185 };
2186 // Width-truncate the member value to the enum's actual byte
2187 // width before comparing. `raw` is read from `needed` bytes
2188 // (zero-extended into a u64), but a member's stored value for
2189 // a signed variant is the full 32-bit value (e.g. -1 →
2190 // 0xFFFF_FFFF). For a sub-4-byte signed enum the member value
2191 // would never match the narrower `raw` without masking, so
2192 // the variant name would be silently lost. Resolved name is
2193 // best-effort: an unknown raw value yields `None` rather than
2194 // failing the render.
2195 let val_mask: u64 = if needed * 8 >= 64 {
2196 u64::MAX
2197 } else {
2198 (1u64 << (needed * 8)) - 1
2199 };
2200 let variant = e
2201 .members
2202 .iter()
2203 .find(|m| (m.val() as u64) & val_mask == raw)
2204 .and_then(|m| btf.resolve_name(m).ok());
2205 RenderedValue::Enum {
2206 bits: (needed * 8) as u32,
2207 value,
2208 variant,
2209 is_signed: signed,
2210 }
2211 }
2212 Type::Enum64(e) => {
2213 let needed = e.size();
2214 if bytes.len() < needed {
2215 return RenderedValue::Truncated {
2216 needed,
2217 had: bytes.len(),
2218 partial: Box::new(RenderedValue::Bytes {
2219 hex: hex_dump(bytes),
2220 }),
2221 };
2222 }
2223 let raw = read_uint_le(&bytes[..needed]);
2224 let signed = e.is_signed();
2225 let value = if signed {
2226 sign_extend(raw, needed * 8) as i64
2227 } else {
2228 raw as i64
2229 };
2230 // Width-truncate the member value to the enum's byte width
2231 // (see the Enum32 arm) so sub-8-byte signed variants resolve
2232 // their name instead of falling back to the bare integer.
2233 let val_mask: u64 = if needed * 8 >= 64 {
2234 u64::MAX
2235 } else {
2236 (1u64 << (needed * 8)) - 1
2237 };
2238 let variant = e
2239 .members
2240 .iter()
2241 .find(|m| (m.val() & val_mask) == raw)
2242 .and_then(|m| btf.resolve_name(m).ok());
2243 RenderedValue::Enum {
2244 bits: (needed * 8) as u32,
2245 value,
2246 variant,
2247 is_signed: signed,
2248 }
2249 }
2250 Type::Ptr(ptr) => {
2251 if bytes.len() < 8 {
2252 return RenderedValue::Truncated {
2253 needed: 8,
2254 had: bytes.len(),
2255 partial: Box::new(RenderedValue::Bytes {
2256 hex: hex_dump(bytes),
2257 }),
2258 };
2259 }
2260 let val = u64::from_le_bytes(bytes[..8].try_into().unwrap());
2261 // `deref_skipped_reason` carries the cause when chase is
2262 // attempted but produces no deref (cross-page failure,
2263 // 4 KiB cap, plausibility gate, cycle). It stays `None`
2264 // when no chase was attempted (null val, depth cap, no
2265 // reader). The operator distinguishes "we didn't try"
2266 // from "we tried and got nothing useful" via this
2267 // field.
2268 let mut deref_skipped_reason: Option<String> = None;
2269 // `cast_annotation` is normally `None` for BTF-typed
2270 // pointers (the Type::Ptr arm) — the field is reserved
2271 // for the cast analyzer's recovered pointers. The one
2272 // exception is a Fwd-pointee chase the renderer
2273 // recovered via [`MemReader::resolve_arena_type`] (the
2274 // sdt_alloc bridge): the chased pointer is structurally
2275 // BTF-typed but the body lives in another BTF, so the
2276 // renderer surfaces a `sdt_alloc` annotation to flag the
2277 // recovered chase even on this arm. Set inside
2278 // [`chase_arena_pointer`]'s outcome and threaded out via
2279 // the side-channel below.
2280 let mut cast_annotation: Option<Cow<'static, str>> = None;
2281 // [`chase_gate`] applies the null/cycle/depth-cap policy
2282 // shared with [`render_cast_pointer`]: null and
2283 // depth-cap take the "no chase attempted" path
2284 // (`deref` + reason both `None`); a cycle records the
2285 // `cycle → 0x{val:x}` reason and skips the chase. Only
2286 // [`ChaseGate::Proceed`] enters the `mem.and_then`
2287 // closure that performs the actual read.
2288 let deref = match chase_gate(val, depth, visited) {
2289 ChaseGate::Skip { reason } => {
2290 deref_skipped_reason = reason;
2291 None
2292 }
2293 ChaseGate::Proceed => mem.and_then(|m| {
2294 let pointee_type_id = ptr.get_type_id()?;
2295 if m.is_arena_addr(val) {
2296 // Arena chase factored into the shared
2297 // helper so this arm and
2298 // [`render_cast_pointer`]'s arena branch
2299 // produce identical [`RenderedValue::Ptr`]
2300 // shapes (including the
2301 // [`RenderedValue::Truncated`] wrap when
2302 // `btf_size > POINTER_CHASE_CAP`). The
2303 // helper computes `btf_size` from
2304 // `pointee_type_id`, so the local
2305 // peel/size resolution that follows runs
2306 // only on the kptr path below.
2307 // BTF-typed pointer chase: no captured
2308 // `alloc_size` — the renderer arrives via the
2309 // [`Type::Ptr`] arm with a known pointee type
2310 // id, not via a cast finding. The size-match
2311 // fallback only fires when the analyzer's
2312 // STX-flow path emitted `target_type_id == 0`,
2313 // which is the cast-finding code path below.
2314 let outcome =
2315 chase_arena_pointer(btf, pointee_type_id, None, val, m, depth, visited);
2316 if outcome.reason.is_some() {
2317 deref_skipped_reason = outcome.reason;
2318 }
2319 if outcome.sdt_alloc_resolved {
2320 cast_annotation = Some(Cow::Borrowed("sdt_alloc"));
2321 }
2322 return outcome.deref;
2323 }
2324 // Use the Fwd-resolving peel so a kernel kptr
2325 // whose declared pointee is a [`Type::Fwd`] with
2326 // a complete sibling in the BTF lands on the
2327 // sibling rather than failing the size gate
2328 // below. Drops [`effective_type_id`] because
2329 // this arm does not recurse into a struct
2330 // render — the cpumask-name dispatch below
2331 // works against the resolved [`Type`] alone.
2332 let (pointee_ty, _) = peel_modifiers_resolving_fwd(btf, pointee_type_id)?;
2333 let btf_size = type_size(btf, &pointee_ty)?;
2334 if btf_size == 0 {
2335 // Sanity gate: an incomplete pointee type
2336 // is not safe to chase even on the cpumask
2337 // path (BTF reported no bytes — the
2338 // underlying allocation may not exist as
2339 // declared). Match the historical Type::Ptr
2340 // arm behavior.
2341 deref_skipped_reason =
2342 Some("pointee BTF size is 0 (incomplete type)".to_string());
2343 return None;
2344 }
2345 // Kernel kptr: only chase cpumask pointers.
2346 // Read up to NR_CPUS / 8 bytes from the bitmap
2347 // backing storage and plausibility-gate the
2348 // first word against a hardcoded heuristic
2349 // (see below).
2350 let is_cpumask_ptr = match &pointee_ty {
2351 Type::Struct(s) => {
2352 let n = btf.resolve_name(s).unwrap_or_default();
2353 n == "bpf_cpumask" || n == "cpumask"
2354 }
2355 _ => false,
2356 };
2357 if is_cpumask_ptr {
2358 // Read enough bytes to cover NR_CPUS up to
2359 // 8192 (=1024 bytes = 128 u64 words). The
2360 // kernel allocates the `struct cpumask`
2361 // `bits` storage from a slab cache sized to
2362 // `cpumask_size()`, which is `(NR_CPUS + 7)
2363 // / 8` rounded up to a multiple of 8 —
2364 // bounded by NR_CPUS at config time. 1024
2365 // covers every modern distro kernel;
2366 // mainline NR_CPUS_DEFAULT is 8192 for
2367 // x86_64 / aarch64. The per-word walker
2368 // below caps the rendered bits at the
2369 // guest's `nr_cpu_ids` so a small guest
2370 // (e.g. 8 CPUs) doesn't render bits
2371 // 64..8191 from slab padding.
2372 const CPUMASK_READ_CAP: usize = 1024;
2373 let Some(bits_bytes) = m.read_kva(val, CPUMASK_READ_CAP) else {
2374 deref_skipped_reason = Some(format!(
2375 "cpumask kptr read_kva failed at 0x{val:x} \
2376 (unmapped page or no PTE)"
2377 ));
2378 return None;
2379 };
2380 if bits_bytes.len() < 8 {
2381 deref_skipped_reason = Some(format!(
2382 "cpumask kptr read returned {} bytes; need at least 8",
2383 bits_bytes.len()
2384 ));
2385 return None;
2386 }
2387 let max_cpus = m.nr_cpu_ids();
2388 let bits0 = u64::from_le_bytes(bits_bytes[..8].try_into().ok()?);
2389 // Best-effort plausibility heuristic on
2390 // `bits[0]`: a freed slab object's first
2391 // qword is often a freelist next pointer,
2392 // which on x86_64 / aarch64 lands in the
2393 // canonical kernel range
2394 // (0xffff8000_00000000+, top byte 0xff).
2395 // Reject a top-byte-0xff first word as a
2396 // probable stale-pointer pattern — EXCEPT an
2397 // all-ones word (`u64::MAX`), which is a
2398 // fully-online <=64-CPU mask. 0xFFFF..FFFF is
2399 // non-canonical and is never a real freelist
2400 // next pointer (a next pointer carries address
2401 // bits, so it is never all-ones), so decoding
2402 // it as a mask cannot alias a live kernel
2403 // pointer. The `nr_cpu_ids` cap below
2404 // backstops the heuristic: even when
2405 // SLAB_FREELIST_HARDENED XOR-encodes the next
2406 // pointer (defeating the top-byte gate), set
2407 // bits beyond `nr_cpu_ids` are dropped rather
2408 // than rendered as phantom cpu ids. The gate
2409 // is intentionally cheap; a production-grade
2410 // detector would walk the SLUB metadata to
2411 // confirm liveness.
2412 if bits0 == u64::MAX || bits0 >> 56 != 0xff {
2413 let mut cpus = Vec::new();
2414 // Walk every full u64 in the read.
2415 // `bits_bytes.len()` is at least 8
2416 // (gated above) and a multiple of 8 in
2417 // practice (the read cap is a multiple
2418 // of 8 and the read returns a contiguous
2419 // bytes vector).
2420 'walk: for word_idx in 0..(bits_bytes.len() / 8) {
2421 let off = word_idx * 8;
2422 let word_first_cpu = (word_idx * 64) as u64;
2423 // Cap at the guest's nr_cpu_ids:
2424 // bits beyond that are slab
2425 // padding, not part of the
2426 // kernel-meaningful mask.
2427 if word_first_cpu >= max_cpus as u64 {
2428 break;
2429 }
2430 let word =
2431 u64::from_le_bytes(bits_bytes[off..off + 8].try_into().ok()?);
2432 // Per-word pointer-pattern gate.
2433 // Slab garbage in trailing words can
2434 // appear as a high-bit-set u64 that
2435 // would otherwise enumerate phantom
2436 // CPU IDs; bail out of the walk when
2437 // a later word looks like a kernel
2438 // address rather than mask bits. An
2439 // all-ones word is a fully-online
2440 // 64-CPU chunk, not a pointer, so it is
2441 // decoded (same exception as bits[0]).
2442 if word != u64::MAX && word >> 56 == 0xff {
2443 break;
2444 }
2445 for bit in 0..64u32 {
2446 let cpu = (word_idx * 64) as u32 + bit;
2447 // Partial-word cap: max_cpus
2448 // can fall mid-word (e.g.
2449 // nr_cpu_ids=8 means bits
2450 // 8..63 of word 0 are padding).
2451 if cpu >= max_cpus {
2452 break 'walk;
2453 }
2454 if word & (1u64 << bit) != 0 {
2455 cpus.push(cpu);
2456 }
2457 }
2458 }
2459 return Some(Box::new(RenderedValue::CpuList {
2460 cpus: format_cpu_list(&cpus),
2461 }));
2462 } else {
2463 deref_skipped_reason = Some(format!(
2464 "cpumask kptr plausibility gate rejected: bits[0] top \
2465 byte is 0xff at 0x{val:x} (likely freed slab object)"
2466 ));
2467 }
2468 }
2469 None
2470 }),
2471 };
2472 RenderedValue::Ptr {
2473 value: val,
2474 deref,
2475 deref_skipped_reason,
2476 cast_annotation,
2477 }
2478 }
2479 Type::Struct(s) | Type::Union(s) => {
2480 // `peeled_type_id` is the BTF id of `s` after modifier
2481 // peel — the form [`super::cast_analysis::CastMap`] keys
2482 // its `(parent_type_id, member_byte_offset)` lookups
2483 // against. Threaded into `render_struct` so per-member
2484 // cast intercepts can consult the reader.
2485 render_struct(btf, &s, peeled_type_id, bytes, depth, mem, visited)
2486 }
2487 Type::Array(arr) => {
2488 // `Array::get_type_id` returns the element type id
2489 // directly (`btf_array.r#type`), so resolving a chained
2490 // type purely to fish for the id is redundant. The
2491 // element's *Type* (used for size) does need a chained
2492 // resolve.
2493 let len = arr.len();
2494 let Some(elem_type_id) = arr.get_type_id() else {
2495 return RenderedValue::Unsupported {
2496 reason: "array element type id not resolvable".to_string(),
2497 };
2498 };
2499 let Ok(elem_ty) = btf.resolve_chained_type(&arr) else {
2500 return RenderedValue::Unsupported {
2501 reason: "array element type not resolvable".to_string(),
2502 };
2503 };
2504 let Some(elem_size) = type_size(btf, &elem_ty) else {
2505 return RenderedValue::Unsupported {
2506 reason: "array element size not resolvable".to_string(),
2507 };
2508 };
2509 // Flex array detection: BTF encodes a trailing `T[];` /
2510 // `T[0]` member as `array.len == 0`. The C-side runtime
2511 // length lives in a sibling field (e.g. struct topology's
2512 // `nr_children`), which the renderer doesn't have access
2513 // to here. Emit Unsupported with an explicit reason
2514 // rather than silently rendering as `[]` — the operator
2515 // sees that the array IS a flex array and that runtime
2516 // population is opaque to the BTF-only renderer. Best-
2517 // effort element extraction (via a sibling-field read of
2518 // nr_children, etc.) is out of scope: it requires
2519 // parent-struct context the array arm doesn't carry.
2520 //
2521 // `elem_size > 0` is required so a true zero-element
2522 // type-id-only array (synthesized by libbpf for empty
2523 // sections etc.) doesn't accidentally surface as flex.
2524 if len == 0 && elem_size > 0 && !bytes.is_empty() {
2525 return RenderedValue::Unsupported {
2526 reason: format!(
2527 "flex array (BTF len=0); runtime length not \
2528 representable in BTF, {} bytes available at site",
2529 bytes.len()
2530 ),
2531 };
2532 }
2533 let cap = len.min(MAX_ARRAY_ELEMS);
2534 let mut elements = Vec::with_capacity(cap);
2535 for i in 0..cap {
2536 let start = i * elem_size;
2537 let end = start + elem_size;
2538 if end > bytes.len() {
2539 let avail = &bytes[start.min(bytes.len())..];
2540 elements.push(RenderedValue::Truncated {
2541 needed: elem_size,
2542 had: avail.len(),
2543 partial: Box::new(RenderedValue::Bytes {
2544 hex: hex_dump(avail),
2545 }),
2546 });
2547 break;
2548 }
2549 elements.push(render_value_inner(
2550 btf,
2551 elem_type_id,
2552 &bytes[start..end],
2553 depth + 1,
2554 mem,
2555 visited,
2556 ));
2557 }
2558 RenderedValue::Array { len, elements }
2559 }
2560 Type::Fwd(_) => RenderedValue::Unsupported {
2561 reason: "forward declaration: type body not in BTF".to_string(),
2562 },
2563 Type::Func(_) | Type::FuncProto(_) => RenderedValue::Unsupported {
2564 reason: "function type: no value bytes to render".to_string(),
2565 },
2566 Type::Datasec(ds) => {
2567 // `peeled_type_id` is the BTF id of the datasec after
2568 // modifier peel — the form
2569 // [`super::cast_analysis::CastMap`] keys its
2570 // `(parent_type_id, member_byte_offset)` lookups
2571 // against. Threaded into `render_datasec` so per-
2572 // variable cast intercepts can consult the reader,
2573 // mirroring the `render_struct` path that handles
2574 // struct/union members.
2575 render_datasec(btf, &ds, peeled_type_id, bytes, depth, mem, visited)
2576 }
2577 Type::Var(var) => {
2578 // Standalone Var (i.e. asked to render a Var type id
2579 // outside a Datasec walk): forward to its underlying
2580 // type. The Var node carries a name but no storage of
2581 // its own — render its referent against the supplied
2582 // bytes. A failed type-id lookup falls back to
2583 // Unsupported rather than panicking.
2584 let Some(inner_id) = var.get_type_id() else {
2585 return RenderedValue::Unsupported {
2586 reason: "var type id not resolvable".to_string(),
2587 };
2588 };
2589 render_value_inner(btf, inner_id, bytes, depth + 1, mem, visited)
2590 }
2591 Type::Void => RenderedValue::Unsupported {
2592 reason: "void: no value bytes to render".to_string(),
2593 },
2594 // Modifier types should have been peeled by peel_modifiers.
2595 // If one slipped through, treat it as unsupported rather than
2596 // looping forever.
2597 Type::Volatile(_)
2598 | Type::Const(_)
2599 | Type::Restrict(_)
2600 | Type::Typedef(_)
2601 | Type::TypeTag(_)
2602 | Type::DeclTag(_) => RenderedValue::Unsupported {
2603 reason: "unpeeled modifier (BTF cycle?)".to_string(),
2604 },
2605 _ => RenderedValue::Unsupported {
2606 reason: format!("unhandled BTF type kind {ty:?} for type id {peeled_type_id}"),
2607 },
2608 }
2609}
2610
2611fn render_int(int: &btf_rs::Int, bytes: &[u8]) -> RenderedValue {
2612 let needed = int.size();
2613 if bytes.len() < needed {
2614 return RenderedValue::Truncated {
2615 needed,
2616 had: bytes.len(),
2617 partial: Box::new(RenderedValue::Bytes {
2618 hex: hex_dump(bytes),
2619 }),
2620 };
2621 }
2622 if int.is_bool() && needed >= 1 {
2623 // C `_Bool` is canonically 1 byte but BTF can describe wider
2624 // boolean ints. Truthiness must consider every byte in the
2625 // declared width: a 4-byte `_Bool` set to 0x00000100 is true,
2626 // not false. The first-byte-only check predated this fix and
2627 // would silently miss any non-zero byte above the LSB.
2628 return RenderedValue::Bool {
2629 value: bytes[..needed].iter().any(|&b| b != 0),
2630 };
2631 }
2632 if int.is_char() && needed == 1 {
2633 return RenderedValue::Char { value: bytes[0] };
2634 }
2635 // BTF allows ints wider than 8 bytes (e.g. 128-bit __int128).
2636 // `read_uint_le` caps at 8 bytes, so silently feeding it a wider
2637 // span would discard the upper bits. Fall back to a Bytes hex
2638 // dump rather than producing a half-decoded numeric value.
2639 if needed > 8 {
2640 return RenderedValue::Bytes {
2641 hex: hex_dump(&bytes[..needed]),
2642 };
2643 }
2644 let raw = read_uint_le(&bytes[..needed]);
2645 if int.is_signed() {
2646 let value = sign_extend(raw, needed * 8) as i64;
2647 RenderedValue::Int {
2648 bits: (needed * 8) as u32,
2649 value,
2650 }
2651 } else {
2652 RenderedValue::Uint {
2653 bits: (needed * 8) as u32,
2654 value: raw,
2655 }
2656 }
2657}
2658
2659// Hex dump of byte slices is the `pub(crate) hex_dump` helper in
2660// [`super::dump::hex_dump`] — imported above. Single canonical
2661// implementation; renderer and dump path share the same wire shape.
2662
2663fn render_float(size: usize, bytes: &[u8]) -> RenderedValue {
2664 if bytes.len() < size {
2665 return RenderedValue::Truncated {
2666 needed: size,
2667 had: bytes.len(),
2668 partial: Box::new(RenderedValue::Bytes {
2669 hex: hex_dump(bytes),
2670 }),
2671 };
2672 }
2673 let value = match size {
2674 4 => f32::from_le_bytes(bytes[..4].try_into().unwrap()) as f64,
2675 8 => f64::from_le_bytes(bytes[..8].try_into().unwrap()),
2676 _ => {
2677 return RenderedValue::Unsupported {
2678 reason: format!("unsupported float size {size}"),
2679 };
2680 }
2681 };
2682 RenderedValue::Float {
2683 bits: (size * 8) as u32,
2684 value,
2685 }
2686}
2687
2688fn render_struct(
2689 btf: &Btf,
2690 s: &Struct,
2691 parent_type_id: u32,
2692 bytes: &[u8],
2693 depth: u32,
2694 mem: Option<&dyn MemReader>,
2695 visited: &mut HashSet<u64>,
2696) -> RenderedValue {
2697 let type_name = btf.resolve_name(s).ok().filter(|n| !n.is_empty());
2698
2699 // Intercept cpumask-family structs: render as cpu-list instead
2700 // of per-field dump. Detect by struct name. The
2701 // [`MemReader::nr_cpu_ids`] cap rejects bits past the guest's
2702 // actual CPU count — kernel cpumask slab allocations are sized
2703 // to NR_CPUS at config time but only the first `nr_cpu_ids`
2704 // bits carry meaningful data. Without the cap, slab padding /
2705 // freelist garbage in trailing words renders as phantom CPU
2706 // ids (the SLAB_FREELIST_HARDENED top-byte heuristic in the
2707 // Ptr arm doesn't apply to embedded cpumask_t bytes). When
2708 // `mem` is None (no reader plumbed), use `u32::MAX` so the
2709 // pre-existing behavior — every set bit reported — is
2710 // preserved.
2711 let max_cpus = mem.map(|m| m.nr_cpu_ids()).unwrap_or(u32::MAX);
2712 if let Some(ref name) = type_name {
2713 match name.as_str() {
2714 "cpumask" | "cpumask_t" => {
2715 if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2716 return cpu_list;
2717 }
2718 }
2719 "bpf_cpumask" => {
2720 // bpf_cpumask = { cpumask_t cpumask; refcount_t usage; }
2721 // cpumask starts at offset 0.
2722 if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2723 return cpu_list;
2724 }
2725 }
2726 "scx_bitmap" => {
2727 // scx_bitmap = { sdt_id tid (8 bytes); u64 bits[64]; }
2728 if bytes.len() >= 16
2729 && let Some(cpu_list) = try_render_cpumask_bits(&bytes[8..], max_cpus)
2730 {
2731 return cpu_list;
2732 }
2733 }
2734 "llc_cpumask" => {
2735 // scx_mitosis per-LLC inline bitmap:
2736 // `struct llc_cpumask { unsigned long bits[NR]; }` —
2737 // bits at offset 0, the whole struct IS the mask (a
2738 // plain inline array, not a bpf_cpumask kptr). Decode
2739 // the raw bytes like cpumask_t.
2740 if let Some(cpu_list) = try_render_cpumask_bits(bytes, max_cpus) {
2741 return cpu_list;
2742 }
2743 }
2744 _ => {}
2745 }
2746 }
2747
2748 let truncated = bytes.len() < s.size();
2749 // Render every member regardless of whether the full struct
2750 // fits: `render_member` already emits a per-member
2751 // [`RenderedValue::Truncated`] for members that extend past
2752 // the supplied bytes. This way, the outer Truncated's
2753 // `partial` field carries the members that DID decode, instead
2754 // of discarding the whole render. See [`RenderedValue::Truncated`]
2755 // doc for the partial-render contract.
2756 let mut members = Vec::with_capacity(s.members.len());
2757 for m in &s.members {
2758 let bit_off = m.bit_offset() as usize;
2759 let byte_off = bit_off / 8;
2760 if byte_off >= bytes.len() && bytes.len() < s.size() {
2761 continue;
2762 }
2763 let name = btf.resolve_name(m).unwrap_or_default();
2764 let value = render_member(btf, m, Some(parent_type_id), bytes, depth, mem, visited);
2765 if name.is_empty()
2766 && let RenderedValue::Struct { members: inner, .. } = value
2767 {
2768 members.extend(inner);
2769 continue;
2770 }
2771 members.push(RenderedMember { name, value });
2772 }
2773 let rendered = RenderedValue::Struct { type_name, members };
2774 if truncated {
2775 RenderedValue::Truncated {
2776 needed: s.size(),
2777 had: bytes.len(),
2778 partial: Box::new(rendered),
2779 }
2780 } else {
2781 rendered
2782 }
2783}
2784
2785/// Shared BPF cast intercept gate for [`render_member`] and
2786/// [`render_datasec`]. Caller passes the (parent_id, byte_off) key
2787/// the cast analyzer would have seen. Returns `Some(rendered)` when
2788/// every gate aligns and the field is a cast-recovered typed
2789/// pointer; `None` otherwise (caller falls through to its standard
2790/// render path).
2791///
2792/// `field_bytes` must be the parent_bytes / section_bytes slice
2793/// from `byte_off` onward (i.e. the slice already advanced to the
2794/// field's start). The helper reads the first 8 bytes; a slice
2795/// shorter than 8 bytes falls through to `None`.
2796///
2797/// Gates (in order):
2798/// - `mem` is `Some` (no chase is possible without a [`MemReader`]).
2799/// - `peeled` peels to a plain unsigned 8-byte [`Type::Int`] — BPF
2800/// stores typed pointers in `u64` slots; signed, `_Bool`, `char`,
2801/// and sub-u64 widths are not the cast analyzer's output shape.
2802/// - `byte_off` fits in `u32` (datasec / struct offsets exceed
2803/// `u32::MAX` only in malformed BTF; the analyzer keys offsets as
2804/// `u32`).
2805/// - [`MemReader::cast_lookup`] returns a hit for
2806/// `(parent_type_id, byte_off)` (default `None` keeps every
2807/// reader correct without an explicit override).
2808/// - `field_bytes` is at least 8 bytes long (a truncated field
2809/// falls through to the existing partial-decode path so the
2810/// consumer still sees whatever survived).
2811fn try_cast_intercept(
2812 btf: &Btf,
2813 cast_key: (u32, usize),
2814 peeled: &Type,
2815 field_bytes: &[u8],
2816 depth: u32,
2817 mem: Option<&dyn MemReader>,
2818 visited: &mut HashSet<u64>,
2819) -> Option<RenderedValue> {
2820 let (parent_type_id, byte_off) = cast_key;
2821 let reader = mem?;
2822 let Type::Int(int) = peeled else {
2823 return None;
2824 };
2825 if int.size() != 8 || int.is_signed() || int.is_bool() || int.is_char() {
2826 return None;
2827 }
2828 let off_u32 = u32::try_from(byte_off).ok()?;
2829 let hit = reader.cast_lookup(parent_type_id, off_u32)?;
2830 let head = field_bytes.get(..8)?;
2831 let value = u64::from_le_bytes(head.try_into().ok()?);
2832 Some(render_cast_pointer(btf, hit, value, depth, reader, visited))
2833}
2834
2835/// Render a `BTF_KIND_DATASEC` (e.g. `.bss`, `.data`, `.rodata`) by
2836/// walking its `VarSecinfo` entries and rendering each variable into
2837/// the slice of section bytes its `offset()` and `size()` describe.
2838///
2839/// Each VarSecinfo's `get_type_id()` returns a `BTF_KIND_VAR` id; the
2840/// Var carries the variable's name and its underlying type's id. The
2841/// renderer slices `bytes[offset..offset+size]` and recursively
2842/// renders the underlying type into that slice. The result is a
2843/// [`RenderedValue::Struct`] whose `type_name` is the section name
2844/// (e.g. `.bss`) and whose `members` are the section's variables —
2845/// reusing `RenderedValue::Struct` rather than introducing a new
2846/// variant keeps the existing serde shape (`kind: "struct"`) and
2847/// Display layout intact, so a failure dump's `.bss` map renders
2848/// alongside ordinary structs and JSON consumers (the
2849/// `failure_dump_e2e.rs` fixture among them) iterate the variables via
2850/// `value.members[]` exactly as they iterate struct members today.
2851///
2852/// Truncation: an out-of-range `(offset, size)` for the supplied
2853/// `bytes` slice surfaces as a per-variable
2854/// [`RenderedValue::Truncated`] — the variable's name is still
2855/// recorded under [`RenderedMember::name`], so an operator sees
2856/// "variable X needed N bytes, had M" rather than the entire
2857/// section disappearing. Variables with malformed BTF (Var type id
2858/// fails to resolve, chained type isn't a Var) fall through to
2859/// [`RenderedValue::Unsupported`] with the reason recorded.
2860fn render_datasec(
2861 btf: &Btf,
2862 ds: &btf_rs::Datasec,
2863 parent_type_id: u32,
2864 bytes: &[u8],
2865 depth: u32,
2866 mem: Option<&dyn MemReader>,
2867 visited: &mut HashSet<u64>,
2868) -> RenderedValue {
2869 // Section name lives on the Datasec itself
2870 // (BTF_KIND_DATASEC.name_off via `BtfType::get_name_offset`). An
2871 // empty / unresolvable name maps to `None`, matching
2872 // [`RenderedValue::Struct::type_name`]'s contract for anonymous
2873 // aggregates.
2874 let type_name = btf.resolve_name(ds).ok().filter(|n| !n.is_empty());
2875 let mut members = Vec::with_capacity(ds.variables.len());
2876 for var_info in &ds.variables {
2877 let offset = var_info.offset() as usize;
2878 let size = var_info.size();
2879 // Resolve the chained Var so we can pull the variable's
2880 // name and its underlying type id. A non-Var here indicates
2881 // malformed BTF (libbpf always emits Var per VarSecinfo);
2882 // record the failure as an Unsupported member rather than
2883 // dropping the slot.
2884 let chained = match btf.resolve_chained_type(var_info) {
2885 Ok(t) => t,
2886 Err(_) => {
2887 members.push(RenderedMember {
2888 name: String::new(),
2889 value: RenderedValue::Unsupported {
2890 reason: "datasec var type not resolvable".to_string(),
2891 },
2892 });
2893 continue;
2894 }
2895 };
2896 let var = match chained {
2897 Type::Var(v) => v,
2898 other => {
2899 members.push(RenderedMember {
2900 name: String::new(),
2901 value: RenderedValue::Unsupported {
2902 reason: format!("datasec entry resolved to non-Var ({})", other.name()),
2903 },
2904 });
2905 continue;
2906 }
2907 };
2908 let var_name = btf.resolve_name(&var).unwrap_or_default();
2909 let inner_id = match var.get_type_id() {
2910 Some(id) => id,
2911 None => {
2912 members.push(RenderedMember {
2913 name: var_name,
2914 value: RenderedValue::Unsupported {
2915 reason: "var underlying type id not resolvable".to_string(),
2916 },
2917 });
2918 continue;
2919 }
2920 };
2921 // BPF cast intercept: a `u64` global variable that the cast
2922 // analyzer recovered as a typed pointer renders as `Ptr`
2923 // with the recovered target chased through the appropriate
2924 // address-space reader. Mirrors the gate in
2925 // [`render_member`] for struct members, but keyed on the
2926 // datasec id + variable offset (the cast analyzer's
2927 // `(parent, off)` pair for a BSS / data global). Shared
2928 // gating logic lives in [`try_cast_intercept`].
2929 let cast_intercept = peel_modifiers(btf, inner_id).and_then(|inner_ty| {
2930 let field_bytes = bytes.get(offset..).unwrap_or_default();
2931 try_cast_intercept(
2932 btf,
2933 (parent_type_id, offset),
2934 &inner_ty,
2935 field_bytes,
2936 depth,
2937 mem,
2938 visited,
2939 )
2940 });
2941 if let Some(rv) = cast_intercept {
2942 members.push(RenderedMember {
2943 name: var_name,
2944 value: rv,
2945 });
2946 continue;
2947 }
2948 // Slice the section bytes for this variable. If the section
2949 // bytes are shorter than offset+size, emit a per-member
2950 // Truncated whose `partial` is whatever the inner renderer
2951 // can decode from the available subset (mirrors
2952 // `render_member`'s short-bytes behaviour). `checked_add`
2953 // guards against pathological BTF where `offset + size`
2954 // would overflow `usize` — without it, a torn VarSecinfo
2955 // could wrap past `usize::MAX` and the `<= bytes.len()`
2956 // comparison would silently become true, indexing out of
2957 // bounds.
2958 let end = offset.checked_add(size);
2959 let value = match end {
2960 Some(end) if end <= bytes.len() => {
2961 render_value_inner(btf, inner_id, &bytes[offset..end], depth + 1, mem, visited)
2962 }
2963 _ => {
2964 let avail_start = offset.min(bytes.len());
2965 let avail = &bytes[avail_start..];
2966 let partial = render_value_inner(btf, inner_id, avail, depth + 1, mem, visited);
2967 RenderedValue::Truncated {
2968 needed: size,
2969 had: avail.len(),
2970 partial: Box::new(partial),
2971 }
2972 }
2973 };
2974 members.push(RenderedMember {
2975 name: var_name,
2976 value,
2977 });
2978 }
2979 RenderedValue::Struct { type_name, members }
2980}
2981
2982fn render_member(
2983 btf: &Btf,
2984 m: &Member,
2985 parent_type_id: Option<u32>,
2986 parent_bytes: &[u8],
2987 depth: u32,
2988 mem: Option<&dyn MemReader>,
2989 visited: &mut HashSet<u64>,
2990) -> RenderedValue {
2991 let bit_off = m.bit_offset() as usize;
2992 let Some(member_type_id) = m.get_type_id() else {
2993 return RenderedValue::Unsupported {
2994 reason: "member has no type id".to_string(),
2995 };
2996 };
2997
2998 if let Some(width) = m.bitfield_size()
2999 && width > 0
3000 {
3001 return render_bitfield(btf, member_type_id, parent_bytes, bit_off, width as usize);
3002 }
3003
3004 // Non-bitfield: assume bit_off is byte-aligned. Compute the
3005 // member's size from its (peeled) type and slice.
3006 if !bit_off.is_multiple_of(8) {
3007 return RenderedValue::Unsupported {
3008 reason: format!("non-bitfield member at non-byte bit offset {bit_off}"),
3009 };
3010 }
3011 let byte_off = bit_off / 8;
3012 let Some(member_ty) = peel_modifiers(btf, member_type_id) else {
3013 return RenderedValue::Unsupported {
3014 reason: "member type modifiers unresolvable".to_string(),
3015 };
3016 };
3017 let Some(size) = type_size(btf, &member_ty) else {
3018 return RenderedValue::Unsupported {
3019 reason: "member type size unresolvable".to_string(),
3020 };
3021 };
3022
3023 // BPF cast intercept: a `u64` member that the cast analyzer
3024 // recovered as a typed pointer is rendered as `Ptr` with the
3025 // recovered target chased through the appropriate address-space
3026 // reader. Shared gating logic lives in [`try_cast_intercept`];
3027 // the additional `parent_type_id?` here ensures we are inside a
3028 // struct that [`render_struct`] dispatched (a standalone Int
3029 // render carries no parent and skips the intercept).
3030 let cast_intercept = parent_type_id.and_then(|parent| {
3031 let field_bytes = parent_bytes.get(byte_off..).unwrap_or_default();
3032 try_cast_intercept(
3033 btf,
3034 (parent, byte_off),
3035 &member_ty,
3036 field_bytes,
3037 depth,
3038 mem,
3039 visited,
3040 )
3041 });
3042 if let Some(rv) = cast_intercept {
3043 return rv;
3044 }
3045
3046 if let Some(parent) = parent_type_id
3047 && let Type::Array(arr) = &member_ty
3048 && let Some(elem_tid) = arr.get_type_id()
3049 && let Some(elem_term) = peel_modifiers(btf, elem_tid)
3050 && type_size(btf, &elem_term) == Some(8)
3051 && matches!(
3052 elem_term,
3053 Type::Int(ref i) if i.size() == 8 && !i.is_signed() && !i.is_bool() && !i.is_char()
3054 )
3055 {
3056 let arr_len = arr.len();
3057 let has_any_cast = mem.is_some_and(|m| {
3058 (0..arr_len).any(|i| {
3059 let elem_off = (byte_off + i * 8) as u32;
3060 m.cast_lookup(parent, elem_off).is_some()
3061 })
3062 });
3063 if has_any_cast {
3064 let cap = arr_len.min(MAX_ARRAY_ELEMS);
3065 let mut elements = Vec::with_capacity(cap);
3066 for i in 0..cap {
3067 let elem_off = byte_off + i * 8;
3068 let elem_bytes = parent_bytes.get(elem_off..elem_off + 8).unwrap_or_default();
3069 if let Some(rv) = try_cast_intercept(
3070 btf,
3071 (parent, elem_off),
3072 &elem_term,
3073 elem_bytes,
3074 depth + 1,
3075 mem,
3076 visited,
3077 ) {
3078 elements.push(rv);
3079 } else {
3080 elements.push(render_value_inner(
3081 btf,
3082 elem_tid,
3083 elem_bytes,
3084 depth + 1,
3085 mem,
3086 visited,
3087 ));
3088 }
3089 }
3090 return RenderedValue::Array {
3091 len: arr_len,
3092 elements,
3093 };
3094 }
3095 }
3096
3097 // `checked_add` guards against pathological BTF where
3098 // `byte_off + size` would overflow `usize` (a torn member with
3099 // a wild bit_offset / size pair). Without the check, the wrap
3100 // would silently make the `> parent_bytes.len()` test false
3101 // and the slice would index out of bounds.
3102 let end = byte_off.checked_add(size);
3103 match end {
3104 Some(end) if end <= parent_bytes.len() => render_value_inner(
3105 btf,
3106 member_type_id,
3107 &parent_bytes[byte_off..end],
3108 depth + 1,
3109 mem,
3110 visited,
3111 ),
3112 _ => {
3113 // Attempt a partial decode from whatever bytes ARE available
3114 // for this member: the inner renderer will itself emit a
3115 // Truncated/Bytes/etc. that carries the recoverable subset.
3116 // Wrapping that subset in this outer Truncated tells the
3117 // consumer "the full member needed N bytes, only M survived,
3118 // here's what we got".
3119 let avail_start = byte_off.min(parent_bytes.len());
3120 let avail = &parent_bytes[avail_start..];
3121 let partial = render_value_inner(btf, member_type_id, avail, depth + 1, mem, visited);
3122 RenderedValue::Truncated {
3123 needed: size,
3124 had: avail.len(),
3125 partial: Box::new(partial),
3126 }
3127 }
3128 }
3129}
3130
3131/// Cap on bytes any pointer chase reads from a target. Shared
3132/// between the [`Type::Ptr`] arena branch and [`render_cast_pointer`]
3133/// so a single tunable applies to every chase the renderer performs:
3134/// a single arena page is 4 KiB, the [`MemReader::read_arena`]
3135/// contract bails on cross-page reads, and `MemReader::read_kva`
3136/// callers should avoid pulling many pages of slab content into the
3137/// dump for a single recovered pointer. Targets larger than the cap
3138/// surface as a [`RenderedValue::Truncated`] wrapping the partial
3139/// decode so the consumer can tell the rendered subtree was clipped.
3140const POINTER_CHASE_CAP: usize = 4096;
3141
3142/// Outcome of [`chase_gate`]: skip the chase (with optional reason
3143/// for `deref_skipped_reason`) or proceed with the read+recurse.
3144///
3145/// Lets the [`Type::Ptr`] arm and [`render_cast_pointer`] share a
3146/// single null/cycle/depth-cap policy: null and depth-cap produce
3147/// `Skip { reason: None }` (no chase attempted, no reason emitted);
3148/// cycle produces `Skip { reason: Some("cycle → 0x{val:x}") }` so
3149/// Display shows the cycle marker.
3150enum ChaseGate {
3151 /// Skip the chase. `reason` populates `deref_skipped_reason` on
3152 /// the resulting [`RenderedValue::Ptr`]; `None` means no chase
3153 /// was attempted and the operator sees an unannotated raw
3154 /// pointer.
3155 Skip { reason: Option<String> },
3156 /// All gates passed; the caller should perform the chase.
3157 Proceed,
3158}
3159
3160/// Pre-chase gate shared between the [`Type::Ptr`] arm and
3161/// [`render_cast_pointer`]. Returns [`ChaseGate::Skip`] when the
3162/// renderer must not chase `val` (null, already-visited cycle, or
3163/// recursion depth cap), [`ChaseGate::Proceed`] otherwise.
3164///
3165/// Order of checks matches both call sites' historical behavior:
3166/// null first, then cycle, then depth. `val == 0` short-circuits
3167/// before consulting `visited`, so a stray zero entry in the set
3168/// (which should not occur) does not surface as a phantom cycle.
3169fn chase_gate(val: u64, depth: u32, visited: &HashSet<u64>) -> ChaseGate {
3170 if val == 0 {
3171 return ChaseGate::Skip { reason: None };
3172 }
3173 if visited.contains(&val) {
3174 return ChaseGate::Skip {
3175 reason: Some(format!("cycle → 0x{val:x}")),
3176 };
3177 }
3178 if depth >= MAX_RENDER_DEPTH {
3179 return ChaseGate::Skip { reason: None };
3180 }
3181 ChaseGate::Proceed
3182}
3183
3184/// Compose a `deref_skipped_reason` string for a pointer chase
3185/// whose peeled target type has no BTF-resolvable storage size.
3186///
3187/// [`type_size`] returns `None` for [`Type::Fwd`] (forward-declared
3188/// struct/union with body in another BTF), [`Type::Func`],
3189/// [`Type::FuncProto`], [`Type::Datasec`], [`Type::Var`],
3190/// [`Type::Void`], and [`Type::DeclTag`] (which `peel_modifiers`
3191/// peels in practice — listed for completeness in case a future
3192/// analyzer or BTF shape leaks one through). Each variant has a
3193/// distinct cause; surfacing the variant name plus, when available,
3194/// the BTF-declared name of the type lets operators correlate the
3195/// failure with their source layout (e.g. `struct sdt_data` lives
3196/// in the sdt_alloc library's BTF and surfaces as Fwd in the
3197/// scheduler's own BTF).
3198///
3199/// `kind_label` is the call site's chase prefix (`"arena chase"` /
3200/// `"kernel cast"`) so the reason matches the existing message
3201/// style at each site without forcing each caller to thread the
3202/// label through a `format!`.
3203fn unsizable_chase_reason(
3204 btf: &Btf,
3205 kind_label: &'static str,
3206 target_type_id: u32,
3207 target_ty: &Type,
3208) -> String {
3209 match target_ty {
3210 Type::Fwd(fwd) => {
3211 // A Fwd is `struct X;` or `union X;` with no body in
3212 // this BTF — typical when a scheduler library defines
3213 // the struct (e.g. `struct sdt_data` in the sdt_alloc
3214 // library) and the using program only references it
3215 // via pointer. The chase has no BTF-declared size to
3216 // bound the read, so it skips when both the
3217 // sdt_alloc bridge ([`MemReader::resolve_arena_type`])
3218 // and the cross-BTF Fwd resolution index
3219 // ([`MemReader::cross_btf_resolve_fwd`]) have already
3220 // been consulted and neither produced a hit — i.e.
3221 // the body lives in some BTF the renderer cannot
3222 // reach with the available indexes.
3223 let aggregate = if fwd.is_union() { "union" } else { "struct" };
3224 let name = btf.resolve_name(fwd).ok().filter(|n| !n.is_empty());
3225 match name {
3226 Some(n) => format!(
3227 "{kind_label} target {aggregate} {n} (type id \
3228 {target_type_id}) is a forward declaration; \
3229 body not in this BTF"
3230 ),
3231 None => format!(
3232 "{kind_label} target type id {target_type_id} \
3233 is an anonymous {aggregate} forward declaration; \
3234 body not in this BTF"
3235 ),
3236 }
3237 }
3238 Type::Func(_) => format!(
3239 "{kind_label} target type id {target_type_id} is a \
3240 function (BTF_KIND_FUNC); functions have no storage size"
3241 ),
3242 Type::FuncProto(_) => format!(
3243 "{kind_label} target type id {target_type_id} is a \
3244 function prototype (BTF_KIND_FUNC_PROTO); prototypes \
3245 have no storage size"
3246 ),
3247 Type::Datasec(_) => format!(
3248 "{kind_label} target type id {target_type_id} is a \
3249 datasec (BTF_KIND_DATASEC); not a pointer chase target"
3250 ),
3251 Type::Var(_) => format!(
3252 "{kind_label} target type id {target_type_id} is a \
3253 var (BTF_KIND_VAR); not a pointer chase target"
3254 ),
3255 Type::Void => format!(
3256 "{kind_label} target type id {target_type_id} is void; \
3257 chasing a void* requires runtime type info"
3258 ),
3259 // `peel_modifiers` peels DeclTag in practice, so reaching
3260 // it here implies a malformed BTF chain — keep the
3261 // diagnostic explicit rather than collapsing into the
3262 // generic fall-through.
3263 Type::DeclTag(_) => format!(
3264 "{kind_label} target type id {target_type_id} is a \
3265 decl-tag (BTF_KIND_DECL_TAG); modifiers should have \
3266 peeled (malformed BTF chain?)"
3267 ),
3268 // Defense-in-depth fall-through: every other variant
3269 // ([`Type::Int`], [`Type::Float`], [`Type::Enum`],
3270 // [`Type::Enum64`], [`Type::Struct`], [`Type::Union`],
3271 // [`Type::Ptr`], [`Type::Array`], and the modifier
3272 // wrappers `peel_modifiers` strips) returns `Some` from
3273 // [`type_size`], so reaching this arm means a future
3274 // [`Type`] variant slipped through without a sizing rule.
3275 // Keep the legacy generic message rather than pretending
3276 // we know the cause.
3277 _ => format!(
3278 "{kind_label} target type id {target_type_id} has \
3279 unresolvable size"
3280 ),
3281 }
3282}
3283
3284/// Outcome of an arena pointer chase. Exactly one of `deref` /
3285/// `reason` carries content: `deref` is the rendered target subtree
3286/// when the chase succeeded; `reason` populates
3287/// [`RenderedValue::Ptr::deref_skipped_reason`] when the chase was
3288/// attempted but did not land. `sdt_alloc_resolved` records whether
3289/// the renderer recovered the chase target's BTF type id from the
3290/// [`MemReader::resolve_arena_type`] sdt_alloc bridge instead of the
3291/// pointer's own BTF declaration — callers surface this through
3292/// [`RenderedValue::Ptr::cast_annotation`] so operators can
3293/// distinguish `BTF-typed → Fwd-resolved-via-sdt_alloc` chases from
3294/// ordinary BTF-typed chases at a glance.
3295struct ArenaChaseOutcome {
3296 deref: Option<Box<RenderedValue>>,
3297 reason: Option<String>,
3298 sdt_alloc_resolved: bool,
3299}
3300
3301/// Try the sdt_alloc bridge for a `BTF_KIND_FWD` chase target.
3302///
3303/// Returns the raw [`ArenaResolveHit`] when the chased address
3304/// falls in a known sdt_alloc slot. The caller feeds the hit's
3305/// `target_type_id` through the same peel → cross-BTF → size
3306/// pipeline it runs for the direct target, so the bridge hit
3307/// gets the full resolution chain (including cross-BTF Fwd
3308/// fallback) instead of being silently dropped when the resolved
3309/// type is also Fwd in the entry BTF.
3310fn try_sdt_alloc_bridge(
3311 mem: &dyn MemReader,
3312 val: u64,
3313 target_ty: &Type,
3314) -> Option<ArenaResolveHit> {
3315 if !matches!(target_ty, Type::Fwd(_)) {
3316 return None;
3317 }
3318 if let Some(hit) = mem.resolve_arena_type(val) {
3319 return Some(hit);
3320 }
3321 // Index miss — the sdt_alloc tree walker may have found 0
3322 // live allocations (race with scheduler unregistration
3323 // freeing all slots before the freeze captures bitmaps).
3324 // Fall back to the sdt_alloc meta when the chased address
3325 // is in the arena window. This is TYPE-GATED (only fires
3326 // for Fwd pointee chases from typed Ptr fields, not for
3327 // arbitrary u64 fields) so it cannot produce the "false
3328 // positive factory" behavior the blanket meta fallback had.
3329 mem.resolve_arena_type_meta_fallback(val)
3330}
3331
3332/// Slice past `header_skip` bytes when the sdt_alloc bridge fires.
3333///
3334/// Returns the byte slice starting at `header_skip` within
3335/// `raw_bytes`, or `None` when the read returned fewer bytes than
3336/// the header skip needs (page-tail truncation, short read). Both
3337/// chase arms — [`chase_arena_pointer`] and [`render_cast_pointer`]
3338/// — apply this slice after their per-arm read so the callers
3339/// share a single underrun guard.
3340fn apply_header_skip(raw_bytes: &[u8], header_skip: usize) -> Option<&[u8]> {
3341 raw_bytes.get(header_skip..)
3342}
3343
3344/// [`MemReader`] adapter that suppresses any lookup whose result
3345/// is keyed against the entry BTF's id space, while delegating
3346/// every other method.
3347///
3348/// Used by [`chase_arena_pointer`] and [`render_cast_pointer`] when
3349/// [`try_cross_btf_fwd_resolve`] succeeds and the chase recursion
3350/// switches from the entry BTF to a sibling BTF.
3351///
3352/// Three methods carry entry-BTF-keyed payloads and MUST be
3353/// suppressed when the chase has crossed a BTF boundary:
3354///
3355/// - [`MemReader::cast_lookup`]: the cast analyzer's
3356/// [`super::cast_analysis::CastMap`] keys on `(parent_type_id,
3357/// member_byte_offset)` against the entry BTF's id space. Sibling
3358/// BTFs re-use the same numeric id space, so a cast hit at
3359/// `(entry_id=N, off=K)` would incorrectly fire when the renderer
3360/// reaches a sibling BTF's struct that happens to carry id `N`. The
3361/// renderer would treat a plain `u64` field in the sibling struct
3362/// as a cast-recovered pointer, producing a phantom `Ptr` render
3363/// with a "type id unresolvable" skip reason against an id that is
3364/// only meaningful in the entry BTF.
3365///
3366/// - [`MemReader::resolve_arena_type`][]: the
3367/// `super::dump::render_map::ArenaSlotIndex` populates
3368/// [`ArenaResolveHit::target_type_id`] with BTF type ids resolved
3369/// against the **entry BTF** at index-build time (the sdt_alloc
3370/// pre-pass runs against the program BTF, which is the entry BTF
3371/// for the chase that initiated the dump). When a cross-BTF chase
3372/// recurses into a sibling BTF and encounters another `Type::Fwd`,
3373/// [`try_sdt_alloc_bridge`] would call `mem.resolve_arena_type`
3374/// and receive an entry-BTF id; passing that id to
3375/// [`peel_modifiers_resolving_fwd`]`(sibling_btf, …)` looks up the
3376/// wrong id in the sibling BTF's space — silent wrong-render (the
3377/// sibling BTF carries a different type at that id) or silent skip
3378/// (the sibling BTF lacks the id). The "no invalid data made"
3379/// contract requires this to be a hard `None`.
3380///
3381/// - [`MemReader::resolve_arena_type_meta_fallback`][]: same rationale
3382/// as `resolve_arena_type` — the sdt_alloc meta payload's
3383/// `target_type_id` is resolved against the entry BTF at index-
3384/// build time. A cross-BTF recursion that fell through to this
3385/// override would receive an entry-BTF id and feed it into the
3386/// sibling BTF's id space, the same wrong-render / wrong-skip
3387/// failure mode. Inherits the trait default `None` (the impl
3388/// does not delegate to `inner`), keeping the suppression
3389/// coextensive with `resolve_arena_type`.
3390///
3391/// The remaining [`MemReader`] methods (read_kva / read_arena /
3392/// is_arena_addr / nr_cpu_ids / cross_btf_resolve_fwd /
3393/// is_already_rendered) carry no entry-BTF id payload — they
3394/// operate on raw addresses and string names — so they stay live.
3395/// Chases originating inside the cross-BTF subtree still resolve
3396/// through the same arena snapshot, kernel page-walker, cross-BTF
3397/// Fwd index, and rendered-slot dedup set. The suppression is
3398/// narrowly scoped to the three id-keyed lookups.
3399struct CrossBtfMemReader<'a> {
3400 inner: &'a dyn MemReader,
3401}
3402
3403impl MemReader for CrossBtfMemReader<'_> {
3404 fn read_kva(&self, kva: u64, len: usize) -> Option<Vec<u8>> {
3405 self.inner.read_kva(kva, len)
3406 }
3407 fn is_arena_addr(&self, addr: u64) -> bool {
3408 self.inner.is_arena_addr(addr)
3409 }
3410 fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
3411 self.inner.read_arena(addr, len)
3412 }
3413 fn nr_cpu_ids(&self) -> u32 {
3414 self.inner.nr_cpu_ids()
3415 }
3416 // cast_lookup intentionally NOT delegated: returns the trait
3417 // default `None`. See struct doc for why suppression is correct
3418 // when the chase has crossed BTFs.
3419 //
3420 // resolve_arena_type intentionally NOT delegated either:
3421 // [`ArenaResolveHit::target_type_id`] is keyed against the entry
3422 // BTF, so a cross-BTF recursion that consulted it would map a
3423 // sibling-BTF chase onto an entry-BTF id and silently wrong-render
3424 // (or silently skip with a misleading reason). False negative is
3425 // the safe direction — the sibling chase can still surface its
3426 // pointee via [`MemReader::cross_btf_resolve_fwd`] and the
3427 // ordinary [`Type::Ptr`] arm.
3428 //
3429 // resolve_arena_type_meta_fallback intentionally NOT delegated
3430 // either: same entry-BTF id-space rationale as
3431 // resolve_arena_type. The sdt_alloc meta payload's
3432 // `target_type_id` is resolved against the entry BTF; feeding it
3433 // into a sibling-BTF chase would produce the same wrong-render /
3434 // wrong-skip failure mode. Inherits trait default `None`.
3435 fn cross_btf_resolve_fwd(&self, name: &str, kind: FwdKind) -> Option<CrossBtfRef<'_>> {
3436 self.inner.cross_btf_resolve_fwd(name, kind)
3437 }
3438 fn is_already_rendered(&self, addr: u64) -> bool {
3439 // Delegate: the rendered-slot set keys on raw arena
3440 // addresses (low-32 windowed `slot_start`), not BTF type
3441 // ids, so the entry-BTF / sibling-BTF distinction does not
3442 // apply. A cross-BTF recursion that lands back on a
3443 // previously-rendered slot must skip the deref so the
3444 // payload doesn't appear twice in the dump — once under
3445 // the typed-allocator surface, once via the cross-BTF
3446 // chase that returned to the same slot.
3447 self.inner.is_already_rendered(addr)
3448 }
3449}
3450
3451/// Pre-read state assembled by [`resolve_chase_target`].
3452///
3453/// Both chase arms — [`chase_arena_pointer`] and
3454/// [`render_cast_pointer`]'s kernel arm — share the entire
3455/// "resolve target type, fire sdt_alloc bridge, fall back to
3456/// cross-BTF Fwd resolve, settle final size" sequence. That
3457/// sequence ends right before the per-arm read (`read_arena` vs
3458/// `read_kva`) — at which point the renderer holds enough
3459/// state to size the read, slice the header, and recurse. This
3460/// struct is exactly that "ready to read" snapshot.
3461///
3462/// `cross_btf_hit` is preserved by value (the type derives
3463/// [`Copy`], so threading it through the resolver does not
3464/// surface borrow lifetime headaches) so the per-arm post-read
3465/// step can build its own [`CrossBtfMemReader`] wrap on the
3466/// recursion path.
3467struct ResolvedTarget<'a> {
3468 /// Type id of the chase target within [`Self::current_btf`]'s
3469 /// id space. Threaded into [`render_value_inner`] for the
3470 /// recursion.
3471 effective_type_id: u32,
3472 /// BTF the recursion runs against — the entry BTF when
3473 /// cross-BTF stayed dormant, the resolved sibling BTF when
3474 /// the cross-BTF Fwd index returned a hit.
3475 current_btf: &'a Btf,
3476 /// Storage size of the resolved payload. The per-arm read
3477 /// budget is `header_skip + btf_size` clamped at the arm's
3478 /// cap (4 KiB for arena, 4 KiB AND page-remaining for
3479 /// kernel).
3480 btf_size: usize,
3481 /// Bytes the chase must skip past the chased address before
3482 /// the payload struct begins. `0` for a payload-start chase;
3483 /// the slot's header size for a slot-start chase the
3484 /// sdt_alloc bridge resolved.
3485 header_skip: usize,
3486 /// `true` when the resolved payload type id came from the
3487 /// sdt_alloc bridge ([`MemReader::resolve_arena_type`])
3488 /// rather than the cast analyzer's declared
3489 /// `target_type_id`. Surfaces through
3490 /// [`RenderedValue::Ptr::cast_annotation`] so operators see
3491 /// the layout came from the bridge.
3492 sdt_alloc_resolved: bool,
3493 /// Set when the cross-BTF Fwd index returned a hit (the
3494 /// chase target's body lives in a sibling BTF). The
3495 /// post-read recursion wraps `mem` in a
3496 /// [`CrossBtfMemReader`] in this case so id-keyed lookups
3497 /// (cast / arena type) cannot fire against the sibling
3498 /// BTF's id space — see [`CrossBtfMemReader`]'s doc for the
3499 /// collision rationale.
3500 cross_btf_hit: Option<CrossBtfRef<'a>>,
3501}
3502
3503/// Outcome of [`resolve_chase_target`].
3504enum ChaseResolve<'a> {
3505 /// Pre-read sequence completed; chase is ready to read and
3506 /// recurse. The caller plugs [`Self::Ready`]'s state into its
3507 /// per-arm reader and post-read gates.
3508 Ready(ResolvedTarget<'a>),
3509 /// Pre-read sequence skipped the chase. The reason and
3510 /// `sdt_alloc_resolved` flag flow into the caller's
3511 /// per-arm `Ptr` builder so the no-deref render still
3512 /// surfaces the bridge state when it fired before the skip.
3513 Skip {
3514 reason: String,
3515 sdt_alloc_resolved: bool,
3516 },
3517}
3518
3519/// Shared pre-read resolver for arena and kernel chase arms.
3520///
3521/// Encapsulates the steps both [`chase_arena_pointer`] and
3522/// [`render_cast_pointer`]'s kernel arm execute identically:
3523///
3524/// 1. Peel modifiers + resolve [`Type::Fwd`] to a complete
3525/// same-name sibling within `btf` via
3526/// [`peel_modifiers_resolving_fwd`].
3527/// 2. Try the sdt_alloc bridge ([`try_sdt_alloc_bridge`]) when
3528/// the post-peel terminal is still [`Type::Fwd`]. A bridge
3529/// fire returns the bridge's resolved `target_ty` /
3530/// `effective_type_id` plus the slot's `header_skip` and the
3531/// payload's `btf_size`; this resolver rebinds the local
3532/// target_ty / effective_type_id from the returned struct.
3533/// 3. When the bridge stays dormant, try the cross-BTF Fwd
3534/// index ([`try_cross_btf_fwd_resolve`]). A hit switches the
3535/// rendering BTF to the resolved sibling and adopts its
3536/// type id.
3537/// 4. Resolve the final `btf_size` against `current_btf` (or
3538/// reuse the bridge's `btf_size` when the bridge fired).
3539/// 5. Reject `btf_size == 0` payloads (incomplete types whose
3540/// BTF size resolves but is zero).
3541///
3542/// `kind_label` ("arena chase" / "kernel cast") flows into every
3543/// skip reason so the caller-visible messages still distinguish
3544/// the two arms; the renderer's test module asserts the prefix on
3545/// each path.
3546///
3547/// On the success path, the [`ChaseResolve::Ready`] payload carries
3548/// every value the per-arm read+recurse needs:
3549/// [`ResolvedTarget::effective_type_id`],
3550/// [`ResolvedTarget::current_btf`], [`ResolvedTarget::btf_size`],
3551/// [`ResolvedTarget::header_skip`],
3552/// [`ResolvedTarget::sdt_alloc_resolved`], and
3553/// [`ResolvedTarget::cross_btf_hit`]. The bridge state is captured
3554/// `Some(...)` exactly when the bridge fired; the `cross_btf_hit`
3555/// is captured exactly when the cross-BTF index returned a hit
3556/// (mutually exclusive with bridge fire).
3557fn resolve_chase_target<'a>(
3558 btf: &'a Btf,
3559 mem: &'a dyn MemReader,
3560 val: u64,
3561 target_type_id: u32,
3562 kind_label: &'static str,
3563) -> ChaseResolve<'a> {
3564 // Step 1: peel modifiers AND resolve a Fwd terminal to a
3565 // complete Struct/Union sibling of the same name when one
3566 // is in the BTF. Without the Fwd shortcut, `type_size`
3567 // returns `None` for a `Type::Fwd` terminal and the chase
3568 // would skip even when the renderer has full layout info
3569 // one BTF id away. `effective_type_id` is what the render
3570 // recursion uses to resolve members — the resolved
3571 // Struct/Union id when a sibling was found, otherwise the
3572 // post-peel original id.
3573 let Some((mut target_ty, mut effective_type_id)) =
3574 peel_modifiers_resolving_fwd(btf, target_type_id)
3575 else {
3576 return ChaseResolve::Skip {
3577 reason: format!("{kind_label} target type id {target_type_id} unresolvable"),
3578 sdt_alloc_resolved: false,
3579 };
3580 };
3581 // Step 2: when the post-peel terminal is still `Type::Fwd`,
3582 // ask the sdt_alloc bridge whether the chased address falls
3583 // inside an allocator slot whose payload type id the
3584 // pre-pass resolved. The bridge is pure-return: a fire
3585 // produces a fresh target_ty / effective_type_id which we
3586 // rebind here, plus the slot's `header_skip` and the
3587 // resolved payload's `btf_size`.
3588 let bridge = try_sdt_alloc_bridge(mem, val, &target_ty);
3589 let (sdt_alloc_resolved, header_skip) = match &bridge {
3590 Some(hit) => {
3591 if let Some((resolved_ty, resolved_id)) =
3592 peel_modifiers_resolving_fwd(btf, hit.target_type_id)
3593 {
3594 target_ty = resolved_ty;
3595 effective_type_id = resolved_id;
3596 }
3597 (true, hit.header_skip)
3598 }
3599 None => (false, 0usize),
3600 };
3601 // Step 3: when the bridge stayed dormant, try the cross-BTF
3602 // Fwd index. A `Type::Fwd` terminal whose complete body
3603 // lives in a sibling BTF resolves through the renderer's
3604 // cross-BTF index — the typical multi-`.bpf.objs` shape
3605 // (one object declares `struct cgx_target;` forward,
3606 // another defines the body). When the index returns a hit
3607 // we switch the rendering BTF to the resolved sibling and
3608 // adopt its type id. The bridge-fired path skips this
3609 // probe — its resolved id is in the entry BTF and the
3610 // recursion doesn't need to switch.
3611 let cross_btf_hit = if matches!(target_ty, Type::Fwd(_)) {
3612 try_cross_btf_fwd_resolve(mem, btf, &target_ty)
3613 } else {
3614 None
3615 };
3616 let current_btf: &Btf = match cross_btf_hit {
3617 Some(hit) => {
3618 target_ty = match hit.btf.resolve_type_by_id(hit.type_id) {
3619 Ok(ty) => ty,
3620 Err(_) => {
3621 tracing::debug!(
3622 kind_label,
3623 hit_type_id = hit.type_id,
3624 "cross-BTF Fwd resolve returned a type_id that does not resolve \
3625 in the sibling BTF",
3626 );
3627 return ChaseResolve::Skip {
3628 reason: format!(
3629 "{kind_label}: cross-BTF Fwd type id {} unresolved in sibling BTF",
3630 hit.type_id
3631 ),
3632 sdt_alloc_resolved,
3633 };
3634 }
3635 };
3636 effective_type_id = hit.type_id;
3637 hit.btf
3638 }
3639 None => btf,
3640 };
3641 // Step 4: resolve the final `btf_size`. The bridge fire
3642 // already paid this resolve; reuse its value when present.
3643 let btf_size = {
3644 let Some(sz) = type_size(current_btf, &target_ty) else {
3645 return ChaseResolve::Skip {
3646 reason: unsizable_chase_reason(current_btf, kind_label, target_type_id, &target_ty),
3647 sdt_alloc_resolved,
3648 };
3649 };
3650 sz
3651 };
3652 // Step 5: reject zero-size payloads (incomplete types whose
3653 // BTF size resolves but is zero). The `incomplete type`
3654 // substring is what the kernel-arm
3655 // `cast_chase_kernel_target_btf_size_zero` test asserts.
3656 if btf_size == 0 {
3657 return ChaseResolve::Skip {
3658 reason: format!(
3659 "{kind_label} target type id {target_type_id} BTF size is 0 (incomplete type)"
3660 ),
3661 sdt_alloc_resolved,
3662 };
3663 }
3664 ChaseResolve::Ready(ResolvedTarget {
3665 effective_type_id,
3666 current_btf,
3667 btf_size,
3668 header_skip,
3669 sdt_alloc_resolved,
3670 cross_btf_hit,
3671 })
3672}
3673
3674/// Run [`render_value_inner`] against the resolved target and
3675/// wrap the result for the chase arm.
3676///
3677/// The post-read recursion is also identical between
3678/// [`chase_arena_pointer`] and [`render_cast_pointer`]: both
3679/// insert the chased value into `visited` before recursing,
3680/// optionally wrap `mem` in a [`CrossBtfMemReader`] when the
3681/// chase crossed a BTF boundary, recurse against
3682/// `effective_type_id` in `current_btf`, remove the value from
3683/// `visited` after, and box-wrap the rendered value in a
3684/// [`RenderedValue::Truncated`] when the read was clipped.
3685///
3686/// `truncated_at_cap` is the per-arm "the read was clipped"
3687/// flag — arena's path uses `total_needed > POINTER_CHASE_CAP`
3688/// (the snapshot is single-page-bound), kernel's path uses
3689/// `total_needed > read_size` (read_size is bound by both the
3690/// global cap AND the page-remaining length).
3691fn recurse_into_target(
3692 resolved: &ResolvedTarget<'_>,
3693 target_bytes: &[u8],
3694 val: u64,
3695 depth: u32,
3696 mem: &dyn MemReader,
3697 visited: &mut HashSet<u64>,
3698 truncated_at_cap: bool,
3699) -> Box<RenderedValue> {
3700 visited.insert(val);
3701 let cross_btf_wrap = resolved
3702 .cross_btf_hit
3703 .as_ref()
3704 .map(|_| CrossBtfMemReader { inner: mem });
3705 let recurse_mem: &dyn MemReader = match &cross_btf_wrap {
3706 Some(w) => w,
3707 None => mem,
3708 };
3709 let inner = render_value_inner(
3710 resolved.current_btf,
3711 resolved.effective_type_id,
3712 target_bytes,
3713 depth + 1,
3714 Some(recurse_mem),
3715 visited,
3716 );
3717 visited.remove(&val);
3718 if truncated_at_cap {
3719 // Partial render: only the first capped bytes of a
3720 // larger struct were read. Wrap so the consumer can
3721 // tell the rendered tree is incomplete even though it
3722 // looks structurally sound.
3723 Box::new(RenderedValue::Truncated {
3724 needed: resolved.btf_size,
3725 had: target_bytes.len(),
3726 partial: Box::new(inner),
3727 })
3728 } else {
3729 Box::new(inner)
3730 }
3731}
3732
3733/// Chase an arena pointer and render the target struct.
3734///
3735/// See [`ArenaChaseOutcome`] for the return shape. The caller plugs
3736/// `deref` and `reason` into [`RenderedValue::Ptr`]; when
3737/// `sdt_alloc_resolved` is `true` the caller adds an
3738/// `sdt_alloc`-flavoured `cast_annotation` so the recovered chase is
3739/// distinguishable from a native BTF chase.
3740///
3741/// `target_type_id == 0` selects the deferred-resolve arena path:
3742/// the [`MemReader::resolve_arena_type`] bridge fires first; on a
3743/// miss the analyzer-supplied `alloc_size` (captured at the
3744/// `scx_static_alloc_internal` call site) drives a size-based BTF
3745/// match via [`super::sdt_alloc::discover_payload_btf_id`]. Both
3746/// paths bypass the cross-BTF probe — their resolved ids are in
3747/// the entry BTF — so they synthesise a `ResolvedTarget` with
3748/// `cross_btf_hit = None`. `alloc_size` is `None` for any caller
3749/// that arrived via [`Type::Ptr`] (BTF-typed pointer chase) or
3750/// for cast findings whose source allocator did not capture a
3751/// size (kfunc / `scx_alloc_internal`).
3752///
3753/// Preconditions the caller must satisfy:
3754/// * The [`chase_gate`] outcome was [`ChaseGate::Proceed`] for
3755/// `val` at this `depth` (i.e. `val != 0`, not in `visited`,
3756/// `depth < MAX_RENDER_DEPTH`).
3757/// * `mem.is_arena_addr(val)` returned `true`. The cast path
3758/// wraps a separate "arena cast value outside arena window"
3759/// reason around an out-of-window value before invoking the
3760/// helper; the [`Type::Ptr`] arm dispatches on
3761/// `is_arena_addr` so it only enters this helper when the
3762/// check passed.
3763///
3764/// `visited` bookkeeping is internal: the helper inserts `val`
3765/// before recursing and removes it after, matching the path-based
3766/// cycle convention used in both call sites.
3767fn chase_arena_pointer(
3768 btf: &Btf,
3769 target_type_id: u32,
3770 alloc_size: Option<u64>,
3771 val: u64,
3772 mem: &dyn MemReader,
3773 depth: u32,
3774 visited: &mut HashSet<u64>,
3775) -> ArenaChaseOutcome {
3776 // Dedup: when the chased address points at a slot the dump
3777 // pre-pass has already rendered into `report.sdt_allocations`,
3778 // skip the per-map chase so the slot's typed payload already
3779 // appears in the failure dump under the sdt_alloc surface — not
3780 // also embedded inside whichever TASK_STORAGE / HASH map's value
3781 // pointed back at it. The dedup set is restricted to slots from
3782 // typed allocators (resolved `target_type_id != 0`); untyped
3783 // pre-pass renders (hex fallback) must NOT suppress per-map
3784 // chases because the cast analyzer's shape inference may resolve
3785 // a target type the heuristic missed. The default trait impl
3786 // returns `false`, so readers without a rendered-slot index
3787 // proceed with the chase as before.
3788 if mem.is_already_rendered(val) {
3789 return ArenaChaseOutcome {
3790 deref: None,
3791 reason: Some("already rendered in sdt_allocations".to_string()),
3792 sdt_alloc_resolved: false,
3793 };
3794 }
3795 // Special case: `target_type_id == 0` means the cast analyzer's
3796 // STX-flow path tagged the slot as Arena WITHOUT a resolved
3797 // BTF type id (the deferred-resolve arena cast path —
3798 // allocator-return seeds produce findings whose target shape
3799 // is determined entirely by the
3800 // [`MemReader::resolve_arena_type`] bridge at chase time).
3801 // Consult the bridge first; on a miss, fall back to the
3802 // analyzer-supplied `alloc_size` (captured at the
3803 // `scx_static_alloc_internal` call site) and resolve the
3804 // payload type by size match via
3805 // [`super::sdt_alloc::discover_payload_btf_id`]. The bump
3806 // allocator emits no per-slot header that the bridge could
3807 // index, so the size-match fallback is the only resolution
3808 // path for `scx_static_alloc_internal` allocations. Both
3809 // paths bypass the cross-BTF probe — their resolved ids are
3810 // in the entry BTF — by synthesising a `ResolvedTarget` with
3811 // `cross_btf_hit = None`, which feeds directly into the
3812 // post-read recursion.
3813 let resolved = if target_type_id == 0 {
3814 let bridge_hit = mem.resolve_arena_type(val);
3815 let (effective_target_id, header_skip, resolution_source) = match bridge_hit {
3816 Some(hit) => (hit.target_type_id, hit.header_skip, "bridge"),
3817 None => {
3818 // Bridge miss: try the analyzer-supplied
3819 // `alloc_size` fallback. `Some(n)` means a
3820 // `scx_static_alloc_internal` call captured the
3821 // sizeof argument; consult
3822 // [`super::sdt_alloc::discover_payload_btf_id`]
3823 // to size-match against BTF. The bump allocator
3824 // has no header so `header_skip == 0`. `None`
3825 // means no captured size — surface the bridge
3826 // miss as the chase reason.
3827 let size = match alloc_size {
3828 Some(s) => s,
3829 None => {
3830 // No per-slot alloc_size. Use the pre-resolved
3831 // (size, struct_name) pairs from embedded BTFs
3832 // to find a type via cross-BTF Fwd resolution.
3833 // Each entry was resolved at analysis time by
3834 // running discover_payload_btf_id against the
3835 // full embedded BTF (which carries struct bodies
3836 // the kernel's split BTF may have dropped to Fwd).
3837 // Try each: read `size` bytes from the arena,
3838 // resolve the name via cross_btf_resolve_fwd,
3839 // and use the FIRST that succeeds. The read-size
3840 // gate ensures we only render bytes that belong
3841 // to THIS allocation (no overread into adjacent).
3842 for (candidate_size, struct_name) in mem.alloc_size_types() {
3843 if mem.read_arena(val, *candidate_size as usize).is_none() {
3844 continue;
3845 }
3846 if let Some(cross_ref) =
3847 mem.cross_btf_resolve_fwd(struct_name, FwdKind::Struct)
3848 && let Ok(ty) = cross_ref.btf.resolve_type_by_id(cross_ref.type_id)
3849 && let Some(btf_size) = type_size(cross_ref.btf, &ty)
3850 && btf_size > 0
3851 && btf_size <= *candidate_size as usize
3852 {
3853 let read_size = btf_size.min(POINTER_CHASE_CAP);
3854 let truncated = btf_size > POINTER_CHASE_CAP;
3855 if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3856 let payload = recurse_into_target(
3857 &ResolvedTarget {
3858 effective_type_id: cross_ref.type_id,
3859 current_btf: cross_ref.btf,
3860 btf_size,
3861 header_skip: 0,
3862 sdt_alloc_resolved: false,
3863 cross_btf_hit: Some(CrossBtfRef {
3864 btf: cross_ref.btf,
3865 type_id: cross_ref.type_id,
3866 }),
3867 },
3868 &raw_bytes,
3869 val,
3870 depth,
3871 mem,
3872 visited,
3873 truncated,
3874 );
3875 return ArenaChaseOutcome {
3876 deref: Some(payload),
3877 reason: None,
3878 sdt_alloc_resolved: false,
3879 };
3880 }
3881 }
3882 // Also try direct resolution in the entry BTF
3883 let choice = super::sdt_alloc::discover_payload_btf_id(
3884 btf,
3885 *candidate_size as usize,
3886 "",
3887 );
3888 if choice.target_type_id != 0
3889 && let Some((resolved_ty, resolved_id)) =
3890 peel_modifiers_resolving_fwd(btf, choice.target_type_id)
3891 && let Some(btf_size) = type_size(btf, &resolved_ty)
3892 && btf_size > 0
3893 {
3894 let read_size = btf_size.min(POINTER_CHASE_CAP);
3895 let truncated = btf_size > POINTER_CHASE_CAP;
3896 if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3897 let payload = recurse_into_target(
3898 &ResolvedTarget {
3899 effective_type_id: resolved_id,
3900 current_btf: btf,
3901 btf_size,
3902 header_skip: 0,
3903 sdt_alloc_resolved: true,
3904 cross_btf_hit: None,
3905 },
3906 &raw_bytes,
3907 val,
3908 depth,
3909 mem,
3910 visited,
3911 truncated,
3912 );
3913 return ArenaChaseOutcome {
3914 deref: Some(payload),
3915 reason: None,
3916 sdt_alloc_resolved: true,
3917 };
3918 }
3919 }
3920 }
3921 tracing::debug!(
3922 val = format_args!("{:#x}", val),
3923 "arena chase: STX-flow tagged slot as Arena (target_type_id=0, \
3924 deferred resolve), but resolve_arena_type had no entry and \
3925 no alloc_size was supplied; allocator pre-pass may not have \
3926 populated the index for this allocator",
3927 );
3928 return ArenaChaseOutcome {
3929 deref: None,
3930 reason: Some(format!(
3931 "arena chase: STX-flow path tagged slot as Arena with \
3932 deferred resolve; bridge had no entry for 0x{val:x}"
3933 )),
3934 sdt_alloc_resolved: false,
3935 };
3936 }
3937 };
3938 let choice = super::sdt_alloc::discover_payload_btf_id(btf, size as usize, "");
3939 if choice.target_type_id != 0 {
3940 (choice.target_type_id, 0usize, "alloc_size")
3941 } else {
3942 // Entry BTF size match failed (the target struct
3943 // is likely Fwd in split BTF). Try cross-BTF Fwd
3944 // resolution using alloc_size_types entries that
3945 // match the captured size.
3946 for (candidate_size, struct_name) in mem.alloc_size_types() {
3947 if *candidate_size != size {
3948 continue;
3949 }
3950 if mem.read_arena(val, *candidate_size as usize).is_none() {
3951 continue;
3952 }
3953 if let Some(cross_ref) =
3954 mem.cross_btf_resolve_fwd(struct_name, FwdKind::Struct)
3955 && let Ok(ty) = cross_ref.btf.resolve_type_by_id(cross_ref.type_id)
3956 && let Some(btf_size) = type_size(cross_ref.btf, &ty)
3957 && btf_size > 0
3958 && btf_size <= *candidate_size as usize
3959 {
3960 let read_size = btf_size.min(POINTER_CHASE_CAP);
3961 let truncated = btf_size > POINTER_CHASE_CAP;
3962 if let Some(raw_bytes) = mem.read_arena(val, read_size) {
3963 let payload = recurse_into_target(
3964 &ResolvedTarget {
3965 effective_type_id: cross_ref.type_id,
3966 current_btf: cross_ref.btf,
3967 btf_size,
3968 header_skip: 0,
3969 sdt_alloc_resolved: false,
3970 cross_btf_hit: Some(CrossBtfRef {
3971 btf: cross_ref.btf,
3972 type_id: cross_ref.type_id,
3973 }),
3974 },
3975 &raw_bytes,
3976 val,
3977 depth,
3978 mem,
3979 visited,
3980 truncated,
3981 );
3982 return ArenaChaseOutcome {
3983 deref: Some(payload),
3984 reason: None,
3985 sdt_alloc_resolved: false,
3986 };
3987 }
3988 }
3989 }
3990 tracing::debug!(
3991 val = format_args!("{:#x}", val),
3992 alloc_size = size,
3993 reason = %choice.reason,
3994 "arena chase: STX-flow tagged slot as Arena (target_type_id=0, \
3995 deferred resolve), bridge returned no entry, and alloc_size \
3996 fallback could not resolve a unique BTF type",
3997 );
3998 return ArenaChaseOutcome {
3999 deref: None,
4000 reason: Some(format!(
4001 "arena chase: STX-flow path tagged slot as Arena with \
4002 deferred resolve; alloc_size={size} fallback unresolved \
4003 ({reason})",
4004 reason = choice.reason
4005 )),
4006 sdt_alloc_resolved: false,
4007 };
4008 }
4009 }
4010 };
4011 let Some((resolved_ty, resolved_id)) =
4012 peel_modifiers_resolving_fwd(btf, effective_target_id)
4013 else {
4014 tracing::debug!(
4015 source = resolution_source,
4016 effective_target_id,
4017 "arena chase: resolution source returned target_type_id but the type \
4018 does not resolve in the program BTF",
4019 );
4020 return ArenaChaseOutcome {
4021 deref: None,
4022 reason: Some(format!(
4023 "arena chase: type id {effective_target_id} unresolved in BTF"
4024 )),
4025 sdt_alloc_resolved: true,
4026 };
4027 };
4028 let Some(btf_size) = type_size(btf, &resolved_ty) else {
4029 return ArenaChaseOutcome {
4030 deref: None,
4031 reason: Some(unsizable_chase_reason(
4032 btf,
4033 "arena chase",
4034 effective_target_id,
4035 &resolved_ty,
4036 )),
4037 sdt_alloc_resolved: true,
4038 };
4039 };
4040 if btf_size == 0 {
4041 return ArenaChaseOutcome {
4042 deref: None,
4043 reason: Some(format!(
4044 "arena chase target type id {effective_target_id} \
4045 BTF size is 0 (incomplete type)"
4046 )),
4047 sdt_alloc_resolved: true,
4048 };
4049 }
4050 ResolvedTarget {
4051 effective_type_id: resolved_id,
4052 current_btf: btf,
4053 btf_size,
4054 header_skip,
4055 sdt_alloc_resolved: true,
4056 cross_btf_hit: None,
4057 }
4058 } else {
4059 match resolve_chase_target(btf, mem, val, target_type_id, "arena chase") {
4060 ChaseResolve::Ready(r) => r,
4061 ChaseResolve::Skip {
4062 reason,
4063 sdt_alloc_resolved,
4064 } => {
4065 return ArenaChaseOutcome {
4066 deref: None,
4067 reason: Some(reason),
4068 sdt_alloc_resolved,
4069 };
4070 }
4071 }
4072 };
4073 // The single-page (4 KiB) cap ([`POINTER_CHASE_CAP`]) matches
4074 // the arena page granularity exposed by
4075 // [`MemReader::read_arena`]: a pointee larger than 4 KiB
4076 // renders only its first page. Cross-page chase would require
4077 // splitting the read into per-page chunks AND stitching them —
4078 // a future enhancement once a scheduler ships an
4079 // arena-allocated payload larger than 4 KiB. Today the
4080 // truncation surfaces explicitly via the
4081 // [`RenderedValue::Truncated`] wrapper below when btf_size
4082 // exceeds the cap, so the operator sees the rendered subtree
4083 // is partial.
4084 //
4085 // When the sdt_alloc bridge fired with `header_skip > 0`, the
4086 // total bytes the chase needs from `val` are
4087 // `header_skip + btf_size`. The cap still applies to the
4088 // requested span — slot-start chases of payloads close to the
4089 // cap may surface as `Truncated` because the header eats into
4090 // the page-bounded read budget.
4091 let total_needed = resolved.header_skip.saturating_add(resolved.btf_size);
4092 let read_size = total_needed.min(POINTER_CHASE_CAP);
4093 let truncated_at_cap = total_needed > POINTER_CHASE_CAP;
4094 let Some(raw_bytes) = mem.read_arena(val, read_size) else {
4095 // [`MemReader::read_arena`] returns `None` when the full
4096 // requested length cannot be satisfied — most commonly
4097 // because the read crosses a page boundary in the captured
4098 // arena snapshot. Annotate so the consumer sees why the
4099 // deref didn't land.
4100 return ArenaChaseOutcome {
4101 deref: None,
4102 reason: Some(format!(
4103 "arena read failed (cross-page boundary or unmapped \
4104 page); needed {read_size} bytes from 0x{val:x}"
4105 )),
4106 sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4107 };
4108 };
4109 // Slot-start bridge fire: skip the header before rendering the
4110 // payload struct. `read_arena` is contractually
4111 // single-page-bound so the slice never under-runs unless the
4112 // page-tail cropped the read; in that pathological case
4113 // surface a clear skip reason rather than rendering a
4114 // partial-header-stripped buffer.
4115 let Some(target_bytes) = apply_header_skip(&raw_bytes, resolved.header_skip) else {
4116 return ArenaChaseOutcome {
4117 deref: None,
4118 reason: Some(format!(
4119 "arena read at 0x{val:x} returned {} bytes; \
4120 sdt_alloc bridge needs at least {} for header skip",
4121 raw_bytes.len(),
4122 resolved.header_skip
4123 )),
4124 sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4125 };
4126 };
4127 let payload = recurse_into_target(
4128 &resolved,
4129 target_bytes,
4130 val,
4131 depth,
4132 mem,
4133 visited,
4134 truncated_at_cap,
4135 );
4136 ArenaChaseOutcome {
4137 deref: Some(payload),
4138 reason: None,
4139 sdt_alloc_resolved: resolved.sdt_alloc_resolved,
4140 }
4141}
4142
4143/// Try the cross-BTF Fwd resolution bridge for a `BTF_KIND_FWD`
4144/// chase target. Returns `Some(CrossBtfRef { btf, type_id })` when
4145/// the [`MemReader::cross_btf_resolve_fwd`] override returns a
4146/// hit — the renderer's cast-analysis pre-pass populated a name-
4147/// keyed index over every embedded `.bpf.objs` BTF and the named
4148/// struct/union has a complete body in a sibling object.
4149///
4150/// `entry_btf` is the BTF the chase entered with — used to
4151/// translate the Fwd's name offset to a string the implementation
4152/// can key its index against. `target_ty` must be the post-peel
4153/// terminal of [`peel_modifiers_resolving_fwd`]; the helper
4154/// itself gates on `matches!(target_ty, Type::Fwd(_))` so calling
4155/// on a non-Fwd terminal is a no-op and returns `None`. Anonymous
4156/// Fwds (empty resolved name) likewise return `None` — the index
4157/// keys on non-empty names.
4158///
4159/// The aggregate-kind match ([`btf_rs::Fwd::is_struct`]) is
4160/// preserved end-to-end: a `Fwd` declared as `struct foo` only
4161/// resolves to a `struct foo` body in another BTF, never to a
4162/// `union foo`. Mirrors the same gate
4163/// [`peel_modifiers_resolving_fwd`] applies for in-BTF resolution.
4164fn try_cross_btf_fwd_resolve<'a>(
4165 mem: &'a dyn MemReader,
4166 entry_btf: &Btf,
4167 target_ty: &Type,
4168) -> Option<CrossBtfRef<'a>> {
4169 let Type::Fwd(fwd) = target_ty else {
4170 return None;
4171 };
4172 let kind = FwdKind::from_is_struct(fwd.is_struct());
4173 let name = entry_btf.resolve_name(fwd).ok()?;
4174 if name.is_empty() {
4175 return None;
4176 }
4177 mem.cross_btf_resolve_fwd(&name, kind)
4178}
4179
4180/// Build a [`RenderedValue::Ptr`] for a cast-recovered pointer with
4181/// uniform field assembly.
4182///
4183/// Every site in [`render_cast_pointer`] that emits a `Ptr` shares
4184/// the same four-field shape (`value`, optional `deref`, optional
4185/// `deref_skipped_reason`, optional `cast_annotation`). The helper
4186/// resolves the canonical annotation through [`cast_annotation_for`]
4187/// — a 4-cell match over
4188/// [`super::cast_analysis::AddrSpace`] × `sdt_alloc_resolved`
4189/// returning a `&'static str` — so every annotation the renderer
4190/// emits is a borrow into the binary's read-only string pool, not a
4191/// per-chase heap allocation. Adding a new address-space variant
4192/// surfaces as a non-exhaustive match at compile time, keeping the
4193/// analyzer enum and the operator-visible tag in lockstep.
4194/// `addr_space` here is the address space the renderer ACTUALLY
4195/// chased through (runtime decision), not the analyzer's hint, so
4196/// the annotation reflects the path the chase took.
4197///
4198/// `sdt_alloc_resolved` extends the annotation to
4199/// `cast→{addr_space} (sdt_alloc)` when the chase recovered the
4200/// target's BTF type id via [`MemReader::resolve_arena_type`]
4201/// instead of the cast analyzer's declared `target_type_id`.
4202/// Operators see at a glance that the rendered subtree's layout
4203/// came from the sdt_alloc bridge rather than the analyzer's own
4204/// flow-tracked type recovery.
4205fn cast_ptr(
4206 value: u64,
4207 deref: Option<Box<RenderedValue>>,
4208 reason: Option<String>,
4209 addr_space: super::cast_analysis::AddrSpace,
4210 sdt_alloc_resolved: bool,
4211) -> RenderedValue {
4212 RenderedValue::Ptr {
4213 value,
4214 deref,
4215 deref_skipped_reason: reason,
4216 cast_annotation: Some(Cow::Borrowed(cast_annotation_for(
4217 addr_space,
4218 sdt_alloc_resolved,
4219 ))),
4220 }
4221}
4222
4223/// Resolve the canonical cast annotation tag to a `&'static str`.
4224///
4225/// The `(addr_space, sdt_alloc_resolved)` pair maps to one of four
4226/// fixed strings — exhaustively enumerated below so
4227/// [`super::cast_analysis::AddrSpace`]'s closed variant set drives
4228/// an exhaustive match. A new variant produces a compile error
4229/// here, forcing the operator-visible tag to stay in lockstep with
4230/// the analyzer enum.
4231///
4232/// The pre-existing [`super::cast_analysis::AddrSpace`]
4233/// [`std::fmt::Display`] impl is kept for other call sites
4234/// (free-form formatting, error messages); the renderer side
4235/// bypasses `Display` because the closed set lets us hand back
4236/// static strings instead of allocating a new `String` per
4237/// chase.
4238fn cast_annotation_for(
4239 addr_space: super::cast_analysis::AddrSpace,
4240 sdt_alloc_resolved: bool,
4241) -> &'static str {
4242 use super::cast_analysis::AddrSpace;
4243 match (addr_space, sdt_alloc_resolved) {
4244 (AddrSpace::Arena, false) => "cast→arena",
4245 (AddrSpace::Arena, true) => "cast→arena (sdt_alloc)",
4246 (AddrSpace::Kernel, false) => "cast→kernel",
4247 (AddrSpace::Kernel, true) => "cast→kernel (sdt_alloc)",
4248 }
4249}
4250
4251/// Render a cast-recovered typed pointer.
4252///
4253/// Builds [`RenderedValue::Ptr`] mirroring the [`Type::Ptr`] arm's
4254/// shape so consumers (Display, JSON serializer, `is_flat_scalar`
4255/// classifier) handle cast-recovered pointers and BTF-typed pointers
4256/// uniformly. Pre-chase gating (null, cycle, depth cap) goes through
4257/// the shared [`chase_gate`] helper, so a linked-list /
4258/// parent-pointer cycle in cast-recovered pointers surfaces the
4259/// same `[cycle]` glyph as the [`Type::Ptr`] arm and a null cast
4260/// value renders identically.
4261///
4262/// Address-space dispatch is RUNTIME-driven: [`MemReader::is_arena_addr`]
4263/// is consulted on the actual pointer value to decide whether to
4264/// chase via [`MemReader::read_arena`] (in-window) or
4265/// [`MemReader::read_kva`] (out-of-window). The
4266/// [`super::cast_analysis::CastHit::addr_space`] tag from the
4267/// analyzer is treated as a hint only — runtime evidence from the
4268/// pointer value is authoritative because the analyzer's
4269/// flow-insensitive register tracking can mis-classify across
4270/// branch joins, while `is_arena_addr` is a structural property of
4271/// the live address space. When both readers might succeed the
4272/// arena reader wins (its frozen snapshot is more reliable than a
4273/// live walk through `read_kva`).
4274///
4275/// `cast_annotation` on the resulting [`RenderedValue::Ptr`]
4276/// records which path actually executed (`"cast→arena"` or
4277/// `"cast→kernel"`) so operators can distinguish cast-recovered
4278/// pointers from BTF-typed pointers without inspecting the
4279/// rendered subtree. When [`try_sdt_alloc_bridge`] fired during
4280/// the chase (the analyzer's `target_type_id` resolved to a
4281/// `BTF_KIND_FWD` whose real id was recovered via
4282/// [`MemReader::resolve_arena_type`]), [`cast_ptr`] extends the
4283/// annotation with a trailing ` (sdt_alloc)` — `"cast→arena
4284/// (sdt_alloc)"` / `"cast→kernel (sdt_alloc)"` — so operators
4285/// see the layout came from the bridge rather than the cast
4286/// analyzer's declared id. The [`Type::Ptr`] arm normally leaves
4287/// the field `None`, with one exception: when its arena branch
4288/// also fires the sdt_alloc bridge it sets `cast_annotation` to
4289/// the unprefixed `"sdt_alloc"` (no `cast→` prefix because the
4290/// chased pointer is structurally BTF-typed, not analyzer-
4291/// recovered).
4292///
4293/// On read failure (cross-page boundary in the arena snapshot,
4294/// unmapped page, etc.) the render emits `Ptr` with
4295/// `deref_skipped_reason` populated and `deref: None` — the chase
4296/// was attempted, distinguishing it from the no-chase paths above.
4297fn render_cast_pointer(
4298 btf: &Btf,
4299 hit: CastHit,
4300 value: u64,
4301 depth: u32,
4302 mem: &dyn MemReader,
4303 visited: &mut HashSet<u64>,
4304) -> RenderedValue {
4305 // [`chase_gate`] applies the null/cycle/depth-cap policy
4306 // shared with the [`Type::Ptr`] arm: null and depth-cap take
4307 // the "no chase attempted" path (`deref` + reason both
4308 // `None`); a cycle records the `cycle → 0x{value:x}` reason.
4309 // Only [`ChaseGate::Proceed`] enters the per-arm read+recurse
4310 // logic below. The cast_annotation on the no-chase path
4311 // reflects the analyzer's hint so operators still see the
4312 // pointer was a cast finding rather than a BTF-typed pointer.
4313 if let ChaseGate::Skip { reason } = chase_gate(value, depth, visited) {
4314 return cast_ptr(value, None, reason, hit.addr_space, false);
4315 }
4316 // Runtime address-space detection: if the value falls in the
4317 // arena window, chase through `read_arena` (frozen-snapshot
4318 // backed, no slab-liveness concern). Otherwise chase through
4319 // `read_kva` with the existing plausibility gate. The
4320 // analyzer's hint is preserved in `cast_annotation` so a
4321 // hint→runtime mismatch is visible; the renderer doesn't try
4322 // to reconcile them — the runtime decision wins.
4323 if mem.is_arena_addr(value) {
4324 // Thread the analyzer-captured `alloc_size` into the chase
4325 // helper so the deferred-resolve path
4326 // (`target_type_id == 0`) can fall back to size-based BTF
4327 // matching when the bridge has no entry — the
4328 // `scx_static_alloc_internal` resolution path.
4329 let outcome = chase_arena_pointer(
4330 btf,
4331 hit.target_type_id,
4332 hit.alloc_size,
4333 value,
4334 mem,
4335 depth,
4336 visited,
4337 );
4338 return cast_ptr(
4339 value,
4340 outcome.deref,
4341 outcome.reason,
4342 super::cast_analysis::AddrSpace::Arena,
4343 outcome.sdt_alloc_resolved,
4344 );
4345 }
4346 // Kernel-arm chase: out-of-arena value, read through the
4347 // page-table walker. Resolve target type id to a Type so
4348 // `type_size` can size the read. Failure here is rare in
4349 // practice — the cast analyzer only emits ids it itself
4350 // resolved through the same BTF — but the fallthrough emits a
4351 // labelled skip rather than panicking. Use the Fwd-resolving
4352 // peel so a [`Type::Fwd`] target with a complete sibling in
4353 // the BTF lands on the sibling rather than skipping. The
4354 // arena arm above shares the same shortcut via
4355 // [`chase_arena_pointer`].
4356 //
4357 // Special case: `target_type_id == 0` is the cast analyzer's
4358 // STX-flow Arena sentinel (the deferred-resolve arena cast
4359 // path emits this when the target is unresolved at analysis
4360 // time). The hint was Arena but the runtime value fell
4361 // outside the arena window, so we landed here. Without a BTF
4362 // id to resolve, the kernel arm cannot size the read; surface
4363 // a clear skip reason so the operator sees the analyzer/runtime
4364 // mismatch.
4365 if hit.target_type_id == 0 {
4366 return cast_ptr(
4367 value,
4368 None,
4369 Some(format!(
4370 "kernel cast target unresolved (analyzer hinted Arena \
4371 with deferred resolve, but runtime value 0x{value:x} \
4372 fell outside the arena window)"
4373 )),
4374 super::cast_analysis::AddrSpace::Kernel,
4375 false,
4376 );
4377 }
4378 // Pre-read sequence shared with [`chase_arena_pointer`]: peel +
4379 // Fwd-resolve target, try sdt_alloc bridge, fall back to
4380 // cross-BTF Fwd index, settle final btf_size, reject zero-size
4381 // payloads. Returns a [`ResolvedTarget`] ready for the per-arm
4382 // `read_kva`, or a skip reason that flows into the `cast_ptr`
4383 // builder so the no-deref render still surfaces the bridge
4384 // state when it fired before the skip.
4385 let resolved = match resolve_chase_target(btf, mem, value, hit.target_type_id, "kernel cast") {
4386 ChaseResolve::Ready(r) => r,
4387 ChaseResolve::Skip {
4388 reason,
4389 sdt_alloc_resolved,
4390 } => {
4391 return cast_ptr(
4392 value,
4393 None,
4394 Some(reason),
4395 super::cast_analysis::AddrSpace::Kernel,
4396 sdt_alloc_resolved,
4397 );
4398 }
4399 };
4400 // Kernel reads honour [`POINTER_CHASE_CAP`] and also cap at
4401 // the remaining bytes in the current 4 KiB page so `read_kva`
4402 // cannot accidentally cross a page boundary into an unrelated
4403 // allocation. The kernel direct-map / vmalloc walker returns
4404 // whatever the page tables resolve, but a struct that
4405 // straddles a page boundary may have its tail in a freed or
4406 // unrelated page — bounding the read at the page edge keeps
4407 // the dump from leaking adjacent slab content.
4408 //
4409 // When the sdt_alloc bridge fired with `header_skip > 0`,
4410 // the total bytes the chase needs from `value` are
4411 // `header_skip + btf_size` — the header bytes are skipped
4412 // before the payload starts. The page-bounded cap still
4413 // applies to the requested span.
4414 const PAGE_SIZE: u64 = 4096;
4415 // PAGE_SIZE - (value % PAGE_SIZE) bytes remain in the current
4416 // 4 KiB page from `value` onward. usize fits the result
4417 // because PAGE_SIZE is 4096 (well below usize::MAX) and the
4418 // modulo result is in [0, PAGE_SIZE).
4419 let page_remaining = (PAGE_SIZE - (value % PAGE_SIZE)) as usize;
4420 let total_needed = resolved.header_skip.saturating_add(resolved.btf_size);
4421 let read_size = total_needed.min(POINTER_CHASE_CAP).min(page_remaining);
4422 let truncated_at_cap = total_needed > read_size;
4423 let Some(raw_bytes) = mem.read_kva(value, read_size) else {
4424 // The runtime check rejected the arena window AND the
4425 // kernel read failed — surface both pieces of evidence so
4426 // the operator can correlate the analyzer hint with the
4427 // actual chase outcome. If the analyzer hinted Arena but
4428 // the value was outside the arena window, this is a
4429 // structural mismatch: the analyzer may have flagged a
4430 // non-pointer field.
4431 let suffix = if matches!(hit.addr_space, super::cast_analysis::AddrSpace::Arena) {
4432 " (cast analysis may have flagged a non-pointer field)"
4433 } else {
4434 ""
4435 };
4436 return cast_ptr(
4437 value,
4438 None,
4439 Some(format!(
4440 "kernel read_kva failed at 0x{value:x} \
4441 (unmapped page or no PTE); needed {read_size} bytes{suffix}"
4442 )),
4443 super::cast_analysis::AddrSpace::Kernel,
4444 resolved.sdt_alloc_resolved,
4445 );
4446 };
4447 // Slot-start bridge fire on the kernel arm: skip the header
4448 // before the plausibility gate and the recursion. Refuse to
4449 // proceed when the read returned fewer bytes than the header
4450 // skip needs — surfaces the page-tail truncation as a clear
4451 // skip reason rather than rendering a corrupted slice.
4452 let Some(target_bytes) = apply_header_skip(&raw_bytes, resolved.header_skip) else {
4453 return cast_ptr(
4454 value,
4455 None,
4456 Some(format!(
4457 "kernel read_kva at 0x{value:x} returned {} bytes; \
4458 sdt_alloc bridge needs at least {} for header skip",
4459 raw_bytes.len(),
4460 resolved.header_skip
4461 )),
4462 super::cast_analysis::AddrSpace::Kernel,
4463 resolved.sdt_alloc_resolved,
4464 );
4465 };
4466 // Plausibility gate: a freed slab object's first qword is
4467 // often a freelist next pointer, which on x86_64 / aarch64
4468 // typically lands in the kernel direct-map range
4469 // (0xffff800000000000+, top byte 0xff). Reject reads where
4470 // the first 8 bytes look like that pattern as a probable
4471 // stale-pointer signature. Same heuristic as the cpumask
4472 // kptr chase in the `Type::Ptr` arm. Arena reads are exempt
4473 // (the arena helper above does not apply this gate) — arena
4474 // pages are caller-controlled allocations whose first bytes
4475 // are not used for slab freelist metadata. The gate runs
4476 // against the post-skip payload bytes when the bridge fired.
4477 if target_bytes.len() >= 8 {
4478 let first_qword = u64::from_le_bytes(target_bytes[..8].try_into().unwrap());
4479 if first_qword >> 56 == 0xff {
4480 return cast_ptr(
4481 value,
4482 None,
4483 Some(format!(
4484 "kernel cast plausibility gate rejected: first qword \
4485 top byte is 0xff at 0x{value:x} (likely freed slab \
4486 object freelist pointer)"
4487 )),
4488 super::cast_analysis::AddrSpace::Kernel,
4489 resolved.sdt_alloc_resolved,
4490 );
4491 }
4492 }
4493 let deref_payload = recurse_into_target(
4494 &resolved,
4495 target_bytes,
4496 value,
4497 depth,
4498 mem,
4499 visited,
4500 truncated_at_cap,
4501 );
4502 cast_ptr(
4503 value,
4504 Some(deref_payload),
4505 None,
4506 super::cast_analysis::AddrSpace::Kernel,
4507 resolved.sdt_alloc_resolved,
4508 )
4509}
4510
4511fn render_bitfield(
4512 btf: &Btf,
4513 member_type_id: u32,
4514 parent_bytes: &[u8],
4515 bit_off: usize,
4516 width: usize,
4517) -> RenderedValue {
4518 if width == 0 || width > 64 {
4519 return RenderedValue::Unsupported {
4520 reason: format!("bitfield width {width} out of range"),
4521 };
4522 }
4523 let byte_start = bit_off / 8;
4524 let bit_shift = bit_off % 8;
4525 let bits_needed = bit_shift + width;
4526 let bytes_needed = bits_needed.div_ceil(8);
4527 if byte_start + bytes_needed > parent_bytes.len() {
4528 let avail_start = byte_start.min(parent_bytes.len());
4529 let avail = &parent_bytes[avail_start..];
4530 return RenderedValue::Truncated {
4531 needed: bytes_needed,
4532 had: avail.len(),
4533 partial: Box::new(RenderedValue::Bytes {
4534 hex: hex_dump(avail),
4535 }),
4536 };
4537 }
4538 // Pull up to 16 bytes (max bitfield is 64 bits + 7 bit shift = 71
4539 // bits = 9 bytes); use a fixed buffer to avoid heap.
4540 let mut buf = [0u8; 16];
4541 buf[..bytes_needed].copy_from_slice(&parent_bytes[byte_start..byte_start + bytes_needed]);
4542 // Pack into a u128 little-endian, then mask + shift.
4543 let mut packed: u128 = 0;
4544 for (i, b) in buf[..bytes_needed].iter().enumerate() {
4545 packed |= (*b as u128) << (i * 8);
4546 }
4547 let raw = ((packed >> bit_shift) & ((1u128 << width) - 1)) as u64;
4548
4549 let Some(member_ty) = peel_modifiers(btf, member_type_id) else {
4550 return RenderedValue::Unsupported {
4551 reason: "bitfield type modifiers unresolvable".to_string(),
4552 };
4553 };
4554 // BTF bitfields can carry signed Int *or* signed Enum / Enum64
4555 // bases (e.g. `enum scx_exit_kind` declared with negative
4556 // members). Treat all three as signed for the sign-extension
4557 // step so a negative-valued bitfield rendered through any of
4558 // them comes back as a correctly-signed `Int` rather than a
4559 // raw-bits `Uint`.
4560 let signed = match &member_ty {
4561 Type::Int(i) => i.is_signed(),
4562 Type::Enum(e) => e.is_signed(),
4563 Type::Enum64(e) => e.is_signed(),
4564 _ => false,
4565 };
4566 if signed {
4567 let value = sign_extend(raw, width) as i64;
4568 RenderedValue::Int {
4569 bits: width as u32,
4570 value,
4571 }
4572 } else {
4573 RenderedValue::Uint {
4574 bits: width as u32,
4575 value: raw,
4576 }
4577 }
4578}
4579
4580/// Peel pass-through qualifier types (Volatile, Const, Restrict,
4581/// Typedef, TypeTag, DeclTag) and return the underlying [`Type`].
4582/// Returns `None` if the chain exceeds [`MAX_MODIFIER_DEPTH`] or fails
4583/// to resolve.
4584pub(crate) fn peel_modifiers(btf: &Btf, type_id: u32) -> Option<Type> {
4585 peel_modifiers_with_id(btf, type_id).map(|(ty, _)| ty)
4586}
4587
4588/// Peel modifiers like [`peel_modifiers_with_id`], then if the
4589/// terminal is a [`Type::Fwd`] resolve it to a complete
4590/// [`Type::Struct`] / [`Type::Union`] of the same name in the same
4591/// BTF when one exists.
4592///
4593/// `BTF_KIND_FWD` is a forward declaration (`struct foo;` with no
4594/// body) clang emits when a type is referenced only via pointer in
4595/// the compilation unit. Concatenated BPF objects routinely include
4596/// both a `Fwd` and a complete `Struct`/`Union` with the same name —
4597/// the `Fwd` from a header that only declares the type, the
4598/// complete shape from a `.bpf.c` that defines it. The chase
4599/// pipeline calls [`type_size`] right after peeling; `type_size`
4600/// returns `None` for `Type::Fwd`, which produces "unresolvable
4601/// size" skips even when the BTF carries a fully-typed sibling
4602/// the renderer could land against. This helper elides that gap by
4603/// preferring the complete sibling whenever one is present.
4604///
4605/// Returns the original peeled (Type, id) pair when:
4606/// - the terminal is not a [`Type::Fwd`] (no resolution needed),
4607/// - the [`Type::Fwd`] has no name (anonymous fwds cannot be
4608/// keyed for lookup),
4609/// - no sibling [`Type::Struct`]/[`Type::Union`] of the same name
4610/// AND matching aggregate kind (struct vs union, per
4611/// [`btf_rs::Fwd::is_struct`] / `is_union`) exists in the BTF.
4612///
4613/// The aggregate-kind match is crucial — a `Fwd` declared as
4614/// `struct foo` must NOT resolve to a `union foo` in the same BTF
4615/// (the wire format permits same-name struct + union declarations,
4616/// rare but legal). The renderer would render the wrong layout if
4617/// we collapsed the two.
4618///
4619/// Single-pass resolution: the helper calls
4620/// [`peel_modifiers_with_id`] once, inspects the terminal, and
4621/// either returns the original peeled pair or returns the first
4622/// matching sibling found in the by-name candidate list. There is
4623/// no re-entry into [`peel_modifiers_resolving_fwd`] from within
4624/// itself; the bounded modifier-peel cap inside
4625/// [`peel_modifiers_with_id`] is what protects against malformed
4626/// `Fwd -> Typedef -> Fwd` chains.
4627pub(crate) fn peel_modifiers_resolving_fwd(btf: &Btf, type_id: u32) -> Option<(Type, u32)> {
4628 let (ty, tid) = peel_modifiers_with_id(btf, type_id)?;
4629 let Type::Fwd(ref fwd) = ty else {
4630 return Some((ty, tid));
4631 };
4632 let kind = FwdKind::from_is_struct(fwd.is_struct());
4633 let Ok(name) = btf.resolve_name(fwd) else {
4634 return Some((ty, tid));
4635 };
4636 if name.is_empty() {
4637 return Some((ty, tid));
4638 }
4639 let Ok(candidate_ids) = btf.resolve_ids_by_name(&name) else {
4640 return Some((ty, tid));
4641 };
4642 for cid in candidate_ids {
4643 if cid == tid {
4644 // The Fwd itself shows up in the by-name list; skip it
4645 // so the loop searches only siblings.
4646 continue;
4647 }
4648 let Some((candidate_ty, candidate_id)) = peel_modifiers_with_id(btf, cid) else {
4649 continue;
4650 };
4651 match (&candidate_ty, kind) {
4652 (Type::Struct(_), FwdKind::Struct) => return Some((candidate_ty, candidate_id)),
4653 (Type::Union(_), FwdKind::Union) => return Some((candidate_ty, candidate_id)),
4654 _ => continue,
4655 }
4656 }
4657 Some((ty, tid))
4658}
4659
4660/// Peel pass-through qualifiers starting from a [`Type`] value
4661/// rather than a BTF type id. Single shared helper for callers that
4662/// already hold a resolved [`Type`] and would otherwise re-implement
4663/// the peel loop. Returns the original `start` type unchanged when
4664/// it is already non-modifier; returns `None` only on `btf_rs`
4665/// resolve failure or the [`MAX_MODIFIER_DEPTH`] cap.
4666pub(crate) fn peel_modifiers_from_type(btf: &Btf, start: Type) -> Option<Type> {
4667 let mut t = start;
4668 for _ in 0..MAX_MODIFIER_DEPTH {
4669 // Each modifier kind binds a different `btf_rs` type
4670 // (`Volatile`, `Const`, `Restrict`, `Typedef`, `TypeTag`,
4671 // `DeclTag`), so or-patterns that share an `inner` binding
4672 // would force the binding to a single type. Use separate
4673 // arms — `resolve_chained_type` is generic over `BtfType`
4674 // so each arm reduces to one line.
4675 let next = match &t {
4676 Type::Volatile(inner) => btf.resolve_chained_type(inner).ok()?,
4677 Type::Const(inner) => btf.resolve_chained_type(inner).ok()?,
4678 Type::Restrict(inner) => btf.resolve_chained_type(inner).ok()?,
4679 Type::Typedef(inner) => btf.resolve_chained_type(inner).ok()?,
4680 Type::TypeTag(inner) => btf.resolve_chained_type(inner).ok()?,
4681 Type::DeclTag(inner) => btf.resolve_chained_type(inner).ok()?,
4682 _ => return Some(t),
4683 };
4684 t = next;
4685 }
4686 None
4687}
4688
4689/// Same as [`peel_modifiers`] but also returns the BTF type id of the
4690/// peeled (terminal) type. The cast-intercept path keys
4691/// [`MemReader::cast_lookup`] on the *struct* type id of the parent
4692/// aggregate — not the modifier-wrapped surface id — so the
4693/// post-peel id is what the cast_analysis [`super::cast_analysis::CastMap`]
4694/// stores. Mirrors [`super::bpf_map::resolve_to_struct_id`]'s
4695/// modifier-peeling shape; the renderer uses this variant whenever
4696/// it needs both the resolved Type and its id.
4697pub(crate) fn peel_modifiers_with_id(btf: &Btf, mut type_id: u32) -> Option<(Type, u32)> {
4698 for _ in 0..MAX_MODIFIER_DEPTH {
4699 let ty = btf.resolve_type_by_id(type_id).ok()?;
4700 match &ty {
4701 Type::Volatile(t) => type_id = t.get_type_id()?,
4702 Type::Const(t) => type_id = t.get_type_id()?,
4703 Type::Restrict(t) => type_id = t.get_type_id()?,
4704 Type::Typedef(t) => type_id = t.get_type_id()?,
4705 Type::TypeTag(t) => type_id = t.get_type_id()?,
4706 // DeclTag doesn't change the underlying type, just adds
4707 // metadata; peel through it too.
4708 Type::DeclTag(t) => type_id = t.get_type_id()?,
4709 _ => return Some((ty, type_id)),
4710 }
4711 }
4712 None
4713}
4714
4715/// Compute the storage size in bytes of a (peeled) BTF type.
4716///
4717/// Returns `None` for types whose size is not resolvable from BTF
4718/// alone (Func, FuncProto, Datasec, Var, Void) or where the chain
4719/// requires further resolution that fails.
4720pub(crate) fn type_size(btf: &Btf, ty: &Type) -> Option<usize> {
4721 match ty {
4722 Type::Int(int) => Some(int.size()),
4723 Type::Float(f) => Some(f.size()),
4724 Type::Enum(e) => Some(e.size()),
4725 Type::Enum64(e) => Some(e.size()),
4726 Type::Struct(s) | Type::Union(s) => Some(s.size()),
4727 Type::Ptr(_) => Some(8),
4728 Type::Array(arr) => {
4729 let len = arr.len();
4730 let elem_peeled = peel_modifiers(btf, arr.get_type_id()?)?;
4731 let elem_size = type_size(btf, &elem_peeled)?;
4732 Some(len * elem_size)
4733 }
4734 Type::Volatile(t) | Type::Const(t) | Type::Restrict(t) => {
4735 let inner = btf.resolve_chained_type(t).ok()?;
4736 type_size(btf, &inner)
4737 }
4738 Type::Typedef(t) => {
4739 let inner = btf.resolve_chained_type(t).ok()?;
4740 type_size(btf, &inner)
4741 }
4742 Type::TypeTag(t) => {
4743 let inner = btf.resolve_chained_type(t).ok()?;
4744 type_size(btf, &inner)
4745 }
4746 // Function types, datasec, var, fwd, void have no value size.
4747 _ => None,
4748 }
4749}
4750
4751/// Read a little-endian unsigned integer up to 8 bytes wide.
4752fn read_uint_le(bytes: &[u8]) -> u64 {
4753 let mut buf = [0u8; 8];
4754 let n = bytes.len().min(8);
4755 buf[..n].copy_from_slice(&bytes[..n]);
4756 u64::from_le_bytes(buf)
4757}
4758
4759/// Sign-extend `raw` (unsigned, low `bits` populated) to a signed i64-
4760/// representable value held in u64 bit pattern.
4761fn sign_extend(raw: u64, bits: usize) -> u64 {
4762 if bits == 0 || bits >= 64 {
4763 return raw;
4764 }
4765 let shift = 64 - bits;
4766 ((raw << shift) as i64 >> shift) as u64
4767}
4768
4769#[cfg(test)]
4770mod tests;
4771
4772/// Host-unit coverage for renderer decode and chase branches that the
4773/// `tests/` submodule leaves unexercised: the `render_float` byte-decode
4774/// arms, the `chase_arena_pointer` deferred-resolve `alloc_size`
4775/// fallback (both the size-match success and the unresolved skip), the
4776/// `apply_header_skip` underrun guard, the raw `MAX_RENDER_DEPTH`
4777/// backstop, the flex-array (`BTF len=0`) arm, and the
4778/// `MemReader::alloc_size_types` / `resolve_arena_type_meta_fallback`
4779/// trait defaults. Each test asserts the exact rendered value so a
4780/// behavioural regression fails the assertion rather than slipping
4781/// through a vacuous `is_some()` check.
4782///
4783/// Local `MemReader` stubs are defined here rather than reusing the
4784/// `tests/` submodule's `CastStubReader`: the `tests/` types are private
4785/// to that module tree, and the underrun test needs a reader whose
4786/// `read_arena` returns a SHORTER slice than requested (a page-tail
4787/// crop) — behaviour the shared `CastStubReader` deliberately does not
4788/// have (it returns `None` on a short read).
4789#[cfg(test)]
4790mod blueprint_coverage_tests {
4791 use super::*;
4792 use crate::monitor::cast_analysis::{AddrSpace, CastHit, CastMap};
4793 use crate::test_support::btf_blob::{CastSynMember, CastSynType, cast_build_btf};
4794
4795 /// Append a NUL-terminated `name` to a BTF string section and
4796 /// return its byte offset. Mirrors the `push` closure the `tests/`
4797 /// submodule fixtures use.
4798 fn push_name(strings: &mut Vec<u8>, name: &str) -> u32 {
4799 let off = strings.len() as u32;
4800 strings.extend_from_slice(name.as_bytes());
4801 strings.push(0);
4802 off
4803 }
4804
4805 /// Build a BTF blob carrying one 4-byte `BTF_KIND_FLOAT` (id 1) and
4806 /// one 8-byte `BTF_KIND_FLOAT` (id 2). Returns `(blob, f32_id,
4807 /// f64_id)`.
4808 fn float_btf_f32_and_f64() -> (Vec<u8>, u32, u32) {
4809 let mut strings: Vec<u8> = vec![0];
4810 let n_f32 = push_name(&mut strings, "f32");
4811 let n_f64 = push_name(&mut strings, "f64");
4812 let types = vec![
4813 CastSynType::Float {
4814 name_off: n_f32,
4815 size: 4,
4816 },
4817 CastSynType::Float {
4818 name_off: n_f64,
4819 size: 8,
4820 },
4821 ];
4822 (cast_build_btf(&types, &strings), 1, 2)
4823 }
4824
4825 /// Build a BTF blob with a single `BTF_KIND_FLOAT` of `size` bytes
4826 /// (id 1). Returns `(blob, float_id)`.
4827 fn float_btf_with_size(size: u32) -> (Vec<u8>, u32) {
4828 let mut strings: Vec<u8> = vec![0];
4829 let n = push_name(&mut strings, "flt");
4830 let types = vec![CastSynType::Float { name_off: n, size }];
4831 (cast_build_btf(&types, &strings), 1)
4832 }
4833
4834 /// `render_float` decodes a 4-byte BTF float via `f32::from_le_bytes`
4835 /// and an 8-byte one via `f64::from_le_bytes`, reached through the
4836 /// `Type::Float` arm of `render_value_inner`. 1.5 (f32) and -2.25
4837 /// (f64) are exactly representable, so the `f64` equality is exact.
4838 #[test]
4839 fn render_float_f32_and_f64_decode_via_btf() {
4840 let (blob, f32_id, f64_id) = float_btf_f32_and_f64();
4841 let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4842 assert_eq!(
4843 render_value(&btf, f32_id, &1.5f32.to_le_bytes()),
4844 RenderedValue::Float {
4845 bits: 32,
4846 value: 1.5,
4847 },
4848 );
4849 assert_eq!(
4850 render_value(&btf, f64_id, &(-2.25f64).to_le_bytes()),
4851 RenderedValue::Float {
4852 bits: 64,
4853 value: -2.25,
4854 },
4855 );
4856 }
4857
4858 /// A BTF float whose declared `size` is neither 4 nor 8 (here 2, a
4859 /// `_Float16` / `bf16` width) routes `render_float` to the
4860 /// `Unsupported` arm. The input slice is exactly `size` bytes so the
4861 /// `bytes.len() < size` truncation guard does not fire first.
4862 #[test]
4863 fn render_float_unsupported_size_yields_unsupported() {
4864 let (blob, f16_id) = float_btf_with_size(2);
4865 let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4866 assert_eq!(
4867 render_value(&btf, f16_id, &[0u8; 2]),
4868 RenderedValue::Unsupported {
4869 reason: "unsupported float size 2".to_string(),
4870 },
4871 );
4872 }
4873
4874 /// Fewer bytes than the float's declared size trips the truncation
4875 /// guard in `render_float`, carrying the available bytes as a
4876 /// lowercase space-separated hex `Bytes` partial. Mirrors the
4877 /// dedicated Int / Enum / Enum64 truncation tests, which `render_float`
4878 /// previously lacked.
4879 #[test]
4880 fn render_float_truncated_carries_bytes_partial() {
4881 let (blob, f64_id) = float_btf_with_size(8);
4882 let btf = Btf::from_bytes(&blob).expect("synthetic float BTF parses");
4883 assert_eq!(
4884 render_value(&btf, f64_id, &[0xaa, 0xbb]),
4885 RenderedValue::Truncated {
4886 needed: 8,
4887 had: 2,
4888 partial: Box::new(RenderedValue::Bytes {
4889 hex: "aa bb".to_string(),
4890 }),
4891 },
4892 );
4893 }
4894
4895 /// Stub `MemReader` for the deferred-resolve arena chase tests. The
4896 /// cast intercept fires only for the keyed `(parent_type_id, off)`
4897 /// pairs in `cast_map`, so the inner payload struct's own `u64`
4898 /// member is NOT mis-chased as a pointer. `resolve_arena_type`
4899 /// returns `None` (bridge miss) so the chase falls through to the
4900 /// `alloc_size` path; `alloc_size_types` keeps the trait default
4901 /// (empty) so the cross-BTF fallback loop is empty.
4902 #[derive(Default)]
4903 struct AllocSizeStubReader {
4904 arena_window: Option<(u64, u64)>,
4905 arena_bytes_at: std::collections::HashMap<u64, Vec<u8>>,
4906 cast_map: CastMap,
4907 }
4908 impl MemReader for AllocSizeStubReader {
4909 fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
4910 None
4911 }
4912 fn is_arena_addr(&self, addr: u64) -> bool {
4913 match self.arena_window {
4914 Some((lo, hi)) => addr >= lo && addr < hi,
4915 None => false,
4916 }
4917 }
4918 fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
4919 let bytes = self.arena_bytes_at.get(&addr)?;
4920 if bytes.len() < len {
4921 return None;
4922 }
4923 Some(bytes[..len].to_vec())
4924 }
4925 fn cast_lookup(&self, parent_type_id: u32, member_byte_offset: u32) -> Option<CastHit> {
4926 self.cast_map
4927 .get(&(parent_type_id, member_byte_offset))
4928 .copied()
4929 }
4930 // resolve_arena_type intentionally left at the trait default
4931 // (None) so the deferred-resolve chase takes the alloc_size
4932 // fallback path rather than the bridge path.
4933 }
4934
4935 /// Build a BTF for the `alloc_size` fallback tests:
4936 /// - id 1: u64 (plain unsigned, the cast field's wire type)
4937 /// - id 2: struct task_ctx { u64 weight @ 0 } size 8 — the ONLY
4938 /// size-8 struct, so `discover_payload_btf_id(btf, 8, "")`
4939 /// resolves it unambiguously
4940 /// - id 3: struct outer { u64 p @ 0 } size 16 — the cast-carrying
4941 /// parent; size 16 keeps it OUT of the size-8 candidate set so
4942 /// task_ctx is the unique size-8 match
4943 ///
4944 /// Returns `(blob, outer_id, task_ctx_id)`.
4945 fn alloc_size_btf() -> (Vec<u8>, u32, u32) {
4946 let mut strings: Vec<u8> = vec![0];
4947 let n_u64 = push_name(&mut strings, "u64");
4948 let n_task = push_name(&mut strings, "task_ctx");
4949 let n_weight = push_name(&mut strings, "weight");
4950 let n_outer = push_name(&mut strings, "outer");
4951 let n_p = push_name(&mut strings, "p");
4952 let types = vec![
4953 CastSynType::Int {
4954 name_off: n_u64,
4955 size: 8,
4956 encoding: 0,
4957 offset: 0,
4958 bits: 64,
4959 },
4960 CastSynType::Struct {
4961 name_off: n_task,
4962 size: 8,
4963 members: vec![CastSynMember {
4964 name_off: n_weight,
4965 type_id: 1,
4966 byte_offset: 0,
4967 }],
4968 },
4969 CastSynType::Struct {
4970 name_off: n_outer,
4971 size: 16,
4972 members: vec![CastSynMember {
4973 name_off: n_p,
4974 type_id: 1,
4975 byte_offset: 0,
4976 }],
4977 },
4978 ];
4979 (cast_build_btf(&types, &strings), 3, 2)
4980 }
4981
4982 /// Deferred-resolve arena cast (`target_type_id == 0`) where the
4983 /// `resolve_arena_type` bridge misses and `alloc_size = Some(8)`:
4984 /// `discover_payload_btf_id` size-matches the unique size-8 struct,
4985 /// the chase reads its bytes from the arena, and the rendered `Ptr`
4986 /// carries `cast→arena (sdt_alloc)`. Exercises the
4987 /// `chase_arena_pointer`'s `alloc_size` size-match success arm — dead in the `tests/`
4988 /// suite because every existing `CastHit` uses `alloc_size: None`.
4989 #[test]
4990 fn cast_chase_arena_alloc_size_fallback_resolves_via_discover_payload_btf_id() {
4991 let (blob, outer_id, task_ctx_id) = alloc_size_btf();
4992 let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
4993 // task_ctx_id is distinct from outer_id so the keyed cast_map
4994 // hit on (outer_id, 0) does not re-fire when the inner
4995 // task_ctx.weight member is rendered.
4996 assert_ne!(task_ctx_id, outer_id);
4997
4998 const ARENA_LO: u64 = 0x10_0000_0000;
4999 const ARENA_HI: u64 = 0x10_0001_0000;
5000 const TARGET_ADDR: u64 = 0x10_0000_1000;
5001 // outer { p: TARGET_ADDR } in 16 bytes (cast field @ 0).
5002 let mut outer_bytes = vec![0u8; 16];
5003 outer_bytes[0..8].copy_from_slice(&TARGET_ADDR.to_le_bytes());
5004 // task_ctx at TARGET_ADDR: weight = 0x42.
5005 let inner_bytes = 0x42u64.to_le_bytes().to_vec();
5006 let mut arena_bytes = std::collections::HashMap::new();
5007 arena_bytes.insert(TARGET_ADDR, inner_bytes);
5008 let mut cast_map = CastMap::new();
5009 cast_map.insert(
5010 (outer_id, 0),
5011 CastHit {
5012 target_type_id: 0,
5013 addr_space: AddrSpace::Arena,
5014 alloc_size: Some(8),
5015 },
5016 );
5017 let reader = AllocSizeStubReader {
5018 arena_window: Some((ARENA_LO, ARENA_HI)),
5019 arena_bytes_at: arena_bytes,
5020 cast_map,
5021 };
5022
5023 let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5024 let RenderedValue::Struct { ref members, .. } = v else {
5025 panic!("expected outer Struct render, got {v:?}");
5026 };
5027 assert_eq!(members.len(), 1);
5028 assert_eq!(members[0].name, "p");
5029 let RenderedValue::Ptr {
5030 value,
5031 ref deref,
5032 ref deref_skipped_reason,
5033 ref cast_annotation,
5034 } = members[0].value
5035 else {
5036 panic!("cast field must render as Ptr, got {:?}", members[0].value);
5037 };
5038 assert_eq!(value, TARGET_ADDR);
5039 assert!(
5040 deref_skipped_reason.is_none(),
5041 "alloc_size size-match resolve must not surface a skip reason; got {deref_skipped_reason:?}",
5042 );
5043 assert_eq!(
5044 cast_annotation.as_deref(),
5045 Some("cast→arena (sdt_alloc)"),
5046 "deferred-resolve size-match sets the sdt_alloc-flavoured cast annotation",
5047 );
5048 let inner = deref
5049 .as_deref()
5050 .expect("alloc_size size-match must produce a deref");
5051 let RenderedValue::Struct {
5052 type_name: ref inner_name,
5053 members: ref inner_members,
5054 } = *inner
5055 else {
5056 panic!("deref payload must be the resolved task_ctx struct, got {inner:?}");
5057 };
5058 assert_eq!(inner_name.as_deref(), Some("task_ctx"));
5059 assert_eq!(inner_members.len(), 1);
5060 assert_eq!(inner_members[0].name, "weight");
5061 assert_eq!(
5062 inner_members[0].value,
5063 RenderedValue::Uint {
5064 bits: 64,
5065 value: 0x42,
5066 },
5067 );
5068 }
5069
5070 /// Deferred-resolve arena cast where the bridge misses and
5071 /// `alloc_size = Some(99)` matches no struct: `discover_payload_btf_id`
5072 /// returns `target_type_id 0` with reason `"no candidate of size 99"`,
5073 /// and `alloc_size_types` is empty, so the chase surfaces the
5074 /// labelled unresolved skip with `deref: None` and the plain
5075 /// `cast→arena` annotation (sdt_alloc NOT resolved). Exercises
5076 /// `chase_arena_pointer`'s `alloc_size` fallback-unresolved skip arm.
5077 #[test]
5078 fn cast_chase_arena_alloc_size_fallback_unresolved_surfaces_skip_reason() {
5079 let (blob, outer_id, _task_ctx_id) = alloc_size_btf();
5080 let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
5081
5082 const ARENA_LO: u64 = 0x10_0000_0000;
5083 const ARENA_HI: u64 = 0x10_0001_0000;
5084 const TARGET_ADDR: u64 = 0x10_0000_1000;
5085 let mut outer_bytes = vec![0u8; 16];
5086 outer_bytes[0..8].copy_from_slice(&TARGET_ADDR.to_le_bytes());
5087 // No arena bytes needed: the size-99 discover_payload_btf_id
5088 // miss returns the unresolved skip BEFORE any arena read, and
5089 // alloc_size_types() is empty (trait default) so the cross-BTF
5090 // fallback loop never runs.
5091 let arena_bytes: std::collections::HashMap<u64, Vec<u8>> = std::collections::HashMap::new();
5092 let mut cast_map = CastMap::new();
5093 cast_map.insert(
5094 (outer_id, 0),
5095 CastHit {
5096 target_type_id: 0,
5097 addr_space: AddrSpace::Arena,
5098 alloc_size: Some(99),
5099 },
5100 );
5101 let reader = AllocSizeStubReader {
5102 arena_window: Some((ARENA_LO, ARENA_HI)),
5103 arena_bytes_at: arena_bytes,
5104 cast_map,
5105 };
5106
5107 let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5108 let RenderedValue::Struct { ref members, .. } = v else {
5109 panic!("expected outer Struct render, got {v:?}");
5110 };
5111 let RenderedValue::Ptr {
5112 ref deref,
5113 ref deref_skipped_reason,
5114 ref cast_annotation,
5115 ..
5116 } = members[0].value
5117 else {
5118 panic!("cast field must render as Ptr, got {:?}", members[0].value);
5119 };
5120 assert!(deref.is_none(), "size-99 miss must not produce a deref");
5121 assert_eq!(
5122 deref_skipped_reason.as_deref(),
5123 Some(
5124 "arena chase: STX-flow path tagged slot as Arena with \
5125 deferred resolve; alloc_size=99 fallback unresolved \
5126 (no candidate of size 99)"
5127 ),
5128 );
5129 assert_eq!(
5130 cast_annotation.as_deref(),
5131 Some("cast→arena"),
5132 "unresolved deferred-resolve keeps the plain (non-sdt_alloc) annotation",
5133 );
5134 }
5135
5136 /// Stub `MemReader` whose `read_arena` returns a slice SHORTER than
5137 /// the requested length — modelling a page-tail crop in the captured
5138 /// arena snapshot. The shared `CastStubReader` returns `None` on a
5139 /// short read, which routes to the read-failed branch; this reader
5140 /// returns the truncated slice so the chase reaches
5141 /// `apply_header_skip`'s underrun guard specifically.
5142 struct ShortReadArenaReader {
5143 arena_window: (u64, u64),
5144 /// Bytes returned for the chased address regardless of the
5145 /// requested length — capped at what is stored (the page-tail
5146 /// crop).
5147 bytes_at: std::collections::HashMap<u64, Vec<u8>>,
5148 arena_type_at: std::collections::HashMap<u64, ArenaResolveHit>,
5149 }
5150 impl MemReader for ShortReadArenaReader {
5151 fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
5152 None
5153 }
5154 fn is_arena_addr(&self, addr: u64) -> bool {
5155 addr >= self.arena_window.0 && addr < self.arena_window.1
5156 }
5157 fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
5158 // Page-tail crop: return min(stored, requested) bytes — a
5159 // successful-but-short read, distinct from the shared
5160 // reader's None-on-short-read behaviour.
5161 let bytes = self.bytes_at.get(&addr)?;
5162 let n = bytes.len().min(len);
5163 Some(bytes[..n].to_vec())
5164 }
5165 fn resolve_arena_type(&self, addr: u64) -> Option<ArenaResolveHit> {
5166 self.arena_type_at.get(&addr).copied()
5167 }
5168 }
5169
5170 /// `BTF_KIND_FWD`-pointee `Type::Ptr` chase where the
5171 /// `resolve_arena_type` bridge fires with `header_skip = 16` but the
5172 /// arena read returns only 8 bytes (page-tail crop): `apply_header_skip`
5173 /// cannot slice past the header, so the chase surfaces the underrun
5174 /// skip with `deref: None` and the `sdt_alloc` annotation (the bridge
5175 /// did resolve; only the read fell short). Exercises the
5176 /// `chase_arena_pointer`'s `apply_header_skip` underrun guard, which a contract-conforming
5177 /// `read_arena` never reaches.
5178 #[test]
5179 fn arena_chase_header_skip_exceeds_read_surfaces_underrun_skip() {
5180 // BTF: id1 u64; id2 Fwd struct sdt_data (no body); id3 Ptr->id2;
5181 // id4 struct outer { sdt_data *data @ 0 } size 8; id5 struct
5182 // task_ctx { u64 weight @ 0 } size 8 (the bridge-resolved
5183 // payload, btf_size = 8).
5184 let mut strings: Vec<u8> = vec![0];
5185 let n_u64 = push_name(&mut strings, "u64");
5186 let n_fwd = push_name(&mut strings, "sdt_data");
5187 let n_outer = push_name(&mut strings, "outer");
5188 let n_data = push_name(&mut strings, "data");
5189 let n_task = push_name(&mut strings, "task_ctx");
5190 let n_weight = push_name(&mut strings, "weight");
5191 let types = vec![
5192 CastSynType::Int {
5193 name_off: n_u64,
5194 size: 8,
5195 encoding: 0,
5196 offset: 0,
5197 bits: 64,
5198 },
5199 CastSynType::Fwd {
5200 name_off: n_fwd,
5201 is_union: false,
5202 },
5203 CastSynType::Ptr { type_id: 2 },
5204 CastSynType::Struct {
5205 name_off: n_outer,
5206 size: 8,
5207 members: vec![CastSynMember {
5208 name_off: n_data,
5209 type_id: 3,
5210 byte_offset: 0,
5211 }],
5212 },
5213 CastSynType::Struct {
5214 name_off: n_task,
5215 size: 8,
5216 members: vec![CastSynMember {
5217 name_off: n_weight,
5218 type_id: 1,
5219 byte_offset: 0,
5220 }],
5221 },
5222 ];
5223 let blob = cast_build_btf(&types, &strings);
5224 let btf = Btf::from_bytes(&blob).expect("synthetic BTF parses");
5225 let (outer_id, task_ctx_id) = (4u32, 5u32);
5226
5227 const ARENA_LO: u64 = 0x10_0000_0000;
5228 const ARENA_HI: u64 = 0x10_0001_0000;
5229 const SLOT_ADDR: u64 = 0x10_0000_1000;
5230 let outer_bytes = SLOT_ADDR.to_le_bytes().to_vec();
5231 // Seed only 8 bytes at SLOT_ADDR. The chase needs
5232 // header_skip(16) + btf_size(8) = 24 bytes; read_arena returns
5233 // 8 (page-tail crop), which is < header_skip, so apply_header_skip
5234 // cannot land on the payload.
5235 let mut bytes_at = std::collections::HashMap::new();
5236 bytes_at.insert(SLOT_ADDR, vec![0u8; 8]);
5237 let mut arena_type_at = std::collections::HashMap::new();
5238 arena_type_at.insert(
5239 SLOT_ADDR,
5240 ArenaResolveHit {
5241 target_type_id: task_ctx_id,
5242 header_skip: 16,
5243 },
5244 );
5245 let reader = ShortReadArenaReader {
5246 arena_window: (ARENA_LO, ARENA_HI),
5247 bytes_at,
5248 arena_type_at,
5249 };
5250
5251 let v = render_value_with_mem(&btf, outer_id, &outer_bytes, &reader);
5252 let RenderedValue::Struct { ref members, .. } = v else {
5253 panic!("expected outer Struct render, got {v:?}");
5254 };
5255 assert_eq!(members[0].name, "data");
5256 let RenderedValue::Ptr {
5257 ref deref,
5258 ref deref_skipped_reason,
5259 ref cast_annotation,
5260 ..
5261 } = members[0].value
5262 else {
5263 panic!("data field must render as Ptr, got {:?}", members[0].value);
5264 };
5265 assert!(deref.is_none(), "underrun must not produce a deref");
5266 assert_eq!(
5267 deref_skipped_reason.as_deref(),
5268 Some(
5269 "arena read at 0x1000001000 returned 8 bytes; \
5270 sdt_alloc bridge needs at least 16 for header skip"
5271 ),
5272 );
5273 assert_eq!(
5274 cast_annotation.as_deref(),
5275 Some("sdt_alloc"),
5276 "the bridge resolved (sdt_alloc) even though the read fell short",
5277 );
5278 }
5279
5280 /// Stub `MemReader` for the depth-cap chain: every seeded arena
5281 /// address stores the NEXT distinct arena address, so the renderer
5282 /// recurses through a non-cycling pointer chain. Distinct addresses
5283 /// keep the visited-set cycle check from firing before the raw
5284 /// `MAX_RENDER_DEPTH` backstop.
5285 struct ChainArenaReader {
5286 arena_window: (u64, u64),
5287 bytes_at: std::collections::HashMap<u64, Vec<u8>>,
5288 }
5289 impl MemReader for ChainArenaReader {
5290 fn read_kva(&self, _kva: u64, _len: usize) -> Option<Vec<u8>> {
5291 None
5292 }
5293 fn is_arena_addr(&self, addr: u64) -> bool {
5294 addr >= self.arena_window.0 && addr < self.arena_window.1
5295 }
5296 fn read_arena(&self, addr: u64, len: usize) -> Option<Vec<u8>> {
5297 let bytes = self.bytes_at.get(&addr)?;
5298 if bytes.len() < len {
5299 return None;
5300 }
5301 Some(bytes[..len].to_vec())
5302 }
5303 }
5304
5305 /// A non-cycling chain of >32 distinct addresses (each `node`
5306 /// pointing at a fresh address) exhausts the recursion depth before
5307 /// any address repeats, so the raw `MAX_RENDER_DEPTH` backstop fires
5308 /// — the deepest deref is `Unsupported { reason: "render depth 32
5309 /// exceeded" }` — rather than the visited-set cycle check. The
5310 /// existing `tests/` cycle cases all trip the visited check first, so
5311 /// this is the only test that reaches the depth cap.
5312 #[test]
5313 fn render_value_inner_depth_cap_yields_unsupported() {
5314 // id1: struct node { node *next @ 0 } size 8; id2: Ptr -> id1.
5315 let mut strings: Vec<u8> = vec![0];
5316 let n_node = push_name(&mut strings, "node");
5317 let n_next = push_name(&mut strings, "next");
5318 let types = vec![
5319 CastSynType::Struct {
5320 name_off: n_node,
5321 size: 8,
5322 members: vec![CastSynMember {
5323 name_off: n_next,
5324 type_id: 2,
5325 byte_offset: 0,
5326 }],
5327 },
5328 CastSynType::Ptr { type_id: 1 },
5329 ];
5330 let blob = cast_build_btf(&types, &strings);
5331 let btf = Btf::from_bytes(&blob).expect("synthetic node BTF parses");
5332 let node_id = 1u32;
5333
5334 const ARENA_BASE: u64 = 0x10_0000_0000;
5335 const ARENA_HI: u64 = 0x10_0010_0000;
5336 // A_i = ARENA_BASE + (i+1) * 0x1000, all distinct, all in window.
5337 // 41 nodes is well past the ~16 levels needed to reach depth 32.
5338 let addr = |i: usize| ARENA_BASE + ((i as u64) + 1) * 0x1000;
5339 let mut bytes_at = std::collections::HashMap::new();
5340 for i in 0..41 {
5341 bytes_at.insert(addr(i), addr(i + 1).to_le_bytes().to_vec());
5342 }
5343 let reader = ChainArenaReader {
5344 arena_window: (ARENA_BASE, ARENA_HI),
5345 bytes_at,
5346 };
5347
5348 // Outer node whose `next` points at A_0.
5349 let outer = addr(0).to_le_bytes().to_vec();
5350 let v = render_value_with_mem(&btf, node_id, &outer, &reader);
5351
5352 // Descend the Struct -> Ptr -> deref chain to the terminal node.
5353 // A Struct yields its sole member's value; a Ptr surfaces its
5354 // skip reason (assert none is a cycle) then descends into its
5355 // deref; an Unsupported is the terminal.
5356 let mut current = &v;
5357 let mut terminal_reason: Option<String> = None;
5358 for _ in 0..200 {
5359 match current {
5360 RenderedValue::Struct { members, .. } => {
5361 assert_eq!(members.len(), 1, "node has exactly one member");
5362 current = &members[0].value;
5363 }
5364 RenderedValue::Ptr {
5365 deref,
5366 deref_skipped_reason,
5367 ..
5368 } => {
5369 if let Some(reason) = deref_skipped_reason {
5370 assert!(
5371 !reason.contains("cycle"),
5372 "distinct-address chain must NOT trip the cycle check: {reason}",
5373 );
5374 }
5375 match deref {
5376 Some(inner) => current = inner.as_ref(),
5377 None => {
5378 // A null/no-deref Ptr terminates the walk
5379 // without reaching the depth cap — that would
5380 // be a setup bug (the chain ran dry early).
5381 panic!(
5382 "chain terminated at a Ptr with no deref and no depth-cap \
5383 Unsupported; skip reason {deref_skipped_reason:?}",
5384 );
5385 }
5386 }
5387 }
5388 RenderedValue::Unsupported { reason } => {
5389 terminal_reason = Some(reason.clone());
5390 break;
5391 }
5392 other => panic!("unexpected node in depth-cap chain: {other:?}"),
5393 }
5394 }
5395 assert_eq!(
5396 terminal_reason.as_deref(),
5397 Some("render depth 32 exceeded"),
5398 "the deepest node must be the MAX_RENDER_DEPTH backstop",
5399 );
5400 }
5401
5402 /// A `BTF_KIND_ARRAY` with `nelems == 0` (a `T[]` / `T[0]` flex
5403 /// member) over a non-zero-size element, rendered against a
5404 /// non-empty byte slice, surfaces `Unsupported` with the flex-array
5405 /// reason carrying the available byte count. `CastSynType` has no
5406 /// Array variant, so the array + a u32 element are hand-emitted on
5407 /// the wire (the `tests/` suite uses the same pattern for nelems=3).
5408 #[test]
5409 fn render_array_flex_len_zero_yields_unsupported() {
5410 const BTF_KIND_ARRAY: u32 = 3;
5411 let mut strings: Vec<u8> = vec![0];
5412 let n_u32 = push_name(&mut strings, "u32");
5413
5414 let mut type_section = Vec::new();
5415 // id1: BTF_KIND_INT u32 (size 4).
5416 type_section.extend_from_slice(&n_u32.to_le_bytes());
5417 type_section.extend_from_slice(&((1u32 << 24) & 0x1f00_0000).to_le_bytes());
5418 type_section.extend_from_slice(&4u32.to_le_bytes());
5419 type_section.extend_from_slice(&32u32.to_le_bytes());
5420 // id2: BTF_KIND_ARRAY of id1, nelems = 0 (flex). btf_array
5421 // { type(4), index_type(4), nelems(4) }; the type record's
5422 // size_type word is unused for arrays.
5423 type_section.extend_from_slice(&0u32.to_le_bytes()); // name_off
5424 type_section.extend_from_slice(&((BTF_KIND_ARRAY << 24) & 0x1f00_0000).to_le_bytes());
5425 type_section.extend_from_slice(&0u32.to_le_bytes()); // size_type (unused)
5426 type_section.extend_from_slice(&1u32.to_le_bytes()); // elem type id
5427 type_section.extend_from_slice(&1u32.to_le_bytes()); // index type id
5428 type_section.extend_from_slice(&0u32.to_le_bytes()); // nelems = 0
5429
5430 let type_len = type_section.len() as u32;
5431 let str_len = strings.len() as u32;
5432 let mut blob = Vec::new();
5433 blob.extend_from_slice(&0xEB9Fu16.to_le_bytes());
5434 blob.push(1);
5435 blob.push(0);
5436 blob.extend_from_slice(&24u32.to_le_bytes());
5437 blob.extend_from_slice(&0u32.to_le_bytes());
5438 blob.extend_from_slice(&type_len.to_le_bytes());
5439 blob.extend_from_slice(&type_len.to_le_bytes());
5440 blob.extend_from_slice(&str_len.to_le_bytes());
5441 blob.extend_from_slice(&type_section);
5442 blob.extend_from_slice(&strings);
5443
5444 let btf = Btf::from_bytes(&blob).expect("synthetic flex-array BTF parses");
5445 let arr_id = 2u32;
5446 match render_value(&btf, arr_id, &[0u8; 8]) {
5447 RenderedValue::Unsupported { reason } => {
5448 assert!(
5449 reason.starts_with("flex array (BTF len=0)")
5450 && reason.contains("8 bytes available at site"),
5451 "flex-array reason names the kind and the available bytes: {reason}",
5452 );
5453 }
5454 other => panic!("expected Unsupported flex-array render, got {other:?}"),
5455 }
5456 }
5457
5458 /// `MemReader::alloc_size_types` and
5459 /// `MemReader::resolve_arena_type_meta_fallback` trait defaults: a
5460 /// reader overriding only `read_kva` returns an empty slice and
5461 /// `None` respectively. Pins the defaults that keep every existing
5462 /// reader correct, mirroring the existing
5463 /// `mem_reader_default_resolve_arena_type_is_none` /
5464 /// `mem_reader_default_nr_cpu_ids_is_u32_max` pins.
5465 #[test]
5466 fn mem_reader_default_alloc_size_types_and_meta_fallback_are_empty_and_none() {
5467 struct DefaultReader;
5468 impl MemReader for DefaultReader {
5469 fn read_kva(&self, _: u64, _: usize) -> Option<Vec<u8>> {
5470 None
5471 }
5472 }
5473 let r = DefaultReader;
5474 assert!(
5475 r.alloc_size_types().is_empty(),
5476 "default alloc_size_types must be empty",
5477 );
5478 assert!(
5479 r.resolve_arena_type_meta_fallback(0x10_0000_1000).is_none(),
5480 "default resolve_arena_type_meta_fallback must be None for an arena-window address",
5481 );
5482 assert!(
5483 r.resolve_arena_type_meta_fallback(0).is_none(),
5484 "default resolve_arena_type_meta_fallback must be None for null too",
5485 );
5486 }
5487}