cellos-supervisor 0.5.1

CellOS execution-cell runner — boots cells in Firecracker microVMs or gVisor, enforces narrow typed authority, emits signed CloudEvents.
Documentation
//! Cell spec path reading (`O_NOFOLLOW` on Unix) and NATS subject template resolution.

use std::fs;
use std::io::Read;
use std::path::Path;

use anyhow::Context;
use sha2::{Digest, Sha256};

/// Read the cell spec. If `path` is `-`, reads the entire document from **stdin** (no `O_NOFOLLOW`;
/// caller supplies bytes from a sealed pipe or CI artifact). Otherwise reads from a file: on Unix,
/// opens with `O_NOFOLLOW` on the final path component so a swapped-in symlink cannot be followed.
pub fn read_cell_spec(path: &Path) -> anyhow::Result<String> {
    if path.as_os_str() == std::ffi::OsStr::new("-") {
        let mut raw = String::new();
        std::io::stdin()
            .read_to_string(&mut raw)
            .context("read cell spec from stdin")?;
        return Ok(raw);
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        let mut opts = fs::OpenOptions::new();
        opts.read(true);
        opts.custom_flags(libc::O_RDONLY | libc::O_NOFOLLOW);
        let mut file = opts
            .open(path)
            .with_context(|| format!("read cell spec {}", path.display()))?;
        let mut raw = String::new();
        file.read_to_string(&mut raw)
            .with_context(|| format!("read cell spec {}", path.display()))?;
        Ok(raw)
    }
    #[cfg(not(unix))]
    {
        fs::read_to_string(path).with_context(|| format!("read cell spec {}", path.display()))
    }
}

/// Compute the SHA-256 of raw spec bytes and return the lowercase hex digest.
///
/// This digest is included in `lifecycle.started` so the audit trail can detect
/// spec file tampering between submission and execution.  The hash covers the
/// raw bytes as-read — JSON whitespace and key ordering are not normalized, so
/// the hash changes if anyone edits the file.
pub fn spec_sha256(raw: &str) -> String {
    let digest = Sha256::digest(raw.as_bytes());
    digest.iter().map(|b| format!("{b:02x}")).collect()
}

/// Token substituted for `{tenantId}` when a spec has no `correlation.tenantId`.
///
/// Mirrors the canonical "absent tenant" placeholder used by
/// `cellos-sink-jetstream` so multi-tenant subject namespaces stay structurally
/// uniform whether or not the operator declared a tenant.
pub const TENANT_ID_DEFAULT_TOKEN: &str = "_no_tenant_";

/// Resolve the NATS event subject for this supervisor run.
///
/// Precedence (highest first):
///
/// 1. `CELLOS_EVENTS_SUBJECT_TEMPLATE` (non-empty): substitute `{spec_id}`,
///    `{run_id}`, and `{tenantId}` verbatim.
/// 2. `CELLOS_TENANT_ID` (T12 RBAC, non-empty): produce
///    `cellos.events.<CELLOS_TENANT_ID>.<spec_id>.<run_id>` so multi-tenant
///    deployments get per-tenant subject namespaces without an explicit
///    template. This is the 1.0 RBAC default for operators that pin a single
///    tenant per supervisor process. Preserves backward compat: unset
///    `CELLOS_TENANT_ID` keeps the legacy literal-subject behaviour below.
/// 3. `CELL_OS_EVENTS_SUBJECT` (legacy literal): used verbatim.
/// 4. Default: `cellos.events.v1`.
///
/// `tenant_id` (the spec-side `correlation.tenantId`) is `None` when the spec
/// has no `correlation.tenantId`; in that case the literal token
/// [`TENANT_ID_DEFAULT_TOKEN`] is substituted in the template path, matching
/// the convention used by the JetStream sink. Callers SHOULD have already
/// validated the tenant id with
/// [`cellos_core::validate_tenant_id_for_subject_token`] at admission so no
/// NATS subject-token reserved char (`.`, `*`, `>`, whitespace) reaches the
/// wire. This function does NOT re-validate — substitution is verbatim.
pub fn resolve_event_subject(spec_id: &str, run_id: &str, tenant_id: Option<&str>) -> String {
    let tmpl = std::env::var("CELLOS_EVENTS_SUBJECT_TEMPLATE").unwrap_or_default();
    if !tmpl.trim().is_empty() {
        let tenant = tenant_id.unwrap_or(TENANT_ID_DEFAULT_TOKEN);
        return tmpl
            .replace("{spec_id}", spec_id)
            .replace("{run_id}", run_id)
            .replace("{tenantId}", tenant);
    }
    // T12 multi-tenant default: when CELLOS_TENANT_ID is set on the
    // supervisor process, lift it into the subject so every event for this
    // run lands on a tenant-namespaced subject. The supervisor is single-
    // tenant-per-process under T12 (one CELLOS_TENANT_ID), so we burn the
    // tenant directly into the subject rather than templating per event.
    // Unset/empty preserves byte-identical pre-T12 behaviour.
    if let Ok(env_tenant) = std::env::var("CELLOS_TENANT_ID") {
        let env_tenant = env_tenant.trim();
        if !env_tenant.is_empty() {
            return format!("cellos.events.{env_tenant}.{spec_id}.{run_id}");
        }
    }
    std::env::var("CELL_OS_EVENTS_SUBJECT").unwrap_or_else(|_| "cellos.events.v1".into())
}

