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"
);
}
}