Gunnar

nordisk/holger-egui

holger-egui-build-thing/src/rules.rs

//! # The rules the declaration cannot carry
//!
//! Moved here from holger's `holger-recipe/src/rules.rs` on 2026-09-16, with the
//! rungs they judge. holger kept the SERVER-side airgap keystone and deleted every
//! UI rule rather than leave one naming a variant it no longer declares — such a
//! rule fails OPEN, which is the defect these rules exist to catch.
//!
//! ## The ladder is measured, not asserted
//!
//! `holger-server-ui` ships four tiers from one source tree, and until 2026-07-23
//! only one of them was real: the manifest declared `mini = ["gui"]`, and **a Cargo
//! feature cannot subtract a dependency**, so the "mini" client linked the identical
//! closure as the full one and merely added tab-filtering code. Measured 2026-07-22
//! in debug, under the bin names of the day: `holger-ui` 1 048 139 472 B vs
//! `holger-ui-mikro` 1 050 137 592 B — the lean tier was 2 MB *bigger*.
//!
//! Every heavy dependency is optional now and hangs off the one pane that needs it.
//! Measured 2026-07-23, release:
//!
//! | tier | bin | bytes | crates | reqwest | ureq | skade |
//! |---|---|---|---|---|---|---|
//! | `ui` | `holger-server-ui` | 31 603 160 | 534 | 1095 | 0 | 0 |
//! | `ui-mini` | `holger-server-ui-mini` | 20 685 888 | 493 | 0 | 0 | 0 |
//! | `ui-mikro` | `holger-server-ui-mikro` | 20 481 176 | 492 | 0 | 0 | 0 |
//!
//! [`READ_ONLY_CLIENT_TIERS`] carries the rule that keeps it true. That is a WEAKER
//! claim than a server's airgap guarantee and is stated as such — a UI obviously
//! talks to a registry. What it cannot do is talk to anything ELSE: no reqwest, no
//! ureq, no OIDC closure, so its only network capability is the tonic gRPC channel
//! to the endpoint it was handed.

use nornir_build_thing::plugin::{
    host, ArtifactFacts, BuildPlan, BuildRequest, PlanStep, RebuildRecipe, Verdict,
};
use nornir_build_thing::variant::{BuildVariant, ServiceShape};

use crate::UI_PACKAGE;

/// This repo's git remote — **not yet created**. `holger-egui` lives only as a local
/// checkout until Rickard's gunnar.rs account is sorted and the hosting call is made
/// (codeberg `nordisk` is the intended home). `None` is the honest answer to "where
/// would a forge pull this from"; a URL that resolves to nothing would not be.
pub const REMOTE: Option<&str> = None;

/// **Crates that must not appear in a THIN artifact's closure** — the storage engine
/// it would need in order to open state in its own process.
///
/// This targets the ENGINE, never "any crate mentioning holger". A thin client
/// legitimately links `holger-server-lib` (`RemoteHolger` and the generated gRPC
/// stubs live there, and 481 such symbols in `holger-server-ui-mini` are correct),
/// and so does `reqwest` in the full tier: OIDC sign-in and Nexus migration are
/// client work. A list that failed those would be switched off within a week.
///
/// The binary-level twin of this check is `tests/thin_client.rs`.
pub const THIN_FORBIDDEN_CRATES: &[&str] = &["skade", "iceberg", "parquet", "datafusion"];

/// **Every Rust outbound-HTTP client a no-egress rung is forbidden to link.**
///
/// `hyper`/`tonic`/`h2` are deliberately ABSENT: they are the gRPC channel every
/// rung legitimately dials. A denylist that failed a client for talking to its own
/// server would be turned off within a week.
pub const FORBIDDEN_OUTBOUND_CLIENTS: &[&str] =
    &["reqwest", "ureq", "isahc", "curl", "surf", "attohttpc", "awc", "http-client"];

