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

nordisk/holger

server/lib/src/posture.rs

//! **What is open on this Holger** (HCON-5, owner 2026-10-27): "Holger's RON needs the ability
//! to configure what is open and what is not." Gunnar's pattern: the public front and the
//! sign-up are open, everything else is private by default, and the admin makes a repository
//! public.
//!
//! * The declaration's `access: (reads: Gated | Open, front: Open | Gated)` block. Absent, the
//!   BUILD decides: an estate build (`estate`, which `metered`, the sold and online product,
//!   implies) is internet-facing, so reads are `Gated`; the default and the downstream builds are
//!   `Open` (an airgapped downstream reads inside its closed net). `front: Open` keeps the front
//!   page and the sign-up doors readable on a gated box.
//! * Every repository may say `visibility: Public`: readable with no token even when reads are
//!   gated. No word is `Private`.
//! * One gate: [`crate::auth::ReadGate`] holds the public names and the front's word; every read
//!   door asks it. A gated read refused for want of a sign-in is refused by name
//!   ([`READ_GATED`], with the repository).
//! * Every boot writes the posture it starts with under `<data>/posture/read-gate`; a boot whose
//!   posture differs from the last one says so in the log and in the audit chain
//!   ([`record_boot`]), so an upgrade never opens or closes reads silently.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::audit::{AuditAction, AuditEvent, AuditLog};

/// Who may read packages.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reads {
    /// A token (any sign-in this box's `auth` accepts) is needed, except for public repositories.
    Gated,
    /// Anybody who reaches the box reads.
    Open,
}

/// Whether the front page and the sign-up doors are readable on a gated box.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum Front {
    Open,
    Gated,
}

/// A repository's word on who reads it.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Visibility {
    /// Readable with no token, even when reads are gated.
    Public,
    /// Read under the box's `access.reads` (the default).
    #[default]
    Private,
}

/// What this build reads by default: an estate build is internet-facing.
pub const BUILD_READS: Reads = if cfg!(feature = "estate") {
    Reads::Gated
} else {
    Reads::Open
};

fn build_reads() -> Reads {
    BUILD_READS
}

fn front_open() -> Front {
    Front::Open
}

/// **The `access:` block.**
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Access {
    #[serde(default = "build_reads")]
    pub reads: Reads,
    #[serde(default = "front_open")]
    pub front: Front,
}

impl Default for Access {
    fn default() -> Self {
        Access {
            reads: BUILD_READS,
            front: Front::Open,
        }
    }
}

/// The refusal of a gated read with no sign-in, by name.
pub const READ_GATED: &str = "[read-gated]";
/// An `access` block the box refuses at load, by name.
pub const ACCESS_REFUSED: &str = "[access-refused]";

/// Pure: **the refusal words** for an anonymous read of `repo` on a gated box.
pub fn read_gated(repo: &str) -> String {
    if repo.is_empty() {
        format!("{READ_GATED} reading packages on this Holger needs a sign-in")
    } else {
        format!("{READ_GATED} reading `{repo}` on this Holger needs a sign-in (it is not a public repository)")
    }
}

/// One repository as the posture needs it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoWord<'a> {
    pub name: &'a str,
    pub visibility: Visibility,
    /// `ron_require_auth` or a `ron_require_role` floor: the repository gates itself.
    pub gates_itself: bool,
}

/// **The posture this box runs with.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Posture {
    /// Reads need a sign-in (except public repositories).
    pub gated: bool,
    /// The front page and the sign-up doors stay readable when gated.
    pub front_open: bool,
    /// The public repositories, by name.
    pub public: Vec<String>,
    /// Why, in words (the log line and the audit detail).
    pub why: String,
}

