Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-recipe/tests/dev_narrate_symbols.rs

//! **A RELEASE build contains no narrate sender at all — read off the symbol
//! table, and held by the declaration** (DEV-NARRATE, owner + coordinator
//! 2026-09-30).
//!
//! The dev medium streams a box's boot log, sealed, over udp/51822 (Skidbladnir
//! `nordisk-narrate`). The promise to the owner is that the channel is a DEV
//! tool: a release binary does not DECLINE to narrate, it does not CONTAIN a
//! narrator. Two halves, as `metered_symbols.rs` proves for entitlement:
//!
//! 1. **The artifact.** `gunnar-server` built with the release rows' switches
//!    has ZERO `nordisk_narrate` symbols; built with `dev-narrate` it has the
//!    sender — or the clean table above proves nothing. Same reader as
//!    `OptionAudit` (`linkage::demangled_symbols`); a stripped binary is
//!    UNMEASURED and fails.
//! 2. **The declaration.** Only the four `…-narrate` rows name the feature; no
//!    crate's `default` list does; every medium's `dev-narrate` option defaults
//!    OFF and forbids `nordisk_narrate` (so the composer's own symbol audit
//!    refuses an OFF medium that carries it).

use std::path::PathBuf;
use std::process::Command;

use gunnar_recipe::recipe::{DEV_NARRATE_FEATURE, DEV_NARRATE_FORBIDDEN, DEV_NARRATE_OPTION};
use nornir_build_thing::linkage::{count_families, demangled_symbols};
use nornir_build_thing::plugin::BuildThing;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("two below the root")
        .to_path_buf()
}