/// The read-only rungs, which must link no outbound HTTP client at all. That is
/// exactly what pairs them with holger's `mini` SERVER: an operator dropping a
/// client into the same red zone should not be handing it an HTTP stack.
///
/// ⚠ RENAMED 2026-08-01 from `henriks-mini-holger` / `henriks-mikro-holger`. Those
/// names had stopped existing in the declaration while this list still named them,
/// so the read-only guarantee was checked on NOTHING. Every name here is held to
/// the declaration by [`tests::every_rule_names_a_declared_variant`].
pub const READ_ONLY_CLIENT_TIERS: &[&str] = &["ui-mini", "ui-mikro"];

/// The ONE rung allowed to embed, and it must be asked for by name — the same shape
/// as dwarves' `--embedded`. Its existence is what stops [`thin_ui_variants`] from
/// being vacuous: a rule no artifact ever violates proves nothing.
pub const EMBEDDED_UI_VARIANT: &str = "ui-embedded";

/// The features that link an outbound HTTP client, and therefore must never appear
/// in a [`READ_ONLY_CLIENT_TIERS`] build. `signin` pulls `nornir-auth-client/online`
/// (reqwest), `tools` pulls `agent-lib` (reqwest), `demo` pulls `world-connector`
/// (reqwest), and `gui` is the full tier that turns on all three.
pub const EGRESS_UI_FEATURES: &[&str] = &["gui", "signin", "tools", "demo"];

/// **Every rung is THIN**, and this READS which they are instead of listing them.
/// The constellation rule, in Rickard's words: *"se till att alla UI alltid är
/// default thin byggda och reflektera detta mot build thing!"*
///
/// A client that embeds its own server shows its own in-process world instead of the
/// server's — with nothing on screen telling the operator which — and becomes a
/// second writer against the warehouse lock.
pub fn thin_ui_variants() -> Vec<String> {
    crate::declared()
        .variants
        .iter()
        .filter(|v| {
            v.package.as_deref() == Some(UI_PACKAGE) && v.shape == ServiceShape::ThinStandalone
        })
        .map(|v| v.name.clone())
        .collect()
}

/// The TIER features — one per rung, each what its `[[bin]]` names in
/// `required-features`. Read off the declaration.
pub fn ui_tier_features() -> Vec<String> {
    crate::declared()
        .variants
        .iter()
        .filter(|v| v.package.as_deref() == Some(UI_PACKAGE))
        .flat_map(|v| v.features.clone())
        .collect()
}

/// The fat/thin shape of a rung. An undeclared name answers
/// [`ServiceShape::FatService`], the schema's own default for an omitted key.
pub fn shape_of(variant: &str) -> ServiceShape {
    crate::declared().get(variant).map(|v| v.shape).unwrap_or(ServiceShape::FatService)
}

/// True when `variant` is one of the read-only rungs.
pub fn is_read_only_client(variant: &str) -> bool {
    READ_ONLY_CLIENT_TIERS.contains(&variant)
}

/// True when `variant` is a rung declared THIN — every one except the named
/// embedded exception.
pub fn is_thin_ui(variant: &str) -> bool {
    thin_ui_variants().iter().any(|v| v == variant)
}

/// True when `variant` must link **no outbound HTTP client**. The `ui` rung is
/// excluded on purpose — a full operator UI signs in over OIDC, and applying the
/// rule to it would only teach people to turn it off.
pub fn links_no_http_client(variant: &str) -> bool {
    is_read_only_client(variant)
}