/// Pure: **decide the posture** from the declaration's `access` block (`None`: the build's
/// default), the auth block's legacy `require_auth_for_reads` and the repositories. A block that
/// contradicts itself is refused by name ([`ACCESS_REFUSED`]).
pub fn decide(
    access: Option<&Access>,
    require_auth_for_reads: bool,
    repos: &[RepoWord<'_>],
) -> Result<Posture, String> {
    if let (Some(a), true) = (access, require_auth_for_reads) {
        if a.reads == Reads::Open {
            return Err(format!(
                "{ACCESS_REFUSED} `access: (reads: Open)` and `auth.require_auth_for_reads: true` contradict each other; say Gated, or drop the auth key"
            ));
        }
    }
    let mut public = Vec::new();
    for r in repos.iter().filter(|r| r.visibility == Visibility::Public) {
        if r.gates_itself {
            return Err(format!(
                "{ACCESS_REFUSED} repository `{}` says `visibility: Public` and gates itself (`ron_require_auth` or `ron_require_role`); a public repository is read by anybody",
                r.name
            ));
        }
        public.push(r.name.to_string());
    }
    let a = access.copied().unwrap_or_default();
    let gated = require_auth_for_reads || a.reads == Reads::Gated;
    let why = match (access.is_some(), gated, require_auth_for_reads) {
        (_, true, true) if access.is_none() => {
            "the declaration says auth.require_auth_for_reads: true".to_string()
        }
        (true, true, _) => "the declaration's access block says reads: Gated".to_string(),
        (true, false, _) => "the declaration's access block says reads: Open".to_string(),
        (false, true, _) => {
            "an estate build is internet-facing: reads are gated unless its access block opens them"
                .to_string()
        }
        (false, false, _) => {
            "this build reads openly by default (an airgapped downstream's posture)".to_string()
        }
    };
    Ok(Posture {
        gated,
        front_open: a.front == Front::Open,
        public,
        why,
    })
}

/// Pure: **the posture in plain words**, for the console (read only).
pub fn words(p: &Posture) -> String {
    let mut s = if p.gated {
        "Reading packages needs a sign-in.".to_string()
    } else {
        "Anybody who reaches this Holger reads its packages.".to_string()
    };
    if p.gated {
        if p.public.is_empty() {
            s.push_str(" No repository is public.");
        } else {
            s.push_str(&format!(
                " Public, readable with no sign-in: {}.",
                p.public.join(", ")
            ));
        }
        s.push_str(if p.front_open {
            " The front page and the sign-up are open."
        } else {
            " The front page needs a sign-in too."
        });
    }
    s
}

/// The posture file, under the data root.
pub const POSTURE_FILE: &str = "posture/read-gate";

/// What a boot found against the last one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Boot {
    /// No posture was recorded before (a first boot, or a box older than HCON-5).
    First {
        on: bool,
    },
    Same {
        on: bool,
    },
    Changed {
        from: bool,
        to: bool,
    },
}

/// **Record this boot's posture**; a change is warned and audited (`holger-boot`, `read-gate`).
/// A change the audit chain does not take is refused by name and NOT recorded (RA5-21): the
/// box does not start under an unaudited posture, and the next boot sees the change again.
pub fn record_boot(
    data_root: &Path,
    on: bool,
    why: &str,
    audit: &dyn AuditLog,
) -> Result<Boot, String> {
    let path = data_root.join(POSTURE_FILE);
    let last = std::fs::read_to_string(&path)
        .ok()
        .map(|t| t.trim() == "on");
    let boot = match last {
        None => Boot::First { on },
        Some(was) if was == on => Boot::Same { on },
        Some(was) => Boot::Changed { from: was, to: on },
    };
    match &boot {
        Boot::First { on } => log::info!("read gate: {} at this boot ({why})", word(*on)),
        Boot::Same { .. } => {}
        Boot::Changed { from, to } => {
            let detail = format!(
                "read gate {} -> {} at this boot: {why}",
                word(*from),
                word(*to)
            );
            log::warn!("{detail}");
            let event = AuditEvent::new(
                "holger-boot",
                AuditAction::Other,
                "",
                "read-gate",
                "",
                200,
                0,
            )
            .with_detail(detail.clone());
            audit.record(event).map_err(|e| format!("{AUDIT_REFUSED} the read gate's change ({detail}) could not be audited ({e}); this boot does not take it"))?;
        }
    }
    if let Some(dir) = path.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    if let Err(e) = std::fs::write(&path, word(on)) {
        log::warn!(
            "the read gate's posture could not be recorded at {}: {e}",
            path.display()
        );
    }
    Ok(boot)
}