#[cfg(test)]
mod tests {
    use super::resolve_event_subject;

    // Guard env-var mutation so resolve_event_subject tests don't race.
    static SUBJECT_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn resolve_subject_default_when_neither_var_set() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::remove_var("CELL_OS_EVENTS_SUBJECT");
        assert_eq!(
            resolve_event_subject("my-spec", "run-1", None),
            "cellos.events.v1"
        );
    }

    #[test]
    fn resolve_subject_literal_from_env() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::set_var("CELL_OS_EVENTS_SUBJECT", "cellos.events.custom");
        let result = resolve_event_subject("my-spec", "run-1", None);
        std::env::remove_var("CELL_OS_EVENTS_SUBJECT");
        assert_eq!(result, "cellos.events.custom");
    }

    #[test]
    fn resolve_subject_template_substitutes_spec_id_and_run_id() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::set_var(
            "CELLOS_EVENTS_SUBJECT_TEMPLATE",
            "cellos.{spec_id}.{run_id}.v1",
        );
        let result = resolve_event_subject("demo-cell", "run-42", None);
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        assert_eq!(result, "cellos.demo-cell.run-42.v1");
    }

    #[test]
    fn resolve_subject_template_takes_priority_over_literal_env() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::set_var("CELLOS_EVENTS_SUBJECT_TEMPLATE", "cellos.{spec_id}.v1");
        std::env::set_var("CELL_OS_EVENTS_SUBJECT", "cellos.events.custom");
        let result = resolve_event_subject("cell-abc", "run-1", None);
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::remove_var("CELL_OS_EVENTS_SUBJECT");
        assert_eq!(result, "cellos.cell-abc.v1");
    }

    // ── T12: CELLOS_TENANT_ID multi-tenant subject default ──────────────────

    #[test]
    fn resolve_subject_cellos_tenant_id_produces_namespaced_subject() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::remove_var("CELL_OS_EVENTS_SUBJECT");
        std::env::set_var("CELLOS_TENANT_ID", "tenant-a");
        let result = resolve_event_subject("demo-cell", "run-42", None);
        std::env::remove_var("CELLOS_TENANT_ID");
        assert_eq!(result, "cellos.events.tenant-a.demo-cell.run-42");
    }

    #[test]
    fn resolve_subject_no_cellos_tenant_id_preserves_legacy_default() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::remove_var("CELL_OS_EVENTS_SUBJECT");
        std::env::remove_var("CELLOS_TENANT_ID");
        let result = resolve_event_subject("demo-cell", "run-42", None);
        assert_eq!(result, "cellos.events.v1");
    }

    #[test]
    fn resolve_subject_template_beats_cellos_tenant_id() {
        let _g = SUBJECT_MUTEX.lock().unwrap();
        std::env::set_var("CELLOS_EVENTS_SUBJECT_TEMPLATE", "cellos.{spec_id}.v1");
        std::env::set_var("CELLOS_TENANT_ID", "tenant-a");
        let result = resolve_event_subject("demo-cell", "run-42", None);
        std::env::remove_var("CELLOS_EVENTS_SUBJECT_TEMPLATE");
        std::env::remove_var("CELLOS_TENANT_ID");
        assert_eq!(result, "cellos.demo-cell.v1");
    }
}