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

nordisk/holger

server/lib/src/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());
    }
}