/// A posture change the audit chain did not take, refused by name.
pub const AUDIT_REFUSED: &str = "[posture-unaudited]";

fn word(on: bool) -> &'static str {
    if on {
        "on"
    } else {
        "off"
    }
}

/// **The feed key naming the estate's public repositories** (NIGHT-PUBREPO, owner 2026-10-28;
/// holger-recipe `estate_ron::PUBLIC_REPOSITORIES_FEED`): the ONE box feeds an estate's main and
/// twin `public_repositories = "crates,npm"` from the estate's declaration, and the box makes
/// exactly those `visibility: Public` ([`crate::Holger::resolve_public_repositories`]).
pub const PUBLIC_REPOSITORIES_FEED: &str = "public_repositories";
/// A public list naming a repository this box does not declare, refused by name.
pub const PUBLIC_UNKNOWN: &str = "[public-repository-unknown]";
/// The public list recorded at the last boot, under the data root.
pub const PUBLIC_FILE: &str = "posture/public";

/// Pure: **the public list as fed** (`"crates, npm"`): the names, trimmed, empty pieces dropped,
/// each once, in the fed order.
pub fn public_list_of(fed: &str) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for n in fed.split(',').map(str::trim).filter(|n| !n.is_empty()) {
        if !out.iter().any(|o| o == n) {
            out.push(n.to_string());
        }
    }
    out
}

/// Pure: **the refusal of a public name this box does not declare**, naming what it declares.
pub fn public_unknown(name: &str, declared: &[&str]) -> String {
    format!("{PUBLIC_UNKNOWN} `{name}` is fed as a public repository and this Holger declares no repository of that name (it declares: {})", declared.join(", "))
}