/// The plan for a rung, as DATA. A read-only rung's plan carries the closure
/// resolve, so the no-egress claim can be CHECKED rather than asserted.
pub fn plan(variant: &str) -> Option<BuildPlan> {
    let set = crate::declared();
    let v = set.get(variant)?;
    let mut plan = BuildPlan {
        variant: variant.to_string(),
        steps: vec![PlanStep {
            name: format!("build-{variant}"),
            program: "cargo".to_string(),
            args: v.cargo_args(),
            env: Vec::new(),
        }],
        env: Vec::new(),
        expected_artifacts: v
            .bin
            .as_ref()
            .map(|b| vec![format!("target/release/{b}")])
            .unwrap_or_default(),
        rationale: None,
    };
    if links_no_http_client(variant) {
        let mut args = vec![
            "tree".to_string(),
            "-e".to_string(),
            "no-dev".to_string(),
            "-p".to_string(),
            v.package.clone().unwrap_or_default(),
            "--features".to_string(),
            v.features.join(","),
        ];
        if v.no_default_features {
            args.push("--no-default-features".to_string());
        }
        plan.steps.push(PlanStep {
            name: format!("egress-closure-{variant}"),
            program: "cargo".to_string(),
            args,
            env: Vec::new(),
        });
        plan.rationale = Some(format!(
            "'{variant}' is a read-only rung: the build is followed by a dependency-closure \
             resolve so the no-HTTP-client claim can be CHECKED rather than asserted"
        ));
    } else {
        plan.rationale = Some(format!("'{variant}' materializes to one cargo invocation"));
    }
    host::log(&format!("holger-egui: planned {} step(s) for '{variant}'", plan.steps.len()));
    Some(plan)
}

/// The generic rules, plus the one only this repo knows: **a read-only rung may not
/// request a pane that links an HTTP client.** This is what stops `mini = ["gui"]`
/// from ever being written again.
pub fn validate(req: &BuildRequest) -> Verdict {
    let set = crate::declared();
    let base = match &req.variant {
        Some(name) => match set.get(name) {
            None => {
                return Verdict::refuse(format!(
                    "no variant '{name}'; holger-egui declares: {}",
                    set.names().join(", ")
                ))
            }
            Some(v) => match set.validate(v) {
                Ok(()) => Verdict::ok(),
                Err(e) => Verdict::refuse(e.to_string()),
            },
        },
        None => {
            let probe = BuildVariant {
                name: "<ad-hoc>".to_string(),
                features: req.features.clone(),
                target: req.target.clone(),
                ..Default::default()
            };
            match set.validate(&probe) {
                Ok(()) => Verdict::ok(),
                Err(e) => Verdict::refuse(e.to_string()),
            }
        }
    };

    let features: Vec<String> = match &req.variant {
        Some(name) => set.get(name).map(|v| v.features.clone()).unwrap_or_default(),
        None => req.features.clone(),
    };
    let no_egress = req
        .variant
        .as_deref()
        .map(links_no_http_client)
        .unwrap_or_else(|| ["gui-mini", "gui-mikro"].iter().any(|t| features.iter().any(|f| f == t)));
    if !no_egress {
        return base;
    }
    // The tier names in the message are JOINED from the rule list, not typed out:
    // the sentence used to name `henriks-mini-holger`/`henriks-mikro-holger` and went
    // on naming them for a week after they were renamed.
    let no_egress_tiers = READ_ONLY_CLIENT_TIERS.join("`/`");
    let mut v = base;
    for bad in EGRESS_UI_FEATURES {
        if features.iter().any(|f| f == bad) {
            v = v.and(Verdict::refuse(format!(
                "a read-only rung may not enable '{bad}': it links an outbound HTTP path, \
                 and `{no_egress_tiers}` exist precisely to be provably unable to reach \
                 anything but the gRPC channel they are given"
            )));
        }
    }
    if v.ok {
        v = Verdict::ok_with(
            "read-only rung — the built artifact must link no outbound HTTP client; its only \
             network capability is the gRPC channel it is given (checked by judge_artifact \
             against the real dependency closure)",
        );
    }
    v
}

