ktstr 0.5.2

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
// Generates vmlinux.h from kernel BTF using libbpf's btf_dump API.
// Uses the shared kernel resolver (src/kernel_path.rs) to find the
// BTF source. See resolve_btf() for the full search order.

use std::env;
use std::path::PathBuf;
use std::process::{Command, Stdio};

use libbpf_cargo::SkeletonBuilder;

include!("src/kernel_path.rs");

fn main() {
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    // Cache invalidation: track the env var that selects a kernel
    // and the build-script inputs (kernel_path resolver, C generator
    // source). Deliberately NOT emitting a `rerun-if-changed` on the
    // BTF source path itself:
    //
    //   1. `vmlinux` is consumed here only as the BTF source for
    //      `vmlinux.h` generation on the C side below, not as an
    //      input that the Rust compiler reads. BPF CO-RE (Compile
    //      Once Run Everywhere) relocates field offsets at LOAD
    //      time against the runtime kernel's BTF, so a field-layout
    //      drift between the compile-time `vmlinux.h` and the
    //      runtime kernel is resolved by libbpf on BPF object load
    //      — there is no compile-time correctness dependency on
    //      the exact byte content of the vmlinux used to generate
    //      `vmlinux.h`.
    //   2. `rerun-if-changed` on the BTF would force build.rs to
    //      re-run on every kernel rebuild. That runs the BPF
    //      skeleton generator unnecessarily when the drift (per
    //      (1)) has no compile-time correctness impact.
    //
    // However, WHEN build.rs does run (triggered by a watched
    // input — KTSTR_KERNEL change, kernel_path.rs edit, or a
    // previously-absent `vmlinux.h`), it SHOULD detect a BTF
    // content change and regenerate. The pre-hash design only
    // regenerated when `vmlinux.h` was absent entirely, which
    // meant a BTF-content change paired with an unrelated build-
    // script trigger would leave stale `vmlinux.h` in place. A
    // SipHasher13 hash of the BTF bytes is written alongside
    // `vmlinux.h` as `vmlinux.btf.hash`; regen fires when the
    // file is absent OR the stored hash differs from the current
    // BTF's hash. Operators who need to force regen unconditionally
    // still have `cargo clean` as the escape hatch. The algorithm
    // mirrors `src/test_support/sidecar.rs::sidecar_variant_hash`
    // so the project uses a single stable hash family.
    println!("cargo:rerun-if-env-changed=KTSTR_KERNEL");
    println!("cargo:rerun-if-changed=src/kernel_path.rs");
    println!("cargo:rerun-if-changed=src/bpf/vmlinux_gen.c");
    let ktstr_kernel = env::var("KTSTR_KERNEL").ok();

    // Generate vmlinux.h from kernel BTF.
    let vmlinux_h = out_dir.join("vmlinux.h");
    let hash_path = out_dir.join("vmlinux.btf.hash");
    // Resolve BTF + compute content hash eagerly. `resolve_btf`
    // returns `Option` to degrade cleanly when no BTF is reachable
    // (no KTSTR_KERNEL + no host BTF): if `vmlinux.h` is already in
    // place from an earlier build, we keep it rather than panicking
    // — matches the CO-RE design (runtime BTF fixes field drift
    // anyway), so a disappearing source is not a build-blocking
    // event. A MISSING `vmlinux.h` still panics below because we
    // have nothing to fall back on.
    let current_btf = resolve_btf(ktstr_kernel.as_deref());
    // Hash the BTF source for drift detection. Fault-tolerant: a
    // BTF path that resolved but whose bytes cannot be read (EACCES,
    // or a race where the file vanished between resolve and read)
    // downgrades to `None` instead of panicking, so we fall back to
    // the existence-only gate for `vmlinux.h`. The eventual regen
    // path below re-reads the bytes via `vmlinux_gen` and fails
    // loudly there if the source is truly unusable.
    let current_hash: Option<String> = current_btf.as_ref().and_then(|p| match std::fs::read(p) {
        Ok(bytes) => Some(format!("{:016x}", siphash_13(&bytes))),
        Err(e) => {
            println!(
                "cargo:warning=BTF source {} present but unreadable \
                     ({e}); skipping hash check, reusing existing vmlinux.h",
                p.display(),
            );
            None
        }
    });
    let stored_hash: Option<String> = std::fs::read_to_string(&hash_path)
        .ok()
        .map(|s| s.trim().to_string());
    // Regen fires on any of three conditions:
    //   - `vmlinux.h` is absent (first build or post-`cargo clean`);
    //   - the stored hash is absent but we have a current hash (the
    //     vmlinux.h was generated by an older build.rs that didn't
    //     track hashes — upgrade in place);
    //   - current and stored hashes differ (real drift).
    // An unreadable BTF with vmlinux.h already in place falls
    // through to "no regen" per `current_hash.is_none()`.
    let should_regen =
        !vmlinux_h.exists() || (current_hash.is_some() && current_hash != stored_hash);
    if should_regen {
        let btf_source = current_btf.unwrap_or_else(|| {
            panic!(
                "no BTF source found. Set KTSTR_KERNEL to a kernel build \
                 directory, or ensure /sys/kernel/btf/vmlinux exists."
            );
        });
        println!("generating vmlinux.h from {}", btf_source.display());

        // libbpf-sys (links = "bpf") emits installed headers at
        // DEP_BPF_INCLUDE with bpf/ prefix (bpf/btf.h, bpf/libbpf.h).
        let libbpf_include =
            PathBuf::from(env::var("DEP_BPF_INCLUDE").expect("DEP_BPF_INCLUDE not set"));

        // Compile the C vmlinux generator + driver into a standalone binary.
        let vmlinux_gen_bin = out_dir.join("vmlinux_gen");
        let driver_src = out_dir.join("vmlinux_gen_main.c");
        std::fs::write(
            &driver_src,
            format!(
                r#"
extern int generate_vmlinux_h(const char *, const char *);
int main(void) {{
    return generate_vmlinux_h("{btf}", "{out}") == 0 ? 0 : 1;
}}
"#,
                btf = btf_source.display(),
                out = vmlinux_h.display(),
            ),
        )
        .expect("write driver source");

        // libbpf-sys with vendored feature installs static libraries
        // (libbpf.a, libelf.a, libz.a) in the parent of DEP_BPF_INCLUDE.
        let libbpf_lib_dir = libbpf_include.parent().unwrap();

        let compiler = cc::Build::new().get_compiler();
        let status = Command::new(compiler.path())
            .args([
                "src/bpf/vmlinux_gen.c",
                driver_src.to_str().unwrap(),
                "-o",
                vmlinux_gen_bin.to_str().unwrap(),
                &format!("-I{}", libbpf_include.display()),
                &format!("-L{}", libbpf_lib_dir.display()),
                "-lbpf",
                "-lelf",
                "-lz",
            ])
            .status()
            .expect("compile vmlinux_gen");
        assert!(status.success(), "failed to compile vmlinux_gen");

        let status = Command::new(&vmlinux_gen_bin)
            .status()
            .expect("run vmlinux_gen");
        assert!(
            status.success(),
            "vmlinux_gen failed — check BTF source: {}",
            btf_source.display()
        );

        // Record the BTF content hash alongside `vmlinux.h`. A
        // future build.rs invocation reads this file and compares
        // against the freshly-hashed BTF; a mismatch triggers
        // regeneration above.
        //
        // Normally `current_hash` was populated at the top of
        // `main`. The one path that leaves it `None` while still
        // reaching this regen branch is: `!vmlinux_h.exists()` AND
        // `std::fs::read(&btf_source)` failed during the eager hash
        // attempt. In that case, the generator above successfully
        // invoked `vmlinux_gen` against `btf_source`, which means
        // libbpf could read it — the earlier read failure was
        // transient or the generator accessed the file via a path
        // libbpf handles differently (e.g. sysfs BTF). Re-read and
        // hash here so the sidecar is always populated alongside a
        // successful regen; on a second-read failure, skip the
        // sidecar (the generator already succeeded — the build is
        // in a good state; a missing sidecar forces the next
        // build.rs run to regenerate conservatively, which is
        // correct).
        let hash_opt: Option<String> = match current_hash.as_deref() {
            Some(h) => Some(h.to_string()),
            None => match std::fs::read(&btf_source) {
                Ok(bytes) => Some(format!("{:016x}", siphash_13(&bytes))),
                Err(e) => {
                    println!(
                        "cargo:warning=post-regen BTF re-read failed ({e}); \
                         skipping hash sidecar — next build.rs run will \
                         regenerate conservatively"
                    );
                    None
                }
            },
        };
        if let Some(hash) = hash_opt {
            // Trailing newline so `cat` / editor-open produces a
            // clean single-line display. The reader at the top of
            // main uses `.trim()` on the stored value, so the
            // newline round-trips.
            std::fs::write(&hash_path, format!("{hash}\n"))
                .unwrap_or_else(|e| panic!("write BTF hash sidecar {}: {e}", hash_path.display()));
        }
    }

    // arm64 bpf_tracing.h casts pt_regs through struct user_pt_regs,
    // a UAPI type that kernel BTF may omit. Append it if absent so
    // PT_REGS_PARMn_CORE compiles on arm64 hosts.
    if cfg!(target_arch = "aarch64") {
        let content = std::fs::read_to_string(&vmlinux_h).expect("read vmlinux.h");
        if !content.contains("struct user_pt_regs {") {
            use std::io::Write;
            let mut f = std::fs::OpenOptions::new()
                .append(true)
                .open(&vmlinux_h)
                .expect("open vmlinux.h for append");
            writeln!(
                f,
                "\n/* Added by build.rs: arm64 UAPI type needed by bpf_tracing.h */\n\
                 struct user_pt_regs {{\n\
                 \t__u64 regs[31];\n\
                 \t__u64 sp;\n\
                 \t__u64 pc;\n\
                 \t__u64 pstate;\n\
                 }};\n"
            )
            .expect("append user_pt_regs to vmlinux.h");
        }
    }

    let clang_args = [
        format!("-I{}", out_dir.display()),
        format!("-I{}", "src/bpf"),
    ];

    // Build the kprobe BPF skeleton.
    let skel_path = out_dir.join("probe_skel.rs");
    SkeletonBuilder::new()
        .source("src/bpf/probe.bpf.c")
        .obj(out_dir.join("probe.o"))
        .clang_args(clang_args.clone())
        .reference_obj(true)
        .build_and_generate(&skel_path)
        .expect("build probe BPF skeleton");

    // Build the fentry BPF skeleton (separate for independent loading).
    let fentry_skel_path = out_dir.join("fentry_probe_skel.rs");
    SkeletonBuilder::new()
        .source("src/bpf/fentry_probe.bpf.c")
        .obj(out_dir.join("fentry_probe.o"))
        .clang_args(clang_args)
        .reference_obj(true)
        .build_and_generate(&fentry_skel_path)
        .expect("build fentry probe BPF skeleton");

    println!("cargo::rerun-if-changed=src/bpf/probe.bpf.c");
    println!("cargo::rerun-if-changed=src/bpf/fentry_probe.bpf.c");
    println!("cargo::rerun-if-changed=src/bpf/intf.h");

    // Generate ALL_SHIFTS registry from src/budget.rs so the
    // budget-feature tests can assert exhaustive classification
    // coverage. Scans `const NAME_SHIFT: u32 = N;` declarations and
    // emits a `pub(crate) const ALL_SHIFTS: &[(u32, &str)]` slice
    // into OUT_DIR. The test in budget.rs takes the union of its
    // one-bit and multi-bit shift enumerations and asserts equality
    // with this slice — a new SHIFT constant added without updating
    // either enumeration fails the union check.
    generate_shift_registry(&out_dir);

    // Build busybox from source for guest shell mode.
    // Cache: skip if $OUT_DIR/busybox exists. After build.rs config
    // changes, run `cargo clean` to force a rebuild.
    let busybox_bin = out_dir.join("busybox");
    if !busybox_bin.exists() {
        println!("cargo:warning=compiling busybox (first build only)...");

        // Check required tools before attempting build.
        if Command::new("make").arg("--version").output().is_err() {
            panic!(
                "busybox build requires 'make' — install build-essential \
                 (Debian/Ubuntu) or base-devel (Fedora/Arch)"
            );
        }
        if Command::new("gcc").arg("--version").output().is_err() {
            panic!(
                "busybox build requires 'gcc' — install build-essential \
                 (Debian/Ubuntu) or base-devel (Fedora/Arch)"
            );
        }

        let busybox_src = out_dir.join("busybox-src");

        // Recover from interrupted download: if the directory exists but
        // has no Makefile, the previous extraction was incomplete.
        if busybox_src.exists() && !busybox_src.join("Makefile").exists() {
            std::fs::remove_dir_all(&busybox_src).expect("remove incomplete busybox-src");
        }

        // Download busybox source: try tarball first, fall back to git clone.
        // Warning before network access so a hang is diagnosable.
        if !busybox_src.join("Makefile").exists() {
            let tarball_url = "https://github.com/mirror/busybox/archive/refs/tags/1_36_1.tar.gz";
            // Authenticated GitHub requests get 1000/hr per token vs the
            // 60/hr IP-based unauth limit. GitHub Actions auto-issues
            // GITHUB_TOKEN per job; outside CI the env var is typically
            // absent and the request goes unauth, which still works for
            // public repos at low rate.
            let github_token = std::env::var("GITHUB_TOKEN").ok();
            let attempt = |attempt_idx: u32| -> Result<(), String> {
                let extract_dir = out_dir.join("busybox-extract");
                if extract_dir.exists() {
                    let _ = std::fs::remove_dir_all(&extract_dir);
                }
                // `timeout()` bounds the whole request including the body
                // when read via `.bytes()` (which uses `wait::timeout`
                // internally per `reqwest::blocking::Response::bytes`),
                // but does NOT apply when reading the response via the
                // `Read` trait -- streaming bypasses reqwest's timeout
                // machinery so a slow-drip server can hang the build
                // indefinitely. Buffer the body so the timeout actually
                // fires.
                let client = reqwest::blocking::Client::builder()
                    .timeout(std::time::Duration::from_secs(120))
                    .connect_timeout(std::time::Duration::from_secs(30))
                    .user_agent(concat!("ktstr-build/", env!("CARGO_PKG_VERSION")))
                    .build()
                    .map_err(|e| format!("http client: {e}"))?;
                let mut req = client.get(tarball_url);
                if let Some(ref token) = github_token {
                    req = req.bearer_auth(token);
                }
                let resp = req
                    .send()
                    .and_then(|r| r.error_for_status())
                    .map_err(|e| format!("attempt {attempt_idx} request: {e}"))?;
                let body = resp
                    .bytes()
                    .map_err(|e| format!("attempt {attempt_idx} body: {e}"))?;
                let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(body));
                let mut archive = tar::Archive::new(gz);
                archive
                    .unpack(&extract_dir)
                    .map_err(|e| format!("extract: {e}"))?;
                let inner = extract_dir.join("busybox-1_36_1");
                std::fs::rename(&inner, &busybox_src).map_err(|e| {
                    format!(
                        "expected extracted directory {} — tarball layout may have changed: {e}",
                        inner.display()
                    )
                })?;
                std::fs::remove_dir_all(&extract_dir).ok();
                Ok(())
            };

            const MAX_TARBALL_ATTEMPTS: u32 = 4;
            let mut tarball_err: Option<String> = None;
            for i in 1..=MAX_TARBALL_ATTEMPTS {
                println!(
                    "cargo:warning=downloading busybox source tarball (attempt {i}/{MAX_TARBALL_ATTEMPTS}) from {tarball_url}"
                );
                match attempt(i) {
                    Ok(()) => {
                        tarball_err = None;
                        break;
                    }
                    Err(e) => {
                        println!("cargo:warning=busybox tarball attempt {i} failed: {e}");
                        tarball_err = Some(e);
                        if i < MAX_TARBALL_ATTEMPTS {
                            // Exponential backoff: 2s, 4s, 8s before the next try.
                            let backoff = 1u64 << i;
                            std::thread::sleep(std::time::Duration::from_secs(backoff));
                        }
                    }
                }
            }

            // Fall back to shallow git clone if tarball failed.
            if !busybox_src.join("Makefile").exists() {
                let tarball_err = tarball_err.unwrap_or_else(|| "unknown".to_string());
                let git_url = "https://github.com/mirror/busybox.git";
                println!(
                    "cargo:warning=busybox tarball failed ({tarball_err}), \
                     cloning {git_url} (requires network)"
                );

                // Clean up any partial state from failed tarball extraction.
                if busybox_src.exists() {
                    std::fs::remove_dir_all(&busybox_src).expect("remove partial busybox-src");
                }
                let extract_dir = out_dir.join("busybox-extract");
                if extract_dir.exists() {
                    std::fs::remove_dir_all(&extract_dir).ok();
                }

                let interrupt = std::sync::atomic::AtomicBool::new(false);
                let clone_err = (|| -> Result<(), Box<dyn std::error::Error>> {
                    let mut prep = gix::prepare_clone(git_url, &busybox_src)?
                        .with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(
                            1.try_into().expect("non-zero"),
                        ))
                        .with_ref_name(Some("1_36_1"))?;
                    let (mut checkout, _) =
                        prep.fetch_then_checkout(gix::progress::Discard, &interrupt)?;
                    let (_repo, _) = checkout.main_worktree(gix::progress::Discard, &interrupt)?;
                    println!("cargo:warning=busybox source cloned via git");
                    Ok(())
                })()
                .err();

                if !busybox_src.join("Makefile").exists() {
                    let clone_err = clone_err
                        .map(|e| e.to_string())
                        .unwrap_or_else(|| "checkout missing Makefile".to_string());
                    panic!(
                        "failed to obtain busybox source.\n\
                         tarball ({tarball_url}): {tarball_err}\n\
                         git clone ({git_url}): {clone_err}\n\
                         Check network connectivity. First build requires internet access."
                    );
                }
            }
        }

        // Configure busybox.
        let status = Command::new("make")
            .arg("defconfig")
            .current_dir(&busybox_src)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .expect("make defconfig");
        assert!(status.success(), "busybox make defconfig failed");

        // Enable static linking, disable CONFIG_TC (requires iproute2 headers).
        let config_path = busybox_src.join(".config");
        let config = std::fs::read_to_string(&config_path).expect("read busybox .config");
        let config = config
            .replace("# CONFIG_STATIC is not set", "CONFIG_STATIC=y")
            .replace("CONFIG_TC=y", "# CONFIG_TC is not set");
        std::fs::write(&config_path, config).expect("write patched busybox .config");

        // Resolve patched config non-interactively. Busybox's Kbuild
        // lacks olddefconfig; pipe empty input to oldconfig so every
        // NEW prompt accepts its default without blocking on stdin.
        let status = Command::new("make")
            .arg("oldconfig")
            .current_dir(&busybox_src)
            .stdin(Stdio::null())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .expect("make oldconfig");
        assert!(status.success(), "busybox make oldconfig failed");

        // Build busybox.
        let nproc = std::thread::available_parallelism()
            .map(|n| n.get().to_string())
            .unwrap_or_else(|_| "1".to_string());
        let status = Command::new("make")
            .arg(format!("-j{nproc}"))
            .current_dir(&busybox_src)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .expect("busybox make");
        assert!(status.success(), "busybox build failed");

        // Copy binary to OUT_DIR.
        std::fs::copy(busybox_src.join("busybox"), &busybox_bin)
            .expect("copy busybox binary to OUT_DIR");
    }
}

