ktstr/cli/kernel_build/
kconfig.rs

1//! kconfig fragment merging, validation, and post-build probing.
2//!
3//! Holds the helpers that compose, parse, and validate the kernel
4//! configuration:
5//! - [`configure_kernel`] runs `make defconfig` (when no `.config`
6//!   exists), checks the merged fragment is present, appends and
7//!   re-runs `olddefconfig` only when needed.
8//! - [`all_fragment_lines_present`] / [`is_kconfig_semantic_line`]
9//!   gate the no-op short-circuit so a clean `.config` doesn't
10//!   churn the configure pass.
11//! - [`read_extra_kconfig`] parses the user's `--extra-kconfig PATH`
12//!   into the fragment string with actionable error wording per
13//!   I/O failure mode.
14//! - [`append_extra_kconfig_suffix`] composes the cache-key suffix
15//!   so the extras-aware build lands at a distinct cache slot.
16//! - [`warn_extra_kconfig_overrides_baked_in`] /
17//!   [`warn_dropped_extra_kconfig_lines`] surface user/baked-in
18//!   conflicts and silent olddefconfig drops respectively.
19//! - [`parse_kconfig_symbol`] / [`render_kconfig_value`] are the
20//!   shared primitives behind those warning passes.
21//! - [`validate_kernel_config`] is the post-build critical-option
22//!   pass.
23
24use std::path::Path;
25
26use anyhow::{Context, Result, bail};
27
28use super::super::kernel_cmd::EMBEDDED_KCONFIG;
29use super::make::{MAKE_POLL_INTERVAL, MAKE_TIMEOUT, run_make_captured};
30
31/// Ensure the kconfig fragment is applied to the kernel's .config.
32///
33/// Creates a default .config via `make defconfig` if none exists.
34/// Pure check used by [`configure_kernel`]: every non-empty line of
35/// `fragment` (including disable directives like
36/// `# CONFIG_X is not set`) must appear as an exact line of `config`.
37///
38/// Exact-line matching avoids the prefix-aliasing hazard of the prior
39/// `config.contains(fragment_line)` formulation, where a fragment line
40/// false-matches when it appears as a substring of an unrelated
41/// `.config` line — e.g. fragment `CONFIG_NR_CPUS=1` appearing inside
42/// `CONFIG_NR_CPUS=128`, or any numeric-tail option where the
43/// requested value is a prefix of the existing value.
44///
45/// `# CONFIG_X is not set` comments ARE kconfig semantics (the
46/// canonical way to disable an option), so they participate in the
47/// check.
48///
49/// Free-text comments (`#` lines that are NOT the
50/// `# CONFIG_X is not set` form, e.g. `# Build for testing scx`)
51/// are dropped before the probe: kbuild emits its own header into
52/// `.config` and strips user-added free-text comments, so a fragment
53/// containing decorative `#` lines would always fail this check and
54/// trigger a redundant `make olddefconfig` re-resolve on every
55/// configure pass.
56///
57/// Genuinely empty lines are also skipped.
58pub(super) fn all_fragment_lines_present(fragment: &str, config: &str) -> bool {
59    let existing: std::collections::HashSet<&str> = config.lines().map(str::trim).collect();
60    fragment
61        .lines()
62        .map(str::trim)
63        .filter(|t| is_kconfig_semantic_line(t))
64        .all(|t| existing.contains(t))
65}
66
67/// True for lines that participate in kconfig semantics — i.e.
68/// `CONFIG_X=...` assignments and the `# CONFIG_X is not set`
69/// disable-directive form. Empty lines and free-text `#` comments
70/// return false.
71///
72/// Drives [`all_fragment_lines_present`]'s filter so a fragment with
73/// decorative comments doesn't churn the configure pass on every
74/// rebuild. The disable-directive form is a kconfig sentinel kbuild
75/// emits into `.config` as the canonical way to record a disabled
76/// `tristate`/`bool` symbol; it survives `make olddefconfig` and
77/// must participate in the present-in-config check.
78pub(super) fn is_kconfig_semantic_line(trimmed: &str) -> bool {
79    if trimmed.is_empty() {
80        return false;
81    }
82    if let Some(rest) = trimmed.strip_prefix('#') {
83        // `# CONFIG_X is not set` — the disable-directive sentinel
84        // kbuild writes verbatim into .config. Tolerant of internal
85        // whitespace variation by trimming the rest.
86        let rest = rest.trim_start();
87        return rest.starts_with("CONFIG_") && rest.ends_with(" is not set");
88    }
89    // Non-comment, non-empty line: a CONFIG_X=... assignment or
90    // similar. The probe defers semantic validity to the existing
91    // hash-set match against the on-disk `.config`.
92    true
93}
94
95/// Read a `--extra-kconfig PATH` file. Returns `Ok(content)` on
96/// success or `Err(message)` with an actionable diagnostic naming
97/// `--extra-kconfig` and the user's literal input path verbatim so
98/// a typo names the exact string they passed.
99///
100/// Four distinguishing arms each produce an actionable message:
101/// - `ENOENT` (not-found) → tells the operator to verify the path
102///   spelling and that the file exists
103/// - `EISDIR` (is-a-directory) → tells the operator to pass a regular
104///   file rather than a directory
105/// - `EACCES` (permission-denied) → tells the operator to check file
106///   ownership and mode
107/// - empty file (zero-byte read success) → emits a `tracing::warn!`
108///   explaining the cache-slot consequence and pointing at the likely
109///   operator intent (a non-empty fragment), then proceeds
110///
111/// Other I/O errors fall through with the OS error rendered verbatim
112/// (`--extra-kconfig {path}: {os error}`). A non-UTF-8 file errors
113/// with a message identifying the constraint (kconfig fragments are
114/// ASCII text).
115///
116/// Symlinks resolve transparently (`std::fs::read` opens through
117/// `open(2)` which follows symlinks per kernel default).
118pub fn read_extra_kconfig(path: &Path, cli_label: &str) -> std::result::Result<String, String> {
119    let display = path.display();
120    let bytes = match std::fs::read(path) {
121        Ok(b) => b,
122        Err(e) => {
123            let msg = match e.kind() {
124                std::io::ErrorKind::NotFound => {
125                    format!(
126                        "--extra-kconfig {display}: file not found; check the \
127                         path spelling and that the file exists"
128                    )
129                }
130                std::io::ErrorKind::IsADirectory => {
131                    format!(
132                        "--extra-kconfig {display}: is a directory; pass a \
133                         regular file containing kconfig fragment lines"
134                    )
135                }
136                std::io::ErrorKind::PermissionDenied => {
137                    format!(
138                        "--extra-kconfig {display}: permission denied; check \
139                         file ownership and mode (the kconfig fragment must \
140                         be readable by the current user)"
141                    )
142                }
143                _ => format!("--extra-kconfig {display}: {e}"),
144            };
145            return Err(msg);
146        }
147    };
148
149    // Empty file: warn but proceed. The build still lands at a
150    // distinct cache slot via the `-xkc{hash_of_empty}` segment, but
151    // no user symbols merge — the operator likely meant to populate
152    // the file with `CONFIG_X=...` lines.
153    if bytes.is_empty() {
154        let path_str = display.to_string();
155        tracing::warn!(
156            cli_label = cli_label,
157            path = %path_str,
158            "--extra-kconfig file is empty; the build will land at a \
159             distinct cache slot but no user symbols will merge into the \
160             configuration. Did you mean to populate {path_str} with \
161             CONFIG_X=... lines?",
162        );
163    }
164
165    String::from_utf8(bytes).map_err(|_| {
166        format!(
167            "--extra-kconfig {display}: file is not valid UTF-8; kconfig \
168             fragments must be ASCII text"
169        )
170    })
171}
172
173/// Append the `-xkc{extra_hash}` segment to a cache key built around
174/// the bare baked-in suffix (`...-kc{baked_hash}`), bringing it to the
175/// two-segment shape produced by [`crate::cache_key_suffix_with_extra`].
176///
177/// `local_source` and `git_clone` populate `acquired.cache_key` against
178/// the bare [`crate::cache_key_suffix`] which carries only the baked-in
179/// hash. With `--extra-kconfig` set, the cache lookup and the
180/// post-build store must target the extras-aware slot — this helper
181/// performs the plain string append so both binaries share one merge
182/// path. Plain append (vs rewriting the suffix) preserves the upstream
183/// key prefix exactly and is robust to any future shape change in the
184/// head segments. No-op when `extra` is `None`.
185pub fn append_extra_kconfig_suffix(cache_key: &mut String, extra: Option<&str>) {
186    if let Some(content) = extra {
187        cache_key.push_str("-xkc");
188        cache_key.push_str(&crate::extra_kconfig_hash(content));
189    }
190}
191
192/// Pre-configure pass that warns when a user `--extra-kconfig` line
193/// overrides a baked-in symbol from `EMBEDDED_KCONFIG`. The build
194/// proceeds with the user value winning (per kbuild's last-wins
195/// rule and the design intent of `--extra-kconfig`); this helper
196/// just surfaces the override so the operator sees that their
197/// fragment is shadowing a baked-in setting.
198///
199/// Output shape:
200/// `--extra-kconfig overrides baked-in CONFIG_FOO (was =y, now =n)`
201/// where the "was"/"now" values are extracted from the matching
202/// lines on each side. `# CONFIG_X is not set` (kbuild's disable
203/// directive) is normalized to "is not set" in the rendered
204/// before/after for readability.
205///
206/// Free-text `#`-comments and blank lines in the user fragment are
207/// skipped — only `CONFIG_X=...` assignments and `# CONFIG_X is
208/// not set` directives count as overrides.
209pub(super) fn warn_extra_kconfig_overrides_baked_in(extra: &str, cli_label: &str) {
210    // Build a per-symbol map of the baked-in declarations once.
211    // `EMBEDDED_KCONFIG` is small (~370 lines per ktstr.kconfig)
212    // so a single pass is cheap.
213    let mut baked: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
214    for raw in EMBEDDED_KCONFIG.lines() {
215        let line = raw.trim();
216        if let Some(sym) = parse_kconfig_symbol(line) {
217            baked.insert(sym, line);
218        }
219    }
220
221    for raw in extra.lines() {
222        let line = raw.trim();
223        let Some(user_sym) = parse_kconfig_symbol(line) else {
224            continue;
225        };
226        let Some(baked_line) = baked.get(user_sym) else {
227            continue;
228        };
229        if *baked_line == line {
230            continue;
231        }
232        tracing::warn!(
233            cli_label = cli_label,
234            symbol = user_sym,
235            was = *baked_line,
236            now = line,
237            "--extra-kconfig overrides baked-in {user_sym} (was {}, now {})",
238            render_kconfig_value(baked_line, user_sym),
239            render_kconfig_value(line, user_sym),
240        );
241    }
242}
243
244/// Extract the symbol name from a kbuild `.config`-shaped line.
245///
246/// Returns `Some("CONFIG_FOO")` for `CONFIG_FOO=...` or
247/// `# CONFIG_FOO is not set`, `None` for anything else (free-text
248/// comments, blank lines, malformed input).
249pub(super) fn parse_kconfig_symbol(line: &str) -> Option<&str> {
250    if let Some(rest) = line.strip_prefix("# ")
251        && let Some(sym) = rest.strip_suffix(" is not set")
252        && sym.starts_with("CONFIG_")
253    {
254        return Some(sym);
255    }
256    if line.starts_with("CONFIG_")
257        && let Some((sym, _)) = line.split_once('=')
258    {
259        return Some(sym);
260    }
261    None
262}
263
264/// Render the value half of a `.config` line for the override
265/// warning's `(was =y, now =n)` formatting. For an assignment
266/// line `CONFIG_FOO=y`, returns `=y`. For a disable directive
267/// `# CONFIG_FOO is not set`, returns `is not set`. Falls back to
268/// the full line when the shape is unrecognized so the operator
269/// still gets information.
270pub(super) fn render_kconfig_value<'a>(line: &'a str, sym: &str) -> &'a str {
271    if let Some(value) = line.strip_prefix(sym)
272        && value.starts_with('=')
273    {
274        return value;
275    }
276    if line == format!("# {sym} is not set") {
277        return "is not set";
278    }
279    line
280}
281
282/// Post-`olddefconfig` validation pass for `--extra-kconfig` lines.
283///
284/// Scan the user's `extra` fragment line-by-line and verify each
285/// non-empty, non-comment line either appears verbatim in the final
286/// `.config` or matches the is-not-set sentinel for a
287/// missing-from-output symbol (which kbuild renders as
288/// `# CONFIG_X is not set` or simply omits). When a user line was
289/// silently dropped by olddefconfig (typically because of an unmet
290/// dependency), emit a `tracing::warn!` naming the requested setting
291/// and the actual `.config` state — operator gets the diagnostic
292/// without the build failing.
293///
294/// Best-effort: a missing or unreadable `.config` collapses to
295/// silent return, since the surrounding pipeline failure would be
296/// the actionable signal in those cases.
297///
298/// Lines starting with `#` that are NOT the kbuild
299/// `# CONFIG_X is not set` form are treated as free-text comments
300/// and skipped — they exist in user fragments but have no
301/// `.config` counterpart.
302pub(super) fn warn_dropped_extra_kconfig_lines(kernel_dir: &Path, extra: &str, cli_label: &str) {
303    let config_path = kernel_dir.join(".config");
304    let Ok(final_config) = std::fs::read_to_string(&config_path) else {
305        return;
306    };
307    let final_lines: std::collections::HashSet<&str> =
308        final_config.lines().map(str::trim).collect();
309
310    for raw_line in extra.lines() {
311        let line = raw_line.trim();
312        if line.is_empty() {
313            continue;
314        }
315        // Skip free-text comments — `# foo bar baz`. The kbuild
316        // disable form `# CONFIG_X is not set` is a special-case
317        // comment that participates in `.config` and IS checked.
318        let is_disable_directive = line.starts_with("# CONFIG_") && line.ends_with(" is not set");
319        let is_assignment = line.starts_with("CONFIG_") && line.contains('=');
320        if !is_disable_directive && !is_assignment {
321            continue;
322        }
323        if final_lines.contains(line) {
324            continue;
325        }
326        // Line missing — either dropped by olddefconfig or rewritten
327        // (e.g. `=y` → `is not set` because dep didn't resolve).
328        // Look up the symbol's actual final value to enrich the
329        // warning. Symbol name is everything up to `=` (assignment)
330        // or between `# ` and ` is not set` (disable directive).
331        let sym_name = if is_assignment {
332            line.split('=').next().unwrap_or(line)
333        } else {
334            line.trim_start_matches("# ")
335                .trim_end_matches(" is not set")
336        };
337        let final_state = final_config
338            .lines()
339            .find(|l| {
340                let t = l.trim();
341                t.starts_with(&format!("{sym_name}=")) || t == format!("# {sym_name} is not set")
342            })
343            .map(str::trim)
344            .unwrap_or(
345                "(absent — symbol not present in .config; likely \
346                        disabled or unrecognized by kconfig)",
347            );
348        tracing::warn!(
349            cli_label = cli_label,
350            requested = line,
351            final_state = final_state,
352            "--extra-kconfig line did not survive `make olddefconfig` (likely an \
353             unmet dependency or unrecognized symbol)"
354        );
355    }
356}
357
358/// Checks each non-empty line of the fragment against the current
359/// `.config` via `all_fragment_lines_present`. If every fragment
360/// line already appears in `.config`, the file is not touched
361/// (preserving mtime for make's dependency tracking). If any are
362/// missing, appends the full fragment and runs `make olddefconfig`
363/// to resolve new options with defaults — without this, the
364/// subsequent `make` launches interactive `conf` prompts that hang
365/// when stdout/stderr are piped.
366///
367/// The `defconfig` / `olddefconfig` invocations run through
368/// `run_make_captured` rather than [`super::make::run_make`]: this
369/// step runs under a live "Configuring kernel..." spinner, so their
370/// output is captured (and replayed only on failure) instead of
371/// raw-inherited, which would clobber the spinner. `progress` is
372/// `Some` under the spinner (failure replay lands above the bars) and
373/// `None` in tests.
374pub(crate) fn configure_kernel(
375    kernel_dir: &Path,
376    fragment: &str,
377    progress: Option<&crate::cli::FetchProgress>,
378) -> Result<()> {
379    let config_path = kernel_dir.join(".config");
380    if !config_path.exists() {
381        run_make_captured(
382            kernel_dir,
383            &["defconfig"],
384            progress,
385            MAKE_TIMEOUT,
386            MAKE_POLL_INTERVAL,
387        )?;
388    }
389
390    let config_content = std::fs::read_to_string(&config_path)?;
391    if all_fragment_lines_present(fragment, &config_content) {
392        return Ok(());
393    }
394
395    let mut config = std::fs::OpenOptions::new()
396        .append(true)
397        .open(&config_path)?;
398    std::io::Write::write_all(&mut config, fragment.as_bytes())?;
399
400    run_make_captured(
401        kernel_dir,
402        &["olddefconfig"],
403        progress,
404        MAKE_TIMEOUT,
405        MAKE_POLL_INTERVAL,
406    )?;
407
408    Ok(())
409}
410
411/// Critical `.config` options checked by [`validate_kernel_config`].
412///
413/// Each entry pairs a `CONFIG_X` name with a diagnostic hint —
414/// human-readable context on the dependency that typically causes the
415/// option to be silently dropped during `make`. The list is curated:
416/// every entry here is an option whose absence at the post-build
417/// check has historically surfaced as a specific tool-install or
418/// arch-default-override. The companion test
419/// `critical_options_are_in_embedded_kconfig` proves every name is
420/// present in [`EMBEDDED_KCONFIG`] as `=y`, so a kconfig edit that
421/// removes a critical entry fails the test immediately instead of
422/// surfacing later as a build that passes validation but behaves
423/// differently.
424const VALIDATE_CONFIG_CRITICAL: &[(&str, &str)] = &[
425    (
426        "CONFIG_SCHED_CLASS_EXT",
427        "depends on CONFIG_DEBUG_INFO_BTF — ensure pahole >= 1.16 is installed (dwarves package)",
428    ),
429    (
430        "CONFIG_DEBUG_INFO_BTF",
431        "requires pahole >= 1.16 (dwarves package)",
432    ),
433    ("CONFIG_BPF_SYSCALL", "required for BPF program loading"),
434    (
435        "CONFIG_FTRACE",
436        "gate for all tracing infrastructure — arm64 defconfig disables it, \
437         silently dropping KPROBE_EVENTS and BPF_EVENTS",
438    ),
439    (
440        "CONFIG_KPROBE_EVENTS",
441        "required for ktstr probe pipeline (depends on FTRACE + KPROBES)",
442    ),
443    (
444        "CONFIG_BPF_EVENTS",
445        "required for BPF kprobe/tracepoint attachment (depends on KPROBE_EVENTS + PERF_EVENTS)",
446    ),
447    (
448        "CONFIG_VIRTIO_BLK",
449        "required for ktstr DiskConfig — backs /dev/vd* in the guest. Depends on VIRTIO + BLOCK; \
450         a user --extra-kconfig that strips BLOCK would silently disable this and disk-IO WorkTypes \
451         would fail with a confusing 'no /dev/vda' inside the guest instead of a clear build error",
452    ),
453    (
454        "CONFIG_ACPI",
455        "the linchpin of the x86 PCI transport — the guest parses the MCFG (ECAM base), the \
456         _SB.PCI0 DSDT _PRT (PCI INTx -> GSI routing), and the FADT PM register blocks only when \
457         ACPI is enabled. x86_64 defconfig sets it, but an --extra-kconfig that strips it would \
458         drop PCI_MMCONFIG too (default-y only on PCI + ACPI) and PCI INTx would fall back to \
459         legacy MP-table routing with no NIC entry — the exact silent failure the FADT PM blocks \
460         + _PRT exist to fix. Validated here (not only via PCI_MMCONFIG) because it is the most \
461         catastrophic to lose",
462    ),
463    (
464        "CONFIG_PCI",
465        "required for the virtio-PCI transport — the host bridge at 00:00.0 and any PCI device \
466         (NICs) enumerate only with PCI compiled in. x86_64 defconfig sets it, but an \
467         --extra-kconfig that strips it would silently leave the guest with no /sys/bus/pci and \
468         every PCI device invisible instead of a clear build error",
469    ),
470    (
471        "CONFIG_VIRTIO_PCI",
472        "the guest virtio-PCI DRIVER that binds the virtio-net NIC (a PCI function). Without it \
473         the guest enumerates the PCI device but no driver attaches, so eth* never appears and \
474         every NIC WorkType/e2e fails with a confusing 'no eth0' inside the guest instead of a \
475         clear build error. Distinct from CONFIG_PCI (the bus) and CONFIG_VIRTIO_NET (the \
476         transport-agnostic net driver)",
477    ),
478];
479
480/// Critical `.config` options that exist only in `arch/x86` Kconfig, so they are
481/// required only when cargo-ktstr targets x86_64 (which builds an x86_64 kernel).
482///
483/// `CONFIG_PCI_MMCONFIG` (x86 MMCONFIG-based ECAM, `arch/x86/Kconfig`) has no
484/// arm64 counterpart — arm64 ECAM is `PCI_HOST_GENERIC` — so on an arm64 build
485/// `make` drops the unknown symbol and validating it would be a guaranteed false
486/// failure. `ktstr.kconfig` still requests it as `=y` (a no-op on arm64).
487#[cfg(target_arch = "x86_64")]
488const VALIDATE_CONFIG_CRITICAL_X86: &[(&str, &str)] = &[(
489    "CONFIG_PCI_MMCONFIG",
490    "backs ECAM (extended config space, reg >= 256) which the MCFG table and _SB.PCI0 DSDT \
491     window describe and the PCI enumeration e2e exercises (a host-bridge-class device is \
492     sized to 4096-byte config and read at reg 0x100 via ECAM). default-y on x86_64 when \
493     PCI + ACPI are set, but an --extra-kconfig that strips it would silently drop ECAM — \
494     base config still works over CAM, so the loss is invisible until an extended-config \
495     access (the e2e, or a future MSI-X device) fails",
496)];
497
498/// The critical `.config` options to validate for the arch that cargo-ktstr targets:
499/// the arch-neutral [`VALIDATE_CONFIG_CRITICAL`] plus, on x86_64, the x86-only
500/// [`VALIDATE_CONFIG_CRITICAL_X86`]. Single source of truth for both the
501/// post-build check and its tests, so the two never drift.
502fn critical_config_options() -> Vec<(&'static str, &'static str)> {
503    #[cfg_attr(not(target_arch = "x86_64"), allow(unused_mut))]
504    let mut opts = VALIDATE_CONFIG_CRITICAL.to_vec();
505    #[cfg(target_arch = "x86_64")]
506    opts.extend_from_slice(VALIDATE_CONFIG_CRITICAL_X86);
507    opts
508}
509
510/// Validate the output .config for critical options that the kconfig
511/// fragment requested but the kernel build system may have silently
512/// disabled (e.g. CONFIG_DEBUG_INFO_BTF requires pahole).
513///
514/// Call after `make` succeeds. Returns `Err` with a diagnostic
515/// message listing missing options and likely causes.
516pub fn validate_kernel_config(kernel_dir: &std::path::Path) -> Result<()> {
517    let config_path = kernel_dir.join(".config");
518    let config = std::fs::read_to_string(&config_path)
519        .with_context(|| format!("read {}", config_path.display()))?;
520
521    // Build a HashSet of trimmed `.config` lines once, then probe
522    // each critical option in O(1). The previous formulation
523    // walked `config.lines()` once per critical option (O(N×M)
524    // with M≈11 critical options (10 arch-neutral + 1 x86-only)
525    // and N≈5000 .config lines), which turned every kernel-build
526    // pipeline into a ~55K-line scan.
527    // Trimming each line matches `all_fragment_lines_present`'s
528    // configure-time behavior so the same `.config` parses
529    // identically across both checks — without trim, a
530    // configure-time write that produced trailing whitespace (or
531    // a `.config` edited by hand on a Windows host with `\r\n`
532    // line endings) would silently flag every critical option as
533    // missing here while passing the configure-time check.
534    let existing: std::collections::HashSet<&str> = config.lines().map(str::trim).collect();
535
536    let mut missing = Vec::new();
537    let critical = critical_config_options();
538    for &(option, hint) in &critical {
539        let enabled = format!("{option}=y");
540        if !existing.contains(enabled.as_str()) {
541            missing.push((option, hint));
542        }
543    }
544
545    if !missing.is_empty() {
546        let mut msg =
547            String::from("kernel build completed but critical config options are missing:\n");
548        for (option, hint) in &missing {
549            msg.push_str(&format!("  {option} not set — {hint}\n"));
550        }
551        msg.push_str(
552            "\nThe kernel build system silently disables options whose dependencies \
553             are not met. Install missing tools and rebuild with --force.",
554        );
555        bail!("{msg}");
556    }
557    Ok(())
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    // -- validate_kernel_config --
565
566    /// Every entry in `VALIDATE_CONFIG_CRITICAL` must appear as `=y`
567    /// in the embedded kconfig fragment. If a critical option is
568    /// dropped from the fragment, builds skip it but validation
569    /// keeps flagging it as missing — the user sees a build failure
570    /// no tool install fixes. This test catches the drift at
571    /// compile-test time.
572    #[test]
573    fn critical_options_are_in_embedded_kconfig() {
574        let fragment = crate::EMBEDDED_KCONFIG;
575        for &(option, _) in &critical_config_options() {
576            let enabled = format!("{option}=y");
577            assert!(
578                fragment.lines().any(|l| l.trim() == enabled),
579                "VALIDATE_CONFIG_CRITICAL lists {option:?} but ktstr.kconfig does not \
580                 enable it; either add `{option}=y` to the fragment or drop the entry \
581                 from VALIDATE_CONFIG_CRITICAL",
582            );
583        }
584    }
585
586    #[test]
587    fn validate_kernel_config_all_present() {
588        let dir = tempfile::TempDir::new().unwrap();
589        // Every critical option present => validation passes. Generated from
590        // VALIDATE_CONFIG_CRITICAL (not a hand-picked subset) so the test stays
591        // complete as the critical list grows.
592        let content: String = critical_config_options()
593            .iter()
594            .map(|(option, _)| format!("{option}=y\n"))
595            .collect();
596        std::fs::write(dir.path().join(".config"), content).unwrap();
597        assert!(validate_kernel_config(dir.path()).is_ok());
598    }
599
600    #[test]
601    fn validate_kernel_config_missing_btf() {
602        let dir = tempfile::TempDir::new().unwrap();
603        std::fs::write(
604            dir.path().join(".config"),
605            "CONFIG_SCHED_CLASS_EXT=y\n\
606             CONFIG_BPF_SYSCALL=y\n\
607             CONFIG_FTRACE=y\n\
608             CONFIG_KPROBE_EVENTS=y\n\
609             CONFIG_BPF_EVENTS=y\n",
610        )
611        .unwrap();
612        let err = validate_kernel_config(dir.path()).unwrap_err();
613        let msg = format!("{err}");
614        assert!(msg.contains("CONFIG_DEBUG_INFO_BTF"), "got: {msg}");
615    }
616
617    #[test]
618    fn validate_kernel_config_missing_multiple() {
619        let dir = tempfile::TempDir::new().unwrap();
620        std::fs::write(dir.path().join(".config"), "CONFIG_BPF_SYSCALL=y\n").unwrap();
621        let err = validate_kernel_config(dir.path()).unwrap_err();
622        let msg = format!("{err}");
623        assert!(msg.contains("CONFIG_SCHED_CLASS_EXT"), "got: {msg}");
624        assert!(msg.contains("CONFIG_DEBUG_INFO_BTF"), "got: {msg}");
625    }
626
627    #[test]
628    fn validate_kernel_config_no_config_file() {
629        let dir = tempfile::TempDir::new().unwrap();
630        assert!(validate_kernel_config(dir.path()).is_err());
631    }
632
633    /// Pin the per-line `str::trim` semantics in
634    /// [`validate_kernel_config`]. CRLF line endings (`\r\n` from a
635    /// Windows-edited file) and trailing-space lines must STILL
636    /// validate when the option-after-trim equals the expected
637    /// `CONFIG_X=y` form. A regression that dropped `.map(str::trim)`
638    /// would surface every option as missing.
639    #[test]
640    fn validate_kernel_config_trim_handles_crlf_and_trailing_whitespace() {
641        let dir = tempfile::TempDir::new().unwrap();
642        // Write EVERY critical option as `=y`, alternating dirty line endings
643        // (`\r\n`) and trailing spaces so each required line needs trimming to
644        // be recognized. Generated from VALIDATE_CONFIG_CRITICAL (not a
645        // hand-picked subset) so the test stays complete — and keeps testing
646        // trim on the whole set — as the critical list grows.
647        let mut content = String::new();
648        let critical = critical_config_options();
649        for (i, (option, _)) in critical.iter().enumerate() {
650            if i % 2 == 0 {
651                content.push_str(&format!("{option}=y\r\n")); // CRLF
652            } else {
653                content.push_str(&format!("{option}=y \n")); // trailing space
654            }
655        }
656        std::fs::write(dir.path().join(".config"), content).unwrap();
657        let result = validate_kernel_config(dir.path());
658        assert!(
659            result.is_ok(),
660            "validate_kernel_config must trim per-line whitespace \
661             before the HashSet probe; got: {result:?}",
662        );
663    }
664
665    // -- parse_kconfig_symbol + render_kconfig_value --
666
667    #[test]
668    fn parse_kconfig_symbol_assignment() {
669        assert_eq!(parse_kconfig_symbol("CONFIG_FOO=y"), Some("CONFIG_FOO"));
670        assert_eq!(parse_kconfig_symbol("CONFIG_FOO=m"), Some("CONFIG_FOO"));
671        assert_eq!(parse_kconfig_symbol("CONFIG_FOO=n"), Some("CONFIG_FOO"));
672        assert_eq!(
673            parse_kconfig_symbol("CONFIG_BAR=\"value\""),
674            Some("CONFIG_BAR")
675        );
676    }
677
678    #[test]
679    fn parse_kconfig_symbol_disable_directive() {
680        assert_eq!(
681            parse_kconfig_symbol("# CONFIG_FOO is not set"),
682            Some("CONFIG_FOO")
683        );
684    }
685
686    #[test]
687    fn parse_kconfig_symbol_rejects_free_text_comment() {
688        assert!(parse_kconfig_symbol("# user note about foo").is_none());
689        assert!(parse_kconfig_symbol("#").is_none());
690        assert!(parse_kconfig_symbol("# this is a doc line").is_none());
691    }
692
693    #[test]
694    fn parse_kconfig_symbol_rejects_blank_and_non_config() {
695        assert!(parse_kconfig_symbol("").is_none());
696        assert!(parse_kconfig_symbol("not a kconfig line").is_none());
697        assert!(parse_kconfig_symbol("FOO=y").is_none());
698    }
699
700    #[test]
701    fn render_kconfig_value_assignment_returns_value_with_equals() {
702        assert_eq!(render_kconfig_value("CONFIG_FOO=y", "CONFIG_FOO"), "=y");
703        assert_eq!(render_kconfig_value("CONFIG_FOO=n", "CONFIG_FOO"), "=n");
704        assert_eq!(
705            render_kconfig_value("CONFIG_BAR=\"value\"", "CONFIG_BAR"),
706            "=\"value\""
707        );
708    }
709
710    #[test]
711    fn render_kconfig_value_disable_returns_is_not_set() {
712        assert_eq!(
713            render_kconfig_value("# CONFIG_FOO is not set", "CONFIG_FOO"),
714            "is not set"
715        );
716    }
717
718    #[test]
719    fn render_kconfig_value_falls_back_to_full_line_on_unknown_shape() {
720        let s = "CONFIG_FOO without equals";
721        assert_eq!(render_kconfig_value(s, "CONFIG_FOO"), s);
722    }
723
724    // -- warn_extra_kconfig_overrides_baked_in --
725    //
726    // The function's only observable behavior is the `tracing::warn!`
727    // it emits when a user line overrides a baked-in symbol. Each
728    // test installs a capturing subscriber via `traced_test` and
729    // asserts on the rendered warning (or its absence) — a no-panic
730    // assertion would not catch a dropped emit, a mis-rendered
731    // was/now value, or a spurious warning. Mirrors
732    // `vmm::rust_init::tests::send_sys_rdy_retry_exits_when_budget_exhausted`.
733
734    /// CONFIG_BPF is baked in as `=y` (ktstr.kconfig); a
735    /// `# CONFIG_BPF is not set` user line is an actual override and
736    /// must fire the WARN naming the symbol and rendering the
737    /// before/after values (`=y` → `is not set`).
738    #[test]
739    #[tracing_test::traced_test]
740    fn warn_extra_kconfig_overrides_emits_on_actual_override() {
741        assert!(
742            EMBEDDED_KCONFIG.lines().any(|l| l.trim() == "CONFIG_BPF=y"),
743            "fixture invariant: CONFIG_BPF must be baked in as =y",
744        );
745        let user = "# CONFIG_BPF is not set\n";
746        warn_extra_kconfig_overrides_baked_in(user, "test");
747        assert!(
748            logs_contain("overrides baked-in CONFIG_BPF"),
749            "override of a baked-in symbol must emit the WARN naming it",
750        );
751        // Rendered before/after tokens: baked `=y`, user `is not set`.
752        assert!(
753            logs_contain("(was =y, now is not set)"),
754            "WARN must render the baked before-value and the user after-value",
755        );
756        // Structured fields rendered into the line.
757        for field in [
758            "symbol=\"CONFIG_BPF\"",
759            "was=\"CONFIG_BPF=y\"",
760            "now=\"# CONFIG_BPF is not set\"",
761            "cli_label=\"test\"",
762        ] {
763            assert!(
764                logs_contain(field),
765                "WARN must include structured field `{field}`",
766            );
767        }
768    }
769
770    #[test]
771    #[tracing_test::traced_test]
772    fn warn_extra_kconfig_overrides_silent_on_empty_fragment() {
773        warn_extra_kconfig_overrides_baked_in("", "test");
774        assert!(
775            !logs_contain("overrides baked-in"),
776            "an empty fragment overrides nothing — no WARN",
777        );
778    }
779
780    #[test]
781    #[tracing_test::traced_test]
782    fn warn_extra_kconfig_overrides_silent_on_novel_symbol() {
783        let novel = "CONFIG_KTSTR_TEST_NOVEL_SYMBOL_OVERRIDE_TEST=y\n";
784        assert!(
785            !EMBEDDED_KCONFIG.contains("CONFIG_KTSTR_TEST_NOVEL_SYMBOL_OVERRIDE_TEST"),
786            "test fixture must use a symbol absent from EMBEDDED_KCONFIG"
787        );
788        warn_extra_kconfig_overrides_baked_in(novel, "test");
789        assert!(
790            !logs_contain("overrides baked-in"),
791            "a symbol not present in EMBEDDED_KCONFIG overrides nothing — no WARN",
792        );
793    }
794
795    /// `CONFIG_BPF=y` matches the baked-in line byte-for-byte, so the
796    /// `*baked_line == line` guard suppresses the WARN. A regression
797    /// that warned on an identical value would surface here.
798    #[test]
799    #[tracing_test::traced_test]
800    fn warn_extra_kconfig_overrides_silent_on_matching_assignment() {
801        assert!(
802            EMBEDDED_KCONFIG.lines().any(|l| l.trim() == "CONFIG_BPF=y"),
803            "fixture invariant: CONFIG_BPF must be baked in as =y",
804        );
805        let user = "CONFIG_BPF=y\n";
806        warn_extra_kconfig_overrides_baked_in(user, "test");
807        assert!(
808            !logs_contain("overrides baked-in"),
809            "a user line identical to the baked-in line is not an override — no WARN",
810        );
811    }
812
813    #[test]
814    #[tracing_test::traced_test]
815    fn warn_extra_kconfig_overrides_silent_on_free_text_comments() {
816        let user = "# this is a comment about something\n# another comment\n";
817        warn_extra_kconfig_overrides_baked_in(user, "test");
818        assert!(
819            !logs_contain("overrides baked-in"),
820            "free-text `#` comments are not kconfig assignments — no WARN",
821        );
822    }
823
824    // -- read_extra_kconfig --
825
826    #[test]
827    fn read_extra_kconfig_returns_content_for_valid_file() {
828        let dir = tempfile::TempDir::new().unwrap();
829        let path = dir.path().join("frag.kconfig");
830        let content = "CONFIG_FOO=y\nCONFIG_BAR=m\n";
831        std::fs::write(&path, content).unwrap();
832        let got = read_extra_kconfig(&path, "test").unwrap();
833        assert_eq!(got, content, "content must round-trip byte-for-byte");
834    }
835
836    #[test]
837    fn read_extra_kconfig_not_found_arm_names_path_and_intent() {
838        let dir = tempfile::TempDir::new().unwrap();
839        let missing = dir.path().join("does-not-exist.kconfig");
840        let display = missing.display().to_string();
841        let err = read_extra_kconfig(&missing, "cargo ktstr").expect_err("missing file must Err");
842        assert!(
843            err.contains(&display),
844            "ENOENT message must name the literal path: {err}"
845        );
846        assert!(
847            err.contains("--extra-kconfig"),
848            "ENOENT message must name the flag: {err}"
849        );
850        assert!(
851            err.contains("file not found"),
852            "ENOENT arm must surface a `file not found` token: {err}"
853        );
854    }
855
856    #[test]
857    fn read_extra_kconfig_directory_arm_distinguishes_from_not_found() {
858        let dir = tempfile::TempDir::new().unwrap();
859        let display = dir.path().display().to_string();
860        let err =
861            read_extra_kconfig(dir.path(), "cargo ktstr").expect_err("directory path must Err");
862        assert!(
863            err.contains(&display),
864            "EISDIR message must name the path: {err}"
865        );
866        assert!(
867            err.contains("is a directory"),
868            "EISDIR arm must surface its specific token: {err}"
869        );
870    }
871
872    #[test]
873    fn read_extra_kconfig_invalid_utf8_arm_names_constraint() {
874        let dir = tempfile::TempDir::new().unwrap();
875        let path = dir.path().join("binary.bin");
876        std::fs::write(&path, [0xFFu8, 0xFE, 0x00, 0x42]).unwrap();
877        let err = read_extra_kconfig(&path, "cargo ktstr").expect_err("non-UTF-8 file must Err");
878        assert!(
879            err.contains("not valid UTF-8"),
880            "UTF-8 arm must surface the constraint name: {err}"
881        );
882        assert!(
883            err.contains("ASCII text"),
884            "UTF-8 arm must mention the kconfig content constraint: {err}"
885        );
886    }
887
888    #[test]
889    fn read_extra_kconfig_empty_file_returns_empty_string() {
890        let dir = tempfile::TempDir::new().unwrap();
891        let path = dir.path().join("empty.kconfig");
892        std::fs::write(&path, "").unwrap();
893        let got = read_extra_kconfig(&path, "cargo ktstr").unwrap();
894        assert_eq!(
895            got, "",
896            "empty file must round-trip as empty String, not Err"
897        );
898    }
899
900    #[test]
901    #[cfg(unix)]
902    fn read_extra_kconfig_follows_symlink_chain() {
903        use std::os::unix::fs::symlink;
904        let dir = tempfile::TempDir::new().unwrap();
905        let target = dir.path().join("real.kconfig");
906        let link = dir.path().join("link.kconfig");
907        std::fs::write(&target, "CONFIG_BPF=y\n").unwrap();
908        symlink(&target, &link).unwrap();
909        let got = read_extra_kconfig(&link, "test").unwrap();
910        assert_eq!(
911            got, "CONFIG_BPF=y\n",
912            "symlink must resolve to target content"
913        );
914    }
915
916    // -- warn_dropped_extra_kconfig_lines --
917    //
918    // The function exists to emit a `tracing::warn!` when a requested
919    // `--extra-kconfig` line did not survive `make olddefconfig`. Each
920    // test installs a capturing subscriber via `traced_test` and
921    // asserts the WARN fires (with the enriched `final_state`) for the
922    // dropped/rewritten cases, and stays silent for the present /
923    // missing-config / free-text cases — a no-panic assertion would
924    // catch none of those regressions.
925
926    /// No `.config` on disk: the `read_to_string` Err arm returns
927    /// silently (best-effort), so no WARN even though the requested
928    /// line is "missing".
929    #[test]
930    #[tracing_test::traced_test]
931    fn warn_dropped_extra_kconfig_lines_silent_when_config_missing() {
932        let dir = tempfile::TempDir::new().unwrap();
933        let extra = "CONFIG_FOO=y\n";
934        warn_dropped_extra_kconfig_lines(dir.path(), extra, "test");
935        assert!(
936            !logs_contain("did not survive"),
937            "a missing .config collapses to a silent return — no WARN",
938        );
939    }
940
941    #[test]
942    #[tracing_test::traced_test]
943    fn warn_dropped_extra_kconfig_lines_silent_when_all_present() {
944        let dir = tempfile::TempDir::new().unwrap();
945        std::fs::write(dir.path().join(".config"), "CONFIG_FOO=y\nCONFIG_BAR=m\n").unwrap();
946        let extra = "CONFIG_FOO=y\nCONFIG_BAR=m\n";
947        warn_dropped_extra_kconfig_lines(dir.path(), extra, "test");
948        assert!(
949            !logs_contain("did not survive"),
950            "every requested line present verbatim in .config — no WARN",
951        );
952    }
953
954    /// A requested assignment whose symbol is entirely absent from the
955    /// final `.config` (dropped by olddefconfig) must fire the WARN
956    /// naming the requested line and the absent-symbol `final_state`
957    /// sentinel.
958    #[test]
959    #[tracing_test::traced_test]
960    fn warn_dropped_extra_kconfig_lines_emits_on_dropped_line() {
961        let dir = tempfile::TempDir::new().unwrap();
962        std::fs::write(dir.path().join(".config"), "CONFIG_BPF=y\n").unwrap();
963        let extra = "CONFIG_KTSTR_DROPPED_TEST_NOVEL=y\n";
964        warn_dropped_extra_kconfig_lines(dir.path(), extra, "test");
965        assert!(
966            logs_contain("did not survive"),
967            "a dropped requested line must emit the WARN",
968        );
969        assert!(
970            logs_contain("requested=\"CONFIG_KTSTR_DROPPED_TEST_NOVEL=y\""),
971            "WARN must carry the requested line",
972        );
973        // Absent symbol: `final_state` falls to the sentinel string.
974        assert!(
975            logs_contain("symbol not present in .config"),
976            "WARN's final_state must surface the absent-symbol sentinel",
977        );
978    }
979
980    /// A requested `=y` that olddefconfig rewrote to `is not set`
981    /// (unmet dependency) is "missing" from `.config` verbatim, so the
982    /// WARN fires with the rewritten `# CONFIG_X is not set` line as
983    /// the enriched `final_state`.
984    #[test]
985    #[tracing_test::traced_test]
986    fn warn_dropped_extra_kconfig_lines_emits_on_rewritten_line() {
987        let dir = tempfile::TempDir::new().unwrap();
988        std::fs::write(
989            dir.path().join(".config"),
990            "# CONFIG_KTSTR_REWRITE_TEST is not set\n",
991        )
992        .unwrap();
993        let extra = "CONFIG_KTSTR_REWRITE_TEST=y\n";
994        warn_dropped_extra_kconfig_lines(dir.path(), extra, "test");
995        assert!(
996            logs_contain("did not survive"),
997            "a rewritten requested line must emit the WARN",
998        );
999        assert!(
1000            logs_contain("requested=\"CONFIG_KTSTR_REWRITE_TEST=y\""),
1001            "WARN must carry the requested =y line",
1002        );
1003        // `final_state` enrichment: the actual rewritten .config line.
1004        assert!(
1005            logs_contain("final_state=\"# CONFIG_KTSTR_REWRITE_TEST is not set\""),
1006            "WARN must enrich final_state with the rewritten .config value",
1007        );
1008    }
1009
1010    /// A free-text `#` header is not a kconfig assignment nor a disable
1011    /// directive, so it is skipped; the lone real assignment is present
1012    /// — no WARN.
1013    #[test]
1014    #[tracing_test::traced_test]
1015    fn warn_dropped_extra_kconfig_lines_silent_on_free_text_comments() {
1016        let dir = tempfile::TempDir::new().unwrap();
1017        std::fs::write(dir.path().join(".config"), "CONFIG_BPF=y\n").unwrap();
1018        let extra = "# decorative header\nCONFIG_BPF=y\n";
1019        warn_dropped_extra_kconfig_lines(dir.path(), extra, "test");
1020        assert!(
1021            !logs_contain("did not survive"),
1022            "free-text comments are skipped and the assignment is present — no WARN",
1023        );
1024    }
1025
1026    // -- configure_kernel --
1027
1028    #[test]
1029    fn configure_kernel_appends_missing() {
1030        let dir = tempfile::TempDir::new().unwrap();
1031        std::fs::write(dir.path().join(".config"), "CONFIG_BPF=y\n").unwrap();
1032        std::fs::write(dir.path().join("Makefile"), "olddefconfig:\n\t@true\n").unwrap();
1033        let fragment = "CONFIG_EXTRA=y\n";
1034        configure_kernel(dir.path(), fragment, None).unwrap();
1035        let config = std::fs::read_to_string(dir.path().join(".config")).unwrap();
1036        assert!(config.contains("CONFIG_EXTRA=y"));
1037        assert!(config.contains("CONFIG_BPF=y"));
1038    }
1039
1040    #[test]
1041    fn configure_kernel_skips_when_present() {
1042        let dir = tempfile::TempDir::new().unwrap();
1043        let initial = "CONFIG_BPF=y\nCONFIG_EXTRA=y\n";
1044        std::fs::write(dir.path().join(".config"), initial).unwrap();
1045        let fragment = "CONFIG_EXTRA=y\n";
1046        configure_kernel(dir.path(), fragment, None).unwrap();
1047        let config = std::fs::read_to_string(dir.path().join(".config")).unwrap();
1048        assert_eq!(config, initial);
1049    }
1050
1051    /// Fragment asks `CONFIG_NR_CPUS=1`, .config has
1052    /// `CONFIG_NR_CPUS=128`. A plain `contains(fragment_line)` would
1053    /// false-match the substring "CONFIG_NR_CPUS=1" inside
1054    /// "CONFIG_NR_CPUS=128" and skip the append. Exact-line matching
1055    /// via the HashSet helper distinguishes the two and appends.
1056    #[test]
1057    fn configure_kernel_rejects_numeric_prefix_false_match() {
1058        let dir = tempfile::TempDir::new().unwrap();
1059        let initial = "CONFIG_NR_CPUS=128\n";
1060        std::fs::write(dir.path().join(".config"), initial).unwrap();
1061        std::fs::write(dir.path().join("Makefile"), "olddefconfig:\n\t@true\n").unwrap();
1062        let fragment = "CONFIG_NR_CPUS=1\n";
1063        configure_kernel(dir.path(), fragment, None).unwrap();
1064        let config = std::fs::read_to_string(dir.path().join(".config")).unwrap();
1065        assert!(
1066            config.lines().any(|l| l.trim() == "CONFIG_NR_CPUS=1"),
1067            "CONFIG_NR_CPUS=1 must be appended as its own line: {config:?}"
1068        );
1069        assert!(
1070            config.lines().any(|l| l.trim() == "CONFIG_NR_CPUS=128"),
1071            "original CONFIG_NR_CPUS=128 must be preserved: {config:?}"
1072        );
1073    }
1074
1075    /// The `!config_path.exists()` branch: with no pre-existing
1076    /// `.config`, configure_kernel runs `make defconfig` first (via
1077    /// run_make_captured), then appends the fragment and runs
1078    /// olddefconfig. The other configure_kernel tests all pre-write a
1079    /// `.config`, so the defconfig branch was otherwise uncovered. Fake
1080    /// Makefile targets stand in for the kernel's: `defconfig` writes a
1081    /// base `.config`, `olddefconfig` is a no-op.
1082    #[test]
1083    fn configure_kernel_runs_defconfig_when_config_absent() {
1084        let dir = tempfile::TempDir::new().unwrap();
1085        std::fs::write(
1086            dir.path().join("Makefile"),
1087            "defconfig:\n\t@printf 'CONFIG_BASE=y\\n' > .config\nolddefconfig:\n\t@true\n",
1088        )
1089        .unwrap();
1090        let fragment = "CONFIG_EXTRA=y\n";
1091        configure_kernel(dir.path(), fragment, None).unwrap();
1092        let config = std::fs::read_to_string(dir.path().join(".config")).unwrap();
1093        assert!(
1094            config.contains("CONFIG_BASE=y"),
1095            "defconfig target must have created the base .config: {config:?}"
1096        );
1097        assert!(
1098            config.contains("CONFIG_EXTRA=y"),
1099            "fragment must be appended after defconfig: {config:?}"
1100        );
1101    }
1102
1103    // -- all_fragment_lines_present pure helper --
1104
1105    #[test]
1106    fn all_fragment_lines_present_exact_match() {
1107        let config = "CONFIG_FOO=y\nCONFIG_BAR=m\n";
1108        assert!(all_fragment_lines_present("CONFIG_FOO=y\n", config));
1109        assert!(all_fragment_lines_present("CONFIG_BAR=m\n", config));
1110        assert!(all_fragment_lines_present(
1111            "CONFIG_FOO=y\nCONFIG_BAR=m\n",
1112            config
1113        ));
1114    }
1115
1116    #[test]
1117    fn all_fragment_lines_present_numeric_prefix_not_present() {
1118        let config = "CONFIG_NR_CPUS=128\n";
1119        assert!(!all_fragment_lines_present("CONFIG_NR_CPUS=1\n", config));
1120        assert!(!all_fragment_lines_present("CONFIG_NR_CPUS=12\n", config));
1121    }
1122
1123    #[test]
1124    fn all_fragment_lines_present_disable_directive_participates() {
1125        let config = "CONFIG_BPF=y\n";
1126        assert!(!all_fragment_lines_present(
1127            "# CONFIG_BPF is not set\n",
1128            config
1129        ));
1130    }
1131
1132    #[test]
1133    fn all_fragment_lines_present_empty_lines_skipped() {
1134        let config = "CONFIG_FOO=y\n";
1135        assert!(all_fragment_lines_present("\n\nCONFIG_FOO=y\n\n", config));
1136    }
1137
1138    #[test]
1139    fn all_fragment_lines_present_free_text_comment_stripped() {
1140        let config = "CONFIG_FOO=y\n";
1141        let fragment = "# Build for testing scx schedulers\nCONFIG_FOO=y\n";
1142        assert!(
1143            all_fragment_lines_present(fragment, config),
1144            "free-text comment must not block the present-in-config check"
1145        );
1146    }
1147
1148    #[test]
1149    fn all_fragment_lines_present_disable_directive_still_participates() {
1150        let config = "CONFIG_FOO=y\n# CONFIG_BAR is not set\n";
1151        let fragment_present = "# CONFIG_BAR is not set\n";
1152        assert!(
1153            all_fragment_lines_present(fragment_present, config),
1154            "disable directive present in config must satisfy probe"
1155        );
1156        let config_missing = "CONFIG_FOO=y\n";
1157        assert!(
1158            !all_fragment_lines_present(fragment_present, config_missing),
1159            "disable directive missing from config must fail probe"
1160        );
1161    }
1162
1163    #[test]
1164    fn all_fragment_lines_present_section_header_comment_stripped() {
1165        let config = "CONFIG_FOO=y\nCONFIG_BAR=m\n";
1166        let fragment = "\
1167# == BPF support ==\n\
1168CONFIG_FOO=y\n\
1169# == Tracing ==\n\
1170CONFIG_BAR=m\n";
1171        assert!(all_fragment_lines_present(fragment, config));
1172    }
1173
1174    #[test]
1175    fn all_fragment_lines_present_changed_baked_in_with_sched_ext_present() {
1176        // Regression for the configure-skip bug: the prior `has_sched_ext`
1177        // proxy reported "configured" whenever CONFIG_SCHED_CLASS_EXT=y
1178        // persisted in a stale `.config`, so an edited baked-in symbol
1179        // (CONFIG_NR_CPUS 64->512) was silently never re-applied and the
1180        // built kernel mismatched its cache key. `all_fragment_lines_present`
1181        // must read the changed value as not-present even when sched_ext is
1182        // present, so the build.rs gate reconfigures.
1183        let config = "CONFIG_SCHED_CLASS_EXT=y\nCONFIG_NR_CPUS=64\n";
1184        let fragment = "CONFIG_SCHED_CLASS_EXT=y\nCONFIG_NR_CPUS=512\n";
1185        assert!(
1186            !all_fragment_lines_present(fragment, config),
1187            "changed CONFIG_NR_CPUS (64->512) must read as not-present even with sched_ext=y"
1188        );
1189    }
1190
1191    // -- is_kconfig_semantic_line predicate --
1192
1193    #[test]
1194    fn is_kconfig_semantic_line_classifies_assignment_disable_and_comment() {
1195        assert!(is_kconfig_semantic_line("CONFIG_FOO=y"));
1196        assert!(is_kconfig_semantic_line("CONFIG_NR_CPUS=128"));
1197        assert!(is_kconfig_semantic_line("# CONFIG_BPF is not set"));
1198        assert!(is_kconfig_semantic_line("#  CONFIG_BPF is not set"));
1199        assert!(!is_kconfig_semantic_line("# Build for testing"));
1200        assert!(!is_kconfig_semantic_line("# == Section header =="));
1201        assert!(!is_kconfig_semantic_line(""));
1202        assert!(!is_kconfig_semantic_line("# CONFIG_FOO is enabled"));
1203        assert!(!is_kconfig_semantic_line("# CONFIG_FOO"));
1204    }
1205}