/// **The thin keystone, applied to a BUILT artifact.** The host resolves the
/// dependency closure and hands the crate names over; this decides what they mean.
pub fn judge_artifact(facts: &ArtifactFacts) -> Verdict {
    let Some(variant) = facts.variant.as_deref() else {
        return Verdict::refuse("cannot judge an artifact that does not say which variant it is");
    };
    if facts.linked_crates.is_empty() {
        return Verdict::refuse(format!(
            "no dependency closure supplied for '{variant}' — an UNMEASURED no-egress claim is \
             not a no-egress claim"
        ));
    }
    let linked: Vec<&str> = FORBIDDEN_OUTBOUND_CLIENTS
        .iter()
        .copied()
        .filter(|c| facts.linked_crates.iter().any(|l| l == c))
        .collect();

    // Checked BEFORE the egress rule because it is the stronger failure: a thin
    // client that linked skade is broken whether or not it also links reqwest.
    if is_thin_ui(variant) {
        let engine: Vec<&str> = THIN_FORBIDDEN_CRATES
            .iter()
            .copied()
            .filter(|c| facts.linked_crates.iter().any(|l| l == c))
            .collect();
        if !engine.is_empty() {
            return Verdict::refuse(format!(
                "'{variant}' is a rung and every rung is THIN, but this artifact links the \
                 storage engine: {}. A client that embeds its own server shows its own \
                 in-process world instead of the server's and becomes a second writer against \
                 the warehouse lock — move whatever pulled it behind '{EMBEDDED_UI_VARIANT}', \
                 the build that is asked for by name.",
                engine.join(", ")
            ));
        }
    }

    if links_no_http_client(variant) {
        if linked.is_empty() {
            return Verdict::ok_with(format!(
                "'{variant}' is a read-only rung and links NO outbound HTTP client — it can \
                 reach the gRPC endpoint it is given and nothing else"
            ));
        }
        return Verdict::refuse(format!(
            "'{variant}' is a read-only rung but links outbound HTTP client(s): {}. This \
             artifact must not be promoted.",
            linked.join(", ")
        ));
    }
    // The embedded rung is EXPECTED to carry the engine — that is what naming it asks
    // for — and a fat variant is never failed for being fat. But an `ui-embedded` with
    // no engine in it is a thin client under a fat name, and the rule it exists to
    // discriminate against becomes vacuous.
    if variant == EMBEDDED_UI_VARIANT {
        let engine: Vec<&str> = THIN_FORBIDDEN_CRATES
            .iter()
            .copied()
            .filter(|c| facts.linked_crates.iter().any(|l| l == c))
            .collect();
        return if engine.is_empty() {
            Verdict::refuse(format!(
                "'{variant}' is the EMBEDDED build but links none of {THIN_FORBIDDEN_CRATES:?} — \
                 the in-process engine is missing, so this is a thin client under a fat name, \
                 and the thin rule becomes vacuous"
            ))
        } else {
            Verdict::ok_with(format!(
                "'{variant}' embeds by design and links {} — expected, and it is the ONLY rung \
                 that may",
                engine.join(", ")
            ))
        };
    }
    Verdict::ok()
}

