server/lib/src/boundary.rs
//! **The boundary: one-way by default, fetching only on purpose** (lane HOL-CDS, owner
//! 2026-10-30: "safer if we have to add code for fetching").
//!
//! ```ron
//! boundary: OneWay, // the default: packages come in ONLY through the inbox
//! domain: "onprem-lab", // shown in the console's top bar, the CLI and every response
//! trust: (first_import: Admin),// the first import is an admin's action on this side
//! ```
//!
//! * **`OneWay`** (the default of a downstream): packages arrive only as signed bundles in
//! the inbox (`bundle.inbox`, the diode's drop folder, a USB stick). A config that names a
//! way out refuses the start, every reason with its line and `holger-boundary-1`: a
//! `mirror:` block, an `export:` door, a sync receipt (`sync.downstreams`, or an upstream
//! with a `url`, `token_file` or `invite`), and any outbound target (a `*-remote`
//! repository, a pull-through `url`, a twin, `recharge_from`, online intel,
//! `network: Online`, an estate build). The `holger-downstream` default build does not
//! even contain the fetch code: no mirror timer, no gRPC client of another Holger, no sync
//! pull and no receipt (`--features fetch` adds them; `server/cli/tests/one_way_ships_no_client.rs`
//! holds the tree to it).
//! * **`Fetch`**: today's downstream, fetching from its ONE upstream Holger (the `mirror:`
//! block with one `ron_kind: Holger` source, the sync pull and its receipt). Accepted only
//! by a build with the `fetch` feature (`holger-boundary-2` otherwise).
//!
//! Every function here is pure: [`refusal`] takes the ways out a config names and, when the
//! file's text is at hand, the line each top-level key starts on.
use serde::{Deserialize, Serialize};
/// The response header that carries [`crate::Holger::domain`] (HTTP and gRPC alike).
pub const DOMAIN_HEADER: &str = "x-holger-domain";
/// Whether this build carries the fetch code (`fetch` feature).
pub const FETCH_BUILT: bool = cfg!(feature = "fetch");
/// **The one-way inbox's folder** under the data root, where signed bundles arrive (the
/// diode's drop folder). ONE name: the import door's `BUNDLE_INBOX` is this constant, and the
/// flow draws it, in a build without the import door too.
pub const INBOX_DIR: &str = "bundle.inbox";
static DOMAIN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Name this process's domain (once, at start; a later call is ignored).
pub fn set_domain(domain: &str) {
let _ = DOMAIN.set(domain.to_string());
}
/// This process's `domain:` label, when its config names one.
pub fn domain() -> Option<&'static str> {
DOMAIN.get().map(String::as_str)
}
/// The domain as a header value (`None`: no domain, or one a header cannot carry).
pub fn domain_header_value() -> Option<hyper::header::HeaderValue> {
domain().and_then(|d| hyper::header::HeaderValue::from_str(d).ok())
}
/// **The gRPC door's `X-Holger-Domain`** (a tower layer over tonic's routes): every
/// answer, gRPC and gRPC-web alike, carries this process's domain.
#[derive(Clone, Default)]
pub struct DomainLayer;
impl<S> tower_layer::Layer<S> for DomainLayer {
type Service = DomainService<S>;
fn layer(&self, inner: S) -> Self::Service {
DomainService { inner }
}
}
/// [`DomainLayer`]'s service.
#[derive(Clone)]
pub struct DomainService<S> {
inner: S,
}
impl<S, Req, B> tonic::codegen::Service<Req> for DomainService<S>
where
S: tonic::codegen::Service<Req, Response = hyper::Response<B>>,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = hyper::Response<B>;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Req) -> Self::Future {
let fut = self.inner.call(req);
Box::pin(async move {
let mut resp = fut.await?;
if let Some(v) = domain_header_value() {
resp.headers_mut().insert(DOMAIN_HEADER, v);
}
Ok(resp)
})
}
}
/// **`boundary:`** — which way packages may come in.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Boundary {
/// Only through the inbox. Nothing dials, nothing is sent back.
#[default]
OneWay,
/// From its ONE upstream Holger, over mTLS or WireGuard (`fetch` builds only).
Fetch,
}
impl Boundary {
pub fn name(self) -> &'static str {
match self {
Boundary::OneWay => "OneWay",
Boundary::Fetch => "Fetch",
}
}
}
/// **`trust.first_import:`** — who establishes an absent anti-rollback floor.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FirstImport {
/// An admin, on this side (`holger bundle allow-first-import`, the console,
/// `POST /-/bundle/first-import`), for one vendor, product and sequence. Never a flag
/// the importer sets and never a marker file in the inbox (NCSC gap 2).
#[default]
Admin,
}
/// **The `trust:` block.**
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TrustSettings {
#[serde(default)]
pub first_import: FirstImport,
}
/// One way out a config names: the top-level key it is written under (for its line; empty
/// when it is not in the file, e.g. the build itself) and the reason in words.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WayOut {
pub key: &'static str,
pub what: String,
}
impl WayOut {
pub fn new(key: &'static str, what: impl Into<String>) -> Self {
Self {
key,
what: what.into(),
}
}
}
/// ★ **The refusal of a `boundary`**, or `None` when the config keeps to it.
///
/// * `OneWay` with any way out: `holger-boundary-1`, every reason with its line.
/// * `Fetch` in a build without `fetch`: `holger-boundary-2`.
///
/// `ron` is `(file name, its text)` when the config was read from a file.
pub fn refusal(
boundary: Boundary,
ways_out: &[WayOut],
fetch_built: bool,
ron: Option<(&str, &str)>,
) -> Option<String> {
match boundary {
Boundary::Fetch if !fetch_built => Some(holger_errcode::BOUNDARY_FETCH_NOT_BUILT.tag(format!(
"{}`boundary: Fetch`: this build has no fetch code (the default `holger-downstream` is one-way); \
build it with `--features fetch`, or remove the line to take packages only through the inbox",
at(ron, "boundary")
))),
Boundary::Fetch => None,
Boundary::OneWay if ways_out.is_empty() => None,
Boundary::OneWay => {
let reasons: Vec<String> = ways_out.iter().map(|w| format!("{}{}", at(ron, w.key), w.what)).collect();
Some(holger_errcode::BOUNDARY_ONE_WAY_REFUSED.tag(format!(
"this Holger is `boundary: OneWay`{}: packages come in only through its inbox (bundle.inbox, the diode's \
drop folder or a USB stick), so it dials nothing and sends nothing back. Remove: {}. To fetch from ONE \
upstream Holger instead, build with `--features fetch` and set `boundary: Fetch`",
if ron.is_some_and(|(_, t)| top_level_lines(t).iter().any(|(k, _)| k == "boundary")) { "" } else { " (the default)" },
reasons.join("; ")
)))
}
}
}
/// `"<file> line <n>: "` for a top-level key, or `""` when it is not in the text.
fn at(ron: Option<(&str, &str)>, key: &str) -> String {
let Some((file, text)) = ron else {
return String::new();
};
if key.is_empty() {
return String::new();
}
match top_level_lines(text).into_iter().find(|(k, _)| k == key) {
Some((_, line)) => format!("{file} line {line}: "),
None => String::new(),
}
}
/// ★ **Is `domain` a label?** 1 to 64 printable ASCII characters (it rides in a header).
pub fn validate_domain(domain: &str) -> Result<(), String> {
let ok = !domain.trim().is_empty()
&& domain.len() <= 64
&& domain.bytes().all(|b| (0x20..0x7f).contains(&b))
&& domain.trim() == domain;
if ok {
Ok(())
} else {
Err(holger_errcode::BOUNDARY_DOMAIN_INVALID.tag(format!(
"`domain: {domain:?}` is not a label: 1 to 64 printable ASCII characters, no leading or trailing space"
)))
}
}
/// Pure: **every top-level key of a RON config and the 1-based line it starts on**, in file
/// order. Top level is depth one inside the outer `(...)`; strings, chars and comments
/// (`//`, nested `/* */`) are skipped.
pub fn top_level_lines(ron: &str) -> Vec<(String, usize)> {
let b = ron.as_bytes();
let (mut i, mut line, mut depth) = (0usize, 1usize, 0i32);
let mut out = Vec::new();
let mut ident: Option<(usize, usize)> = None; // (start, line) of the last identifier at depth 1
while i < b.len() {
let c = b[i];
match c {
b'\n' => {
line += 1;
i += 1;
continue;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
continue;
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut nest = 1;
i += 2;
while i < b.len() && nest > 0 {
if b[i] == b'\n' {
line += 1;
}
if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
nest += 1;
i += 1;
} else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
nest -= 1;
i += 1;
}
i += 1;
}
continue;
}
b'"' => {
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
i += 1;
} else if b[i] == b'\n' {
line += 1;
}
i += 1;
}
i += 1;
ident = None;
continue;
}
b'(' | b'[' | b'{' => {
depth += 1;
ident = None;
}
b')' | b']' | b'}' => {
depth -= 1;
ident = None;
}
b':' if depth == 1 => {
if let Some((s, l)) = ident.take() {
out.push((ron[s..i].trim().to_string(), l));
}
}
c if depth == 1 && (c.is_ascii_alphanumeric() || c == b'_') => {
if ident.is_none()
|| (i > 0 && !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_'))
{
ident = Some((i, line));
}
}
c if depth == 1 && !c.is_ascii_whitespace() => ident = None,
_ => {}
}
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const RON: &str = r#"(
// a comment with mirror: in it
repositories: [ (ron_name: "a", x: "mirror: no") ],
domain: "lab",
/* mirror: not here
either */
mirror: Some((ron_sources: [])),
export: Some((push_to: Some("/x"))),
)"#;
#[test]
fn the_top_level_keys_are_found_on_their_lines_and_nothing_nested_is() {
let keys = top_level_lines(RON);
assert_eq!(
keys,
vec![
("repositories".to_string(), 3),
("domain".to_string(), 4),
("mirror".to_string(), 7),
("export".to_string(), 8)
]
);
}
#[test]
fn one_way_names_every_way_out_with_its_line_and_code() {
let ways = vec![
WayOut::new(
"mirror",
"the `mirror:` block pulls from an upstream Holger",
),
WayOut::new("export", "the `export:` block is an export door"),
];
let r = refusal(Boundary::OneWay, &ways, false, Some(("ds.ron", RON))).unwrap();
assert!(r.starts_with("[holger-boundary-1]"), "{r}");
assert!(r.contains("ds.ron line 7: the `mirror:` block"), "{r}");
assert!(r.contains("ds.ron line 8: the `export:` block"), "{r}");
assert!(r.contains("(the default)"), "{r}");
assert!(refusal(Boundary::OneWay, &[], false, None).is_none());
}
#[test]
fn fetch_needs_the_fetch_build() {
let r = refusal(Boundary::Fetch, &[], false, None).unwrap();
assert!(r.starts_with("[holger-boundary-2]"), "{r}");
assert!(refusal(Boundary::Fetch, &[WayOut::new("mirror", "m")], true, None).is_none());
}
#[test]
fn a_domain_is_a_short_printable_label() {
assert!(validate_domain("onprem-lab").is_ok());
assert!(validate_domain("Site A / hall 2").is_ok());
for bad in ["", " ", " lab", "lab\n", "x\u{7f}", &"a".repeat(65), "å"] {
let e = validate_domain(bad).unwrap_err();
assert!(e.starts_with("[holger-boundary-3]"), "{bad:?}: {e}");
}
}
#[test]
fn the_defaults_are_one_way_and_an_admins_first_import() {
assert_eq!(Boundary::default(), Boundary::OneWay);
assert_eq!(TrustSettings::default().first_import, FirstImport::Admin);
let t: TrustSettings = ron::from_str("(first_import: Admin)").unwrap();
assert_eq!(t.first_import, FirstImport::Admin);
assert!(ron::from_str::<TrustSettings>("(first_import: Caller)").is_err());
}
}