/// **Record this boot's public repositories** (NIGHT-PUBREPO): a list that differs from the last
/// boot's (none before: a box older than the list) is warned and audited (`holger-boot`,
/// `public-repositories`), so a fed list never opens or closes a repository silently. Answers
/// the change's words when there was one. A change the audit chain does not take is refused by
/// name and NOT recorded (RA5-21).
pub fn record_public(
    data_root: &Path,
    public: &[String],
    audit: &dyn AuditLog,
) -> Result<Option<String>, String> {
    let path = data_root.join(PUBLIC_FILE);
    let last = std::fs::read_to_string(&path)
        .map(|t| public_list_of(&t))
        .unwrap_or_default();
    let named = |l: &[String]| {
        if l.is_empty() {
            "none".to_string()
        } else {
            l.join(", ")
        }
    };
    let said = (last.as_slice() != public).then(|| {
        format!(
            "public repositories {} -> {} at this boot",
            named(&last),
            named(public)
        )
    });
    if let Some(detail) = &said {
        log::warn!("{detail}");
        let event = AuditEvent::new(
            "holger-boot",
            AuditAction::Other,
            "",
            "public-repositories",
            "",
            200,
            0,
        )
        .with_detail(detail.clone());
        audit.record(event).map_err(|e| format!("{AUDIT_REFUSED} the public repositories' change ({detail}) could not be audited ({e}); this boot does not take it"))?;
    }
    if let Some(dir) = path.parent() {
        let _ = std::fs::create_dir_all(dir);
    }
    if let Err(e) = std::fs::write(&path, public.join(",")) {
        log::warn!(
            "the public repositories could not be recorded at {}: {e}",
            path.display()
        );
    }
    Ok(said)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audit::MemoryAuditLog;

    fn repo(name: &str, visibility: Visibility) -> RepoWord<'_> {
        RepoWord {
            name,
            visibility,
            gates_itself: false,
        }
    }

    /// ★ HCON-5: with no `access` block the BUILD decides: an estate build gates reads, the
    /// default and the downstream builds read openly (airgap); the front stays open.
    #[test]
    fn with_no_access_block_the_build_decides() {
        let p = decide(None, false, &[]).unwrap();
        assert_eq!(p.gated, cfg!(feature = "estate"), "{p:?}");
        assert!(p.front_open);
        assert_eq!(BUILD_READS == Reads::Gated, cfg!(feature = "estate"));
    }

    /// ★ HCON-5: the declaration's word wins over the build's default, both ways.
    #[test]
    fn the_access_block_says_what_is_open() {
        let gated = decide(
            Some(&Access {
                reads: Reads::Gated,
                front: Front::Open,
            }),
            false,
            &[
                repo("crates-nordisk", Visibility::Public),
                repo("pypi-internal", Visibility::Private),
            ],
        )
        .unwrap();
        assert!(gated.gated && gated.front_open);
        assert_eq!(gated.public, vec!["crates-nordisk".to_string()]);
        let open = decide(
            Some(&Access {
                reads: Reads::Open,
                front: Front::Open,
            }),
            false,
            &[],
        )
        .unwrap();
        assert!(!open.gated, "an estate may open its reads in its own file");
        assert!(
            decide(None, true, &[]).unwrap().gated,
            "the legacy key still gates"
        );
    }

    /// ★ HCON-5: a bad `access` block is refused at load by name.
    #[test]
    fn a_bad_access_block_is_refused_by_name() {
        let e = decide(
            Some(&Access {
                reads: Reads::Open,
                front: Front::Open,
            }),
            true,
            &[],
        )
        .unwrap_err();
        assert!(
            e.starts_with(ACCESS_REFUSED) && e.contains("require_auth_for_reads"),
            "{e}"
        );
        let e = decide(
            None,
            false,
            &[RepoWord {
                name: "sealed",
                visibility: Visibility::Public,
                gates_itself: true,
            }],
        )
        .unwrap_err();
        assert!(
            e.starts_with(ACCESS_REFUSED) && e.contains("`sealed`"),
            "{e}"
        );
        let parsed: Result<Access, _> = ron::from_str("(reads: Closed)");
        assert!(parsed.is_err(), "an unknown word is no access block");
        let parsed: Result<Access, _> = ron::from_str("(reads: Gated, frontpage: Open)");
        assert!(
            parsed.unwrap_err().to_string().contains("frontpage"),
            "an unknown key is refused by its name"
        );
    }

    /// The console's plain words for the posture.
    #[test]
    fn the_posture_in_plain_words() {
        let p = Posture {
            gated: true,
            front_open: true,
            public: vec!["crates-nordisk".into()],
            why: String::new(),
        };
        assert_eq!(words(&p), "Reading packages needs a sign-in. Public, readable with no sign-in: crates-nordisk. The front page and the sign-up are open.");
        let p = Posture {
            gated: false,
            front_open: true,
            public: vec![],
            why: String::new(),
        };
        assert_eq!(
            words(&p),
            "Anybody who reaches this Holger reads its packages."
        );
        assert!(read_gated("pypi-internal").starts_with("[read-gated] reading `pypi-internal`"));
    }

    /// ★ A boot that changes the posture says so, in the audit chain; the same posture is quiet.
    #[test]
    fn a_boot_that_changes_the_read_gate_is_audited() {
        let dir = tempfile::tempdir().unwrap();
        let audit = MemoryAuditLog::new();
        assert_eq!(
            record_boot(dir.path(), false, "the declaration", &audit),
            Ok(Boot::First { on: false })
        );
        assert_eq!(
            record_boot(dir.path(), false, "the declaration", &audit),
            Ok(Boot::Same { on: false })
        );
        assert!(
            audit.events().is_empty(),
            "nothing changed, nothing audited"
        );
        assert_eq!(
            record_boot(
                dir.path(),
                true,
                "the declaration's access block says reads: Gated",
                &audit
            ),
            Ok(Boot::Changed {
                from: false,
                to: true
            })
        );
        let ev = audit.events();
        assert_eq!(ev.len(), 1, "the change is one audit record");
        assert_eq!(ev[0].ident, "holger-boot");
        assert_eq!(ev[0].artifact, "read-gate");
        assert!(
            ev[0].detail.contains("off -> on") && ev[0].detail.contains("reads: Gated"),
            "{}",
            ev[0].detail
        );
        assert_eq!(
            std::fs::read_to_string(dir.path().join(POSTURE_FILE))
                .unwrap()
                .trim(),
            "on"
        );
    }

    /// ★ NIGHT-PUBREPO: the fed list (`public_repositories = "crates, npm"`) as names; an
    /// unknown name is refused naming what the box declares.
    /// An audit chain that refuses every append (full, damaged).
    struct Refusing;
    impl AuditLog for Refusing {
        fn record(&self, _: AuditEvent) -> Result<(), crate::audit::AuditError> {
            Err(crate::audit::AuditError::Other(
                "the audit store is full".into(),
            ))
        }
    }

    /// ★ RA5-21: **a posture change the audit chain did not take is not recorded as done.** The
    /// boot that turns the read gate off, or changes the public list, with a chain that refuses
    /// the append: the change is refused by name and the posture file keeps the last one, so the
    /// next boot (its chain healthy) still sees the change and audits it.
    #[test]
    fn a_posture_change_the_audit_chain_refused_is_never_forgotten() {
        let dir = tempfile::tempdir().unwrap();
        let audit = MemoryAuditLog::new();
        record_boot(dir.path(), true, "the declaration", &audit).unwrap();
        let e = record_boot(
            dir.path(),
            false,
            "the declaration's access block says reads: Open",
            &Refusing,
        )
        .unwrap_err();
        assert!(
            e.starts_with(AUDIT_REFUSED) && e.contains("on -> off"),
            "refused by name: {e}"
        );
        let again = record_boot(
            dir.path(),
            false,
            "the declaration's access block says reads: Open",
            &audit,
        );
        assert_eq!(
            format!("{again:?}"),
            format!(
                "{:?}",
                Ok::<Boot, String>(Boot::Changed {
                    from: true,
                    to: false
                })
            ),
            "the unaudited change is still a change"
        );
        let public = vec!["crates".to_string()];
        assert!(record_public(dir.path(), &public, &Refusing)
            .unwrap_err()
            .starts_with(AUDIT_REFUSED));
        let said = record_public(dir.path(), &public, &audit);
        assert!(
            format!("{said:?}").contains("none -> crates"),
            "the unaudited opening is audited at the next boot: {said:?}"
        );
        assert_eq!(audit.events().len(), 2, "both changes in the trail");
    }

    #[test]
    fn the_fed_public_list_is_read_as_names() {
        assert_eq!(PUBLIC_REPOSITORIES_FEED, "public_repositories");
        assert_eq!(
            public_list_of(" crates, npm,,crates "),
            vec!["crates".to_string(), "npm".to_string()]
        );
        assert!(public_list_of("").is_empty() && public_list_of(" , ").is_empty());
        let e = public_unknown("pypi", &["bundles", "crates"]);
        assert!(
            e.starts_with(PUBLIC_UNKNOWN) && e.contains("`pypi`") && e.contains("bundles, crates"),
            "{e}"
        );
    }

    /// ★ NIGHT-PUBREPO: a boot whose public repositories differ from the last boot's is audited;
    /// the same list is quiet, and a box that never had a list counts as none public.
    #[test]
    fn a_boot_that_changes_the_public_repositories_is_audited() {
        let dir = tempfile::tempdir().unwrap();
        let audit = MemoryAuditLog::new();
        assert_eq!(
            record_public(dir.path(), &[], &audit),
            Ok(None),
            "none before, none now"
        );
        let public = vec!["crates".to_string(), "npm".to_string()];
        let said = record_public(dir.path(), &public, &audit)
            .unwrap()
            .expect("opened");
        assert!(said.contains("none -> crates, npm"), "{said}");
        assert_eq!(
            record_public(dir.path(), &public, &audit),
            Ok(None),
            "the same list is quiet"
        );
        let said = record_public(dir.path(), &public[..1], &audit)
            .unwrap()
            .expect("closed");
        assert!(said.contains("crates, npm -> crates"), "{said}");
        let ev = audit.events();
        assert_eq!(ev.len(), 2, "two changes, two audit records");
        assert!(
            ev.iter()
                .all(|e| e.ident == "holger-boot" && e.artifact == "public-repositories"),
            "{ev:?}"
        );
        let fresh = tempfile::tempdir().unwrap();
        assert!(
            record_public(fresh.path(), &public, &audit)
                .unwrap()
                .unwrap()
                .contains("none -> crates, npm"),
            "a box older than the list had none public"
        );
    }
}