pub fn rebuild_recipe(variant: &str) -> Option<RebuildRecipe> {
    let set = crate::declared();
    let v = set.get(variant)?;
    Some(RebuildRecipe {
        repo: crate::REPO.to_string(),
        remote: REMOTE.map(str::to_string),
        rev: None,
        variant: variant.to_string(),
        cargo_args: v.cargo_args(),
        toolchain: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::HolgerEguiBuildThing;
    use nornir_build_thing::plugin::BuildThing;

    fn facts(variant: &str, linked: &[&str]) -> ArtifactFacts {
        ArtifactFacts {
            variant: Some(variant.to_string()),
            file_name: variant.to_string(),
            bytes: 31_603_160,
            sha256: "0".repeat(64),
            linked_crates: linked.iter().map(|s| s.to_string()).collect(),
        }
    }

    /// **Every variant name a RULE mentions must be a declared variant.** This is
    /// the guard the `henriks-*` rename walked straight past: a rule naming a
    /// variant that does not exist fires on nothing and fails OPEN.
    #[test]
    fn every_rule_names_a_declared_variant() {
        let set = crate::declared();
        let declared = set.names();
        let mut ghosts: Vec<(&str, &str)> = Vec::new();
        for (list, name) in [
            (READ_ONLY_CLIENT_TIERS.to_vec(), "READ_ONLY_CLIENT_TIERS"),
            (vec![EMBEDDED_UI_VARIANT], "EMBEDDED_UI_VARIANT"),
        ] {
            for v in list {
                if !declared.contains(&v) {
                    ghosts.push((name, v));
                }
            }
        }
        assert!(ghosts.is_empty(), "rules naming undeclared variants: {ghosts:?}");
        // …and the rule DISCRIMINATES: one that applies to everything or to nothing
        // proves nothing either way.
        assert!(
            !READ_ONLY_CLIENT_TIERS.is_empty()
                && READ_ONLY_CLIENT_TIERS.len() < set.variants.len(),
            "the no-egress rule must apply to SOME but not all rungs"
        );
        assert!(!links_no_http_client("ui"), "the full UI signs in over OIDC");
        assert!(links_no_http_client("ui-mini"), "the read-only rung is held to it");
    }

    /// The ladder is derived, not listed — and the ONE fat rung is the named
    /// exception, which is what stops the thin rule being vacuous.
    #[test]
    fn the_thin_ui_ladder_comes_out_of_the_declaration() {
        assert_eq!(thin_ui_variants(), vec!["ui", "ui-mini", "ui-mikro"]);
        assert!(
            !thin_ui_variants().iter().any(|v| v == EMBEDDED_UI_VARIANT),
            "the embedded rung is the exception the rule needs in order to mean anything"
        );
        assert_eq!(shape_of(EMBEDDED_UI_VARIANT), ServiceShape::FatService);
        for t in thin_ui_variants() {
            assert_eq!(shape_of(&t), ServiceShape::ThinStandalone, "{t}");
        }
        // An undeclared name is fat by the schema's default, not a panic.
        assert_eq!(shape_of("ghost"), ServiceShape::FatService);
    }

    /// **The read-only rungs may not ask for a pane that links an HTTP client.**
    #[test]
    fn a_read_only_client_may_not_ask_for_an_egress_pane() {
        let bt = HolgerEguiBuildThing;
        for bad in EGRESS_UI_FEATURES {
            let v = bt.validate(&BuildRequest::features(["gui-mini", *bad]));
            assert!(!v.ok, "'{bad}' must be refused in a read-only rung");
            assert!(v.line().contains(bad), "{}", v.line());
        }
        // …and the declared rung itself passes, with the guarantee spelled out.
        let v = bt.validate(&BuildRequest::variant("ui-mikro"));
        assert!(v.ok, "{}", v.line());
        assert!(v.line().contains("gRPC channel it is given"), "{}", v.line());
        // The refusal names the rungs declared TODAY — joined from the rule list, so
        // a rename cannot leave the sentence lying.
        let v = bt.validate(&BuildRequest::features(["gui-mini", "signin"]));
        assert!(v.line().contains("ui-mini"), "{}", v.line());
        assert!(!v.line().contains("henriks"), "the dead names must be gone: {}", v.line());
    }

    /// **Every rung is THIN, held against the artifact's real closure.**
    ///
    /// Measured 2026-07-23 on the release binaries: `holger-server-ui`,
    /// `holger-server-ui-mini` and `holger-server-ui-mikro` link no skade, no
    /// iceberg, no parquet — and `holger-server-ui-embedded` links all of them,
    /// which is what makes the check discriminate rather than always pass.
    #[test]
    fn every_ui_tier_is_thin_and_only_the_named_one_may_embed() {
        let bt = HolgerEguiBuildThing;
        assert!(!thin_ui_variants().is_empty(), "a rule over an empty set proves nothing");
        for tier in thin_ui_variants() {
            // A thin rung legitimately links holger's own lib and tonic. Failing it
            // for those would fail every client there is.
            assert!(
                bt.judge_artifact(&facts(&tier, &["holger-server-lib", "tonic", "eframe"])).ok,
                "{tier}"
            );
            // …but not the storage engine.
            let v = bt.judge_artifact(&facts(&tier, &["holger-server-lib", "tonic", "skade"]));
            assert!(!v.ok, "{tier} must be refused for linking skade");
            assert!(v.line().contains("second writer"), "{}", v.line());
            assert!(v.line().contains(EMBEDDED_UI_VARIANT), "{}", v.line());
        }

        let v = bt.judge_artifact(&facts(EMBEDDED_UI_VARIANT, &["skade", "iceberg", "reqwest"]));
        assert!(v.ok, "{}", v.line());
        assert!(v.line().contains("embeds by design"), "{}", v.line());
        let v = bt.judge_artifact(&facts(EMBEDDED_UI_VARIANT, &["tonic", "eframe"]));
        assert!(!v.ok);
        assert!(v.line().contains("vacuous"), "{}", v.line());
    }

    /// A read-only rung that somehow linked reqwest is refused BY NAME, and an
    /// unmeasured claim is refused rather than green-ticked.
    #[test]
    fn the_verdict_discriminates_and_an_unmeasured_claim_is_refused() {
        let bt = HolgerEguiBuildThing;
        let v = bt.judge_artifact(&facts("ui-mini", &["tonic", "hyper", "eframe", "egui"]));
        assert!(v.ok, "{}", v.line());
        assert!(v.line().contains("gRPC endpoint it is given"), "{}", v.line());
        let v = bt.judge_artifact(&facts("ui-mini", &["tonic", "reqwest"]));
        assert!(!v.ok);
        assert!(v.line().contains("must not be promoted"), "{}", v.line());
        // The FULL rung is deliberately NOT under the no-egress rule.
        assert!(bt.judge_artifact(&facts("ui", &["reqwest", "tonic"])).ok);
        // …but it IS under the thin rule: the two work together rather than one
        // shadowing the other.
        assert!(!bt.judge_artifact(&facts("ui", &["reqwest", "skade"])).ok);

        let v = bt.judge_artifact(&ArtifactFacts {
            variant: Some("ui-mini".into()),
            ..Default::default()
        });
        assert!(!v.ok);
        assert!(v.line().contains("UNMEASURED"), "{}", v.line());
    }

    /// A plan is DATA — this crate runs nothing. A read-only rung plans its own
    /// evidence; nothing in a plan is a shell.
    #[test]
    fn a_plan_is_data_and_a_read_only_rung_plans_its_own_evidence() {
        let bt = HolgerEguiBuildThing;
        let p = bt.plan("ui").unwrap();
        assert_eq!(p.steps.len(), 1);
        assert_eq!(p.expected_artifacts, vec!["target/release/holger-server-ui".to_string()]);
        let p = bt.plan("ui-mini").unwrap();
        assert_eq!(p.steps.len(), 2, "build, then resolve the closure");
        assert_eq!(p.steps[1].args[0], "tree");
        assert_eq!(
            p.expected_artifacts,
            vec!["target/release/holger-server-ui-mini".to_string()]
        );
        assert!(p.steps.iter().all(|s| s.program == "cargo"), "no shell in a plan");
        assert!(bt.plan("ghost").is_none());
    }

    /// The rebuild recipe says honestly that this repo has no remote yet.
    #[test]
    fn a_rebuild_recipe_names_no_remote_until_the_repo_has_one() {
        let r = HolgerEguiBuildThing.rebuild_recipe("ui").unwrap();
        assert_eq!(r.repo, "holger-egui");
        assert_eq!(r.remote, None, "holger-egui has no remote yet — see rules::REMOTE");
        assert!(r.cargo_args.contains(&"--bin".to_string()));
        assert!(r.cargo_args.contains(&"holger-server-ui".to_string()));
        assert!(HolgerEguiBuildThing.rebuild_recipe("ghost").is_none());
    }
}