/// Scan src/budget.rs for `const NAME_SHIFT: u32 = N;` declarations
/// and emit a `pub(crate) const ALL_SHIFTS: &[(u32, &str)]` slice
/// into `OUT_DIR/shift_registry.rs`. The slice is sorted by value
/// for stable test output.
///
/// Pattern: line.trim() starts with `const `, contains `: u32 = `
/// literal, name part ends with `_SHIFT`, value part parses as u32
/// (trailing `;` stripped). All four conditions must hold; a line
/// failing any one is skipped.
///
/// This is a deliberate text-scan, not a full Rust parser. Trade-offs:
/// - Full-line comments (`//`, `/* */`, `///`) start with `/`, not
///   `const` — never false-positive. Inline trailing comments on a
///   const line (e.g. `const X_SHIFT: u32 = 5; // foo`) leave the
///   comment text past the `;`; `trim_end_matches(';')` strips only
///   the trailing `;` so the parse-as-u32 step panics fail-loud
///   rather than silently dropping the entry.
/// - String literals containing `SHIFT:` live inside non-const lines
///   — never false-positive. EXCEPTION: a raw multi-line string
///   literal `r#"\nconst FOO_SHIFT: u32 = 4;\n"#` containing a
///   const-shaped line would false-positive (line.trim() yields the
///   raw const text). Low probability — budget.rs holds no such
///   literals today — and surfaces loudly: the false-positive grows
///   the registry by an entry no hand-classified enumeration
///   references, so the test's `unclassified` arm fires (asserts
///   `ALL_SHIFTS.filter(!classified.contains(v))` is empty), NOT a
///   silent drop.
/// - Macro-generated constants emit no source text — invisible to the
///   scan (false negative; documented by naming convention).
/// - `static FOO_SHIFT` and lowercase-named constants — both invisible
///   (false negative; violates Rust convention anyway).
/// - Const expressions whose RHS is non-integer (e.g.
///   `const X_SHIFT: u32 = OTHER + 1;`) — fail-loud panic, not silent
///   drop.
/// - The `: u32 = ` split anchor is rustfmt-canonical (single space
///   each side). A future rustfmt change to multi-space or no-space
///   formatting would cause the scan to miss every existing SHIFT
///   const. The test fails loudly on the first build after such a
///   change: registry shrinks, so each hand-classified SHIFT value
///   appears in `phantom_one_bit` (one_bit_values.difference(&registry))
///   or `phantom_multi_bit` (multi_bit_values.difference(&registry)),
///   tripping the phantom assertion. The regression surfaces
///   immediately, not on the next addition.
///
/// The hand-classified test enumerations in `src/budget.rs::tests`
/// are the consumer; the `all_shifts_classified_in_exactly_one_enumeration`
/// test asserts the union of the two hand-spelled lists equals this
/// generated set.
fn generate_shift_registry(out_dir: &std::path::Path) {
    use std::fmt::Write;
    println!("cargo::rerun-if-changed=src/budget.rs");
    let budget_rs = std::fs::read_to_string("src/budget.rs")
        .expect("read src/budget.rs for shift-registry scan");
    let mut shifts: Vec<(u32, String)> = Vec::new();
    for line in budget_rs.lines() {
        let line = line.trim();
        let Some(rest) = line.strip_prefix("const ") else {
            continue;
        };
        let Some((name_part, val_part)) = rest.split_once(": u32 = ") else {
            continue;
        };
        let name = name_part.trim();
        if !name.ends_with("_SHIFT") {
            continue;
        }
        let val_str = val_part.trim_end_matches(';').trim();
        let val: u32 = val_str.parse().unwrap_or_else(|e| {
            panic!("shift-registry scan: parse `{val_str}` as u32 for {name}: {e}")
        });
        shifts.push((val, name.to_string()));
    }
    shifts.sort_by_key(|(v, _)| *v);

    let mut out = String::from(
        "// Generated by build.rs. Lists every `const *_SHIFT: u32 = N;`\n\
         // declaration in src/budget.rs, sorted by shift value. The\n\
         // budget tests assert their hand-classified one-bit and\n\
         // multi-bit enumerations cover every entry so a new SHIFT\n\
         // cannot land without being classified into the right test.\n\
         pub(crate) const ALL_SHIFTS: &[(u32, &str)] = &[\n",
    );
    for (v, name) in &shifts {
        writeln!(out, "    ({v}, \"{name}\"),").expect("write shift entry");
    }
    out.push_str("];\n");
    std::fs::write(out_dir.join("shift_registry.rs"), out).expect("write shift_registry.rs");
}

/// 64-bit SipHash-1-3 of `bytes`. Used to detect BTF content drift
/// between `vmlinux.h` regenerations.
///
/// Algorithm mirrors `src/test_support/sidecar.rs::sidecar_variant_hash`
/// — `SipHasher13::new_with_keys(0, 0)` + `h.write(bytes)` +
/// `h.finish()`. Zero keys are deliberate: this is a drift hash, not
/// a DoS-mitigation hash, and stable (key-less) output lets a future
/// build.rs invocation compare against a sidecar written by a prior
/// run without coordinating on a key. SipHasher13 is faster than
/// SipHasher24 at the cost of reduced crypto strength — acceptable
/// because the hash is a build-artifact sidecar, not a signed
/// manifest.
fn siphash_13(bytes: &[u8]) -> u64 {
    use siphasher::sip::SipHasher13;
    use std::hash::Hasher;
    let mut h = SipHasher13::new_with_keys(0, 0);
    h.write(bytes);
    h.finish()
}