nordisk/holger
server/lib/src/lib.rs
//! The holger server crate: turns a RON config into a running artifact server.
//!
//! Config is a directed graph of three node kinds — [`Repository`] (a backend
//! plus its upstreams), [`ExposedEndpoint`] (a gRPC + optional HTTP/OCI gateway),
//! and [`StorageEndpoint`] (disk or a znippy archive). [`wire_holger`] resolves
//! that graph in passes: index every node by `ron_name`, check the forward names
//! (repo→storage, repo→exposed) and reverse links (exposed→repos), then build a
//! per-endpoint [`FastRoutes`] table. Any repo with configured upstreams is
//! wrapped in a `ProxyBackend` at this stage so a 404 in the local archive falls
//! through to the upstream pull-through automatically.
//!
//! Two entry modes share the same wired graph: call [`Holger::fetch`]/[`Holger::put`]
//! in-process, or [`Holger::start`] to spin up the tonic gRPC services (and HTTP
//! gateways) fronted by shared auth ([`auth`]) and one append-only audit sink
//! ([`audit`]). Boot is deliberately fault-tolerant — a single bad archive is
//! disabled and logged (see [`Holger::instantiate_backends`]) rather than sinking
//! the whole server; only an all-repos failure hard-fails.
//!
//! Gotcha: the wiring uses raw `*const` pointers between nodes, so the graph is
//! valid only while the owning `Vec`s stay put — never mutate or reallocate
//! `repositories`/`exposed_endpoints`/`storage_endpoints` after `wire_holger`.
//! Related seams: `ron_url`/`ron_http_url` parse as bare `SocketAddr`s, so any
//! scheme'd URL must go through `bare_socket_addr` first (see [`dev_pair_ron`]).
// holger-server-lib/src/lib.rs
//
// Two modes of operation:
// 1. Rust function bindings — use Holger struct directly as a library
// 2. gRPC server — configure exposed_endpoints in RON, call holger.start()
use serde::{Deserialize, Serialize};
use std::{fs::File, io::BufReader, path::Path};
#[allow(unused_imports)]
use anyhow::{Context as _, Result};
#[cfg(test)]
use ron::de::from_reader;
use crate::exposed::fast_routes::FastRoutes;
use crate::exposed::ExposedEndpoint;
pub use crate::repository::Repository;
/// holger's frozen error-code registry, re-exported so every surface (the CLI
/// included) tags failures from ONE source instead of taking its own dep.
pub use holger_errcode as errcode;
use std::collections::HashMap;
use std::sync::Arc;
pub use traits::RepositoryBackendTrait;
/// **The repositories paid signups made** (lane M4): what the metered box's `add_principal`
/// declares and every build's boot serves (`Holger::declare_sold_repositories`).
pub mod sold {
use std::collections::BTreeMap;
use std::path::Path;
/// Under the data root: name → [`SoldRepository`]. Also every repository an admin made
/// at run time (`holger.account.v1.CreateRepository`, lane P4): one record of what was
/// made after the config was written, which the twin carries and a recharge brings back.
pub const SOLD_REPOS_REL: &str = "fleet/sold-repositories.json";
/// One repository made at run time: the estate's repository it is shaped like, the signup
/// it was made for (empty for one an admin made), and — lane P4 — its owner and whether its
/// reads need a credential. The two new fields are left out when empty, so a record written
/// before them reads, and writes back, byte for byte.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SoldRepository {
pub template: String,
pub signup: String,
/// The principal (subject) it was made for. Empty: the estate's, or a sale whose buyer
/// has not registered yet.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub owner: String,
/// Its reads need a credential of this server.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub private: bool,
/// **The people its owner granted** (lane P1), by subject: `read` or `write` (read and
/// write). Left out when empty.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub grants: BTreeMap<String, String>,
}
/// The word a grant is kept under: `write` (read and write) or `read`.
pub fn access_word(write: bool) -> &'static str {
if write {
"write"
} else {
"read"
}
}
/// **What the read gate keeps of `s`** (lane P1): its owner ([`gate_owner`]) and its grants.
pub fn gate_entry(data_root: &Path, s: &SoldRepository) -> crate::auth::SoldEntry {
crate::auth::SoldEntry {
owner: gate_owner(data_root, &s.owner),
grants: s
.grants
.iter()
.map(|(who, a)| (who.clone(), a == "write"))
.collect(),
}
}
/// Pure: a name a repository may be made under — lower-case letters, digits, `-`, `_` and
/// `.`, starting with a letter or digit, at most 64 long.
pub fn repository_name_ok(name: &str) -> bool {
let b = name.as_bytes();
!b.is_empty()
&& b.len() <= 64
&& b[0].is_ascii_alphanumeric()
&& b.iter().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'-' | b'_' | b'.')
})
&& !name.contains("..")
}
/// The repositories made at run time under `data_root` (none when the file is absent or
/// cannot be read).
pub fn read(data_root: &Path) -> BTreeMap<String, SoldRepository> {
std::fs::read(data_root.join(SOLD_REPOS_REL))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
/// **Whose a repository a paid order makes here is** (lane P1): on an estate the shop sold to
/// a buyer (its first-login invite taken in, [`crate::auth::FIRST_ADMIN_MARKER_REL`]), the
/// buyer's subject once they registered, the estate's owners (`@holger-owners`) before;
/// elsewhere nobody's yet (empty: the estate's admins alone).
pub fn buyer_of(data_root: &Path) -> String {
let Ok(bytes) = std::fs::read(data_root.join(crate::auth::FIRST_ADMIN_MARKER_REL)) else {
return String::new();
};
let used_by = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("used_by").and_then(|u| u.as_str()).map(str::to_owned))
.filter(|u| !u.trim().is_empty());
used_by.unwrap_or_else(|| format!("@{}", crate::auth::ESTATE_OWNERS))
}
/// **Who the read gate admits to a repository owned by `owner`** (lane P1): its owner, and
/// on an estate the shop sold to a buyer the people the buyer granted (their invitees, in
/// [`crate::auth::ESTATE_OWNERS`]) — as one `,`-separated matcher list
/// ([`crate::auth::owner_names`]). The estate's admins are admitted by the gate itself.
pub fn gate_owner(data_root: &Path, owner: &str) -> String {
let group = format!("@{}", crate::auth::ESTATE_OWNERS);
let sold_estate = data_root.join(crate::auth::FIRST_ADMIN_MARKER_REL).exists();
let owner = owner.trim();
match (owner.is_empty(), sold_estate && owner != group) {
(_, false) => owner.to_owned(),
(true, true) => group,
(false, true) => format!("{owner},{group}"),
}
}
/// **The buyer registered** (lane P1): every repository waiting for the estate's owners
/// (`@holger-owners`) is the buyer's own from now on, in the record the twin carries.
/// Answers the names bound.
pub fn bind_buyer(data_root: &Path, subject: &str) -> Result<Vec<String>, String> {
let group = format!("@{}", crate::auth::ESTATE_OWNERS);
update(data_root, |sold| {
let mut bound = Vec::new();
for (name, s) in sold.iter_mut() {
if s.owner == group {
s.owner = subject.to_owned();
bound.push(name.clone());
}
}
Ok(bound)
})
}
/// One writer at a time (the pull channel's `add_principal` and an admin's
/// `CreateRepository` both write the record).
static WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// **Change the record under one lock**: read it, let `f` change it (an `Err` writes
/// nothing), write it whole. Answers what `f` answered.
pub fn update<T>(
data_root: &Path,
f: impl FnOnce(&mut BTreeMap<String, SoldRepository>) -> Result<T, String>,
) -> Result<T, String> {
let _one = crate::lock::LockOrRecover::lock_or_recover(&WRITE);
let mut sold = read(data_root);
let out = f(&mut sold)?;
let bytes = serde_json::to_vec_pretty(&sold).map_err(|e| e.to_string())?;
let path = data_root.join(SOLD_REPOS_REL);
std::fs::create_dir_all(path.parent().unwrap_or(data_root))
.and_then(|_| std::fs::write(&path, bytes))
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(out)
}
#[cfg(test)]
mod p1_tests {
use super::*;
/// ★ Lane P1: before the buyer registers, a paid order's repository waits for the
/// estate's owners; their registration binds it to their subject in the record; the gate
/// admits the owner and, on a sold estate, the people the buyer granted.
#[test]
fn the_buyer_is_bound_as_owner_when_they_register() {
let d = tempfile::tempdir().unwrap();
assert_eq!(
buyer_of(d.path()),
"",
"an estate the shop sold nobody: nobody's"
);
assert_eq!(
gate_owner(d.path(), "passkey:p4"),
"passkey:p4",
"an admin's own, elsewhere: the owner alone"
);
let marker = d.path().join(crate::auth::FIRST_ADMIN_MARKER_REL);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, r#"{"sha256":"x","at":1,"seeded":true}"#).unwrap();
assert_eq!(buyer_of(d.path()), "@holger-owners");
update(d.path(), |s| {
s.insert(
"acme-artifacts".into(),
SoldRepository {
template: "crates".into(),
signup: "s-1".into(),
owner: buyer_of(d.path()),
private: true,
..Default::default()
},
);
s.insert(
"p4-own".into(),
SoldRepository {
template: "crates".into(),
signup: String::new(),
owner: "passkey:admin".into(),
private: true,
..Default::default()
},
);
Ok(())
})
.unwrap();
assert_eq!(
bind_buyer(d.path(), "passkey:buyer").unwrap(),
vec!["acme-artifacts".to_string()]
);
let r = read(d.path());
assert_eq!(
(
r["acme-artifacts"].owner.as_str(),
r["p4-own"].owner.as_str()
),
("passkey:buyer", "passkey:admin")
);
std::fs::write(
&marker,
r#"{"sha256":"x","at":1,"seeded":true,"used_by":"passkey:buyer"}"#,
)
.unwrap();
assert_eq!(
buyer_of(d.path()),
"passkey:buyer",
"a later order is the buyer's at once"
);
assert_eq!(
gate_owner(d.path(), "passkey:buyer"),
"passkey:buyer,@holger-owners",
"the buyer and the people they granted"
);
assert_eq!(gate_owner(d.path(), ""), "@holger-owners");
}
}
}
pub use crate::storage::StorageEndpoint;
/// Attestations — signed statements bound to an artifact DIGEST. The evidence
/// plane a gated promotion reads: a gate runs outside holger, signs a DSSE
/// envelope over an in-toto Statement naming specific bytes, and holger verifies
/// and files it. Sits beside `sbom` (which stores documents by coordinate) and
/// `security` (which turns them into verdicts).
#[cfg(feature = "admission")]
pub mod admission;
/// Without `admission` every request is admitted; the doors keep their ticket seam.
#[cfg(not(feature = "admission"))]
#[path = "admission_off.rs"]
pub mod admission;
#[cfg(feature = "admission")]
pub mod admission_grpc;
#[cfg(feature = "intake")]
pub mod attest;
pub mod audit;
pub mod auth;
/// **How much storage there is** — `AdminService.Capacity`: one `statvfs(3)`
/// over the data root plus the archive bytes of every repository that owns
/// storage, and the ONE mapping from a backend to its proto `RepositoryInfo`.
pub mod capacity;
/// The estate's clock on a box: what every expiry check reads, following the
/// front's signed time (edda `estate-clock`).
pub mod clock;
pub mod exposed;
pub mod group;
pub mod grpc;
/// The package-handler catalog behind `ron_handler` — which handler READS a
/// repository's container, as distinct from `ron_repo_type`, which names the
/// container itself.
pub mod handlers;
pub mod keyfile;
pub mod listen;
#[cfg(feature = "pkcs11")]
pub mod pkcs11_signer;
pub mod refusals;
/// **The job queue** (`jobs` feature) — promote, replicate, upload and gate as
/// durable jobs on the shared `jera` ledger, dequeued by a gatling worker pool,
/// so an RPC returns a `JobId` instead of holding the caller for the whole run.
/// Stinky 01 §1.3 measured what it replaces. Off ⇒ every operation runs inline
/// exactly as before and no queue exists.
#[cfg(feature = "jobs")]
pub mod jobs;
#[cfg(all(feature = "jobs", feature = "intake"))]
pub mod pull_intake;
/// Without `jobs` there is no pull-through, but the image layout, the registry client and
/// the provenance material are shared with the scanned pushes (lane HOL-OCI-EVIDENCE,
/// [`promote::oci_push`]).
#[cfg(all(feature = "intake", not(feature = "jobs")))]
pub mod pull_intake {
pub mod http;
pub mod oci;
pub mod provenance;
}
/// **Holger's verbs as the ONE monetize's pulled orders reach them** (metered only).
#[cfg(feature = "metered")]
pub mod fleet_box;
/// Late keys: values fed after the start, applied live (A7 row 8).
pub mod late;
/// **A repository made at run time serves at once** (lane M4b) — see the module.
pub mod live_routes;
/// **The three host services a meter guest is lent** — the usage source, the
/// audit sink and the clock. `holger_plugin_host::NoServices` refuses all three
/// by name; this is what answers them. Gated with the guest kind itself: a build
/// without `meter-guests` has nothing to lend them to.
#[cfg(feature = "meter-guests")]
pub mod meter_services;
/// **The paid state** — the signed entitlement a repository may be capped by.
/// Compiled ONLY by the metered build; the open build does not contain it.
#[cfg(feature = "metered")]
pub mod metered;
/// **Usage** — what a repository is consuming, in holger's own units. In EVERY
/// build: counting is not money, and a box that cannot say how much it holds is
/// worse for everyone.
pub mod metering;
/// **The estate keys the ONE box fed this box again, kept** (MAIL-RELAY + NIGHT-PUBREPO, metered).
#[cfg(feature = "metered")]
pub mod refeed;
/// **A sold Holger's resend sign-in link**, minted and mailed here (A16, metered + passkeys).
#[cfg(all(feature = "metered", feature = "auth-passkey"))]
pub mod sign_in_again;
/// The pull channel's crate, for the CLI's enrolment (metered only).
#[cfg(feature = "metered")]
pub use holger_fleet;
/// Offline vulnerability intelligence (S7): the `intel:` block and the seam the
/// OSV scan reads the local advisory database through. See `intel/`.
pub mod intel;
pub mod metrics;
/// The mirror CHANGE MAP: what an on-prem mirror already has from each upstream
/// repository, and the diff that turns a fresh upstream listing into "fetch only
/// these". Self-contained — no gRPC, no CLI; a caller wires it.
pub mod mirror;
/// The mirror's DECLARATION (`mirror:`) and its CYCLE — the caller
/// [`mirror`] was written for and did not have. Config, transport, loop; no
/// second engine.
pub mod mirror_cycle;
/// The intel crate itself, for the CLI's `scan` verb under `supply-chain`.
#[cfg(feature = "import-door")]
pub use holger_intel;
/// The airgap export as one door: `holger export` and `BundleService.Export` (lane B3).
#[cfg(feature = "intake")]
pub mod export_door;
/// The export's decision profile: what crosses to the airgap side (lane HOL-NCSC).
#[cfg(feature = "intake")]
pub mod export_profile;
/// The box's import doors (lane W4): intel and update / airgap bundles over
/// gRPC, HTTP and the `intel.inbox` / `bundle.inbox` drop directories, one body
/// of code with the CLI verbs. See `import_door.rs`.
#[cfg(feature = "bundle-import")]
pub mod import_door;
pub mod object;
#[cfg(feature = "intake")]
pub mod promote;
// Archive-less deb/rpm/helm/maven3/go repositories as views of the intake chain's
// AVAILABLE set (lane HOL-MVP-FORMATS).
#[cfg(feature = "intake")]
pub mod chain_view;
// The passkey ceremony door. Gated on `auth-passkey`, which only the `full` tier
// pins: the lean tiers are a headless registry core reached by CI runners and
// agents, and a WebAuthn ceremony needs a browser. Gating it here is also what
// keeps the p256/ciborium closure out of `mini` and `embedded` (ship-small).
/// `boundary: OneWay | Fetch`, `domain:` and `trust:` (lane HOL-CDS): one-way by default.
pub mod boundary;
/// Content digests on the engine: blake3 forked over gatling for the digests
/// holger chooses itself (W12).
pub mod declaration;
pub mod digest;
/// `network: Offline`: the boot refusal and the outbound-dial guard.
pub mod egress;
/// The upstream's push of each new signed bundle into the diode's folder (`export:`).
pub mod export_push;
/// A follower's last word and the one rule that judges it (twins + paired downstreams).
pub mod follow_point;
/// **Holger as an identity host** (IDENTITY-HOST): the product part of edda's identity core.
#[cfg(feature = "auth-passkey")]
pub mod identity_host;
/// **MIGRATION** — the ONE-TIME move of an incumbent Nexus / Artifactory
/// registry into this holger, behind `MigrationService` so a UI can drive it.
/// The classifier and the byte mover are shared verbatim with the agent CLI
/// (`traits::migrate` + `holger_agent_lib::migrate`); what lives here is the
/// sink that writes into this server own repositories and the job ledger that
/// lets `Start` return while the work runs for hours.
///
/// ONLINE ONLY. Migrating from Nexus is outbound by definition, so the
/// zero-egress airgap tiers do not carry the code at all.
#[cfg(feature = "migration")]
pub mod migration;
/// **Holger's data, off-site** (lane M5): the main's estate and artifacts, sealed for the
/// operator, shipped to an off-site store on a jera job; restorable into a fresh box.
pub mod offsite;
#[cfg(feature = "auth-passkey")]
pub mod passkey;
pub mod peers;
#[cfg(feature = "intake")]
pub mod properties;
pub mod proxy;
#[cfg(feature = "admission")]
pub mod pull_through;
/// Without `admission` the proxies walk inline; the seam stays.
#[cfg(not(feature = "admission"))]
#[path = "pull_through_off.rs"]
pub mod pull_through;
/// Emptying a repository on the main and applying it on the twin (`purge.rs`).
pub mod purge;
pub mod raw;
/// `/readyz` made real (lane B4): storage, the data root, the scan sandbox.
pub mod ready;
pub mod replication;
/// The monotone time floor and the signed offline time token (lane HOL-NCSC).
#[cfg(feature = "bundle-import")]
pub mod time_floor;
// The estate over the wire (twin / recharge): manifest + fetch, pure half.
pub mod estate_wire;
// The twin follows its primary over the network (artifacts via the mirror, estate via the wire).
/// The repositories a person has here (`holger.account.v1`, lane P4).
pub mod account;
/// The data set grows WITHOUT a restart — holger's door over edda `appliance-grow`
/// (gunnar's phase machine): the write hold, the held backends, the releases,
/// the twin roster, `holger.grow.v1.GrowService`.
pub mod grow;
/// Repository tokens for package clients (`holger.token.v1`, lane P3).
#[cfg(feature = "auth-passkey")]
pub mod repo_token;
/// Where more room for a repository is bought (`holger.room.v1`).
pub mod room;
/// This box's generic settings (`/-/settings/<name>`, owner 2026-10-26): bytes under names the
/// clients choose; holger knows none of them.
pub mod settings;
pub mod twin_follow;
// The twin over mutual TLS 1.3: the replication issuer, door, certify and dial (nordisk-twin,
// `estate`); its names, config block and pure rules in every build.
/// The artifact flow, drawn from the RON with the estate and the counters on it
/// (`AdminService.FlowSvg`, `GET /-/flow.svg`, `GET /-/flow.json`; lane HOL-SVG-RON).
pub mod flow;
/// What is open on this Holger: the `access` block, public repositories, the read gate's
/// posture at boot and its audit (HCON-5).
pub mod posture;
/// The Security page, as data: `AdminService.SecuritySummary` (lane HOL-SEC).
pub mod security_view;
/// The estate board, as data: `AdminService.Topology` and `GET /-/estate.json` (lane E3; H2).
pub mod topology;
/// An estate box is WireGuard-only: its estate doors bind, and its estate links dial, on
/// wg0's overlay only (lane E1).
pub mod tunnel_addr;
pub mod twin_tls;
/// The board's upstreams: proxy members and mirror sources, with their evidence (lane A13).
pub mod upstreams;
/// **The plugin trust root's retired environment fallback** (lane E4): REFUSED
/// since stinky SB #22 — the root is `ron_plugin_trust_roots` only.
pub const PLUGIN_TRUST_ROOT_ENV: &str = "HOLGER_PLUGIN_TRUST_ROOT";
/// **Every security policy the environment once carried**, and where it lives
/// now (stinky SB #22, refused by [`Holger::refuse_env_policy`]).
/// The promotion gate keys keep the frozen `holger-promote-2` they were already
/// refused with; the rest are `holger-server-13`.
pub const RETIRED_ENV_POLICY: [(&str, &str, &holger_errcode::ErrCode); 5] = [
(
"HOLGER_PROMOTE_ENFORCE",
"the `promotion:` block: a stage with `gated: true` / `gates: [\"osv\"]`",
&holger_errcode::PROMOTE_POLICY_BLOCKED,
),
(
"HOLGER_PROMOTE_ADVISORY_DB",
"`promotion:` `advisory_db`",
&holger_errcode::PROMOTE_POLICY_BLOCKED,
),
(
"HOLGER_PROMOTE_DENY_LICENSES",
"`promotion:` `deny_licenses`",
&holger_errcode::PROMOTE_POLICY_BLOCKED,
),
(
"HOLGER_PROMOTE_REQUIRE_PROPERTY",
"`promotion:` `require_property: Some(\"key=value\")`",
&holger_errcode::SERVER_ENV_POLICY_REFUSED,
),
(
PLUGIN_TRUST_ROOT_ENV,
"`ron_plugin_trust_roots`",
&holger_errcode::SERVER_ENV_POLICY_REFUSED,
),
];
/// The retired serve-time quarantine property (W6). Setting it refuses the boot
/// with `holger-intake-14`: the intake chain's QUARANTINED state is the one hold.
pub const RETIRED_QUARANTINE_ENV: &str = "HOLGER_QUARANTINE_PROPERTY";
/// The place of the estate's own name in a shared medium's declaration
/// ([`Holger::resolve_estate`]; holger-recipe `estate_ron::ESTATE_PLACEHOLDER`).
pub const ESTATE_PLACEHOLDER: &str = "{estate}";
/// The admin view (`AdminService.AdminSnapshot`): every disk and volume, the twin, the
/// growth, the clock, plugins/monetize, audit and the installer's log door — an admin's read.
pub mod admin_view;
/// How long this box's paired certificates have left: the grades its health words carry
/// (lane E2, the renewal's alarm).
#[cfg(feature = "estate")]
pub mod cert_health;
// The recharge lane: a dead main filled from its twin, the four refusals first.
/// The CVE lookup as an index: `(ecosystem, package)` → advisories, built once (T10).
#[cfg(feature = "scanners")]
pub mod advisory_index;
/// **The evidence public-key door** (lane HOL-OCI-EVIDENCE): `/-/evidence-key`, the public
/// half of `attestation.signer_cert`, for `cosign verify-attestation --key <url>`.
#[cfg(feature = "intake")]
pub mod evidence_key;
/// Untrusted work as jobs behind the Heimdall job API (lane HOL-SANDBOX-TRAIT): the
/// server's runner, its pinned key, and the programs `holger-scan-worker` runs.
#[cfg(feature = "intake")]
pub mod job_runner;
/// The one mutex-poison rule: recover, log once, clear the poison.
pub mod lock;
/// The allocator's share of the server's memory: glibc's arenas capped (lane HOL-RSS).
pub mod memory;
#[cfg(feature = "estate")]
pub mod recharge_lane;
/// **OCI 1.1 referrers** of Holger's own evidence (lane HOL-REFERRERS): the scan
/// evidence and SBOM statements about an image, in the shapes cosign verifies.
#[cfg(feature = "intake")]
pub mod referrers;
/// **The repository-metadata signing key** (lane HOL-SIGNED-APT-DNF): the operator-made
/// OpenPGP key the deb and rpm chain views sign `InRelease`, `Release.gpg` and
/// `repomd.xml.asc` with, and its public half at `/-/repo-key.asc`.
pub mod repo_sign;
pub mod repository;
pub mod retention;
/// The scan sandbox in the server (lane SANDBOX-SCAN): every scan that unpacks,
/// parses or runs an engine over an artifact runs in the scan worker's container.
#[cfg(feature = "scanners")]
pub mod sandbox;
/// **Signed repository indexes** (lane HOL-SIGN-2): the signer of a deb or rpm repository's
/// `InRelease`, `Release.gpg` and `repomd.xml.asc`, for the chain views and, wrapped around
/// the backend, for an archive-backed repository.
pub mod signed_index;
/// **The sandbox's medium alone** (lane HOL-CDS-5, `sandbox-media`): a one-way downstream
/// appliance scans nothing, yet verifies every bundle in a job (`job_runner`), so it lays crun
/// and the worker's rootfs from its medium at boot, and starts clamd as the main does.
#[cfg(all(feature = "sandbox-media", not(feature = "scanners")))]
pub mod sandbox {
pub mod clamd;
pub mod media;
}
/// ★ **The front's report, kept by main** (lane HOL-FLOW-FRONT): its doors, the names it serves
/// and where each goes, with a proof of its own; the flow unfolds the front to it.
pub mod front_report;
#[cfg(feature = "intake")]
pub mod sbom;
/// The scanner framework: one `Scanner` trait, typed evidence, the evidence store.
pub mod scanner;
pub mod search;
#[cfg(feature = "scanners")]
pub mod security;
/// **The estate's Sigstore trust root** (lane HOL-OCI-EVIDENCE): shipped signed in the medium,
/// seeded at boot, refreshed only by a signed bundle import.
#[cfg(feature = "intake")]
pub mod sigstore_trust;
/// Tier 1's RON: `sources:`, `steps:` and `rules:` (Holger G0).
pub mod sources;
pub mod storage;
#[cfg(feature = "sync")]
pub mod sync;
/// **The twin's role** (plan §3 W9): the declared `role:` key, the refusal a
/// follower answers a write with, the loopback rewrite that keeps a twin off the
/// public interface, and the `twin ≥ primary` store invariant.
pub mod twin;
/// Upstream-registry federation (the `full` tier's harbour clients). Gated behind
/// `federation`: the `mini`/`embedded` tiers do NOT compile it and do NOT link
/// Skidbladnir or any registry-client dependency.
#[cfg(feature = "federation")]
pub mod upstream;
pub mod visitors;
// The `rust-remote` pull-through cache upstream — a SYNC `ureq` HTTP client, so an
// outbound-egress path. Gated behind `online` (like reqwest) so the zero-egress
// airgap tiers (`embedded`/`mini`) link NO HTTP client at all.
#[cfg(feature = "online")]
pub mod upstream_rust;
pub use crate::object::LocalHolger;
#[cfg(feature = "fetch")]
pub use crate::object::RemoteHolger;
/// The property store a build without `supply-chain` does not have: never
/// constructed, only named so the retention planner has one type in both builds.
#[cfg(not(feature = "intake"))]
struct NoPropertyStore;
#[cfg(not(feature = "intake"))]
impl NoPropertyStore {
fn matches(&self, _repo: &str, _id: &traits::ArtifactId, _key: &str, _value: &str) -> bool {
false
}
}
pub use traits::HolgerObject;
// Re-export the core artifact types + the backend trait so in-process consumers
// (e.g. nornir's embedded-holger viz pane) can enumerate a repository's
// backend directly — `holger.repositories[i].backend_repository` is an
// `Arc<dyn RepositoryBackendTrait>`, so the trait must be reachable to call
// `list`/`fetch` on it without depending on `holger-traits` separately.
pub use traits::{ArtifactEntry, ArtifactId};
// ========================= VERSION IDENTITY =========================
/// Short git commit hash of the build tree, stamped by `build.rs` (pure-Rust,
/// no shelling — reads `.git/HEAD` directly). `"unknown"` when built without git
/// metadata (e.g. a source tarball).
pub const GIT_HASH: &str = env!("HOLGER_GIT_HASH");
/// The single-source holger version identity: `holger v<CARGO_PKG_VERSION>
/// (<git-hash>)`. Every version surface — the server bins' `--version`/`-V`, the
/// gRPC + in-process `health.version`, and any About/identity readout — prints
/// THIS exact string so they can never drift. Built at compile time (`concat!`).
pub const VERSION_LINE: &str = concat!(
"holger v",
env!("CARGO_PKG_VERSION"),
" (",
env!("HOLGER_GIT_HASH"),
")"
);
/// The `env_logger` filter every holger binary installs when `RUST_LOG` is unset.
///
/// `env_logger`'s own default is `error`, and that is a security bug in a server
/// whose entire boot-time posture report is `log::warn!`: `warn_unpinned_providers`,
/// `warn_unenforced_mtls_ca`, `warn_unauthenticatable_read_gate` and the
/// `auth_allow_unbound_oidc` acknowledgement all say "a control you think is on is
/// off", and under `error` an operator sees none of them — the binary boots clean
/// and silent with the control disabled. These lines are few and fire once at boot,
/// so there is no volume argument for hiding them.
///
/// `RUST_LOG` still wins wherever it is set, so this only moves the floor for a
/// deployment that never configured logging — which is exactly the deployment least
/// likely to notice the missing warning any other way.
///
/// # Why `mirror_cycle` is named at `info`
///
/// MEASURED on `oden`, 2026-09-20. The on-prem mirror rebooted, its first connect
/// failed because the network was one second behind the service, and the journal
/// then said **nothing for eighty minutes** while `systemctl` reported `active`
/// and `NRestarts: 0`. It looked exactly like a mirror that had died. It had not:
/// it completed a cycle every 300 s the whole time (the change map's mtime lands
/// on boot + n×300 s to the second) — but a completed cycle is `log::info!`, the
/// unit pins no `RUST_LOG`, and under a bare `warn` floor SUCCESS IS INVISIBLE.
/// Silence therefore meant both "mirroring fine" and "not mirroring at all", and
/// an operator could not tell which without reading Arrow files off the disk.
///
/// One directive fixes that, and the volume argument does not apply: a mirror
/// emits one line per declared pair per `ron_every_secs` — two lines every five
/// minutes on the box this was measured on. `RUST_LOG` still wins everywhere.
///
/// # Why the clock is named at `info`
///
/// MEASURED on the mock gate, 2026-10-22 (holger 3bb889e5): a box's fed clock sync
/// that fails logs at `warn` (its first sync runs before the tunnel to the front is
/// up, so it always fails once), and the sync that follows the tunnel and succeeds
/// logs at `info` — under the bare `warn` floor the failure was the box's LAST clock
/// line forever, the front's log said it signed that box's time a second later, and
/// the gate read a healthy clock as a failed one. A sync is one line per trigger
/// (boot, tunnel up, a skew refusal, every 6 h), so naming it costs nothing.
pub const DEFAULT_LOG_FILTER: &str = "warn,holger_server_lib::mirror_cycle=info,holger::clock=info";
/// The log target of a box's clock syncs (named at `info` in [`DEFAULT_LOG_FILTER`]).
pub const CLOCK_LOG_TARGET: &str = "holger::clock";
#[cfg(test)]
mod default_log_filter_tests {
use super::DEFAULT_LOG_FILTER;
/// RED if somebody trims the filter back to a bare `warn`: that is the exact
/// state in which a healthy mirror and a dead one both print nothing. (The
/// CLI's `boot_security_warnings_are_visible_with_no_rust_log_set` proves the
/// floor still admits `warn`; this proves the mirror is still named.)
#[test]
fn the_default_floor_admits_warn_and_names_the_mirror_at_info() {
assert!(
DEFAULT_LOG_FILTER.starts_with("warn"),
"the global floor must still be warn — every boot-time SECURITY line is one: \
{DEFAULT_LOG_FILTER}"
);
assert!(
DEFAULT_LOG_FILTER.contains("holger_server_lib::mirror_cycle=info"),
"a completed mirror cycle logs at info; without this directive a deployment that \
never set RUST_LOG cannot tell a mirroring mirror from a stopped one: \
{DEFAULT_LOG_FILTER}"
);
assert!(
DEFAULT_LOG_FILTER.contains(&format!("{}=info", super::CLOCK_LOG_TARGET)),
"a clock sync that succeeds logs at info; without this directive a box whose first \
sync failed before its tunnel was up says it failed for ever: {DEFAULT_LOG_FILTER}"
);
}
}
/// Lowercase-hex SHA-256 of `data` — the content id the retention executor
/// verifies before a backend unlinks (must match the backend's own recompute).
/// SHA-256 (not blake3) so it agrees with the loose-file backends' native
/// checksum path.
fn sha256_hex(data: &[u8]) -> String {
// LAW #5 dedup: the shared content-hash primitive lives once in edda's
// `nornir-hash` leaf; every backend consumes it so all recomputes agree.
nornir_hash::sha256_hex(data)
}
/// `namespace/name@version` (or `name@version` when unscoped) — the artifact
/// label written into the GC audit record.
fn retention_artifact_label(id: &ArtifactId) -> String {
match &id.namespace {
Some(ns) => format!("{ns}/{}@{}", id.name, id.version),
None => format!("{}@{}", id.name, id.version),
}
}
// ========================= WIRING ENGINE =========================
/// Build the `ProxyBackend` that serves `repo`, or `None` when `repo` declares no
/// upstreams (and is therefore served by its own backend directly).
///
/// Extracted from `wire_holger` so it can be tested. What needs testing is not the
/// proxy — that has its own tests — but the ATTACHMENT: each upstream is paired
/// here with the origin-digest seam that lets the proxy ask what the registry
/// itself published for a coordinate. Both of the proxy's digest gates degrade
/// silently to the old, ungated behaviour when that seam is absent, which is the
/// right thing for an upstream that cannot be asked and indistinguishable from
/// correct when the wiring breaks. So the wiring gets a test of its own.
pub(crate) fn build_proxy_for(repo: &Repository) -> Option<crate::proxy::ProxyBackend> {
let backend_arc = repo.backend_repository.as_ref()?;
if repo.wired_upstreams.is_empty() {
return None;
}
// An upstream with no seam is `plain` and is proxied exactly as before —
// silently turning every existing pull-through repo into a pass-through would
// be a product change nobody asked for.
let upstreams: Vec<crate::proxy::ProxyUpstream> = repo
.wired_upstreams
.iter()
.filter_map(|&ptr| {
if ptr.is_null() {
return None;
}
let upstream: &Repository = unsafe { &*ptr };
let backend = upstream.backend_repository.clone()?;
// The estate board's upstream row (lane A13): declared here, its
// outcomes recorded by the walk under the same backend name.
let address = match (&upstream.ron_base_url, upstream.ron_repo_type.as_str()) {
(Some(u), _) => u.clone(),
(None, _) => String::new(),
};
crate::upstreams::register_proxy(
backend.name(),
&upstream.ron_repo_type,
&address,
&repo.ron_name,
crate::clock::now_unix_ms(),
);
Some(match &upstream.upstream_attestation {
Some(attestation) => {
crate::proxy::ProxyUpstream::with_attestation(backend, attestation.clone())
}
None => crate::proxy::ProxyUpstream::plain(backend),
})
})
.collect();
let mut proxy = crate::proxy::ProxyBackend::with_upstreams(backend_arc.clone(), upstreams);
// Optional negative cache: when the repo declares `ron_negative_cache_secs`, a
// coordinate/path that misses primary + every upstream is remembered as a miss
// for that TTL (0 / unset ⇒ disabled, unchanged pull-through).
if let Some(secs) = repo.ron_negative_cache_secs {
proxy = proxy.with_negative_cache_secs(secs);
}
Some(proxy)
}
/// **A paid signup's repository, shaped like `template`** (lane M4): a copy of the template's
/// declaration (its config only, never its wired backend) under `name`, with its own bytes
/// beside the template's. The boot (`Holger::declare_sold_repositories`) and the run-time
/// path (`live_routes::LiveRoutes::serve`) make it the same way.
pub(crate) fn sold_copy(
template: &Repository,
name: &str,
) -> std::result::Result<Repository, String> {
let mut r = serde_json::to_value(template)
.and_then(serde_json::from_value::<Repository>)
.map_err(|e| e.to_string())?;
let beside = |p: &Option<String>, file: &str| {
p.as_deref().map(|p| {
std::path::Path::new(p)
.parent()
.unwrap_or(std::path::Path::new("."))
.join(file)
.display()
.to_string()
})
};
r.ron_data_dir = beside(&template.ron_data_dir, name);
r.ron_writable_archive = beside(&template.ron_writable_archive, &format!("{name}.znippy"));
r.ron_archive_path = None;
r.ron_name = name.to_string();
Ok(r)
}
/// **One repository's backend in the growth's hold** (`grow::Held`), able to build itself
/// again from the repository's own config. The boot (`wire_holger`) and the run-time path
/// (`live_routes::LiveRoutes::serve`) wrap the same way.
pub(crate) fn held_backend(
repo: &Repository,
inner: Arc<dyn RepositoryBackendTrait>,
hold: &Arc<crate::grow::Hold>,
catalog: &Arc<handlers::HandlerCatalog>,
) -> Arc<dyn RepositoryBackendTrait> {
let rebuild = ron::to_string(repo).ok().map(|spec| {
let catalog = Arc::clone(catalog);
Box::new(move || {
let mut again: Repository = ron::from_str(&spec)?;
again.backend_from_config_with(&catalog)?;
again.backend_repository.take().ok_or_else(|| {
anyhow::anyhow!(
"repository `{}` built no backend from its config",
again.ron_name
)
})
}) as crate::grow::Rebuild
});
let held = crate::grow::Held::wrap(inner, repo.ron_name.clone(), Arc::clone(hold), rebuild);
hold.register(Arc::new(crate::grow::HeldRelease(Arc::clone(&held))));
held
}
/// A config's `role:` key as written (`role: Primary`, `role: Twin(..)`), so the
/// refusal can name it (stinky SB #36) — RON spells no `Some(..)` around it.
fn declared_role<'de, D: serde::Deserializer<'de>>(
d: D,
) -> std::result::Result<Option<twin::NodeRole>, D::Error> {
<twin::NodeRole as serde::Deserialize>::deserialize(d).map(Some)
}
pub fn wire_holger(holger: &mut Holger) -> Result<()> {
// ── THE TWIN'S DOORS ─────────────────────────────────────────────────────
//
// FIRST, before anything is indexed or wired, because a door that got as far
// as being wired with a public address is a door somebody will later bind.
// A twin keeps every port its config declares and moves every PUBLIC door
// (`ron_http_url`) onto loopback (`twin::NodeRole::effective_bind`); its
// gRPC CONTROL door stays on the configured (utility) address, as gunnar's
// twin keeps :50051 — writes refused there by name, certify and recharge
// answered (`twin::NodeRole::effective_control_bind`, owner 2026-09-27).
//
// Done HERE and not at each bind site on purpose: a door added to the config
// — or a second listener added to this struct — is loopbacked without anyone
// remembering to do it, and the RON FILE stays byte-identical to the
// primary's, which is what `[estate-twin-drift]` requires.
// THE ROLE COMES ONLY FROM THE PAIRING (stinky SB #36). The installer's door
// elects it (the weakest box is the twin) and the box records it once
// (`twin::elect`, `holger-role.ron`). An environment variable or a config
// `role:` key that names one is REFUSED by name (`holger-twin-21`) — read, it
// is a second declaration of what the box is; ignored, an operator believes a
// setting holds. `[estate-twin-drift]` keeps the twin's RON byte-identical to
// the primary's, so the config never could carry it.
crate::twin::refuse_declared_role_in_env(holger.ron_role.as_ref())
.map_err(|e| anyhow::anyhow!("{e}"))?;
let data_root = holger.data_root();
let effective = crate::twin::effective_role(&data_root).map_err(|e| anyhow::anyhow!("{e}"))?;
if effective.is_twin() {
log::warn!(
"this box runs as `twin`: the role the pairing elected, recorded in {}",
crate::twin::role_record_path(&data_root).display()
);
}
holger.role = effective;
// THE REPLICATION DOOR's address is read off the CONFIGURED gRPC door, before
// a twin's doors move onto loopback: the twin keeps this one where the config
// put it (a recharge dials it), and a primary later elected live keeps it too.
holger.replication_bind = match &holger.replication_door {
None => None,
Some(d) => Some(match &d.ron_bind {
Some(b) => b.clone(),
None => {
let first = holger.exposed_endpoints.first().ok_or_else(|| {
anyhow::anyhow!("replication_door: no ron_bind and no exposed endpoint to sit beside; declare replication_door.ron_bind")
})?;
twin_tls::default_bind(&first.ron_url)
.map_err(|e| anyhow::anyhow!("replication_door: {e}"))?
}
}),
};
if holger.role.is_twin() {
for exp in &mut holger.exposed_endpoints {
exp.ron_url = holger.role.effective_control_bind(&exp.ron_url);
if let Some(http) = &exp.ron_http_url {
exp.ron_http_url = Some(holger.role.effective_bind(http));
}
}
log::warn!(
"this server is a TWIN of `{}`: its public doors are bound on {}, its gRPC control \
door where the config says, and every write is refused by name ({})",
holger.role.primary().unwrap_or("?"),
twin::TWIN_LOOPBACK_HOST,
holger_errcode::TWIN_REFUSES_WRITES.code
);
}
// ── AN ESTATE BOX IS WIREGUARD-ONLY (lane E1, Gunnar's pattern) ─────────
// Every estate door — the gRPC control plane, the HTTP door the front's console
// and route reach, the replication door the twin follows — binds on wg0's
// overlay address: `0.0.0.0` moves there, loopback (a twin's public doors) stays,
// and a door declared on any other address REFUSES TO START. The door names only
// tunnel hosts, so the twin's credential never points off the tunnel.
if let Some(estate) = crate::tunnel_addr::estate() {
let refuse = |e: String| anyhow::anyhow!("{e}. REFUSING TO START.");
for exp in &mut holger.exposed_endpoints {
exp.ron_url = estate.bind("the gRPC door", &exp.ron_url).map_err(refuse)?;
if let Some(http) = &exp.ron_http_url {
exp.ron_http_url = Some(estate.bind("the HTTP door", http).map_err(refuse)?);
}
}
if let Some(b) = &holger.replication_bind {
holger.replication_bind = Some(estate.bind("the replication door", b).map_err(refuse)?);
}
if let Some(d) = holger.replication_door.as_mut() {
d.ron_advertise = crate::tunnel_addr::tunnel_hosts(
Some(&estate),
std::mem::take(&mut d.ron_advertise),
);
}
log::warn!("this box is an ESTATE box: WireGuard-only — its estate doors answer on the tunnel ({}) only", estate.overlay.map(|o| o.to_string()).unwrap_or_default());
}
let mut repo_map = HashMap::new();
// Endpoints are NAMES a repository's `ron_in`/`ron_out` must resolve to; no
// pointer to them is kept (nothing ever read one — stinky 25).
let mut exposed_names = std::collections::HashSet::new();
let mut storage_names = std::collections::HashSet::new();
for repo in &*holger.repositories {
repo_map.insert(repo.ron_name.clone(), repo as *const Repository);
}
for exp in &*holger.exposed_endpoints {
exposed_names.insert(exp.ron_name.clone());
}
for st in &*holger.storage_endpoints {
storage_names.insert(st.ron_name.clone());
}
// Wire repository IO references
for repo in &mut holger.repositories {
for name in &repo.ron_upstreams {
if let Some(ptr) = repo_map.get(name) {
repo.wired_upstreams.push(*ptr);
} else {
return Err(anyhow::anyhow!("Missing upstream repo: {}", name));
}
}
// Members of a virtual `group` repo (resolved in declaration order).
for name in &repo.ron_members {
if let Some(ptr) = repo_map.get(name) {
repo.wired_members.push(*ptr);
} else {
return Err(anyhow::anyhow!(
"Missing member repo '{}' for group '{}'",
name,
repo.ron_name
));
}
}
if let Some(io) = &repo.ron_in {
if !storage_names.contains(&io.ron_storage_endpoint) {
return Err(anyhow::anyhow!(
"Missing storage endpoint: {}",
io.ron_storage_endpoint
));
}
if !exposed_names.contains(&io.ron_exposed_endpoint) {
return Err(anyhow::anyhow!(
"Missing exposed endpoint: {}",
io.ron_exposed_endpoint
));
}
}
if let Some(io) = &repo.ron_out {
if !storage_names.contains(&io.ron_storage_endpoint) {
return Err(anyhow::anyhow!(
"Missing storage endpoint: {}",
io.ron_storage_endpoint
));
}
if !exposed_names.contains(&io.ron_exposed_endpoint) {
return Err(anyhow::anyhow!(
"Missing exposed endpoint: {}",
io.ron_exposed_endpoint
));
}
}
}
// Wire reverse links (endpoint → repos)
for exp in &mut holger.exposed_endpoints {
for repo in &holger.repositories {
if let Some(io) = &repo.ron_out {
if io.ron_exposed_endpoint == exp.ron_name {
exp.wired_out_repositories.push(repo as *const Repository);
}
}
}
}
// The effective serving backend for one repo: its raw backend, or — when it
// declares upstreams — a `ProxyBackend` wrapping it so 404s fall through. A
// repo with no materialized backend (e.g. a `group`, or one disabled on init)
// yields `None`. Shared by the direct route and by group-member resolution, so
// a group over a caching-proxy member sees the SAME pull-through behaviour.
// ── THE GROWTH'S HOLD ON EVERY REPOSITORY (every build) ──────────────────
//
// Each repository's backend is wrapped ONCE, here, FIRST — under the paid
// limit below and before any route, proxy, mirror, replication pair or job
// takes a clone — in `grow::Held`: every write that exists counts in and out
// of the growth's hold (so the drain sees a mirror pull or a twin's follow
// as surely as an upload), and every read is refused while the set is off.
// It is also how the archive the repository serves from is CLOSED before the
// unmount and opened again after: the wrapper is the only holder of the
// inner backend, and it can build it again from the repository's own config
// through the same `backend_from_config_with` the boot used.
let catalog = Arc::new(holger.handler_catalog()?);
{
let hold = holger.grow_hold();
for repo in &mut holger.repositories {
if repo.ron_repo_type == "group" {
continue;
}
if let Some(inner) = repo.backend_repository.take() {
repo.backend_repository = Some(held_backend(repo, inner, &hold, &catalog));
}
}
}
// ── THE PAID LIMIT ON EVERY PROGRAMMATIC WRITE (metered build only) ──────
//
// Each repository's backend is wrapped ONCE, here, before any route, proxy,
// mirror, replication pair or job can take a clone of it — so every `put`
// that never passes a door is asked the same `metered::Quota` the doors ask.
// A twin is not wrapped (see `metered::QuotaGuard`); nothing is wrapped
// when no monetize key is configured.
#[cfg(feature = "metered")]
if !holger.role.is_twin() {
if let Some(quota) = holger.quota()? {
let root = holger.data_root();
for repo in &mut holger.repositories {
if repo.ron_repo_type == "group" {
continue;
}
if let Some(inner) = repo.backend_repository.take() {
repo.backend_repository = Some(crate::metered::QuotaGuard::wrap(
inner,
repo.ron_name.clone(),
Arc::clone("a),
root.clone(),
));
}
}
}
}
fn effective_backend(repo: &Repository) -> Option<Arc<dyn RepositoryBackendTrait>> {
match build_proxy_for(repo) {
Some(proxy) => Some(Arc::new(proxy)),
None => repo.backend_repository.clone(),
}
}
// Build route tables for each exposed endpoint, wrapping repos that have
// configured upstreams in a ProxyBackend so 404s fall through automatically.
for exp in &mut holger.exposed_endpoints {
let mut routes: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = Vec::new();
for &repo_ptr in &exp.wired_out_repositories {
if repo_ptr.is_null() {
continue;
}
let repo: &Repository = unsafe { &*repo_ptr };
// A virtual `group` repo owns no `backend_repository`; it aggregates
// its members' effective backends behind a single name (first hit wins).
if repo.ron_repo_type == "group" {
let members: Vec<Arc<dyn RepositoryBackendTrait>> = repo
.wired_members
.iter()
.filter_map(|&ptr| {
if ptr.is_null() {
return None;
}
let member: &Repository = unsafe { &*ptr };
effective_backend(member)
})
.collect();
routes.push((
repo.ron_name.clone(),
Arc::new(crate::group::GroupBackend::new(
repo.ron_name.clone(),
members,
)),
));
continue;
}
let Some(backend) = effective_backend(repo) else {
continue;
};
routes.push((repo.ron_name.clone(), backend));
}
exp.aggregated_routes = if routes.is_empty() {
None
} else {
Some(FastRoutes::new(routes))
};
}
// ── A REPOSITORY MADE AT RUN TIME SERVES AT ONCE (lane M4b) ──────────────
// What `LiveRoutes::serve` needs to build one more backend the way the loops
// above did and swap it into its door's table (shared with every clone).
let live = crate::live_routes::LiveRoutes {
doors: holger
.exposed_endpoints
.iter()
.filter_map(|ep| {
ep.aggregated_routes
.as_ref()
.map(|t| (ep.ron_name.clone(), t.clone()))
})
.collect(),
specs: holger
.repositories
.iter()
.filter(|r| r.ron_repo_type != "group")
.filter_map(|r| {
ron::to_string(r)
.ok()
.map(|spec| (r.ron_name.clone(), spec))
})
.collect(),
catalog: Arc::clone(&catalog),
hold: holger.grow_hold(),
read_gate: holger.read_gate(),
#[cfg(feature = "metered")]
quota: if holger.role.is_twin() {
None
} else {
holger.quota()?.map(|q| (q, holger.data_root()))
},
every: Default::default(),
made: Default::default(),
};
let _ = holger.live_routes_cell.set(Arc::new(live));
// ── THE METER PLANE ──────────────────────────────────────────────────────
//
// Built HERE and not in `instantiate_backends`, because the usage source a
// meter guest reads through counts through the route table, and the route
// table does not exist until the loop above has run. A meter that could not
// be given the routes would be a meter loaded with `NoServices` — which is
// exactly the inert plane this replaces.
//
// A module that fails its grant or its verification is an `Err` and stops
// the boot: a metering plane that quietly holds fewer meters than the
// operator configured is a box that stopped counting without saying so.
#[cfg(feature = "meter-guests")]
holger.build_meter_plane()?;
// ── THE MIRROR PLANE ─────────────────────────────────────────────────────
//
// Validated HERE and not at parse time, because half of what makes a mirror
// declaration wrong is a cross-reference: `ron_to` has to name a repository
// that this very config declares AND that came out of `instantiate_backends`
// writable. Both are true only after the loop above.
//
// A refusal stops the boot. A mirror that could not be validated would be a
// server that reports a configured upstream on `/-/flow` and takes nothing
// from it — the same green-looking dead appliance the serving marker exists
// to catch.
if let Some(mirror) = &holger.mirror {
let repos = holger.mirror_repo_posture();
mirror.validate(&repos)?;
}
Ok(())
}
// ========================= ROOT HOLGER =========================
/// Server security posture. `Standard` keeps the existing availability-oriented
/// semantics (open reads, best-effort audit). `Critical` is opt-in and
/// **fail-closed**: startup refuses any configuration that cannot enforce the
/// profile (see [`Holger::validate_profile`]).
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SecurityProfile {
#[default]
Standard,
Critical,
}
/// Whether the node accepts network mutations at all. `ReadWrite` is the existing
/// behavior; `ReadOnly` rejects every network PUT/POST/PATCH/DELETE and every
/// mutating gRPC call **before the request body is collected**, at the auth choke
/// points, in addition to the existing write-gate.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OperationMode {
#[default]
ReadWrite,
ReadOnly,
}
/// The root config node. **Unknown keys are a boot error** — see
/// [`read_ron_config`] for the reasoning; in short, a key holger does not
/// understand is a key holger is not honouring, and the worst instance of that is
/// a security control that reads as ON in the file and is OFF in the process.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Holger {
/// Property keys only an ADMIN may set over the wire, on top of the one the
/// server derives from live state (the promotion engine's required property).
///
/// `None` (the default) ⇒ [`grpc::DEFAULT_RESERVED_PROPERTY_KEYS`], which is
/// `keep` — the documented retention pin. Declare this ONLY if this deployment
/// renamed the pin (`retention --protect-property <other>=…`), because the
/// retention policy is built in another process and the server cannot derive
/// the real key. `Some([])` deliberately reserves nothing.
///
/// The `retention` verb refuses a `--protect-property` key that is not in this
/// set: a pin any Writer can set is not a pin.
#[serde(default)]
pub reserved_properties: Option<Vec<String>>,
pub repositories: Vec<Repository>,
pub exposed_endpoints: Vec<ExposedEndpoint>,
pub storage_endpoints: Vec<StorageEndpoint>,
/// Optional auth configuration. Absent or empty methods = open access.
#[serde(default)]
pub auth: Option<auth::AuthConfig>,
/// **What is open on this Holger** (HCON-5): `access: (reads: Gated | Open, front: Open |
/// Gated)`. Absent: the build decides ([`posture::BUILD_READS`]: an estate build gates
/// reads, the default and downstream builds read openly). A repository's
/// `visibility: Public` is read with no sign-in even when reads are gated.
#[serde(default)]
pub access: Option<posture::Access>,
/// Audit-log configuration. Defaults to ON (the hash-chained log) so a config that
/// says nothing still gets an audit trail; `mode: Off` refuses the start in every
/// profile (`holger-audit-13`, lane HOL-NCSC).
#[serde(default)]
pub audit: AuditSettings,
/// Security posture. Defaults to `Standard` so every existing config is
/// unchanged; `Critical` is opt-in and fail-closed.
#[serde(default)]
pub security_profile: SecurityProfile,
/// Read/write posture. Defaults to `ReadWrite`; `ReadOnly` blocks every
/// network mutation at the choke points before the body is collected.
#[serde(default)]
pub operation_mode: OperationMode,
/// Network posture. Omitted, it is `Auto` (Holger v2.0 B2): the airgap unless the
/// config names an upstream ([`Holger::resolved_network`]). `Offline` is the secure
/// side of an airgap: the boot refuses a config that needs the network, and every
/// outbound dial is refused before a socket is opened ([`egress`]).
#[serde(default)]
pub network: egress::NetworkMode,
/// Directory of `.wasm` package handlers to load at startup, in addition to
/// the ones linked in. A module registers under the handler name its own
/// manifest declares, and any repository may then name it with
/// [`Repository::ron_handler`] — so a new handler is a config string plus a
/// file here, with no holger rebuild.
///
/// **Omitted ⇒ only the linked handlers**, and the plugin loader never runs.
/// A directory that is configured but missing, unreadable, or a file is a
/// boot error rather than an empty catalog: "no plugins" and "I could not
/// look" would otherwise be the same answer, and the second one surfaces in
/// production as a 404 over bytes that exist.
#[serde(default)]
pub ron_plugin_dir: Option<String>,
/// **What each module in [`Holger::ron_plugin_dir`] may do.**
///
/// ```ron
/// ron_plugin_grants: [
/// ( module: "allocation.wasm", grants: ["usage.read", "store.list"] ),
/// ],
/// ```
///
/// From the config FILE, never from an environment variable: the appliance
/// has no shell, so an env var is a decision nobody can make and nobody can
/// read back.
///
/// **A module with no entry gets the EMPTY grant**, so a module dropped into
/// the directory without an operator decision can do nothing at all; if its
/// manifest declares anything, that is a refusal by name at boot. An unknown
/// capability name is a boot error listing the vocabulary, rather than a
/// typo that silently grants less.
#[serde(default)]
pub ron_plugin_grants: Vec<PluginGrant>,
/// **The roots every loaded module must chain to** (lane E4, 2026-10-19): DER
/// certificates, the house's being `korp-root-ca.der` (dwarves `trust/`), the
/// root the forge's plugin-signing certificate is issued under. Each module in
/// [`Holger::ron_plugin_dir`] then needs its detached `.p7s` (CMS over the
/// module's BLAKE3), verified BEFORE it is compiled; a missing signature is
/// `holger-plugin-6`, a bad or a stranger's one `holger-plugin-7`.
///
/// Empty = none (the `HOLGER_PLUGIN_TRUST_ROOT` fallback is refused, stinky
/// SB #22), and then a build that loads
/// METER guests refuses its plugin directory by name (`holger-plugin-13`): a
/// meter counts what a customer pays for and is never loaded unverified. A
/// handler-only build without a root loads its handlers as before.
#[serde(default)]
pub ron_plugin_trust_roots: Vec<String>,
/// The handler half of the module census, recorded while
/// [`Holger::instantiate_backends`] builds the catalog, so `ServerProfile`
/// can report it without compiling every module a second time.
///
/// Not configuration: `#[serde(skip)]`, and a RON file that names it is
/// naming something holger overwrites at boot.
#[serde(skip)]
pub handler_census: Vec<(String, String)>,
/// **The meter plane this boot has**, built once by [`wire_holger`] as soon
/// as the route tables exist — because that is the earliest moment the usage
/// source can be handed the routes it counts through.
///
/// `None` until then, and `None` is not "no meters": it is "the plane has
/// not been built yet". [`Holger::module_census`] tells the two apart, and a
/// caller that never wired the config still gets a census (built with
/// `NoServices`, which is all a census needs).
///
/// Not configuration: `#[serde(skip)]`.
#[cfg(feature = "meter-guests")]
#[serde(skip)]
pub meter_plane: Option<Arc<holger_plugin_host::MeterCatalog>>,
/// The one audit sink this config has, once something has asked for it. See
/// [`Holger::build_audit_log`] for why it is memoized rather than rebuilt.
///
/// Not configuration: `#[serde(skip)]`.
#[serde(skip)]
audit_sink: std::sync::OnceLock<Arc<dyn audit::AuditLog>>,
/// **The ONE job runner of this process** (lane HOL-SANDBOX-TRAIT,
/// [`Holger::jobs`]): made on first use, its per-boot key pinned then; the import
/// door and the scan worker share it. Not configuration: `#[serde(skip)]`.
#[cfg(feature = "intake")]
#[serde(skip)]
jobs_cell: std::sync::OnceLock<Arc<job_runner::Jobs>>,
/// The SIEM exporters [`Holger::start_siem_export`] started.
/// Not configuration: `#[serde(skip)]`.
#[serde(skip)]
siem_exporters: std::sync::OnceLock<audit::siem::ExporterHandles>,
/// The ONE paid-limit check of this process (`metered::Quota`), made on first
/// use, so the HTTP door and every repository's `QuotaGuard` share its
/// readings — two caches would each miss the other's admissions.
/// Not configuration: `#[serde(skip)]`.
#[cfg(feature = "metered")]
#[serde(skip)]
quota_cell: std::sync::OnceLock<Option<Arc<crate::metered::Quota>>>,
/// **The ONE paid-state store of this process**, made on first use, so a key the
/// installer feeds after the start reaches every door that holds it (A7 row 8).
/// Not configuration: `#[serde(skip)]`.
#[cfg(feature = "metered")]
#[serde(skip)]
entitlement_cell: std::sync::OnceLock<Option<Arc<crate::metered::EntitlementStore>>>,
/// **The growth's monetize go-ahead policy**, kept so a late key is applied in place.
/// Not configuration: `#[serde(skip)]`.
#[cfg(feature = "metered")]
#[serde(skip)]
grow_policy_cell: std::sync::OnceLock<Arc<appliance_grow::MonetizePolicy>>,
/// **The monetize key is AWAITED** (A7 row 8): an estate box whose config and kept feed
/// name none builds its paid-state store keyless, and the front's later offer gives it
/// the key live ([`Holger::live_keys`]). Set by holger-server's boot, never by the RON.
#[cfg(feature = "metered")]
#[serde(skip)]
pub monetize_key_awaited: bool,
/// **The growth's ONE write hold** (`crate::grow::Hold`), made on first
/// use: `wire_holger` wraps every repository in it and `start` hands the
/// SAME one to the gRPC gates and the HTTP door.
/// Not configuration: `#[serde(skip)]`.
#[serde(skip)]
grow_hold_cell: std::sync::OnceLock<Arc<crate::grow::Hold>>,
/// **The read gate every door shares** ([`Holger::read_gate`]). Not configuration.
#[serde(skip)]
read_gate_cell: std::sync::OnceLock<Arc<crate::auth::ReadGate>>,
/// **What serves a repository made at run time** ([`crate::live_routes::LiveRoutes`]),
/// set by `wire_holger` once the route tables exist. Not configuration: `#[serde(skip)]`.
#[serde(skip)]
live_routes_cell: std::sync::OnceLock<Arc<crate::live_routes::LiveRoutes>>,
/// **The repositories paid orders made, and whose they are** (lane P1,
/// [`crate::auth::SoldOwners`]): read from `fleet/sold-repositories.json` once, then the ONE
/// table every read gate of this process holds. Not configuration: `#[serde(skip)]`.
#[serde(skip)]
sold_owners_cell: std::sync::OnceLock<crate::auth::SoldOwners>,
/// **The shop's first-login invite**, as the installer fed it (lane P1): the passkey store
/// takes it in once, when it opens ([`passkey::PasskeyDoor::seed_first_admin`]). Not
/// configuration: `#[serde(skip)]`.
#[cfg(feature = "auth-passkey")]
#[serde(skip)]
pub first_admin_invite: Option<passkey::FirstAdminInvite>,
/// **The site the ONE box fed** (RA5-1: `identity_host::site_from_feed` over the installer's
/// feed, set by holger-server's boot): a shop-laid box's sign-up is what its estate declares,
/// never guessed from its host. `None`: the box's own (`identity_host::site_of`). Not
/// configuration: `#[serde(skip)]`.
#[cfg(feature = "auth-passkey")]
#[serde(skip)]
pub estate_site: Option<nornir_identity::Site>,
/// **The growth plane** (`crate::grow::GrowPlane`), made once per process.
/// Not configuration: `#[serde(skip)]`.
#[cfg(feature = "estate")]
#[serde(skip)]
grow_plane_cell: std::sync::OnceLock<Arc<crate::grow::GrowPlane>>,
/// **monetize's Ed25519 public key**, 32 bytes as 64 lowercase hex — the key a
/// signed `EntitlementFact` must verify against.
///
/// **Omitted ⇒ no plan is enforced**: `EntitlementService.Set` answers
/// `holger-metered-4` rather than storing a fact it cannot vouch for. The field
/// is read only by the `metered` build; an open build parses and ignores it, so
/// one config file serves both and a deployment that later buys the metered
/// binary does not have to be re-written.
///
/// It is a PUBLIC key: it goes in the config file, never in the environment.
/// The environment is where `marketplace`'s client SECRET lives, and the
/// difference between the two is the whole reason they are wired differently.
#[serde(default)]
pub monetize_pubkey: Option<String>,
/// **The estate's pay page** (lane M4): where a repository refused for its plan is paid
/// (`{tenant}` the repository). A box of a sold estate is fed it by its front (the shop's
/// `refusal_link`); unset, a paid refusal names none.
#[serde(default)]
pub pay_page: Option<String>,
/// **Retired (A34-1, 2026-10-26): parsed, and changes nothing.** It used to
/// let an `Oidc` method with no resolvable JWKS boot on nornir-auth-oidc's
/// `/userinfo` fall-through, which checked neither `iss` nor `aud`. That path
/// is gone (nornir-auth-oidc 0.2 verifies a JWT or refuses it), so such a
/// method authenticates nobody and [`auth::refuse_unbound_oidc`] refuses it
/// whatever this says. Kept so an old config still loads (the config is
/// strict); `true` is warned about at every boot.
#[serde(default)]
pub auth_allow_unbound_oidc: bool,
/// The **promotion stage ladder** (`.nornir/promote-sync-attest-design.md` §4,
/// `.nornir/promotion-design.md` §1) — the rungs an artifact is promoted along,
/// and the repository that holds it at each rung.
///
/// **Omitted ⇒ no stage model**, which is exactly what holger did before this
/// field existed: [`promote::PromotionEngine::seal`] judges no hop, writes no
/// lineage, and a promotion between any two repository names is permitted. That
/// is the unchanged default and every existing config keeps it.
///
/// Present ⇒ every promotion is resolved onto the ladder BEFORE anything is
/// written, so a backwards hop or a skipped rung leaves the store untouched, and
/// every successful hop is appended to the lineage under
/// `<data_root>/holger-prod-store/lineage/<ladder_name>`.
///
/// Declaring it and getting it wrong is a **boot error**, not a runtime surprise
/// — see [`PromotionSettings::ladder`].
#[serde(default)]
#[cfg(feature = "intake")]
pub promotion: Option<PromotionSettings>,
/// **Tier 1: where packages come from** ([`sources`], Holger G0). Each entry is an
/// `upload` door into the steps, a `crates` pull-through or an upstream `holger`
/// mirror, and lowers onto the shapes above (`repositories`, `mirror`) at load.
#[serde(default)]
pub sources: Vec<sources::Source>,
/// **Tier 1: the steps** ([`sources::Steps`]), the new spelling of `promotion:`
/// with its `intake:` block. Lowered into [`Holger::promotion`] at load; naming
/// both is refused by name.
#[serde(default)]
#[cfg(feature = "intake")]
pub steps: Option<sources::Steps>,
/// **Tier 1: the global floor** ([`sources::Rules`]). Holds for every source; a
/// source's `gates` may only tighten it. Absent with `steps:` ⇒ the security
/// gateway's default floor ([`sources::Rules::default`]).
#[serde(default)]
#[cfg(feature = "intake")]
pub rules: Option<sources::Rules>,
/// The effective policy of every upload source, built by [`Holger::lower_sources`].
/// Not configuration.
#[serde(skip)]
#[cfg(feature = "intake")]
pub source_policies: Option<sources::SourcePolicies>,
/// The `steps:` block as the RON wrote it, kept by [`Holger::lower_sources`] for the flow
/// drawing (lane HOL-SVG-RON): the lowering adds the floor's scanners to what the RON
/// names, and the drawing names what the RON names. Not configuration.
#[serde(skip)]
#[cfg(feature = "intake")]
pub steps_declared: Option<sources::Steps>,
/// The **evidence plane** (`attestation:` block) — the trust roots and the
/// accepted predicate types for [`attest::AttestStore`].
///
/// Absent ⇒ no attestation store is mounted, which is where every existing
/// deployment is: MEASURED at `542e296`, nothing in the shipped tree ever
/// constructed one, so `attest.rs` was 1 458 lines with no caller.
#[serde(default)]
#[cfg(feature = "intake")]
pub attestation: Option<AttestationSettings>,
/// **AI model files** (`models:` block). GGUF, Safetensors and ONNX are
/// checked and described by the intake's model scanner; the pickle family is
/// blocked. `allow_pickle` names the repositories where a pickle-family file
/// is a warning instead (`models: (allow_pickle: ["ml-research"])`); each
/// must be a declared repository, or the boot is refused
/// (`holger-model-8`). Absent ⇒ blocked everywhere.
#[serde(default)]
#[cfg(feature = "scanners")]
pub models: scanner::model::ModelSettings,
/// **The mirror** (`mirror:` block) — the upstreams this holger continuously
/// mirrors, the repositories it takes from each, and what it does about a
/// coordinate whose sameness it cannot prove. See
/// [`mirror_cycle::MirrorSettings`].
///
/// **Absent ⇒ no mirror**, which is where every config written before this
/// key existed is: [`mirror`] shipped as a library with no configuration and
/// no caller, so an older file described a holger that mirrored nothing and
/// still describes exactly that. Nothing is listed, nothing is fetched,
/// nothing is written; not one byte of prior behaviour moves.
///
/// Present and wrong is a **boot error**, not a runtime surprise — see
/// [`mirror_cycle::MirrorSettings::validate`], which [`wire_holger`] calls.
#[serde(default)]
pub mirror: Option<mirror_cycle::MirrorSettings>,
/// **`boundary: OneWay | Fetch`** (lane HOL-CDS, [`boundary::Boundary`]): which way
/// packages may come in. Omitted is `OneWay` on a downstream. A downstream build checks
/// it at every start ([`Holger::check_boundary`]); any other build checks it when the
/// file names it.
#[serde(default)]
pub boundary: Option<boundary::Boundary>,
/// **`domain: "<label>"`**: the security domain this Holger serves, shown in the
/// console's top bar, the CLI's output and every response's `X-Holger-Domain`.
#[serde(default)]
pub domain: Option<String>,
/// **`trust: (first_import: Admin)`** ([`boundary::TrustSettings`]): the first import
/// of a vendor's product is an admin's action on this side (NCSC gap 2).
#[serde(default)]
pub trust: boundary::TrustSettings,
/// **`export: (push_to: Some("<dir>"), every: "<interval>")`** (the upstream's diode
/// send side, [`export_push::ExportPushSettings`]): each new signed bundle of the
/// AVAILABLE set is written there, atomically. On a one-way downstream it is an export
/// door and refuses the start.
#[serde(default)]
pub export: Option<export_push::ExportPushSettings>,
/// The file this config was read from and its text (for the line a refusal names).
/// Not configuration: `#[serde(skip)]`.
#[serde(skip)]
pub ron_source: Option<(String, String)>,
/// **The artifact-flow drawing** (`flow:` block, [`flow::FlowSettings`]): ON by
/// default; `flow: (enabled: false)` makes `/-/flow*` answer 404 and
/// `AdminService.FlowSvg` NotFound.
#[serde(default)]
pub flow: flow::FlowSettings,
/// **Pull-through INTO the intake chain** (`pull_through:` block,
/// [`pull_intake`]): remote sources (an OCI registry such as `cgr.dev`, a
/// PEP 503/691 index such as PyPI or Chainguard Libraries for Python) whose
/// packages are fetched on a client's request, INGESTED into the chain's
/// `incoming` as `pull-through:<source>`, scanned and policy-checked under
/// the floor plus the source's gates, and served only once AVAILABLE.
/// Needs `promotion.intake` and the job queue.
#[cfg(all(feature = "jobs", feature = "intake"))]
#[serde(default)]
pub pull_through: Option<pull_intake::PullThroughSettings>,
/// **The fetch sources** (lane cds4, plan row HOL-CDS-4): each source a fetch job may reach,
/// by its declared hosts (`job_runner::fetch::FetchSource`). On a box whose policy requires a
/// scan, a `plaintext: true` or `internal: true` source is refused by name unless it also
/// says `signed_provenance: true`; either one is a warning on the Security page.
#[cfg(all(feature = "fetch", feature = "intake"))]
#[serde(default)]
pub fetch_sources: Vec<job_runner::fetch::FetchSource>,
/// **The sync protocol** (`sync:` block, Holger v2.0 H4, [`sync::SyncConfig`]): this
/// Holger's name and key, the downstreams whose acks it takes (upstream role) and
/// the upstreams whose signed manifests it applies (downstream role). Needs
/// `promotion.intake`.
#[cfg(feature = "sync")]
#[serde(default)]
pub sync: Option<sync::SyncConfig>,
/// **Offline vulnerability intelligence** (`intel:` block) — where the local
/// advisory database lives, whether the network is refused (the default),
/// and when a stale database warns and blocks. See [`intel::IntelConfig`].
///
/// Absent ⇒ the OSV scan reads `promotion.advisory_db` as before, and every
/// existing config means what it meant. Always compiled, so an mvp box's
/// `intel import` reads the same file a full server does.
#[serde(default)]
pub intel: Option<intel::IntelConfig>,
/// **Download admission and the queued pull-through** ([`admission`]).
/// Absent ⇒ `PassThrough`: every request planned `Full`, no share, nothing
/// refused, proxies walk their upstreams inline — exactly as before.
#[serde(default)]
#[cfg(feature = "admission")]
pub admission: Option<admission::AdmissionConfig>,
/// **A `role:` key — REFUSED by name** (stinky SB #36, `holger-twin-21`).
///
/// Kept as a known key only so that a config carrying one is told WHY it is
/// refused rather than "unknown field". A box's role comes only from the
/// pairing's election, recorded in `holger-role.ron` ([`twin::elect`]); the
/// twin's config stays byte-identical to the primary's, which is what
/// `[estate-twin-drift]` demands.
#[serde(
default,
rename = "role",
skip_serializing_if = "Option::is_none",
deserialize_with = "declared_role"
)]
pub ron_role: Option<twin::NodeRole>,
/// **What this box IS, this boot**: the pairing's election recorded on the
/// box ([`twin::effective_role`]), `Primary` when none was — set by
/// [`wire_holger`], never read from the config (stinky SB #36).
#[serde(skip)]
pub role: twin::NodeRole,
/// **Recharge this box from a twin before it serves** (owner plan 2b,
/// 2026-09-27): `host:port` of the twin (or paired downstream) that holds
/// what the dead main held. At the first start the estate (config and side
/// stores) and every repository are pulled from it ONCE
/// ([`twin_follow::recharge`]); a marker (`holger-recharged`) keeps it once.
/// Absent ⇒ nothing is pulled.
#[serde(default)]
pub recharge_from: Option<String>,
/// **The replication door** (stinky 14 §P3): a second gRPC listener at the
/// gRPC port + 1, mutual TLS 1.3 under this box's OWN issuer, serving a
/// twin (or a recharging main) the repositories and `holger.estate.v1`
/// read-only — see [`twin_tls`]. Absent ⇒ no door, no issuer, the box as
/// before. A twin keeps this door on its CONFIGURED address: it is the one
/// door a twin must keep open, because a recharge dials it.
#[serde(default)]
pub replication_door: Option<twin_tls::ReplicationDoorConfig>,
/// Where the replication door binds, resolved by [`wire_holger`] BEFORE a
/// twin's doors are rewritten onto loopback. Not configuration.
#[serde(skip)]
pub replication_bind: Option<String>,
/// **This process's follower for THIS box** — the posture its doors answer
/// with, captured by [`Holger::start`], so a follow started later (a twin at
/// boot: [`twin_follow::spawn`]) reports to the SAME posture `TwinStatus`
/// and `/-/twin` read. Not configuration.
#[serde(skip)]
pub live_follower: std::sync::OnceLock<twin_follow::Follower>,
/// The job queue [`Holger::start`] opened (`<data_root>/jobs`), kept so the
/// HTTP door's pull-through and the mirror's failed assets reach the SAME
/// ledger the gRPC door uses. Not configuration.
#[cfg(feature = "jobs")]
#[serde(skip)]
pub job_queue_cell: std::sync::OnceLock<Arc<jobs::JobQueue>>,
/// The ONE door admission of this process (both transports share it).
/// Not configuration.
#[serde(skip)]
#[cfg(feature = "admission")]
pub door_admission: std::sync::OnceLock<Arc<admission::DoorAdmission>>,
/// The ONE intake chain of this process, built by [`Holger::start`] from
/// `promotion.intake` and shared by both doors. Not configuration.
/// Shared with every chain view (`chain_view`, lane HOL-MVP-FORMATS): a view built before
/// the chain reads it from here once it is set.
#[serde(skip)]
#[cfg(feature = "intake")]
pub intake_cell: chain_view::ChainSlot,
/// The ONE intake scan pipeline (`promotion.intake.scan`), shared by the
/// SCANNED transition, the `osv` gate's stored-evidence read (W5) and the
/// evidence doors. Filled when the chain is built. Not configuration.
#[serde(skip)]
#[cfg(feature = "intake")]
pub scan_pipeline_cell: promote::gate::EvidenceCell,
/// The ONE scan worker (lane SANDBOX-SCAN), shared by the intake chain, its
/// scan pipeline and the `osv` gate's licence read. Filled when the chain is
/// built. Not configuration.
#[serde(skip)]
#[cfg(feature = "intake")]
pub scan_worker_cell: promote::gate::WorkerCell,
/// The ONE import door of this process (lane W4, [`import_door`]), built by
/// [`Holger::start`] and shared by both transports and the inbox jobs. Not
/// configuration.
#[serde(skip)]
#[cfg(feature = "intake")]
pub import_door_cell: std::sync::OnceLock<Arc<import_door::ImportDoor>>,
/// **The box's repository signing key** (lane HOL-SIGNED-APT-DNF), read once from the
/// data root's key store ([`repo_sign::secret_path`]): the deb and rpm views sign with
/// it and `/-/repo-key.asc` serves its public half. Not configuration: the operator
/// makes it on the box (`holger repo-key create`).
#[serde(skip)]
pub repo_key_cell: std::sync::OnceLock<Option<Arc<repo_sign::RepoKey>>>,
}
/// The declared evidence plane (the `attestation:` block of the RON config).
///
/// Unknown keys are refused for the same reason as everywhere else in this
/// file: a typo'd key would read as configured and run as absent.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[cfg(feature = "intake")]
pub struct AttestationSettings {
/// Paths to DER-encoded root certificates a statement's signer must chain
/// to. At least one is required; see [`Holger::attestation_store`].
pub trust_roots: Vec<String>,
/// in-toto predicate types the store accepts. Empty ⇒ the store's default
/// set ([`attest::AttestPolicy::default`]).
#[serde(default)]
pub accept_predicates: Vec<String>,
/// PKCS#8 private key (DER) holger signs its own evidence with. With
/// [`signer_cert`](Self::signer_cert) this enables the promotion VSA.
///
/// Absent ⇒ holger emits no evidence and SAYS SO in the audit record, rather
/// than promoting silently. holger mints no key: `nornir --seal` signs with
/// `ZnippySealer::dev()`, which mints an in-memory CA and discards the root,
/// so a signature produced that way chains to a CA nobody has. This key is
/// the operator's.
#[serde(default)]
pub signer_key: Option<String>,
/// The X.509 certificate (DER) carrying `signer_key`'s public key. It must
/// chain to one of [`trust_roots`](Self::trust_roots), because holger's own
/// VSA is written through [`attest::AttestStore::accept`] like any other
/// evidence: if it does not verify, it is not filed.
#[serde(default)]
pub signer_cert: Option<String>,
/// `"ed25519"` (default) or `"ecdsa-p256"`.
#[serde(default)]
pub signer_alg: Option<String>,
/// **The signer's key in a hardware token** instead of `signer_key` (lane HOL-NCSC, NCSC
/// P3): `Some(Pkcs11(module: "/usr/lib/softhsm/libsofthsm2.so", token_label: "holger",
/// key_label: "scan", pin_file: "/etc/holger/hsm.pin"))`. The key never leaves the token;
/// `signer_cert` is its certificate. Needs a build with the `pkcs11` feature.
#[serde(default)]
pub signer_hsm: Option<HsmSigner>,
/// Certificates (DER paths) of OTHER signers whose evidence this holger
/// accepts: on the secure side of an airgap, the online holger's evidence
/// signer. Chaining to a root proves a key belongs to the organisation;
/// enrolment names the one key that speaks for the scanners. This holger's
/// own [`signer_cert`](Self::signer_cert) is always enrolled.
#[serde(default)]
pub enrolled_signers: Vec<String>,
}
/// **A signer whose key lives in a hardware token** ([`AttestationSettings::signer_hsm`]).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub enum HsmSigner {
/// A PKCS#11 module (an HSM, a smart card, SoftHSM2): the token with `token_label`, the
/// private key with `key_label`, the user PIN in `pin_file` (a key file: 0600).
Pkcs11 {
module: String,
token_label: String,
key_label: String,
pin_file: String,
},
}
/// The declared promotion ladder (the `promotion:` block of the RON config).
///
/// Unknown keys are refused here for the same reason as on [`Holger`]: a typo'd
/// `allow_skipping` would read as "one rung at a time" in the file while the
/// process ran with the default — and the default is the *safe* one, so the typo
/// would be invisible until the day somebody hotfixed straight to release and it
/// worked.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[cfg(feature = "intake")]
pub struct PromotionSettings {
/// The ladder's name — the lineage namespace. One ecosystem's ladder per name;
/// `stage_history` and `current_stage` are answered within it.
pub ladder_name: String,
/// The rungs, **bottom (rehearsal) first**. A stage names itself and the
/// repository that holds artifacts while they are at it, which is what
/// `seal(from_repo, to_repo, …)` already takes — so the ladder is a lookup over
/// the mechanism that exists, not a second one.
///
/// `gated: true` on a rung means arriving there runs every gate this build
/// carries; `gates: ["osv", …]` names them.
pub stages: Vec<promote::Stage>,
/// **The advisory feed** — a path to an OSV/GHSA/RUSTSEC JSON snapshot.
///
/// This is `HOLGER_PROMOTE_ADVISORY_DB` moved from the environment into the
/// config file. holger ships as an appliance with no OS and no shell, so an
/// environment variable was a configuration surface the operator could not
/// reach. Absent means an empty CVE set; the licence and provenance policy
/// still applies. A path that cannot be read or parsed is a BOOT ERROR, not
/// a silently empty gate.
#[serde(default)]
pub advisory_db: Option<String>,
/// SPDX identifiers the licence policy denies. Was
/// `HOLGER_PROMOTE_DENY_LICENSES`. A denied id never passes, also when it is
/// on `allow_licenses` too.
#[serde(default)]
pub deny_licenses: Vec<String>,
/// SPDX identifiers the licence policy allows. Empty (the default) allows
/// every id that is not denied; non-empty blocks every id outside it. An
/// `OR` passes when one branch is allowed, an `AND` needs every branch
/// (`scanner::license::LicensePolicy`).
#[serde(default)]
pub allow_licenses: Vec<String>,
/// **What a package whose licence is unknown gets** — `Hold` (the default),
/// `Block` or `Pass`. The licence is read from the package's own metadata
/// by the licence scanner (`scanner::license`); a package that states none,
/// none this build can read, or one that is not SPDX, is unknown. `Hold` refuses the hop as "held for
/// review", so an unknown licence never passes silently (B2, 2026-10-19).
#[serde(default)]
#[cfg(feature = "scanners")]
pub unknown_license: security::UnknownLicense,
/// **The gates that run on INGEST** — on a PUT, before the bytes reach the
/// backend.
///
/// Promotion is not the first boundary an artifact crosses; upload is. With
/// this empty (the default, and what holger has always done) a vulnerable
/// artifact is stored and served from the DEV repository, and the gate's job
/// becomes stopping it from moving rather than stopping it from arriving.
///
/// Same catalog, same HOL-GUN-007: a name this build does not carry is a
/// boot error listing what is accepted.
#[serde(default)]
pub ingest_gates: Vec<String>,
/// The in-toto predicate type the `attested` gate requires, when that gate
/// is named by a rung. Absent means the build carries no `attested` gate, so
/// a rung naming it is refused at boot by name rather than skipped.
#[serde(default)]
pub require_attestation: Option<String>,
/// **A custom property the SOURCE coordinate must carry to be promoted** —
/// `"key=value"`, or a bare `"key"` for any value (`"signed=true"`). Was
/// `HOLGER_PROMOTE_REQUIRE_PROPERTY`; an appliance has no shell to set an
/// environment variable in, and that variable is now refused by name.
/// Absent ⇒ no requirement. A value that names no key is a boot error.
#[serde(default)]
pub require_property: Option<String>,
/// **What this estate demands of provenance at the promotion boundary** —
/// `Require`, `Warn` or `Off`. Plan §3 W17.
///
/// Absent ⇒ [`promote::ProvenancePolicy::Warn`], and the direction of that
/// default is load-bearing in BOTH directions: `Require` would make an
/// upgraded binary refuse every promotion on a config that did not change,
/// and `Off` would make an estate that never signed anything print the same
/// as one that considered the question and declined. `Warn` refuses nothing
/// and says, by frozen code, exactly what `Require` would have refused. The
/// full argument is on [`promote::ProvenancePolicy`].
///
/// The gate this key steers is registered on EVERY row, whatever the policy,
/// so `gates: ["provenance"]` is a configuration an operator can write on any
/// shipped holger — which it was not before 2026-09-21.
#[serde(default)]
pub provenance: promote::ProvenancePolicy,
/// Permit a forward hop that jumps rungs (the hotfix-straight-to-release shop).
/// Default `false` — one rung at a time, which is the whole point of having a
/// ladder.
#[serde(default)]
pub allow_skipping: bool,
/// **The intake chain** ([`promote::intake`]): which repositories are
/// incoming, quarantine, approved and production, and the policy for each
/// state. Present ⇒ the ladder above must be exactly those four repositories
/// in that order, every upload into `incoming` is INGESTED through the chain,
/// nothing is written into the other three by a door, and production serves
/// only AVAILABLE artifacts, from the sealed store. Absent ⇒ no intake chain.
#[serde(default)]
pub intake: Option<promote::intake::IntakeSettings>,
}
#[cfg(feature = "intake")]
impl PromotionSettings {
/// The licence policy these settings state: the one the promotion gate and
/// the intake's licence slot both apply.
#[cfg(feature = "scanners")]
pub fn license_policy(&self) -> scanner::license::LicensePolicy {
scanner::license::LicensePolicy::new(
self.allow_licenses.clone(),
self.deny_licenses.clone(),
self.unknown_license,
)
}
/// Resolve the declaration into a [`promote::StageLadder`], refusing a ladder
/// that cannot mean what it says.
///
/// `known_repos` is the set of repository names the config declares. It is
/// passed in rather than read off `self` so this is pure over its arguments and
/// testable without building a whole `Holger`.
///
/// **Four refusals, each because the alternative fails OPEN:**
///
/// 1. **No rungs.** `promotion: Some(…)` with an empty `stages` asked for a
/// stage model and named none. An empty [`promote::StageLadder`] is the
/// "judge nothing" ladder (`stages.rs:196`), so accepting this would install
/// a ladder that permits every hop under a config that reads as though hops
/// were controlled. Declaring nothing and declaring an empty something must
/// not be the same thing.
/// 2. **A repeated stage name.** [`StageLadder::position`] is a `find`, so the
/// second rung of a duplicated name is unreachable and every hop is judged
/// against the first. Silently.
/// 3. **A repeated repository.** [`StageLadder::stage_of_repo`] is also a
/// `find`. Two rungs on one repository means `seal` resolves that repository
/// to whichever rung comes first, so the other rung can never be hopped to
/// *or from*, and a promotion that looks like it crossed a boundary did not.
/// 4. **A repository the config does not declare.** This one would otherwise be
/// a *runtime* refusal — `check_repo_hop` answers `UnknownStage` — which
/// turns a typo in the ladder into a promotion that fails in production with
/// a message about stages rather than at boot with a message about the typo.
/// Worse, the typo can fail open: if the MISspelled name is the `to_repo` of
/// a hop nobody takes, nothing ever notices.
///
/// A one-rung ladder is allowed and is not a mistake: it is a repository that
/// artifacts may be promoted *from* and never *to*, and `check_hop` refuses
/// every hop on it by `NotForward`, which is a coherent thing to want.
pub fn ladder(
&self,
known_repos: &std::collections::BTreeSet<&str>,
) -> Result<promote::StageLadder> {
if self.stages.is_empty() {
anyhow::bail!(
"config `promotion`: the ladder '{}' declares no stages. A ladder with no rungs \
judges nothing, which is what OMITTING the whole `promotion` block means — so \
declaring an empty one would read as 'promotion is controlled' and run as \
'promotion is unconditional'. Name the rungs, or remove the block.",
self.ladder_name
);
}
let mut seen_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut seen_repos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for stage in &self.stages {
if !seen_names.insert(stage.name.as_str()) {
anyhow::bail!(
"config `promotion`: stage name '{}' appears more than once on ladder '{}'. \
A rung is found by name, so the second one is unreachable and every hop \
would be judged against the first.",
stage.name,
self.ladder_name
);
}
if !seen_repos.insert(stage.repo.as_str()) {
anyhow::bail!(
"config `promotion`: repository '{}' is on ladder '{}' at more than one \
stage. A promotion names repositories, so that repository resolves to one \
rung and the other can never be hopped to or from.",
stage.repo,
self.ladder_name
);
}
if !known_repos.contains(stage.repo.as_str()) {
anyhow::bail!(
"config `promotion`: stage '{}' on ladder '{}' names repository '{}', which \
this config does not declare (declared: {}). Caught here rather than at \
promote time, where it would surface as 'no such stage' instead of naming \
the typo.",
stage.name,
self.ladder_name,
stage.repo,
known_repos.iter().copied().collect::<Vec<_>>().join(", ")
);
}
}
if let Some(intake) = &self.intake {
let repos: Vec<&str> = self.stages.iter().map(|s| s.repo.as_str()).collect();
intake.validate(&repos, self.allow_skipping)?;
}
let ladder = promote::StageLadder::new(self.stages.clone());
Ok(if self.allow_skipping {
ladder.allow_skipping()
} else {
ladder
})
}
}
/// Audit-log configuration. On by default; the default backend is the
/// append-only JSON-lines log ([`audit::FileAuditLog`]).
///
/// Unknown keys are refused here for the same reason as on [`Holger`]: a typo'd
/// `enabled` would read as "audit on" in the file while the process installed the
/// default — and under `Critical` the audit sink is a *requirement*, so a typo
/// would turn a fail-closed gate into a pass.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct AuditSettings {
/// Directory for the append-only audit file. Relative paths
/// resolve against the server's working directory.
#[serde(default = "default_audit_dir")]
pub dir: String,
/// Master switch. `false` installs a no-op audit log (audit disabled).
///
/// This is the LEGACY spelling of [`AuditMode::Off`] and is kept because
/// deployed RON files say it. `enabled: false` still means off, whatever
/// `mode` says — see [`AuditSettings::effective_mode`], which is the only
/// place the two are combined.
#[serde(default = "default_true")]
pub enabled: bool,
/// **How much** to record: `Everything` (the default) or `Off`. Under
/// `SecurityProfile::Critical` this must not be `Off`.
#[serde(default)]
pub mode: audit::AuditMode,
/// Records between two checkpoint lines of the hash chain
/// ([`audit::chain`]); `0` writes none. Absent = 256. A checkpoint is
/// signed when `attestation.signer_key`/`signer_cert` are configured.
#[serde(default)]
pub checkpoint_every: Option<u64>,
/// **SIEM export** ([`audit::siem`]): JSON lines, syslog, CEF, OTLP. Absent,
/// or a sink without `enabled: true`, exports nothing.
#[serde(default)]
pub siem: Option<audit::siem::SiemSettings>,
}
fn default_audit_dir() -> String {
"holger-audit".to_string()
}
impl AuditSettings {
/// The mode this config actually asks for, combining the modern `mode` key
/// with the legacy `enabled` switch.
///
/// `enabled: false` wins: a deployed file that says "audit off" must keep
/// meaning it after an upgrade that introduced a `mode` key defaulting to
/// `Everything`. The other direction — `enabled: true, mode: Off` — is simply
/// `Off`; `enabled` cannot turn auditing ON beyond what `mode` asks for.
///
/// One function rather than two call sites reading two fields, because the
/// combination is exactly the sort of thing that ends up spelled differently
/// in the server and in the CLI and then disagrees.
pub fn effective_mode(&self) -> audit::AuditMode {
if !self.enabled {
audit::AuditMode::Off
} else {
self.mode
}
}
}
/// **The built-in meter's name** (`allow-meter`, H2 #2): the open allocation guest,
/// linked. It is the name in the module census and the name an operator's grant line uses.
#[cfg(feature = "allow-meter")]
pub const BUILTIN_METER: &str = "allocation";
/// One line of [`Holger::ron_plugin_grants`]: a module file name, and the
/// capabilities an operator decided it may have.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct PluginGrant {
/// The module's file name as it sits in the plugin directory
/// (`allocation.wasm`), or the name a LINKED guest registers under. Not the
/// name inside its manifest: the operator grants the file they put there.
pub module: String,
/// Capability names, exactly as `Capability::name()` spells them.
#[serde(default)]
pub grants: Vec<String>,
}
/// Anchor a configured directory against the installer's data root. Absolute
/// stays absolute; relative joins `data_root` when one is supplied, else is left
/// CWD-relative. Split out from [`Holger::resolved_audit_dir`] so the policy is
/// testable without mutating process env.
///
/// It is ONE policy, not the audit log's private habit — every server-side
/// directory a RON file names goes through it, so "where does holger keep this"
/// has one answer per install and not one answer per config key. It was named
/// `anchor_audit_dir` while the audit log was its only caller;
/// [`Holger::resolved_ron_plugin_dir`] is the second, and the rename is the
/// admission that the first name was describing the caller rather than the rule.
fn anchor_under_data_root(
configured: &str,
data_root: Option<std::ffi::OsString>,
) -> std::path::PathBuf {
let configured = std::path::Path::new(configured);
if configured.is_absolute() {
return configured.to_path_buf();
}
match data_root {
Some(root) if !root.is_empty() => std::path::PathBuf::from(root).join(configured),
_ => configured.to_path_buf(),
}
}
#[cfg(test)]
mod audit_dir_anchor_tests {
use super::anchor_under_data_root;
use std::ffi::OsString;
use std::path::PathBuf;
#[test]
fn relative_default_anchors_under_data_root() {
assert_eq!(
anchor_under_data_root("holger-audit", Some(OsString::from("/home/me/.holger"))),
PathBuf::from("/home/me/.holger/holger-audit")
);
}
#[test]
fn absolute_config_wins_over_data_root() {
assert_eq!(
anchor_under_data_root("/srv/audit", Some(OsString::from("/home/me/.holger"))),
PathBuf::from("/srv/audit")
);
}
#[test]
fn without_data_root_stays_cwd_relative() {
assert_eq!(
anchor_under_data_root("holger-audit", None),
PathBuf::from("holger-audit")
);
assert_eq!(
anchor_under_data_root("holger-audit", Some(OsString::new())),
PathBuf::from("holger-audit")
);
}
/// The `.wasm` plugin directory gets THE SAME home, and an absent one is still
/// absent.
///
/// `ron_plugin_dir` had no declared home at all: the configured string went
/// into `catalog.load_dir(dir)` raw, so a relative `plugins` pointed at
/// whatever CWD the process was launched in — the host's when holger is
/// embedded, the unit's when installed, something else after a reinstall. The
/// question "does a module survive a reinstall" was answered by the launcher
/// rather than by the config. It is anchored now, by the one helper.
///
/// RED-when-broken: hand `load_dir` the raw string again and the first case
/// here stops joining the data root.
#[test]
fn the_plugin_dir_is_anchored_like_the_audit_dir() {
let plugin_dir_of = |configured: Option<&str>, data_root: Option<&str>| {
// `resolved_ron_plugin_dir` reads `$HOLGER_DATA`; the POLICY it applies
// is this helper, so the policy is asserted without mutating process
// env (the same split `anchor_under_data_root` exists for).
configured
.map(str::trim)
.filter(|d| !d.is_empty())
.map(|d| anchor_under_data_root(d, data_root.map(OsString::from)))
};
assert_eq!(
plugin_dir_of(Some("plugins"), Some("/home/me/.holger")),
Some(PathBuf::from("/home/me/.holger/plugins"))
);
assert_eq!(
plugin_dir_of(Some("/srv/holger-plugins"), Some("/home/me/.holger")),
Some(PathBuf::from("/srv/holger-plugins"))
);
assert_eq!(
plugin_dir_of(Some("plugins"), None),
Some(PathBuf::from("plugins"))
);
// Absent / blank ⇒ no directory, and the loader never runs.
assert_eq!(plugin_dir_of(None, Some("/home/me/.holger")), None);
assert_eq!(plugin_dir_of(Some(" "), Some("/home/me/.holger")), None);
}
}
#[cfg(test)]
mod critical_audit_tests {
use super::*;
fn holger_from(ron: &str) -> Holger {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("h.ron");
std::fs::write(&p, ron).unwrap();
read_ron_config(&p).unwrap()
}
#[test]
fn critical_audit_disabled_build_fails_closed() {
let h = holger_from(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"a",enabled:false),security_profile:Critical)"#,
);
assert!(
h.build_audit_log().is_err(),
"critical profile must refuse to build an audit log when audit is disabled"
);
}
#[test]
fn critical_unwritable_audit_dir_build_fails_closed() {
// A file where the audit dir should be — create_dir_all under it fails.
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, b"x").unwrap();
let dir = blocker.join("audit");
let ron = format!(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"{}",enabled:true),security_profile:Critical)"#,
dir.to_string_lossy()
);
let p = tmp.path().join("h.ron");
std::fs::write(&p, &ron).unwrap();
let h = read_ron_config(&p).unwrap();
assert!(
h.build_audit_log().is_err(),
"critical profile must fail closed when the audit sink cannot be opened"
);
}
#[test]
fn standard_audit_disabled_is_refused_never_a_noop() {
// HOL-NCSC: no profile runs without its audit log (`holger-audit-13`).
let h = holger_from(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"a",enabled:false))"#,
);
let e = format!(
"{:#}",
h.build_audit_log()
.err()
.expect("audit off refuses the start")
);
assert!(e.contains("[holger-audit-13]"), "{e}");
}
/// ★ NCSC (lane HOL-AUDIT): an audit dir that cannot be opened refuses the
/// start in the STANDARD profile too — never a silent no-op logger.
#[test]
fn standard_unopenable_audit_dir_refuses_instead_of_a_noop_logger() {
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("not-a-dir");
std::fs::write(&blocker, b"x").unwrap();
let ron = format!(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"{}",enabled:true))"#,
blocker.join("audit").to_string_lossy()
);
let p = tmp.path().join("h.ron");
std::fs::write(&p, &ron).unwrap();
let h = read_ron_config(&p).unwrap();
let e = h
.build_audit_log()
.err()
.expect("the standard profile must refuse, not fall back to a no-op log")
.to_string();
assert!(e.contains("[holger-audit-12]"), "{e}");
}
fn tampered_audit_dir() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("audit");
let log = audit::FileAuditLog::new(&dir)
.unwrap()
.with_checkpoint_every(0);
for i in 0..3u64 {
audit::AuditLog::record(
&log,
audit::AuditEvent::new(
"rita",
audit::AuditAction::Download,
"pypi",
format!("x-{i}.whl"),
"10.0.0.1:1",
200,
7,
),
)
.unwrap();
}
let path = dir.join(audit::AUDIT_FILE);
let text = std::fs::read_to_string(&path).unwrap().replacen(
"\"ident\":\"rita\"",
"\"ident\":\"mallory\"",
1,
);
std::fs::write(&path, text).unwrap();
(tmp, dir)
}
/// ★ NCSC (lane HOL-AUDIT): the chain is verified at startup. A tampered log
/// refuses a critical boot by name; a standard boot records the break (so it
/// reaches the SIEM) and serves.
#[test]
fn a_tampered_chain_is_detected_at_startup() {
let (_t, dir) = tampered_audit_dir();
let ron = |extra: &str| {
format!(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"{}",enabled:true){extra})"#,
dir.to_string_lossy()
)
};
let critical = holger_from(&ron(",security_profile:Critical"));
let e = critical
.build_audit_log()
.err()
.expect("a critical boot over a broken chain is refused")
.to_string();
assert!(
e.contains("[holger-audit-4]") && e.contains("line 1"),
"{e}"
);
let standard = holger_from(&ron(""));
let log = standard
.build_audit_log()
.expect("the standard profile boots");
let rows = log.query_source().unwrap().events().unwrap();
let last = rows.last().unwrap();
assert!(
last.detail.contains("audit chain BROKEN at startup")
&& last.detail.contains("[holger-audit-4]"),
"{:?}",
last.detail
);
}
// ── the three modes, as an operator writes them in RON ───────────────────
fn audit_ron(extra: &str) -> Holger {
holger_from(&format!(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"a"{extra}))"#
))
}
/// The RON key and its three values, spelled exactly as a config file spells
/// them. This is the contract with every deployed `holger.ron`.
#[test]
fn the_ron_mode_key_accepts_off_and_everything() {
use audit::AuditMode;
for (ron, want) in [
(",mode:Off", AuditMode::Off),
(",mode:Everything", AuditMode::Everything),
] {
let h = audit_ron(ron);
assert_eq!(h.audit.mode, want, "RON `{ron}`");
assert_eq!(h.audit.effective_mode(), want);
}
}
/// A config written before the modes existed keeps the behaviour it had. A
/// default that recorded LESS would silently downgrade every deployment's
/// evidence on upgrade, which is the one direction this must not fail in.
#[test]
fn a_config_that_names_no_mode_still_records_everything() {
let h = audit_ron("");
assert_eq!(h.audit.effective_mode(), audit::AuditMode::Everything);
}
/// `enabled: false` is the legacy spelling of `mode: Off` and must keep
/// meaning it, even against a `mode` that says otherwise — an upgrade must
/// not switch auditing back ON for a deployment that turned it off.
#[test]
fn the_legacy_enabled_switch_still_means_off_and_wins() {
use audit::AuditMode;
assert_eq!(
audit_ron(",enabled:false").audit.effective_mode(),
AuditMode::Off
);
assert_eq!(
audit_ron(",enabled:false,mode:Everything")
.audit
.effective_mode(),
AuditMode::Off,
"`enabled: false` is not overridden by a mode key"
);
assert_eq!(
audit_ron(",enabled:true,mode:Off").audit.effective_mode(),
AuditMode::Off,
"and `enabled` cannot turn auditing on beyond what `mode` asks for"
);
}
/// A misspelled mode REFUSES the config rather than running a different
/// amount of auditing than the file asks for — the same reasoning behind
/// `deny_unknown_fields` on this struct.
#[test]
fn a_misspelled_mode_refuses_the_config() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("h.ron");
std::fs::write(
&p,
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"a",mode:Everyting))"#,
)
.unwrap();
assert!(
read_ron_config(&p).is_err(),
"an unknown mode is a refusal, not a default"
);
}
/// `mode: Off` must trip the critical gate exactly as `enabled: false` does.
/// A fail-closed check that only knew the old key would let a critical node
/// boot unaudited through the new one.
#[test]
fn critical_refuses_mode_off_the_same_way_it_refuses_enabled_false() {
let h = holger_from(
r#"(repositories:[],exposed_endpoints:[],storage_endpoints:[],audit:(dir:"a",mode:Off),security_profile:Critical)"#,
);
let err = h
.validate_profile()
.expect_err("critical must refuse an unaudited node");
assert!(
format!("{err}").contains("audit"),
"the refusal names audit: {err}"
);
assert!(
h.build_audit_log().is_err(),
"and the sink refuses to build too"
);
}
}
fn default_true() -> bool {
true
}
impl Default for AuditSettings {
fn default() -> Self {
Self {
dir: default_audit_dir(),
enabled: true,
// The default must be the old behaviour: an existing RON file that
// names no mode gets the complete, per-call, chained log it got
// before this key existed. A default that recorded LESS would be a
// silent downgrade of every deployment's evidence on upgrade.
mode: audit::AuditMode::Everything,
checkpoint_every: None,
siem: None,
}
}
}
// ========================= LOAD RON CONFIG =========================
/// Read the RON config graph from disk.
///
/// **A key holger does not recognise is a boot error**, not a shrug: [`Holger`]
/// and [`AuditSettings`] are `deny_unknown_fields`. Serde's default is to skip an
/// unknown key silently, and the failure mode that buys is the worst one a config
/// file has — `security_profil: Critical` parses, the process runs `Standard`, and
/// every fail-closed gate in [`Holger::validate_profile`] is simply never reached.
/// Nothing in the logs, nothing in the audit trail, nothing at the door: a node the
/// operator believes is Critical serves like a lab box. The same shape hides a
/// typo'd `operation_mode` (a ReadOnly node accepting writes), a typo'd
/// `require_auth_for_reads`, or a typo'd `auth_allow_unbound_oidc`.
///
/// The cost of the strict reading is a config with a stale key failing to boot.
/// That risk was weighed and taken: no field has ever been REMOVED from `Holger`
/// (checked over the struct's whole history), so there is no such config in the
/// wild; the config is read once, at boot, in front of an operator, not in a hot
/// path; and RON names the offending key and its position in the error, so the fix
/// is mechanical. If a field is ever retired, retire it by keeping the key and
/// ignoring it — `#[serde(default)] #[deprecated]` — rather than by deleting it,
/// which is what `deny_unknown_fields` turns from a convention into a rule.
/// **Is `r` a read-only view of the intake chain's AVAILABLE set** (lane HOL-MVP-FORMATS):
/// a deb, rpm, helm, maven3 or go repository with no archive and no handler of its own.
/// ONE predicate (SEC-REVIEW 2026-11-01 P10) for the wiring that makes the view, the chain's
/// list of views, and the side doors that exempt it: a handler-backed repository is no view,
/// so where a scan is required it is a side door.
#[cfg(feature = "intake")]
pub(crate) fn is_chain_view(r: &Repository) -> bool {
r.ron_archive_path.is_none()
&& r.ron_writable_archive.is_none()
&& r.ron_handler
.as_deref()
.map(str::trim)
.is_none_or(str::is_empty)
&& chain_view::view_format(&r.ron_repo_type).is_some()
}
pub fn read_ron_config<P: AsRef<Path>>(path: P) -> Result<Holger> {
let path = path.as_ref();
let file = File::open(path)?;
let reader = BufReader::new(file);
let holger: Holger = sources::ron_options().from_reader(reader).map_err(|e| {
anyhow::anyhow!(
"config {}: {e}\n\
(holger reads its TOP-LEVEL config strictly — an unrecognised key there is \
refused rather than skipped, because a mistyped key is a setting holger is not \
honouring while the file says it is. Fix the key named above, or delete it.)",
path.display()
)
})?;
let mut holger = holger;
holger.ron_source = std::fs::read_to_string(path)
.ok()
.map(|t| (path.display().to_string(), t));
if let Some(d) = &holger.domain {
boundary::validate_domain(d)
.map_err(|e| anyhow::anyhow!("config {}: {e}", path.display()))?;
}
holger
.lower_sources()
.map_err(|e| anyhow::anyhow!("config {}: {e:#}", path.display()))?;
holger
.apply_access()
.map_err(|e| anyhow::anyhow!("config {}: {e}", path.display()))?;
Ok(holger)
}
/// **How far the strictness goes, and where it stops.**
///
/// `#[serde(deny_unknown_fields)]` is on [`Holger`] and [`AuditSettings`], so a
/// typo in `security_profile`, `operation_mode`, `reserved_properties` or inside
/// the `audit` block is a boot error rather than a silently ignored line.
///
/// It is NOT on the nested node types — `Repository`, `ExposedEndpoint`,
/// `StorageEndpoint`. The `auth` block IS strict since 2026-10-23 (Holger G0):
/// `AuthConfig` and its methods live in the shared `nornir-auth-policy` leaf, which
/// denies unknown fields from 0.1.5, so `require_auth_for_read` (for
/// `require_auth_for_reads`) stops the boot by name. The tier 1 blocks (`sources:`,
/// `steps:`, `rules:` and every source's `gates`, [`sources`]) are strict too.
///
/// A CONSEQUENCE worth stating: with the top level strict, an OLDER holger reading
/// a config written for a NEWER one now fails to boot rather than ignoring the
/// fields it does not know. That is the intended direction — a binary that cannot
/// honour a setting should say so — but it means config and binary must be rolled
/// together. The corollary rule for this file: retire a field by keeping the key
/// and ignoring it, never by deleting it.
///
/// Autogenerate the RON config for the **dev release pair** that nornir embeds:
///
/// * `/cache` — a writable-znippy primary with a `crates-io` (`rust-remote`)
/// upstream → a transparent caching crates.io mirror (write-through fills it).
/// * `/sparring` — a writable-znippy registry → the release rehearsal target;
/// `seal` freezes it into a static drift snapshot.
///
/// nornir writes this to a file and launches `holger start --config <file>` as a
/// subprocess (no library linking, no version coupling). cargo speaks the HTTP
/// SPARSE protocol, so it talks to `http_addr` (the FastRoutes gateway), e.g.
/// `sparse+http://<http_addr>/cache/index/`; `grpc_url` is the control plane.
/// Archives live under `data_dir`.
/// Render the **dev release pair** config — the registry nornir embeds:
///
/// * `/cache` — a writable-znippy primary with a `crates-io` (`rust-remote`)
/// upstream → a transparent caching crates.io mirror (write-through fills it).
/// * `/sparring` — a writable-znippy registry → the release rehearsal target;
/// `seal` freezes it into a static drift snapshot.
///
/// nornir writes this to a file and launches `holger start --config <file>` as a
/// subprocess (no library linking, no version coupling). cargo speaks the HTTP
/// SPARSE protocol, so it talks to `http_addr` (the FastRoutes gateway), e.g.
/// `sparse+http://<http_addr>/cache/index/`; `grpc_url` is the control plane.
/// Archives live under `data_dir`.
///
/// nornir launches `holger start --config <that file>`, which makes the output a
/// shipped contract (pinned byte for byte by `dev_pair_ron_is_byte_identical`).
pub fn dev_pair_ron(data_dir: &Path, grpc_url: &str, http_addr: &str) -> String {
let d = data_dir.display();
let grpc = bare_socket_addr(grpc_url);
let http = bare_socket_addr(http_addr);
let mut stores = String::new();
// The crates.io pull-through (`rust-remote`) exists only with `online`. Without it
// (the small default build, 2026-10-22) the pair is two local registries, so the
// first start still boots instead of refusing an unknown repository type.
let online = cfg!(feature = "online");
let mut repos = String::new();
if online {
repos.push_str(
" ( ron_name: \"crates-io\", ron_repo_type: \"rust-remote\", ron_base_url: Some(\"https://index.crates.io\"), ron_upstreams: [], ron_in: None, ron_out: None ),\n",
);
}
let upstreams = if online { "[\"crates-io\"]" } else { "[]" };
for n in ["cache", "sparring"] {
stores.push_str(&format!(
" ( ron_name: \"{n}-store\", ron_storage_type: \"znippy\", ron_path: \"{d}/{n}/\" ),\n"
));
repos.push_str(&format!(
" ( ron_name: \"{n}\", ron_repo_type: \"rust\", ron_writable_archive: Some(\"{d}/{n}.znippy\"), ron_base_url: Some(\"http://{http}\"), ron_upstreams: {upstreams}, ron_in: None, ron_out: Some(( ron_storage_endpoint: \"{n}-store\", ron_exposed_endpoint: \"dev\" )) ),\n"
));
}
format!(
"(\n exposed_endpoints: [\n ( ron_name: \"dev\", ron_url: \"{grpc}\", ron_http_url: Some(\"{http}\") ),\n ],\n storage_endpoints: [\n{stores} ],\n repositories: [\n{repos} ],\n)\n"
)
}
/// Reduce a URL-ish string to a bare `host:port`: strip any `scheme://` prefix and
/// any trailing `/path`. `start` parses `ron_url` / `ron_http_url` as bare
/// `SocketAddr`s.
pub(crate) fn bare_socket_addr(url: &str) -> &str {
let no_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
no_scheme.split(['/', '?', '#']).next().unwrap_or(no_scheme)
}
#[cfg(test)]
mod dev_pair_tests {
/// Without `online` there is no `rust-remote` type, so the pair has no crates.io
/// upstream: two local registries that boot.
#[cfg(not(feature = "online"))]
#[test]
fn dev_pair_ron_without_online_names_no_remote() {
let r = crate::dev_pair_ron(std::path::Path::new("/d"), "127.0.0.1:1", "127.0.0.1:2");
assert!(
!r.contains("rust-remote") && !r.contains("crates-io"),
"{r}"
);
assert!(
r.contains("ron_name: \"cache\"") && r.contains("ron_name: \"sparring\""),
"{r}"
);
}
#[cfg(feature = "online")]
#[test]
fn dev_pair_ron_is_byte_identical() {
let dir = std::path::PathBuf::from("/var/lib/holger-dev");
let rendered = crate::dev_pair_ron(&dir, "https://127.0.0.1:18443", "127.0.0.1:18444");
let expected = r#"(
exposed_endpoints: [
( ron_name: "dev", ron_url: "127.0.0.1:18443", ron_http_url: Some("127.0.0.1:18444") ),
],
storage_endpoints: [
( ron_name: "cache-store", ron_storage_type: "znippy", ron_path: "/var/lib/holger-dev/cache/" ),
( ron_name: "sparring-store", ron_storage_type: "znippy", ron_path: "/var/lib/holger-dev/sparring/" ),
],
repositories: [
( ron_name: "crates-io", ron_repo_type: "rust-remote", ron_base_url: Some("https://index.crates.io"), ron_upstreams: [], ron_in: None, ron_out: None ),
( ron_name: "cache", ron_repo_type: "rust", ron_writable_archive: Some("/var/lib/holger-dev/cache.znippy"), ron_base_url: Some("http://127.0.0.1:18444"), ron_upstreams: ["crates-io"], ron_in: None, ron_out: Some(( ron_storage_endpoint: "cache-store", ron_exposed_endpoint: "dev" )) ),
( ron_name: "sparring", ron_repo_type: "rust", ron_writable_archive: Some("/var/lib/holger-dev/sparring.znippy"), ron_base_url: Some("http://127.0.0.1:18444"), ron_upstreams: ["crates-io"], ron_in: None, ron_out: Some(( ron_storage_endpoint: "sparring-store", ron_exposed_endpoint: "dev" )) ),
],
)
"#;
assert_eq!(rendered, expected, "the dev-pair RON is a shipped contract");
}
}
impl Holger {
// ─── Rust API (direct function bindings) ─────────────────────────
/// Fetch an artifact by ID from a named repository
pub fn fetch(
&self,
repository: &str,
id: &traits::ArtifactId,
) -> Result<Option<traits::Bytes>> {
let repo = self.get_repo(repository)?;
repo.fetch(id)
}
/// Store an artifact (write-enabled repos only)
pub fn put(&self, repository: &str, id: &traits::ArtifactId, data: &[u8]) -> Result<()> {
let repo = self.get_repo(repository)?;
if !repo.is_writable() {
anyhow::bail!("Repository '{}' is read-only", repository);
}
repo.put(id, data)
}
/// List all configured repository names
pub fn list_repositories(&self) -> Vec<&str> {
self.repositories
.iter()
.map(|r| r.ron_name.as_str())
.collect()
}
/// Cross-repo artifact SEARCH (parity §H, `.nornir/search-design.md`) — the
/// in-process entry the CLI's `search` verb drives. Collects every configured
/// repository's live backend and runs the pure [`search::search_repos`]
/// engine against `query`. Repos with no materialized backend (a virtual
/// `group`, or one disabled on init) are skipped — their members are searched
/// directly. Read-only: it never mutates a backend.
pub fn search_all(&self, query: &search::SearchQuery) -> search::SearchResults {
let repos = self.backend_pairs();
#[cfg(feature = "intake")]
let props = self.property_store();
#[cfg(feature = "intake")]
let lookup: Option<&dyn search::PropertyLookup> = Some(&*props);
#[cfg(not(feature = "intake"))]
let lookup: Option<&dyn search::PropertyLookup> = None;
search::search_repos_with_properties(&repos, query, lookup)
}
/// Collect this store's `(repo_name → live backend)` pairs — the raw material
/// both `search_all` and the replication adapters aggregate over. Repos with
/// no materialized backend (a virtual `group`, or one disabled on init) are
/// skipped.
fn backend_pairs(&self) -> Vec<(String, Arc<dyn RepositoryBackendTrait>)> {
self.repositories
.iter()
.filter_map(|r| {
r.backend_repository
.as_ref()
.map(|b| (r.ron_name.clone(), b.clone()))
})
.collect()
}
/// `(name, writable)` for every repository this config declares — what
/// [`mirror_cycle::MirrorSettings::validate`] checks a `ron_to` against.
///
/// Writability is read off the BACKEND, never off the RON: a repo can
/// declare a `ron_data_dir` and still come out of `instantiate_backends`
/// read-only (a sealed archive, a tier that linked no writer). The mirror
/// has to refuse on what the process actually holds, not on what the file
/// intended.
pub fn mirror_repo_posture(&self) -> Vec<(String, bool)> {
self.repositories
.iter()
.map(|r| {
(
r.ron_name.clone(),
r.backend_repository
.as_ref()
.map(|b| b.is_writable())
.unwrap_or(false),
)
})
.collect()
}
/// **The per-repository read scope**, derived from the repositories that
/// declared [`ron_require_auth`](crate::repository::Repository::ron_require_auth).
///
/// Derived from the config each time it is asked for, because configuration
/// cannot change under a running server — the same reasoning the flow graph
/// is built once on. Empty for every config that has not opted in, which
/// makes [`crate::auth::ReadGate::applies`] exactly
/// `AuthConfig::read_gate_enabled()` and the whole path a no-op.
///
/// **ONE gate per server** (lane P4): built on the first call — the wiring's, after every
/// repository of this start is declared — and the SAME `Arc` from then on, so a repository
/// gated at run time ([`crate::auth::ReadGate::gate_from_now`]) is gated on every door.
pub fn read_gate(&self) -> Arc<crate::auth::ReadGate> {
Arc::clone(self.read_gate_cell.get_or_init(|| {
Arc::new(
crate::auth::ReadGate::from_repos(
self.repositories
.iter()
.filter(|r| r.ron_require_auth)
.map(|r| r.ron_name.clone()),
)
.with_floors(
self.repositories
.iter()
.filter_map(|r| r.ron_require_role.map(|f| (r.ron_name.clone(), f))),
)
// HCON-5: the public repositories and the front's word, one gate for every door.
.with_access(
self.repositories
.iter()
.filter(|r| r.ron_visibility == posture::Visibility::Public)
.map(|r| r.ron_name.clone()),
self.access.unwrap_or_default().front == posture::Front::Open,
)
// Lane P1: every repository made at run time is its owner's (owner-only).
.with_sold(self.sold_owners()),
)
}))
}
/// **The repositories paid orders made, and whose they are** (lane P1): the ONE table
/// every read gate of this process holds, read once from `fleet/sold-repositories.json`.
/// Each is private: read and written by its owner and the estate's admins only.
pub fn sold_owners(&self) -> crate::auth::SoldOwners {
self.sold_owners_cell
.get_or_init(|| {
let path = self.data_root().join(crate::sold::SOLD_REPOS_REL);
let sold: std::collections::BTreeMap<String, crate::sold::SoldRepository> =
std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let root = self.data_root();
crate::auth::SoldOwners::of_entries(sold.into_iter().map(|(name, s)| {
let e = crate::sold::gate_entry(&root, &s);
(name, e)
}))
})
.clone()
}
/// The backend behind one repository name, or `None`.
pub fn backend_named(&self, name: &str) -> Option<Arc<dyn RepositoryBackendTrait>> {
self.repositories
.iter()
.find(|r| r.ron_name == name)
.and_then(|r| r.backend_repository.clone())
}
/// Replicate this store's artifacts INTO another holger store (parity §I,
/// `.nornir/replication-design.md`) — the in-process entry the CLI's
/// `replicate --from --to` verb drives. Reads only from `self` (the source)
/// and writes only into `dest` (the destination); content-id idempotent, so a
/// re-run over an unchanged source is a no-op.
///
/// Repos are paired by NAME: a source repo `foo` replicates into the
/// destination repo `foo`. A source repo with no same-named writable
/// destination is reported (its artifacts fail closed), never silently
/// dropped. `only` restricts the run to a single source repo name; `None`
/// replicates every repo that has a destination match. See
/// [`replication::replicate_pairs`].
pub fn replicate_into(
&self,
dest: &Holger,
only: Option<&str>,
opts: &replication::ReplicationOptions,
) -> replication::ReplicationReport {
let sources = self.backend_pairs();
let dests = dest.backend_pairs();
let mut pairs = Vec::new();
for (sname, sback) in &sources {
if let Some(want) = only {
if sname != want {
continue;
}
}
match dests.iter().find(|(dname, _)| dname == sname) {
Some((dname, dback)) => pairs.push(replication::RepoPair {
source_name: sname.clone(),
source: sback.clone(),
dest_name: dname.clone(),
dest: dback.clone(),
}),
None => log::warn!(
"replicate: source repo '{sname}' has no same-named destination repo — skipped"
),
}
}
replication::replicate_pairs(&pairs, opts)
}
/// The configured audit dir, anchored. An ABSOLUTE `audit.dir` is honoured
/// verbatim (explicit config always wins). A RELATIVE one — including the
/// `holger-audit` default — resolves against `$HOLGER_DATA` when set, and
/// only against the process CWD when it is not.
///
/// `$HOLGER_DATA` is the data root the installer already establishes
/// (`~/.holger` user scope, `/home/holger/.holger` system scope — see
/// `holger-server install`, which also sets `WorkingDirectory=`). An
/// installed server therefore resolves identically either way. What this
/// fixes is the EMBEDDED/uninstalled case: holger running in-process inside
/// dwarves or nornir inherits *their* CWD, so a CWD-relative default
/// scattered a fresh audit log, job ledger, PROD store, SBOM and property
/// store into whatever directory the host happened to start in.
pub fn resolved_audit_dir(&self) -> std::path::PathBuf {
anchor_under_data_root(&self.audit.dir, std::env::var_os("HOLGER_DATA"))
}
/// The resolved `ron_plugin_dir` — the `.wasm` package handlers' HOME, anchored
/// the same way the audit dir is.
///
/// `None` ⇒ no plugin directory is configured and the loader never runs.
///
/// It had NO declared home before: the configured string was handed to
/// `catalog.load_dir(dir)` RAW, so a relative `plugins` resolved against
/// whatever the process's CWD happened to be. For an installed server that is
/// `WorkingDirectory=` and looks fine; for holger running in-process inside
/// dwarves or nornir it is the HOST's CWD, and for a reinstall it is wherever
/// the new unit starts. So "does a module survive a reinstall" had no answer
/// from the config — it depended on the directory the process was started in,
/// which is exactly the drift `resolved_audit_dir` was written to end for the
/// audit log, the job ledger, the PROD store, the SBOM and the property store.
/// A loaded `.wasm` is code holger executes; where it comes from is not a
/// question that should be answered by an accident of the launcher.
///
/// Absolute stays absolute (an operator who names `/srv/holger-plugins` means
/// it); relative joins `$HOLGER_DATA` when the installer has set one, and only
/// falls back to CWD-relative when it has not — identical policy, one helper.
pub fn resolved_ron_plugin_dir(&self) -> Option<std::path::PathBuf> {
let dir = self
.ron_plugin_dir
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())?;
Some(anchor_under_data_root(dir, std::env::var_os("HOLGER_DATA")))
}
/// The server-side operational data root — the parent of the resolved audit
/// dir (the promote PROD store + jera job ledger already live under it), or
/// `.` when no meaningful audit dir is set. One derivation so the gRPC
/// endpoint, the HTTP gateway, and the CLI all agree on where server state is.
pub fn data_root(&self) -> std::path::PathBuf {
self.resolved_audit_dir()
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
/// Where the pairing records live ([`crate::peers`]): the SAME data root the
/// gRPC endpoint and the HTTP gateway use, so the `pair` verb, the serving
/// doors and the mirror's pin check can never read two different places.
/// **Every place the running config keeps repository bytes** (lane HOL-312-HARDEN): each
/// repository's storage places and each storage endpoint's `ron_path` — history for
/// [`crate::twin::held_state_with`].
pub fn history_stores(&self) -> Vec<std::path::PathBuf> {
let mut out: Vec<std::path::PathBuf> = self
.repositories
.iter()
.flat_map(|r| r.storage_places())
.map(|p| p.path)
.collect();
out.extend(
self.storage_endpoints
.iter()
.map(|e| std::path::PathBuf::from(&e.ron_path)),
);
out
}
/// **Where every repository keeps its bytes**, by name — the `disk_bytes`
/// meter's places (`repository::Repository::storage_places`).
pub fn storage_places(&self) -> crate::metering::StoragePlaces {
self.repositories
.iter()
.map(|r| (r.ron_name.clone(), r.storage_places()))
.collect()
}
/// **Each repository's upstreams**, by name: its `upstreams` and its
/// remote-cache upstream repository. Non-empty = what it holds is a CACHE
/// (the admin view's cache line).
/// **Start the disk speed test** over the data root (the admin view's
/// speed figure), once per process, on its own thread. The CLI calls it on
/// an appliance's PID 1 or when `HOLGER_DISK_SPEED` is set.
pub fn start_disk_speed_test(&self) -> bool {
crate::admin_view::start_disk_speed(self.data_root())
}
pub fn upstreams(&self) -> std::collections::BTreeMap<String, Vec<String>> {
self.repositories
.iter()
.map(|r| {
let mut u = r.ron_upstreams.clone();
u.extend(r.ron_upstream_repo.iter().cloned());
(r.ron_name.clone(), u)
})
.collect()
}
/// **The paid-state store**, metered build only; `None` when no
/// `monetize_pubkey` is configured. A configured key that is not a 32-byte
/// Ed25519 key is a BOOT ERROR, not a warning: the alternative is a metered
/// server that looks configured, answers `holger-metered-4` on every push,
/// and is discovered by monetize rather than by the operator. The gRPC
/// `EntitlementService` and both write doors' [`metered::Quota`] open the
/// SAME directory through this one function.
#[cfg(feature = "metered")]
pub fn entitlement_store(
&self,
) -> anyhow::Result<Option<Arc<crate::metered::EntitlementStore>>> {
if let Some(s) = self.entitlement_cell.get() {
return Ok(s.clone());
}
let made = self.make_entitlement_store()?;
Ok(self.entitlement_cell.get_or_init(|| made).clone())
}
#[cfg(feature = "metered")]
fn make_entitlement_store(
&self,
) -> anyhow::Result<Option<Arc<crate::metered::EntitlementStore>>> {
let Some(hex_key) = self
.monetize_pubkey
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
// An estate box whose front offers the key after the start: keyless until then.
if self.monetize_key_awaited {
return Ok(Some(Arc::new(crate::metered::EntitlementStore::awaiting(
crate::metered::store_dir(&self.data_root()),
self.repository_roster(),
))));
}
return Ok(None);
};
let raw = hex::decode(hex_key).map_err(|e| {
anyhow::anyhow!(errcode::METERED_NOT_CONFIGURED.tag(format!(
"`monetize_pubkey` is not hex ({e}) — it is monetize's 32-byte Ed25519 \
public key as 64 lowercase hex characters"
)))
})?;
let bytes: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
anyhow::anyhow!(errcode::METERED_NOT_CONFIGURED.tag(format!(
"`monetize_pubkey` decodes to {} byte(s); an Ed25519 public key is 32",
raw.len()
)))
})?;
let store = crate::metered::EntitlementStore::new(
crate::metered::store_dir(&self.data_root()),
self.repository_roster(),
&bytes,
)
.map_err(|_| {
anyhow::anyhow!(errcode::METERED_NOT_CONFIGURED
.tag("`monetize_pubkey` is 32 bytes but not a valid Ed25519 public key"))
})?;
Ok(Some(Arc::new(store)))
}
/// **The artifact flow's door** (lane HOL-SVG-RON): the shape read from this RON and the
/// paid repositories' disk caps. `None` when the RON says `flow: (enabled: false)`.
pub fn flow_door(&self) -> Option<crate::flow::Door> {
if !self.flow.enabled {
return None;
}
#[cfg(feature = "metered")]
let quota: Option<crate::flow::QuotaOf> = self.quota().ok().flatten().map(|q| {
Arc::new(move |repo: &str| {
q.store()
.get(repo)
.ok()
.flatten()
.and_then(|f| f.caps.get(crate::metering::METER_DISK_BYTES).copied())
.filter(|c| *c > 0)
}) as crate::flow::QuotaOf
});
#[cfg(not(feature = "metered"))]
let quota: Option<crate::flow::QuotaOf> = None;
Some(crate::flow::Door {
shape: Arc::new(crate::flow::FlowGraph::from_config(self)),
quota,
})
}
/// **The write doors' paid-limit check** (`metered::Quota`), metered build
/// only; `None` when nothing is paid for (no monetize key). ONE per process
/// (`quota_cell`): the HTTP door and every `QuotaGuard` share its readings.
#[cfg(feature = "metered")]
pub fn quota(&self) -> anyhow::Result<Option<Arc<crate::metered::Quota>>> {
if let Some(q) = self.quota_cell.get() {
return Ok(q.clone());
}
let made = self.entitlement_store()?.map(|store| {
Arc::new(
crate::metered::Quota::new(store, Arc::new(self.storage_places()))
.with_pay_page(self.pay_page.clone()),
)
});
Ok(self.quota_cell.get_or_init(|| made).clone())
}
/// **What takes a late monetize key and pay page live** (A7 row 8): the paid-state
/// store, the paid limit, the growth's go-ahead policy and the meter plugin's key slot
/// this process holds. Asked after [`Holger::start`]: every one is made by then.
#[cfg(feature = "metered")]
pub fn live_keys(&self) -> crate::late::LiveKeys {
crate::late::LiveKeys {
store: self.entitlement_cell.get().cloned().flatten(),
quota: self.quota_cell.get().cloned().flatten(),
grow: self.grow_policy_cell.get().cloned(),
meters: Some(self.data_root().join("meters")),
}
}
pub fn peers_root(&self) -> std::path::PathBuf {
self.data_root()
}
/// **The growth's write hold** — ONE per process, see `grow_hold_cell`.
pub fn grow_hold(&self) -> Arc<crate::grow::Hold> {
Arc::clone(
self.grow_hold_cell
.get_or_init(|| Arc::new(crate::grow::Hold::new())),
)
}
/// **The growth plane** — the machine over holger's world, made once:
/// the hold every repository was wrapped with, the twin roster under the
/// data root, this build's volume layer (`grow-runtime`: korp's md set) and
/// who may say go (monetize's key on a metered box that has one).
/// `None` in a build without `estate` (H2's estate cut): there is no plane to make.
#[cfg(not(feature = "estate"))]
pub fn grow_plane(
&self,
_posture: &Arc<crate::twin::TwinPosture>,
) -> Option<Arc<crate::grow::GrowPlane>> {
None
}
#[cfg(feature = "estate")]
pub fn grow_plane(
&self,
posture: &Arc<crate::twin::TwinPosture>,
) -> Option<Arc<crate::grow::GrowPlane>> {
Some(Arc::clone(self.grow_plane_cell.get_or_init(|| {
let root = self.data_root();
let roster = Arc::new(crate::grow::TwinRoster::new(Some(&root)));
let world = crate::grow::HolgerWorld::new(
self.grow_hold(),
roster,
crate::grow::build_volumes(),
Arc::clone(posture),
root,
);
let key = self
.monetize_pubkey
.as_deref()
.map(str::trim)
.and_then(|h| hex::decode(h).ok())
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
#[cfg(feature = "metered")]
let policy: Arc<dyn appliance_grow::GoAheadPolicy> = self
.grow_policy_cell
.get_or_init(|| crate::grow::monetize_policy(key))
.clone();
#[cfg(not(feature = "metered"))]
let policy = crate::grow::go_ahead_policy(key);
crate::grow::GrowPlane::new(world, policy)
})))
}
/// The property keys this config reserves to ADMIN, resolved.
///
/// `reserved_properties` when declared (an explicit list REPLACES the default,
/// including an empty one), else [`grpc::DEFAULT_RESERVED_PROPERTY_KEYS`].
///
/// This is the ONE resolution, read by both halves that have to agree: the
/// server installs it as the gRPC admin floor, and the `retention` verb
/// refuses a `--protect-property` key that is not in it. A pin an ordinary
/// Writer can set over the wire is not a pin, and the two halves live in
/// different processes, so they must not each carry their own idea of the
/// default.
#[cfg(feature = "intake")]
pub fn reserved_property_keys(&self) -> Vec<String> {
match &self.reserved_properties {
Some(keys) => keys.clone(),
None => grpc::DEFAULT_RESERVED_PROPERTY_KEYS
.iter()
.map(|k| k.to_string())
.collect(),
}
}
/// The hosted-SBOM store (`<data_root>/holger-sboms`). Derived once from the
/// config so every surface — the `SbomService` gRPC RPCs, the `GET /-/sbom`
/// HTTP door, and the `holger-server sbom` CLI verb — reads and writes the
/// same on-disk store. See [`sbom::SbomStore`], `.nornir/sbom-hosting-design.md`.
#[cfg(feature = "intake")]
pub fn sbom_store(&self) -> sbom::SharedSbomStore {
Arc::new(sbom::SbomStore::with_roster(
self.data_root().join("holger-sboms"),
self.repository_roster(),
))
}
/// The set of repository names this server serves, EXACTLY as configured.
///
/// The metadata stores (`sbom_store`, `property_store`) are closed to this
/// set. Both are addressed by a repository name that arrives off the wire and
/// is authorized as a raw string, so without a roster a caller writes metadata
/// for repositories that do not exist — unbounded directories nobody reaps,
/// and a property search axis seeded with coordinates that were never served.
#[allow(dead_code)]
fn repository_roster(&self) -> Arc<std::collections::BTreeSet<String>> {
Arc::new(
self.repositories
.iter()
.map(|r| r.ron_name.clone())
.collect(),
)
}
/// The custom-property store (`<data_root>/holger-props`). Derived once from
/// the config so every surface — the gRPC `Search` property axis, the
/// `GET`/`PUT /-/properties` HTTP door, and the `holger-server properties` CLI
/// verb — reads and writes the same on-disk store. See
/// [`properties::PropertyStore`], `.nornir/properties-design.md`.
#[cfg(feature = "intake")]
pub fn property_store(&self) -> properties::SharedPropertyStore {
Arc::new(properties::PropertyStore::with_roster(
self.data_root().join("holger-props"),
self.repository_roster(),
))
}
/// The **passkey door** over this box's identity core (`<data_root>/holger-passkeys`), or
/// `None` when no `Passkey` auth method is configured: the whole ceremony surface then
/// answers 404, so a console can tell "not configured" from "configured and refusing".
#[cfg(feature = "auth-passkey")]
pub fn passkey_door(&self) -> anyhow::Result<Option<passkey::SharedPasskeyDoor>> {
let Some(auth) = self.auth.as_ref() else {
return Ok(None);
};
let resolved = self.serving_auth();
let Some(door) = passkey::PasskeyDoor::open_at(
&self.data_root(),
&resolved,
self.build_audit_log()?,
self.estate_site,
)
.map_err(|e| anyhow::anyhow!("passkey door: {e}"))?
else {
return Ok(None);
};
door.attach_gate(self.read_gate());
// The shop's first-login invite (lane P1), taken in once, on the primary: a twin's
// people are its primary's, replicated.
if let Some(first) = self
.first_admin_invite
.as_ref()
.filter(|_| !self.role.is_twin())
{
match door.seed_first_admin(first, auth) {
Ok(said) => log::warn!("passkey: the shop's first-login invite: {said}"),
Err((_, why)) => {
log::error!("passkey: the shop's first-login invite was NOT taken in: {why}")
}
}
}
Ok(Some(door))
}
/// **Sign-up follows the site the feed declares, live** (RA5-1): the identity core this
/// process serves takes the site a later feed declares (`identity_host::follow_site`). A
/// box with no core (no passkey method) follows nothing.
pub fn follow_estate_site(&self, feeds: &dyn late::Feeds) {
#[cfg(feature = "auth-passkey")]
if let Some(core) = identity_host::live(&self.serving_auth()) {
identity_host::follow_site(feeds, core);
}
#[cfg(not(feature = "auth-passkey"))]
let _ = feeds;
}
/// The auth config with any `Passkey` method's `session_key_file` resolved to
/// its default (`<data_root>/holger-passkeys/session.key`) when the operator
/// left it unset. See the call site in [`Holger::start`] for why this must
/// happen before anything reads the config.
/// ★ **The auth config every door serves with** (lane HOL-NCSC): the file's, with the
/// passkey session key resolved ([`Holger::start`] says why), and **configured
/// authentication implies RBAC**: methods with no role policy (no `roles`, `group_roles`,
/// `repo_roles`, `duties` or `default_role`) serve with `default_role: Reader`. Before, such
/// a config was authentication-only and every signed-in identity could do everything.
pub fn serving_auth(&self) -> auth::AuthConfig {
let mut cfg = self.auth_config_with_resolved_passkey_key();
if !cfg.methods.is_empty() && !cfg.rbac_enabled() {
cfg.default_role = Some(auth::Role::Reader);
}
cfg
}
fn auth_config_with_resolved_passkey_key(&self) -> auth::AuthConfig {
let mut cfg = self.auth.clone().unwrap_or_default();
// P3: a box with passkey sign-in and no Admin group gets the people's groups.
let _ = auth::passkey_groups(&mut cfg);
let default_key = self.data_root().join("holger-passkeys").join("session.key");
for method in &mut cfg.methods {
if let auth::AuthMethodConfig::Passkey {
session_key_file, ..
} = method
{
if session_key_file
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
*session_key_file = Some(default_key.to_string_lossy().into_owned());
}
}
}
cfg
}
/// The **promotion engine** (`<data_root>/holger-prod-store`), gates and all.
/// Derived once from the config so every surface — the gRPC
/// `PromotionService.PromoteArtifact` RPC and the `holger-server promote` CLI
/// verb — seals into the SAME immutable PROD store under the SAME policy.
///
/// The gates come from the RON `promotion` block, so a local promotion cannot
/// be a way to walk around the boundary the served path enforces.
///
/// ## What changed, and why
///
/// Until 2026-09-20 the gate was `promote::PolicyGate::from_env()`, reading
/// `HOLGER_PROMOTE_ENFORCE`, `HOLGER_PROMOTE_ADVISORY_DB` and
/// `HOLGER_PROMOTE_DENY_LICENSES`. On an appliance with no OS and no shell
/// those are a configuration surface the operator cannot reach, and unset
/// meant `Ok(None)`, which meant every promotion passed unscanned and looked
/// in the log exactly like one that had been scanned. All three are now keys
/// on `promotion:`.
///
/// **HOL-GUN-007**: a rung that names a gate this build does not carry is an
/// `Err` here, so serve refuses to start and the CLI refuses to promote. The
/// alternative is a rung that reads as guarded and runs unguarded. The same
/// is true of an advisory feed that cannot be read.
///
/// * **Property requirement** (`promotion.require_property: Some("key=value")`,
/// or a bare `key`) — OFF unless set. The old `HOLGER_PROMOTE_REQUIRE_PROPERTY`
/// environment variable is refused by name.
/// * **The served set** — the repositories this config declares. A promotion to
/// any other name is `PromoteError::UnknownRepository`, always.
///
/// No job ledger is attached here: that is serve-side observability which the
/// server layers on with [`promote::PromotionEngine::with_job_ledger`].
/// **The evidence plane** — the [`attest::AttestStore`] under
/// `<data_root>/holger-attestations`, with its trust roots read from the
/// `attestation:` block.
///
/// ## Why this function had to be written
///
/// MEASURED at `542e296`: `server/lib/src/attest.rs` is 1 458 lines
/// implementing DSSE + in-toto verification keyed by artifact digest,
/// delegating the cryptography wholly to `znippy_common::sign::dsse_verify`,
/// and **nothing in the shipped tree constructed an `AttestStore`**. There
/// was no door, no constructor and no consumer. A verification plane nobody
/// can reach verifies nothing.
///
/// `None` when the config declares no `attestation:` block, which is the
/// state every existing deployment is in; the store is then absent rather
/// than empty, and a gate that needs evidence says so.
///
/// ⚠ Trust roots are REQUIRED when the block is present. A store with no
/// roots would accept a self-signed statement, and the point of the plane is
/// that the statement chains to something the operator chose.
#[cfg(feature = "intake")]
pub fn attestation_store(&self) -> Result<Option<Arc<attest::AttestStore>>> {
let Some(cfg) = &self.attestation else {
return Ok(None);
};
if cfg.trust_roots.is_empty() {
anyhow::bail!(
"config `attestation`: no `trust_roots`. An attestation store with no roots \
accepts a statement signed by anything, including the uploader, which is not \
evidence. Name at least one DER-encoded root certificate path, or remove the \
block."
);
}
let roots: Vec<std::path::PathBuf> = cfg
.trust_roots
.iter()
.map(std::path::PathBuf::from)
.collect();
// A declared root that cannot be read is a NAMED boot refusal carrying the
// frozen `holger-attest-10`, never a quietly shorter trust set. See
// `attest::AttestTrust::from_der_root_files`.
let mut trust = attest::AttestTrust::from_der_root_files(&roots).map_err(|e| {
anyhow::anyhow!(
"config `attestation.trust_roots`: {}. An unreadable root refuses the boot \
rather than becoming a store that trusts nothing and reports every statement \
as unverifiable.",
e.tagged()
)
})?;
// Enrolment: this holger's own signer (its evidence is filed through
// `accept` like anybody's, so an unenrolled own key could sign nothing),
// and every signer the block names.
for c in cfg.signer_cert.iter().chain(cfg.enrolled_signers.iter()) {
let der = std::fs::read(c)
.map(|b| attest::der_of(&b))
.with_context(|| {
format!("config `attestation`: reading the signer certificate '{c}'")
})?;
trust.enrol_signer_der(&der);
}
let mut policy = attest::AttestPolicy::none();
for pt in &cfg.accept_predicates {
policy = policy.accept(pt);
}
if cfg.accept_predicates.is_empty() {
policy = attest::AttestPolicy::default();
}
Ok(Some(Arc::new(
attest::AttestStore::new(self.data_root().join("holger-attestations"))
.with_trust(trust)
.with_policy(policy),
)))
}
/// holger's own signing identity for the evidence a promotion emits.
///
/// `None` when the `attestation:` block declares no key, which means a
/// promotion writes no verification summary. That absence is RECORDED in the
/// audit detail by the promote handler; it is not the same as a promotion
/// whose evidence was written.
///
/// Declaring one half of the pair is an error: a key with no certificate
/// cannot produce a verifiable envelope, and failing at the first promotion
/// instead of at boot is the wrong time to find out.
#[cfg(feature = "intake")]
pub fn attestation_signer(&self) -> Result<Option<Arc<attest::AttestSigner>>> {
let Some(cfg) = &self.attestation else {
return Ok(None);
};
// HOL-NCSC (NCSC P3): the key in a hardware token.
if let Some(hsm) = &cfg.signer_hsm {
if cfg.signer_key.is_some() {
anyhow::bail!(
"{}",
holger_errcode::KEY_HSM_REFUSED
.tag("config `attestation`: name `signer_key` or `signer_hsm`, not both")
);
}
let Some(c) = &cfg.signer_cert else {
anyhow::bail!("{}", holger_errcode::KEY_HSM_REFUSED.tag("config `attestation.signer_hsm` needs `signer_cert`, the certificate of the token's key"));
};
let HsmSigner::Pkcs11 {
module,
token_label,
key_label,
pin_file,
} = hsm;
#[cfg(feature = "pkcs11")]
{
let cert = std::fs::read(c)
.with_context(|| format!("config `attestation.signer_cert`: reading '{c}'"))?;
let alg = znippy_common::sign::SigAlg::from_name(
cfg.signer_alg.as_deref().unwrap_or("ed25519"),
)
.map_err(|e| anyhow::anyhow!("config `attestation.signer_alg`: {e:#}"))?;
let signer = pkcs11_signer::Pkcs11Signer::open(
module,
token_label,
key_label,
std::path::Path::new(pin_file),
alg,
cert.clone(),
)
.map_err(|e| anyhow::anyhow!("config `attestation.signer_hsm`: {e}"))?;
return Ok(Some(Arc::new(attest::AttestSigner::from_signer(
Box::new(signer),
cert,
))));
}
#[cfg(not(feature = "pkcs11"))]
{
let _ = (c, module, token_label, key_label, pin_file);
anyhow::bail!("{}", holger_errcode::KEY_HSM_REFUSED.tag("config `attestation.signer_hsm`: this build has no PKCS#11 signer; build with the `pkcs11` feature"));
}
}
match (&cfg.signer_key, &cfg.signer_cert) {
(None, None) => Ok(None),
(Some(k), Some(c)) => {
let key = keyfile::read_private(
std::path::Path::new(k),
"config `attestation.signer_key`",
)
.with_context(|| format!("config `attestation.signer_key`: reading '{k}'"))?;
let cert = std::fs::read(c)
.with_context(|| format!("config `attestation.signer_cert`: reading '{c}'"))?;
let alg = cfg.signer_alg.as_deref().unwrap_or("ed25519");
let signer = attest::AttestSigner::from_pkcs8(alg, &key, &cert)
.map_err(|e| anyhow::anyhow!("config `attestation.signer_*`: {e}"))?;
Ok(Some(Arc::new(signer)))
}
(Some(_), None) => anyhow::bail!(
"config `attestation`: `signer_key` is set and `signer_cert` is not. A DSSE \
envelope carries the signer's certificate fingerprint as its keyid and a \
verifier needs the certificate to check it, so a key alone can produce nothing \
verifiable."
),
(None, Some(_)) => anyhow::bail!(
"config `attestation`: `signer_cert` is set and `signer_key` is not. There is \
nothing to sign with."
),
}
}
/// **The box's repository signing key** (lane HOL-SIGNED-APT-DNF): `None` when the
/// operator made none (`holger repo-key create`); an error when the key store holds one
/// that cannot be used, which refuses the boot of a box with deb or rpm views.
pub fn repo_key(&self) -> Result<Option<Arc<repo_sign::RepoKey>>> {
if let Some(k) = self.repo_key_cell.get() {
return Ok(k.clone());
}
let k = repo_sign::load(&self.data_root())?.map(Arc::new);
if let Some(k) = &k {
log::info!(
"repository signing key {} ({}): the deb and rpm views sign their metadata; \
its public half is at {}",
k.fingerprint_hex(),
k.user_id(),
repo_sign::KEY_PATH
);
}
let _ = self.repo_key_cell.set(k.clone());
Ok(k)
}
/// **The evidence key the public door serves** (lane HOL-OCI-EVIDENCE): the public half
/// of `attestation.signer_cert`, when the block declares a signer (key AND certificate).
/// `None` without one; a certificate that does not read is said in the log and serves
/// nothing (the signer refuses the boot over it elsewhere).
#[cfg(feature = "intake")]
pub fn evidence_key(&self) -> Option<Arc<evidence_key::EvidenceKey>> {
let cfg = self.attestation.as_ref()?;
let (Some(_), Some(cert)) = (&cfg.signer_key, &cfg.signer_cert) else {
return None;
};
let der = std::fs::read(cert)
.map_err(|e| log::warn!("evidence key door: reading '{cert}': {e}"))
.ok()?;
evidence_key::EvidenceKey::from_cert_der(&der)
.map_err(|e| log::warn!("evidence key door: '{cert}': {e}"))
.ok()
.map(Arc::new)
}
/// **The gates THIS BUILD carries**, built from the `promotion:` block and
/// from the meter guests this boot loaded.
///
/// One catalog, two boundaries: the promotion hop and the ingest PUT. They
/// must be built from the same declaration, or a gate could mean one thing
/// on upload and another on promotion.
///
/// How many attestation trust roots this config DECLARES.
///
/// Zero means the evidence plane cannot verify anything — no `attestation:`
/// block, or a block whose `trust_roots` is empty. The provenance gate needs
/// this number and not the store, because the number is knowable before the
/// store is built and the distinction it draws (*could nothing ever have been
/// verified?*) is a property of the declaration, not of the disk.
#[cfg(feature = "intake")]
pub fn declared_trust_roots(&self) -> usize {
self.attestation
.as_ref()
.map(|a| a.trust_roots.len())
.unwrap_or(0)
}
/// Does this config scan at intake (`promotion.intake.scan`)? Then the
/// gates read the stored evidence back before scanning again (W5).
#[cfg(feature = "scanners")]
fn reads_stored_evidence(&self) -> bool {
self.promotion
.as_ref()
.and_then(|p| p.intake.as_ref())
.is_some_and(|i| i.scan.is_some())
}
/// Empty when there is no `promotion:` block **and** no meter is loaded.
#[cfg(feature = "intake")]
pub fn gate_catalog(&self) -> Result<promote::GateCatalog> {
#[allow(unused_mut)]
let mut cat = match &self.promotion {
// No `promotion:` block ⇒ no ladder, so nothing resolves a gate set
// and the catalog is never consulted. It is still built with the
// provenance gate in it, so `available()` tells the truth about what
// this BUILD carries rather than about what this FILE configured.
None => promote::GateCatalog::new().with(std::sync::Arc::new(
promote::gate::ProvenanceGate::new(
promote::ProvenancePolicy::default(),
self.declared_trust_roots(),
),
)),
#[cfg(not(feature = "scanners"))]
Some(settings) => promote::gate::catalog_from_config(
settings.require_attestation.clone(),
settings.provenance,
self.declared_trust_roots(),
),
#[cfg(feature = "scanners")]
Some(settings) => {
let advisory_db = Self::promotion_advisory_db(settings)?;
// W5: with intake scanning on, the `osv` gate reads the stored
// evidence first (the SAME pipeline the SCANNED transition writes).
let osv_with_evidence = self.reads_stored_evidence().then(|| {
promote::gate::OsvGate::new(promote::gate::osv_scanner_for(
settings.license_policy(),
advisory_db.clone(),
))
.with_evidence(self.scan_pipeline_cell.clone())
.with_worker(self.scan_worker_cell.clone())
});
let mut cat = promote::gate::catalog_from_config(
advisory_db,
settings.license_policy(),
settings.require_attestation.clone(),
settings.provenance,
self.declared_trust_roots(),
);
// A gate that is standing and verifying nothing must not print the
// same line as a gate that is standing and verifying. See
// `ProvenanceGate::posture`.
log::info!(
"{}",
promote::gate::ProvenanceGate::new(
settings.provenance,
self.declared_trust_roots()
)
.posture()
);
if let Some(g) = osv_with_evidence {
cat = cat.with(Arc::new(g));
}
cat
}
};
// S7: with an `intel:` block the OSV gate reads the local advisory
// database (offline by default), re-read per check so an import goes
// live without a restart, its staleness folded into the verdict. It
// replaces the `osv` gate by name, so the arm above stays as it was.
#[cfg(feature = "scanners")]
if let (Some(settings), Some(intel)) = (&self.promotion, &self.intel) {
if settings
.advisory_db
.as_deref()
.is_some_and(|p| !p.trim().is_empty())
{
anyhow::bail!(
"config names two advisory sources: `promotion.advisory_db` and the \
`intel:` database. Keep one; the intel database is the signed one."
);
}
let source = intel::scan::IntelSource::from_config(intel)?;
log::info!(
"intel: the OSV gate reads the local advisory database at {} ({})",
intel.db_dir,
if source.net().is_offline() {
"offline: network refused"
} else {
"online, local fallback"
}
);
let scanner = promote::gate::osv_scanner_for(
settings.license_policy(),
security::AdvisoryDb::default(),
);
let mut gate = promote::gate::OsvGate::new(scanner).with_intel(Arc::new(source));
if self.reads_stored_evidence() {
gate = gate
.with_evidence(self.scan_pipeline_cell.clone())
.with_worker(self.scan_worker_cell.clone());
}
cat = cat.with(Arc::new(gate));
}
// Every loaded meter guest, under the name its own manifest declares, so
// a rung may name it explicitly — and `available()` lists it, so an
// operator who mistypes the name is told what was there. The meter gates
// also run whether or not they are named; see [`Holger::meter_gates`].
#[cfg(feature = "meter-guests")]
for g in self.meter_gates() {
cat = cat.with(g);
}
Ok(cat)
}
/// **One `WasmGate` per meter guest this boot loaded.**
///
/// Empty until [`Holger::build_meter_plane`] has run (that is, until
/// [`wire_holger`] has built the route tables), and the whole method is
/// absent from a build without `meter-guests`.
///
/// # Why these run without being named in the config
///
/// A meter guest reaches the plugin directory only if an operator put it
/// there, and it loads only if the same operator gave it a line in the grant
/// table. That is already two explicit decisions. Making a THIRD one — the
/// gate name in a `gates:` list — the condition for the guest's verdict to
/// count would mean a box that loads a metering module, reports it in the
/// census, and then ignores what it says. A loaded meter that is never
/// consulted is a meter that is not standing.
///
/// The verdict is applied exactly as `meter_gate` translates it: `Allow` and
/// `AllowWithNotice` pass (a notice is NOT a refusal; data is never held
/// hostage), `Refuse` rejects, and an `Err` — a trap, exhausted fuel, a
/// missed deadline, undecodable bytes — is recorded undecided and REFUSES
/// the hop. A meter that cannot answer is never a meter that waves
/// everything through.
#[cfg(all(feature = "meter-guests", feature = "intake"))]
pub fn meter_gates(&self) -> Vec<Arc<dyn promote::Gate>> {
let Some(plane) = &self.meter_plane else {
return Vec::new();
};
plane
.meters()
.iter()
.map(|m| {
Arc::new(promote::meter_gate::WasmGate::new(Arc::clone(m)))
as Arc<dyn promote::Gate>
})
.collect()
}
/// Append the meter gates to a resolved set, skipping any the configuration
/// already named so a guest named on a rung is not run twice.
#[cfg(feature = "intake")]
pub fn with_meter_gates(&self, set: promote::GateSet) -> promote::GateSet {
#[cfg(not(feature = "meter-guests"))]
{
set
}
#[cfg(feature = "meter-guests")]
{
let already: Vec<String> = set.names().iter().map(|s| s.to_string()).collect();
let mut set = set;
for g in self.meter_gates() {
if !already.iter().any(|n| n == g.name()) {
set = set.with(g);
}
}
set
}
}
/// The union of every exposed endpoint's route table.
///
/// The usage source counts through ONE table; a repository served on two
/// doors is one repository and must be counted once. First door wins, which
/// is the same first-hit rule `FastRoutes::lookup` already applies.
#[cfg(feature = "meter-guests")]
fn served_routes(&self) -> FastRoutes {
let mut seen: Vec<(String, Arc<dyn traits::RepositoryBackendTrait>)> = Vec::new();
for ep in &self.exposed_endpoints {
let Some(table) = &ep.aggregated_routes else {
continue;
};
for (name, backend) in table.all_repos() {
if !seen.iter().any(|(n, _)| *n == name) {
seen.push((name, backend));
}
}
}
FastRoutes::new(seen)
}
/// **Build the meter plane and lend it the three host services.**
///
/// The usage source is [`crate::metering::count`] over the union of the
/// route tables; the audit sink is this server's ONE append-only log; the
/// clock is holger's wall clock, said to be exactly that. See
/// [`crate::meter_services`] for why none of the three needs a capability
/// check of its own — the grant table is the authority, and a second copy of
/// the rule is a second place for the rule to be wrong.
///
/// Called by [`wire_holger`]. It compiles every module, so it is called once.
#[cfg(feature = "meter-guests")]
pub fn build_meter_plane(&mut self) -> Result<()> {
// The store a meter is lent. Its OWN directory under the data root, and
// never a repository's: a meter that could write where it measures could
// move its own number. The open guest is granted `store.list` only, and
// the grant is what bounds this — not this line.
let store: holger_plugin_host::SharedStore = Arc::new(holger_plugin_host::DirStore::new(
self.data_root().join("meters"),
));
// The commercial meter plugin's verifying key, fed at load from the box's seed (metered).
#[cfg(feature = "metered")]
match crate::fleet_box::meter_key_bytes(self.monetize_pubkey.as_deref()) {
Some(bytes) => store.put(crate::fleet_box::METER_KEY_SLOT, &bytes).map_err(|e| anyhow::anyhow!("meter plane: the monetize key slot: {e}"))?,
None => log::warn!("meter plane: no monetize_pubkey handed to this box, so the meter plugin is fed no key and refuses every fact"),
}
let services: Arc<dyn holger_plugin_host::MeterHostServices> = Arc::new(
crate::meter_services::HolgerMeterServices::new(
self.served_routes(),
self.build_audit_log()?,
)
// The storage meters' filesystem: the same root the door measures,
// so "how much storage is there" has one answer on this box.
.with_data_root(self.data_root())
.with_storage_places(self.storage_places()),
);
let catalog = self.meter_catalog(store, services)?;
if !catalog.is_empty() {
log::info!(
"meter plane: {} guest(s) wired to the usage source, the audit sink and the \
clock; granted {}",
catalog.meters().len(),
catalog
.granted_capabilities()
.iter()
.map(|c| c.name())
.collect::<Vec<_>>()
.join(", "),
);
}
self.meter_plane = Some(Arc::new(catalog));
Ok(())
}
/// **The gates that run on a PUT**, before the bytes reach the backend.
///
/// Empty ⇒ ingest is ungated, which is what holger has always done. Not the
/// same as "every ingest gate allowed": the [`nornir_gate::Decision`] an
/// empty set produces says "none configured".
///
/// A loaded meter guest is appended whether or not the config names it — the
/// upload boundary is the one a quota has to be enforced at, because a cap
/// enforced after the upload is a cap that already cost the disk. See
/// [`Holger::meter_gates`] for why installation is the decision.
#[cfg(feature = "intake")]
pub fn ingest_gates(&self) -> Result<promote::GateSet> {
let named = match &self.promotion {
Some(settings) if !settings.ingest_gates.is_empty() => {
self.gate_catalog()?.resolve(&settings.ingest_gates)?
}
_ => promote::GateSet::empty(),
};
let set = self.with_meter_gates(named);
if !set.is_empty() {
log::info!("INGEST gates ENABLED: {}", set.names().join(", "));
}
Ok(set)
}
/// **The intake chain** this config declares (`promotion.intake`), built once
/// over `audit` and kept: both doors and the CLI drive the same one. `None`
/// when the config declares no intake chain.
///
/// Refuses to build when the incoming repository cannot take a write or
/// cannot map an upload path to a coordinate: the chain tracks every upload
/// by its coordinate, and an upload it cannot name is one it cannot gate.
/// The advisory snapshot `promotion.advisory_db` names, read once per caller;
/// empty when none is named. Shared by the OSV gate and the intake's OSV scanner.
#[cfg(feature = "scanners")]
fn promotion_advisory_db(settings: &PromotionSettings) -> Result<security::AdvisoryDb> {
Ok(match &settings.advisory_db {
Some(p) if !p.trim().is_empty() => security::advisory_db_from_osv_file(p.trim())
.with_context(|| {
format!(
"config `promotion.advisory_db`: '{p}' could not be read as an \
OSV/GHSA/RUSTSEC snapshot. A feed that is named and unreadable \
refuses the boot rather than becoming an empty gate, because an \
empty gate passes everything and says nothing."
)
})?,
_ => security::AdvisoryDb::default(),
})
}
/// The scanners `promotion.intake.scan` configures, as the pipeline the
/// SCANNED transition runs: modgunn's OSV scanner over the promotion advisory
/// snapshot, the wheel SBOM generator, the model scanner (pickle family
/// blocked except where `models.allow_pickle` says), the licence scanner over
/// the promotion licence policy, the required kinds as named, an
/// append-only evidence store under `<data_root>/holger-scan-evidence` that
/// audits into `audit`, and signed attestations when an attestation signer is
/// configured, and the provenance scanner when `scan.provenance` is given. The
/// other kinds stand empty (`error: not configured`).
#[cfg(feature = "scanners")]
fn intake_scan_pipeline(
&self,
settings: &PromotionSettings,
scan: &promote::intake::IntakeScanSettings,
audit: Arc<dyn audit::AuditLog>,
worker: &Arc<sandbox::ScanWorker>,
) -> Result<scanner::ScanPipeline> {
// S7: with an `intel:` block the vulnerability slot reads the local
// advisory database (offline, staleness folded in) through S2's trait.
let vuln: Arc<dyn scanner::Scanner> = match &self.intel {
Some(intel) => Arc::new(crate::intel::scanner::IntelVulnScanner::new(Arc::new(
crate::intel::scan::IntelSource::from_config(intel)?,
))),
None => Arc::new(scanner::osv::OsvVulnScanner::new(
Self::promotion_advisory_db(settings)?,
)),
};
// S10: the model slot; the pickle family is blocked except where
// `models.allow_pickle` names a declared repository.
scanner::model::validate_allow_pickle(
&self.models.allow_pickle,
self.repositories.iter().map(|r| r.ron_name.as_str()),
)?;
// SANDBOX-SCAN: every scanner that reads the artifact's bytes runs in the
// scan worker (the wheel SBOM, the model and licence scanners, the file
// inspection and YARA); the vulnerability slot matches the worker's
// coordinates (an image's packages) against the local advisory DB here;
// ClamAV streams the bytes to clamd's socket.
let mut set = scanner::ScannerSet::new().with(Arc::new(sandbox::VulnFromWorker::new(
vuln,
Arc::clone(worker),
)));
for s in sandbox::WorkerScanner::all(worker) {
set = set.with(s);
}
if let Some(intake) = &settings.intake {
if let Some(c) = &intake.inspect.clamav {
let clam = holger_inspect::malware::ClamAv::from_config(c);
if c.required {
set = set.require(scanner::ScannerKind::Malware);
}
match clam.engine() {
Ok(_) => set = set.with(Arc::new(sandbox::ClamStream::new(Arc::new(clam)))),
Err(why) => log::warn!("intake scanners: clamav not configured: {why}"),
}
}
if intake.inspect.yara.as_ref().is_some_and(|y| y.required) {
set = set.require(scanner::ScannerKind::Malware);
}
}
let mut provenance = None;
// Lane G3a: the provenance scanner, over the `rules` floor and each
// source's gate, reading the material under `<data_root>/holger-provenance`.
if let Some(p) = &scan.provenance {
let bundle_policy = settings
.intake
.as_ref()
.and_then(|i| i.trust_policy.as_deref());
let trust_json = scanner::provenance::ProvenanceScanner::load_trust_root_json(
&p.rules,
bundle_policy,
)?;
let trust =
scanner::provenance::ProvenanceScanner::load_trust_root(&p.rules, bundle_policy)?;
let inbox = match p.inbox.as_deref().filter(|s| !s.trim().is_empty()) {
Some(dir) => std::path::PathBuf::from(dir),
None => self.data_root().join("holger-provenance"),
};
provenance = Some(Arc::new(
scanner::provenance::ProvenanceScanner::new(
p.clone(),
trust,
Arc::new(scanner::provenance::ProvenanceInbox::new(inbox)),
)?
// The material is parsed in the scan worker, never here.
.with_remote(Arc::new(
sandbox::RemoteProvenance::new(Arc::clone(worker), trust_json)
.with_apk_keys(scanner::provenance::ProvenanceScanner::load_apk_keys(
&p.rules,
bundle_policy,
)?)
.with_pgp_keys(scanner::provenance::ProvenanceScanner::load_pgp_keys(
&p.rules,
bundle_policy,
)?),
)),
));
}
for kind in &scan.required {
set = set.require(*kind);
}
let store = self.scan_evidence_store(audit);
let mut pipeline = scanner::ScanPipeline::new(set, store);
if let Some(p) = provenance {
pipeline = pipeline.with_provenance(p);
}
if let (Some(store), Some(signer)) = (self.attestation_store()?, self.attestation_signer()?)
{
pipeline = pipeline.with_attestation(store, signer);
}
let configured: Vec<&str> = scanner::ScannerKind::ALL
.iter()
.filter(|k| pipeline.scanners().is_configured(**k))
.map(|k| k.as_str())
.collect();
let required: Vec<&str> = scan.required.iter().map(|k| k.as_str()).collect();
log::info!(
"intake SCANNED runs scanners: configured {configured:?}, required {required:?}"
);
Ok(pipeline)
}
/// ★ **The job runner of this server** (lane HOL-SANDBOX-TRAIT): the runner
/// `promotion.intake.rules.sandbox` and the `promotion.intake.sandbox` block
/// declare, made once per process and shared by every caller; `None` without an
/// intake chain. `sandbox: off` in a release build refuses (`holger-sandbox-6`).
#[cfg(feature = "intake")]
pub fn jobs(&self) -> Result<Option<Arc<job_runner::Jobs>>> {
The file goes on; the console shows all of it.