ktstr 0.17.0

Test harness for Linux process schedulers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
//! Unit tests for [`super`] (the `probe::process` module).
//! Co-located via the `tests` submodule pattern (sibling file).

#![cfg(test)]

use super::*;

// -- parse_kallsyms --

#[test]
fn parse_kallsyms_happy_path() {
    // Canonical kallsyms layout: `HEX TYPE NAME`. The type column
    // is accepted but ignored by the parser.
    let raw = "ffffffff81000000 T _stext\n\
               ffffffff81000010 T schedule\n\
               ffffffff82000000 D init_mm\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 3);
    assert_eq!(map["_stext"], 0xffffffff81000000);
    assert_eq!(map["schedule"], 0xffffffff81000010);
    assert_eq!(map["init_mm"], 0xffffffff82000000);
}

#[test]
fn parse_kallsyms_skips_lines_missing_name() {
    // Lines with fewer than 3 tokens (addr, type, name — all
    // required) are dropped silently; the rest still parse.
    let raw = "\
        \n\
        ffffffff81000000\n\
        ffffffff81000010 T\n\
        ffffffff81000020 T real_sym\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 1);
    assert_eq!(map["real_sym"], 0xffffffff81000020);
}

#[test]
fn parse_kallsyms_skips_nonhex_addr() {
    // First token must parse as u64 hex; otherwise the line is
    // skipped and parsing continues on the next line.
    let raw = "garbage T should_skip\n\
               ffffffff81000000 T kept\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 1);
    assert_eq!(map["kept"], 0xffffffff81000000);
}

#[test]
fn parse_kallsyms_empty_input_yields_empty_map() {
    // Permanently-empty input is a valid parse, returning an empty
    // map rather than an error.
    let map = parse_kallsyms("");
    assert!(map.is_empty());
}

#[test]
fn parse_kallsyms_duplicate_name_keeps_last() {
    // HashMap::insert semantics: duplicate keys overwrite, so the
    // last occurrence wins. Callers that care about multiple
    // symbols with the same name would need a different parser.
    let raw = "ffffffff81000000 T dup\n\
               ffffffff82000000 T dup\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 1);
    assert_eq!(map["dup"], 0xffffffff82000000);
}

#[test]
fn parse_kallsyms_ignores_trailing_module_tag() {
    // Kernel-built-in symbols omit the trailing `[module]` tag;
    // module symbols include it. The parser only uses the first
    // 3 tokens, so trailing tokens (like `[mptcp]`) are dropped.
    let raw = "ffffffff81000000 T mod_sym\t[mptcp]\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 1);
    assert_eq!(map["mod_sym"], 0xffffffff81000000);
}

#[test]
fn build_field_keys_known_struct() {
    let func = super::BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "p".into(),
            struct_name: Some("task_struct".into()),
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert!(
        keys.iter()
            .any(|(k, _)| k.contains("task_struct") && k.contains("pid"))
    );
    assert!(keys.iter().any(|(k, _)| k.contains("dsq_id")));
}

#[test]
fn build_field_keys_scalar_param() {
    let func = super::BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "flags".into(),
            struct_name: None,
            is_ptr: false,
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert!(keys.iter().any(|(k, _)| k.contains("flags:val.flags")));
}

#[test]
fn build_field_keys_ptr_no_struct() {
    let func = super::BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "ctx".into(),
            struct_name: None,
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    // Raw pointer with no struct info: no keys generated
    assert!(keys.is_empty());
}