/// Build `gunnar-server` with exactly `features`; its path from cargo's own
/// `compiler-artifact` message (never a guessed target dir).
fn build_server(features: &str) -> PathBuf {
    let out = Command::new(env!("CARGO"))
        .current_dir(workspace_root())
        .args([
            "build",
            "-q",
            "--message-format=json-render-diagnostics",
            "-p",
            "gunnar-server",
            "--bin",
            "gunnar-server",
            "--no-default-features",
            "--features",
            features,
        ])
        .output()
        .expect("cargo runs");
    assert!(
        out.status.success(),
        "cargo build --features {features} failed ({}):\n{}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
            .lines()
            .rev()
            .take(40)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect::<Vec<_>>()
            .join("\n")
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    let bin = stdout
        .lines()
        .filter(|l| l.contains(r#""compiler-artifact""#) && l.contains(r#""name":"gunnar-server""#))
        .filter_map(|l| {
            const KEY: &str = r#""executable":""#;
            let i = l.find(KEY)? + KEY.len();
            let rest = &l[i..];
            Some(PathBuf::from(&rest[..rest.find('"')?]))
        })
        .next_back()
        .unwrap_or_else(|| {
            panic!("cargo reported no executable for gunnar-server --features {features}")
        });
    assert!(bin.is_file(), "no binary at {}", bin.display());
    bin
}

fn narrate_symbols(features: &str) -> Vec<(String, usize)> {
    let bin = build_server(features);
    let symbols = demangled_symbols(&bin).unwrap_or_else(|| {
        panic!(
            "{} has no readable symbol table: UNMEASURED is not a pass",
            bin.display()
        )
    });
    assert!(
        symbols.iter().any(|s| s.contains("gunnar_server::")),
        "{} names no gunnar_server code: a clean read would be vacuous",
        bin.display()
    );
    let c = count_families(
        &symbols,
        &[DEV_NARRATE_FORBIDDEN, "gunnar_server::dev_narrate"],
    );
    println!(
        "symbol audit  --features {features}: {} symbols; found {:?}; glue-only {:?}",
        symbols.len(),
        c.found,
        c.glue_only
    );
    let mut all = c.found;
    all.extend(c.glue_only);
    all
}

#[test]
fn the_release_server_has_zero_narrate_symbols_and_the_dev_one_has_the_sender() {
    // The release rows' switches that matter here (the tunnel and DHCP the
    // sender rides beside), host target, dev profile — sequential, one path.
    let release = narrate_symbols("server,engine-znippy,dhcp-client,wg-client");
    assert!(release.is_empty(), "a RELEASE gunnar-server CONTAINS narrate code: {release:?}. Off means never compiled in, not declining to send");
    let dev = narrate_symbols("server,engine-znippy,dhcp-client,wg-client,dev-narrate");
    let names: Vec<&str> = dev.iter().map(|(f, _)| f.as_str()).collect();
    for family in [DEV_NARRATE_FORBIDDEN, "gunnar_server::dev_narrate"] {
        assert!(names.contains(&family), "the DEV gunnar-server has no `{family}` symbols, so the release table's emptiness proves nothing: {dev:?}");
    }
}

#[test]
fn only_the_narrate_rows_name_the_feature_and_every_medium_defaults_it_off() {
    let set = gunnar_recipe::GunnarBuildThing.variants();
    let mut narrate_rows = Vec::new();
    for v in &set.variants {
        if v.features.iter().any(|f| f == DEV_NARRATE_FEATURE) {
            assert!(
                v.name.ends_with("-narrate") && !v.is_default,
                "`{}` names `{DEV_NARRATE_FEATURE}` and is not a `…-narrate` dev row",
                v.name
            );
            narrate_rows.push(v.name.clone());
        } else {
            assert!(
                !v.name.ends_with("-narrate"),
                "`{}` is called a narrate row and does not build the feature",
                v.name
            );
        }
    }
    narrate_rows.sort();
    assert_eq!(
        narrate_rows,
        [
            "gunnar-front-metered-narrate",
            "gunnar-front-narrate",
            "gunnar-server-metered-narrate",
            "gunnar-server-narrate"
        ]
    );
    let mut media = Vec::new();
    for i in &set.installers {
        for o in i.options.iter().filter(|o| o.name == DEV_NARRATE_OPTION) {
            assert!(!o.default, "`{}` turns DEV-NARRATE on by default", i.name);
            assert_eq!(o.feature, DEV_NARRATE_FEATURE);
            assert!(o.forbid_symbols.iter().any(|s| s == DEV_NARRATE_FORBIDDEN), "`{}`'s option does not forbid `{DEV_NARRATE_FORBIDDEN}` when off, so the composer's audit cannot refuse a release medium that carries it", i.name);
            media.push(i.name.clone());
        }
    }
    media.sort();
    assert_eq!(
        media,
        [
            "gunnar-front-installer-metered",
            "gunnar-front-installer-open",
            "gunnar-installer-metered",
            "gunnar-installer-open"
        ],
        "the estate's four media carry the option, and no other"
    );
    // No crate's `default` list reaches for it: a `cargo build` that says
    // nothing must build no sender.
    for krate in [
        "crates/gunnar-server/Cargo.toml",
        "crates/gunnar-front/Cargo.toml",
    ] {
        let text = std::fs::read_to_string(workspace_root().join(krate)).unwrap();
        let default = text
            .lines()
            .find(|l| l.trim_start().starts_with("default ="))
            .unwrap_or("");
        assert!(
            !default.contains("dev-narrate"),
            "{krate}: `{default}` puts DEV-NARRATE in every default build"
        );
    }
}

/// **ON and OFF both resolve to a DECLARED build point** — the medium's option
/// switched ON builds exactly its payload's `…-narrate` row, OFF exactly the
/// payload row itself, and the payload keeps `dev-narrate` as a SWITCH (open),
/// never a decided feature. MEASURED release-3 test2 mock 2026-09-30 (gunnar
/// 3e374eb): `--opt dev-narrate=on` failed the compose with "feature
/// 'dev-narrate' is both enabled and open" — the resolution left the feature
/// on the open axis after adding it (fixed in edda nornir-build-thing
/// `apply_to_set`, the compose's copy); this holds the recipe's half.
#[test]
fn the_dev_narrate_option_on_and_off_resolves_to_a_declared_row() {
    use std::collections::{BTreeMap, BTreeSet};
    let set = gunnar_recipe::GunnarBuildThing.variants();
    for i in set
        .installers
        .iter()
        .filter(|i| i.options.iter().any(|o| o.name == DEV_NARRATE_OPTION))
    {
        let app = set
            .variants
            .iter()
            .find(|v| v.name == i.variant)
            .unwrap_or_else(|| panic!("{}: no payload row {}", i.name, i.variant));
        assert!(
            app.open.contains(DEV_NARRATE_FEATURE)
                && !app.features.iter().any(|f| f == DEV_NARRATE_FEATURE),
            "{}: `{}` must keep dev-narrate a switch",
            i.name,
            app.name
        );
        let narrate = set
            .variants
            .iter()
            .find(|v| v.name == format!("{}-narrate", app.name))
            .unwrap_or_else(|| panic!("{}: no `{}-narrate` row", i.name, app.name));
        for on in [true, false] {
            let supplied: BTreeMap<String, bool> = [
                ("dhcp".to_string(), true),
                ("wireguard".to_string(), true),
                (DEV_NARRATE_OPTION.to_string(), on),
            ]
            .into_iter()
            .filter(|(n, _)| i.options.iter().any(|o| &o.name == n))
            .collect();
            let r = nornir_build_thing::options::resolve_options(&i.options, &i.params, &supplied)
                .unwrap_or_else(|e| panic!("{} dev-narrate={on}: {e:#}", i.name));
            let got: BTreeSet<String> = r.app_features(app).into_iter().collect();
            let want: BTreeSet<String> = if on {
                narrate.features.iter().cloned().collect()
            } else {
                app.features.iter().cloned().collect()
            };
            assert_eq!(
                got, want,
                "{} dev-narrate={on} resolves to no declared row",
                i.name
            );
        }
    }
}