#[test]
fn build_field_keys_empty_params() {
    let func = super::BtfFunc {
        name: "empty".into(),
        params: vec![],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert!(keys.is_empty());
}

#[test]
fn resolve_func_ip_nonexistent() {
    assert!(resolve_func_ip("__nonexistent_kernel_function_xyz__").is_none());
}

#[test]
fn build_field_keys_unknown_struct() {
    let func = super::BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "p".into(),
            struct_name: Some("unknown_struct_xyz".into()),
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert!(keys.is_empty(), "unknown struct should produce no keys");
}

// -- detect_str_param --

#[test]
fn detect_str_param_btf_string_ptr() {
    // param[1] carries is_string_ptr (the BTF signal) but a name
    // ("p1") matching NO name heuristic; param[2] is a non-BTF ptr
    // whose name ("msg") DOES match the heuristic. The BTF loop runs
    // first and returns index 1; were the BTF is_string_ptr loop
    // deleted, the name fallback would instead return index 2. So
    // asserting == 1 pins BOTH the BTF branch and its precedence
    // over the name heuristic (a "fmt"-named string ptr could not —
    // "fmt" is itself a name-heuristic literal, masking the branch).
    let func = BtfFunc {
        name: "test".into(),
        params: vec![
            super::super::btf::BtfParam {
                name: "p".into(),
                struct_name: Some("task_struct".into()),
                is_ptr: true,
                ..Default::default()
            },
            super::super::btf::BtfParam {
                name: "p1".into(),
                struct_name: None,
                is_ptr: true,
                is_string_ptr: true,
                ..Default::default()
            },
            super::super::btf::BtfParam {
                name: "msg".into(),
                struct_name: None,
                is_ptr: true,
                ..Default::default()
            },
        ],
        ..Default::default()
    };
    assert_eq!(detect_str_param(&func), 1);
}

#[test]
fn detect_str_param_name_heuristic() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![
            super::super::btf::BtfParam {
                name: "flags".into(),
                struct_name: None,
                is_ptr: false,
                ..Default::default()
            },
            super::super::btf::BtfParam {
                name: "msg".into(),
                struct_name: None,
                is_ptr: true,
                ..Default::default()
            },
        ],
        ..Default::default()
    };
    assert_eq!(detect_str_param(&func), 1);
}

#[test]
fn detect_str_param_none() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "flags".into(),
            struct_name: None,
            is_ptr: false,
            ..Default::default()
        }],
        ..Default::default()
    };
    assert_eq!(detect_str_param(&func), 0xff);
}

#[test]
fn detect_str_param_struct_ptr_not_string() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "rq".into(),
            struct_name: Some("rq".into()),
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    assert_eq!(detect_str_param(&func), 0xff);
}

#[test]
fn detect_str_param_name_contains_str() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "my_str_ptr".into(),
            struct_name: None,
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    assert_eq!(detect_str_param(&func), 0);
}

// -- build_field_keys with auto_fields --

#[test]
fn build_field_keys_auto_fields() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "ctx".into(),
            struct_name: None,
            is_ptr: true,
            auto_fields: vec![
                ("field_a".into(), "->field_a".into(), RenderHint::Bool),
                ("field_b".into(), "->field_b".into(), RenderHint::Signed),
            ],
            type_name: Some("task_ctx".into()),
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert_eq!(keys.len(), 2);
    assert!(keys[0].0.contains("task_ctx"));
    assert!(keys[0].0.contains("field_a"));
    assert_eq!(keys[0].1, RenderHint::Bool);
    assert!(keys[1].0.contains("field_b"));
    assert_eq!(keys[1].1, RenderHint::Signed);
}

// -- build_field_keys with cpumask fields --

#[test]
fn build_field_keys_includes_cpumask_words() {
    let func = BtfFunc {
        name: "test".into(),
        params: vec![super::super::btf::BtfParam {
            name: "p".into(),
            struct_name: Some("task_struct".into()),
            is_ptr: true,
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert!(
        keys.iter().any(|(k, _)| k.contains("cpumask_0")),
        "should have cpumask_0: {keys:?}",
    );
    assert!(
        keys.iter().any(|(k, _)| k.contains("cpumask_3")),
        "should have cpumask_3: {keys:?}",
    );
}

#[test]
fn build_field_keys_max_six_params() {
    let params: Vec<_> = (0..8)
        .map(|i| super::super::btf::BtfParam {
            name: format!("p{i}"),
            struct_name: None,
            is_ptr: false,
            ..Default::default()
        })
        .collect();
    let func = super::BtfFunc {
        name: "many".into(),
        params,
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    // Only first 6 params processed
    assert!(keys.len() <= 6);
    assert!(keys.iter().any(|(k, _)| k.contains("p5")));
    assert!(!keys.iter().any(|(k, _)| k.contains("p6")));
}

// ---- args[0] kind-conditional filter ----------------------------
//
// The args[0] conditional in ktstr_trigger_tp (the BPF
// tracepoint trigger handler) sets
// `event->args[0] = (kind == SCX_EXIT_ERROR_BPF)
// ? bpf_get_current_task() : 0;` — current-task is emitted
// ONLY for SCX_EXIT_ERROR_BPF (1025). For SCX_EXIT_ERROR
// (1024) the field is 0 because the exit can fire from
// kworker / sysrq context where `current` is unrelated.
//
// The target_tptr filter in run_probe_skeleton drops events
// whose `task_ptr` (sourced from args[0]) is 0, suppressing
// probe output when the BPF side declined to provide a
// causal task. These tests pin the host-side filter against
// both sides of that contract.
//
// SCX_EXIT_ERROR enum values (mirrored from kernel
// ext_internal.h, also defined in src/bpf/intf.h):
const SCX_EXIT_ERROR: u64 = 1024;
const SCX_EXIT_ERROR_BPF: u64 = 1025;

/// Build a synthetic `ProbeEvent` mirroring what the
/// ringbuf callback inside `run_probe_skeleton` constructs
/// from a trigger event. `args[0]` is the causal task
/// pointer the BPF side emitted (per the args[0] conditional
/// in ktstr_trigger_tp); `args[1]` is the exit kind.
/// `task_ptr` is set from `args[0]` in the trigger event
/// constructor in run_probe_skeleton.
fn make_trigger_event(args0: u64, kind: u64) -> ProbeEvent {
    let mut args = [0u64; 6];
    args[0] = args0;
    args[1] = kind;
    ProbeEvent {
        func_idx: 0,
        task_ptr: args0,
        ts: 0,
        args,
        fields: Vec::new(),
        kstack: Vec::new(),
        str_val: None,
        exit_fields: Vec::new(),
        exit_ts: None,
    }
}

#[test]
fn args0_zero_filtered_for_scx_exit_error() {
    // SCX_EXIT_ERROR (1024) fires from non-causal contexts
    // (kworker, sysrq) — the BPF side emits args[0] = 0,
    // so task_ptr is 0. The production `causal_tptr` filter
    // that `run_probe_skeleton` applies to the trigger
    // event's task_ptr must drop this (return None). Driving
    // the real `causal_tptr` (not a re-implemented copy)
    // means a regression that loosens the predicate (e.g.
    // `p >= 1`, or removing the guard entirely) surfaces here.
    let event = make_trigger_event(0, SCX_EXIT_ERROR);
    assert_eq!(
        event.task_ptr, 0,
        "SCX_EXIT_ERROR must propagate args[0]=0 into task_ptr"
    );
    assert_eq!(
        super::causal_tptr(event.task_ptr),
        None,
        "task_ptr=0 must be filtered out by the production \
         causal_tptr (no causal task → no stitch)"
    );
    assert_eq!(
        event.args[1], SCX_EXIT_ERROR,
        "args[1] must carry the exit kind for diagnostics"
    );
}

#[test]
fn args0_task_ptr_retained_for_scx_exit_error_bpf() {
    // SCX_EXIT_ERROR_BPF (1025) fires from a BPF callback in
    // the running task's context — the BPF side emits
    // args[0] = bpf_get_current_task() (a non-zero
    // task_struct pointer), so task_ptr is non-zero. The
    // production `causal_tptr` filter must retain this event
    // (return Some) so stitching can proceed. Asserting
    // against the real `causal_tptr` ties the test to the
    // code path `run_probe_skeleton` actually runs.
    const FAKE_TASK_PTR: u64 = 0xffff_8881_1234_5678; // plausible kernel VA
    let event = make_trigger_event(FAKE_TASK_PTR, SCX_EXIT_ERROR_BPF);
    assert_eq!(
        event.task_ptr, FAKE_TASK_PTR,
        "SCX_EXIT_ERROR_BPF must propagate args[0]=task_ptr into task_ptr"
    );
    assert_eq!(
        super::causal_tptr(event.task_ptr),
        Some(FAKE_TASK_PTR),
        "non-zero task_ptr must survive the production causal_tptr"
    );
    assert_eq!(
        event.args[1], SCX_EXIT_ERROR_BPF,
        "args[1] must carry the exit kind for diagnostics"
    );
}

// -- accept_kallsyms_map (kptr_restrict=2 cache poison guard) -----

#[test]
fn accept_kallsyms_map_rejects_all_zero_addresses() {
    // kernel.kptr_restrict=2 makes /proc/kallsyms readable but
    // zeros every address column. parse_kallsyms accepts the
    // file and yields a map with every value == 0. caching
    // that map would have resolve_func_ip return Some(0) for
    // every later lookup, masking the unprivileged state from
    // the retry-after-sudo path. accept_kallsyms_map must
    // collapse this case to None so the caller treats it as a
    // load failure and the retry clock keeps ticking.
    let raw = "0000000000000000 T schedule\n\
               0000000000000000 T do_exit\n\
               0000000000000000 D init_mm\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 3, "parser still records every line");
    assert!(
        map.values().all(|&a| a == 0),
        "kptr_restrict=2 must yield all-zero addresses",
    );
    assert!(
        accept_kallsyms_map(map).is_none(),
        "all-zero map must be rejected so the cache is not poisoned",
    );
}

#[test]
fn accept_kallsyms_map_accepts_when_any_nonzero() {
    // A single non-zero entry is enough to consider the file
    // genuinely populated — rate-limit pressure makes the
    // tighter test ("require ALL non-zero") wrong, since
    // legitimately exported partial dumps always contain a
    // few zero entries (NULL section markers).
    let raw = "0000000000000000 T zeroed\n\
               ffffffff81000000 T schedule\n";
    let map = parse_kallsyms(raw);
    assert_eq!(map.len(), 2);
    let accepted = accept_kallsyms_map(map).expect("mixed map must be accepted");
    assert_eq!(accepted["schedule"], 0xffffffff81000000);
    assert_eq!(accepted["zeroed"], 0);
}

#[test]
fn accept_kallsyms_map_rejects_empty_map() {
    // An empty map is vacuously all-zero — `any(|&a| a != 0)`
    // returns false on an empty iterator. Treat as a load
    // failure so the retry clock keeps ticking; otherwise an
    // empty-on-first-read race would freeze the cache at "no
    // symbols" forever.
    let map = std::collections::HashMap::<String, u64>::new();
    assert!(accept_kallsyms_map(map).is_none());
}

// -- build_task_param_idx out-of-bounds guard ---------------------

/// Construct a [`BtfFunc`] whose task_struct param sits at
/// `task_pos`. Pads earlier params with scalar `void *` entries
/// so the iterator's `.position` finds the task at the
/// requested index.
fn make_btf_with_task_at(name: &str, task_pos: usize) -> BtfFunc {
    let mut params = Vec::new();
    for i in 0..task_pos {
        params.push(super::super::btf::BtfParam {
            name: format!("a{i}"),
            struct_name: None,
            is_ptr: false,
            ..Default::default()
        });
    }
    params.push(super::super::btf::BtfParam {
        name: "p".into(),
        struct_name: Some("task_struct".into()),
        is_ptr: true,
        ..Default::default()
    });
    BtfFunc {
        name: name.to_string(),
        params,
        ..Default::default()
    }
}

#[test]
fn build_task_param_idx_drops_index_at_six() {
    // ProbeEvent::args is [u64; 6]. A task_struct param at
    // pidx=6 (i.e. arg 7) is past the captured slice — the
    // BPF probe never recorded that arg. Storing pidx=6 in
    // the stitch map would panic on `e.args[pidx]`, so the
    // builder MUST drop the entry rather than admit it.
    let func_ips = vec![(
        42u32,
        0xffff_ffff_8100_0000u64,
        "novel_callback".to_string(),
    )];
    let btf = vec![make_btf_with_task_at("novel_callback", 6)];
    let map = build_task_param_idx(&func_ips, &btf, &[]);
    assert!(
        !map.contains_key(&42),
        "pidx==6 must be dropped (args[6] is out of bounds for [u64; 6])",
    );
}

#[test]
fn build_task_param_idx_drops_index_above_six() {
    // Same boundary as pidx==6, but with a larger pidx to
    // catch a future off-by-one swap (`pidx > 6` instead of
    // `>= 6`) that would silently re-admit pidx=6.
    let func_ips = vec![(7u32, 0xffff_ffff_8100_0000u64, "wide_signature".to_string())];
    let btf = vec![make_btf_with_task_at("wide_signature", 9)];
    let map = build_task_param_idx(&func_ips, &btf, &[]);
    assert!(!map.contains_key(&7), "pidx==9 must be dropped");
}

#[test]
fn build_task_param_idx_keeps_index_at_five() {
    // pidx=5 IS the last valid slot (`args[5]`) — must be
    // kept. This is the boundary partner to the pidx==6
    // drop test: a regression that swaps `>= 6` to `>= 5`
    // would discard real, capturable callbacks.
    let func_ips = vec![(11u32, 0xffff_ffff_8100_0000u64, "tail_task".to_string())];
    let btf = vec![make_btf_with_task_at("tail_task", 5)];
    let map = build_task_param_idx(&func_ips, &btf, &[]);
    assert_eq!(map.get(&11).copied(), Some(5));
}

#[test]
fn build_task_param_idx_uses_bpf_op_callers_first() {
    // `BPF_OP_CALLERS` overrides BTF for the well-known
    // sched_ext op kernel callers — verifies the BTF fallback
    // doesn't shadow the canonical mapping. `do_enqueue_task`
    // is registered with task_arg_idx=1 in the table; the
    // builder must return 1 even when the BTF (synthesized
    // here at task_pos=3) would say otherwise.
    let func_ips = vec![(
        0u32,
        0xffff_ffff_8100_0000u64,
        "do_enqueue_task".to_string(),
    )];
    let btf = vec![make_btf_with_task_at("do_enqueue_task", 3)];
    let map = build_task_param_idx(&func_ips, &btf, &[]);
    assert_eq!(
        map.get(&0).copied(),
        Some(1),
        "BPF_OP_CALLERS task_arg_idx (1) must win over BTF fallback (3)",
    );
}

#[test]
fn build_task_param_idx_phase_b_btf_chained() {
    // Phase B BTF must be searched as a fallback for funcs
    // not in the Phase A `btf_funcs` slice — the stitch map
    // must include Phase B–attached callbacks. Without this,
    // BPF callbacks discovered after the scheduler started
    // would never stitch.
    let func_ips = vec![(33u32, 0xffff_ffff_8100_0000u64, "phase_b_only".to_string())];
    let phase_b = vec![make_btf_with_task_at("phase_b_only", 2)];
    let map = build_task_param_idx(&func_ips, &[], &phase_b);
    assert_eq!(map.get(&33).copied(), Some(2));
}

#[test]
fn build_task_param_idx_skips_func_with_no_task_param() {
    // A function with no task_struct param produces no
    // entry — the stitch retain() falls back to `e.task_ptr ==
    // tptr` for those. Test the absence so a future change
    // that defaults to pidx=0 (silently mis-stitching by
    // arg[0]) is caught.
    let func_ips = vec![(99u32, 0xffff_ffff_8100_0000u64, "no_task".to_string())];
    let btf = vec![BtfFunc {
        name: "no_task".into(),
        params: vec![super::super::btf::BtfParam {
            name: "x".into(),
            struct_name: None,
            is_ptr: false,
            ..Default::default()
        }],
        ..Default::default()
    }];
    let map = build_task_param_idx(&func_ips, &btf, &[]);
    assert!(!map.contains_key(&99));
}

// -- set_probe_sched_exit_state Clean encode round-trip ------------

#[test]
fn set_probe_sched_exit_state_clean_round_trips() {
    // The Clean match arm in set_probe_sched_exit_state (encode
    // Clean -> PROBE_EXIT_STATE_CLEAN=1) is the only branch the
    // existing tests never exercise: production probe-poll code and
    // the scenario/ops tests drive only Crashed/Unknown. This pins
    // the encode side against the decode side in sched_exit_kind
    // (PROBE_EXIT_STATE_CLEAN=1 -> Clean), proving the constant
    // round-trips. PROBE_SCHED_EXIT_STATE is a process-global
    // AtomicU32; no real probe-poll thread runs in host-unit tests,
    // so this set/read pair is uncontended. Restore the mirror to
    // Unknown afterward so neighbor tests start clean, per the doc
    // on set_probe_sched_exit_state.
    super::set_probe_sched_exit_state(SchedExitKind::Clean);
    assert_eq!(
        super::sched_exit_kind(),
        SchedExitKind::Clean,
        "encode(Clean) must decode back to Clean (PROBE_EXIT_STATE_CLEAN=1 round-trip)",
    );
    super::set_probe_sched_exit_state(SchedExitKind::Unknown);
    assert_eq!(
        super::sched_exit_kind(),
        SchedExitKind::Unknown,
        "reset to Unknown must clear the mirror for neighbor tests",
    );
}

// -- build_field_keys 16-field cap (MAX_FIELDS) --------------------

#[test]
fn build_field_keys_caps_known_struct_at_sixteen() {
    // task_struct contributes 12 STRUCT_FIELDS keys per param. Two
    // task_struct params would naively yield 24 keys; the field_idx
    // >= 16 break inside the known-struct branch truncates the
    // second param after exactly 4 keys (field_idx 12..15). Pins
    // that cap so a regression loosening the guard (or dropping it)
    // is caught.
    let func = super::BtfFunc {
        name: "two_task_params".into(),
        params: vec![
            super::super::btf::BtfParam {
                name: "p1".into(),
                struct_name: Some("task_struct".into()),
                is_ptr: true,
                ..Default::default()
            },
            super::super::btf::BtfParam {
                name: "p2".into(),
                struct_name: Some("task_struct".into()),
                is_ptr: true,
                ..Default::default()
            },
        ],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert_eq!(
        keys.len(),
        16,
        "cap truncates at 16, not the naive 12+12=24"
    );
    assert_eq!(
        keys.iter().filter(|(k, _)| k.starts_with("p2:")).count(),
        4,
        "second task_struct param contributes exactly 4 keys before the cap",
    );
}

#[test]
fn build_field_keys_caps_auto_fields_at_sixteen() {
    // The auto_fields branch has its own field_idx >= 16 break
    // (distinct from the known-struct break). A single param with
    // 17 auto_fields must yield 16 keys, dropping the 17th ("f16").
    let func = super::BtfFunc {
        name: "wide_auto".into(),
        params: vec![super::super::btf::BtfParam {
            name: "ctx".into(),
            struct_name: None,
            is_ptr: true,
            type_name: Some("task_ctx".into()),
            auto_fields: (0..17)
                .map(|i| (format!("f{i}"), format!("->f{i}"), RenderHint::Hex))
                .collect(),
            ..Default::default()
        }],
        ..Default::default()
    };
    let keys = build_field_keys(&func);
    assert_eq!(keys.len(), 16, "17 auto_fields must be capped to 16 keys");
    assert!(
        !keys.iter().any(|(k, _)| k.contains(".f16")),
        "the 17th auto field (f16) must be dropped by the cap: {keys:?}",
    );
}

// -- set_rodata_slot out-of-range no-op + positive write ----------

#[test]
fn set_rodata_slot_ignores_slot_past_cap() {
    // Lines 858-874 (slot 0..=3) are covered by VM-running attach
    // tests; only the `_ => {}` no-op for slot > 3 is uncovered.
    // The doc states "No-op for slot indices outside 0..=3" but
    // nothing exercises that contract. Construct a host-side rodata
    // (POD, #[repr(C)], no Default derive — built via zeroed()),
    // write slot 4, and assert EVERY mutable field is unchanged.
    let mut r: crate::bpf_skel::fentry::types::rodata = unsafe { std::mem::zeroed() };
    set_rodata_slot(&mut r, 4, 0xABCD, true);
    assert_eq!(r.ktstr_fentry_func_idx_0, 0, "slot>3 must not touch idx_0");
    assert_eq!(r.ktstr_fentry_func_idx_1, 0, "slot>3 must not touch idx_1");
    assert_eq!(r.ktstr_fentry_func_idx_2, 0, "slot>3 must not touch idx_2");
    assert_eq!(r.ktstr_fentry_func_idx_3, 0, "slot>3 must not touch idx_3");
    assert_eq!(
        r.ktstr_fentry_is_kernel_0, 0,
        "slot>3 must not touch is_kernel_0"
    );
    assert_eq!(
        r.ktstr_fentry_is_kernel_1, 0,
        "slot>3 must not touch is_kernel_1"
    );
    assert_eq!(
        r.ktstr_fentry_is_kernel_2, 0,
        "slot>3 must not touch is_kernel_2"
    );
    assert_eq!(
        r.ktstr_fentry_is_kernel_3, 0,
        "slot>3 must not touch is_kernel_3"
    );
}

#[test]
fn set_rodata_slot_writes_only_target_slot() {
    // Positive partner to the no-op test: slot 2 write lands in
    // exactly slot 2's fields (idx and is_kernel) and leaves the
    // other three slots untouched. is_kernel=true must encode as 1
    // (the `as u8` cast), not a generic nonzero.
    let mut r: crate::bpf_skel::fentry::types::rodata = unsafe { std::mem::zeroed() };
    set_rodata_slot(&mut r, 2, 0xABCD, true);
    assert_eq!(
        r.ktstr_fentry_func_idx_2, 0xABCD,
        "slot 2 idx must be written"
    );
    assert_eq!(
        r.ktstr_fentry_is_kernel_2, 1u8,
        "is_kernel=true encodes as 1u8"
    );
    assert_eq!(r.ktstr_fentry_func_idx_0, 0, "slot 0 untouched");
    assert_eq!(r.ktstr_fentry_func_idx_1, 0, "slot 1 untouched");
    assert_eq!(r.ktstr_fentry_func_idx_3, 0, "slot 3 untouched");
    assert_eq!(r.ktstr_fentry_is_kernel_0, 0, "is_kernel_0 untouched");
    assert_eq!(r.ktstr_fentry_is_kernel_1, 0, "is_kernel_1 untouched");
    assert_eq!(r.ktstr_fentry_is_kernel_3, 0, "is_kernel_3 untouched");
}