cellos-ctl 0.5.2

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! `cellctl apply -f formation.yaml` → POST /v1/formations.
//!
//! YAML is parsed locally only to validate shape and convert to JSON. The
//! authoritative validation happens on the server; we do a minimal local check
//! to surface obvious schema errors with exit-code 3 before any network call.
//!
//! ## Red-team wave 2 hardening
//!
//! `serde_yaml` (the unmaintained YAML 1.1 parser still in the workspace) is
//! known to be vulnerable to the "billion-laughs" alias-expansion attack: a
//! ~1 KiB YAML document can expand to gigabytes of in-memory `Value` tree
//! during deserialization. The full fix is to migrate off `serde_yaml`; until
//! that lands, we defend in depth here:
//!
//! 1. **Hard byte cap on the source file.** A formation document is ~12 KiB
//!    for a 64-cell formation; 256 KiB is two orders of magnitude over any
//!    legitimate input and small enough that even an aggressive alias bomb
//!    cannot inflate past a few hundred MiB of allocator pressure on the
//!    parsing pass (still bad, but not unbounded-CPU bad).
//! 2. **Reject symlinks at the final path component.** `cellctl apply -f
//!    /tmp/attacker-controlled` cannot redirect the read to `/etc/shadow`
//!    via a swapped-in symlink. Matches the SEC-15b / SEC-08 protection
//!    applied to `CELLOS_POLICY_PACK_PATH` and the supervisor spec path.
//! 3. **Reject non-regular files.** Named pipes, devices, and sockets are
//!    not legitimate config sources — `cellctl apply -f /dev/zero` should
//!    fail fast, not spin forever inside `read_to_string`.

use std::path::Path;

use serde_json::Value;

use crate::client::CellosClient;
use crate::exit::{CtlError, CtlResult};
use crate::model::Formation;

/// Maximum size of a `cellctl apply -f` source file. See module docs.
pub(crate) const APPLY_MAX_INPUT_BYTES: u64 = 256 * 1024;

pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
    let yaml = read_apply_input(path)?;

    let body: Value = serde_yaml::from_str(&yaml)?;
    validate_formation_spec(&body, path)?;

    let created: Formation = client.post_json("/v1/formations", &body).await?;
    let name = if !created.name.is_empty() {
        &created.name
    } else {
        &created.id
    };
    println!("formation/{name} created (state={})", created.state);
    Ok(())
}

/// Local shape check — the server is authoritative, but obvious mistakes
/// should fail fast with exit code 3 (validation) rather than 2 (api).
fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
    let Some(obj) = v.as_object() else {
        return Err(CtlError::validation(format!(
            "{}: top-level must be a YAML mapping",
            path.display()
        )));
    };

    // Accept either `kind: Formation` (kubectl-style) OR a bare formation spec
    // (which is what Session 15/16 sketched). Don't be pedantic — server decides.
    if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
        if kind != "Formation" {
            return Err(CtlError::validation(format!(
                "{}: kind must be \"Formation\" (got \"{kind}\")",
                path.display()
            )));
        }
    }

    // Require either `name` or `metadata.name`.
    let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
        || obj
            .get("metadata")
            .and_then(|m| m.get("name"))
            .and_then(|n| n.as_str())
            .is_some();
    if !has_name {
        return Err(CtlError::validation(format!(
            "{}: missing required field `name` (or `metadata.name`)",
            path.display()
        )));
    }
    Ok(())
}

/// Read `cellctl apply -f` input with red-team wave 2 hardening:
/// - reject symlinks (`symlink_metadata` then refuse `is_symlink`),
/// - reject non-regular files (named pipes, devices, sockets),
/// - cap total bytes at [`APPLY_MAX_INPUT_BYTES`] before allocating the body.
///
/// See module docs for the threat model. All failures map to [`CtlError::usage`]
/// (exit code 1) since they describe an invalid invocation, not a server-side
/// validation failure of the document's contents.
pub(crate) fn read_apply_input(path: &Path) -> CtlResult<String> {
    use std::io::Read;
    // symlink_metadata does NOT follow the final component, so an attacker who
    // swapped /tmp/spec.yaml -> /etc/shadow between the user typing the command
    // and us opening the file is caught here rather than silently followed.
    let meta = std::fs::symlink_metadata(path)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
    if meta.file_type().is_symlink() {
        return Err(CtlError::usage(format!(
            "{}: refusing to follow symlink (apply requires a regular file)",
            path.display()
        )));
    }
    if !meta.file_type().is_file() {
        return Err(CtlError::usage(format!(
            "{}: not a regular file (named pipes, devices, and sockets are rejected)",
            path.display()
        )));
    }
    // Pre-size check via metadata.len() — for regular files this is the exact
    // byte count and lets us refuse before any allocation. We still cap the
    // read itself with `take()` so a TOCTOU expansion between stat and open
    // cannot blow past the limit.
    if meta.len() > APPLY_MAX_INPUT_BYTES {
        return Err(CtlError::usage(format!(
            "{}: file is {} bytes; maximum is {} (resists serde_yaml billion-laughs)",
            path.display(),
            meta.len(),
            APPLY_MAX_INPUT_BYTES,
        )));
    }

    let mut file = std::fs::File::open(path)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
    // +1 so we can detect a TOCTOU race that grew the file past the cap.
    let mut buf = String::with_capacity(meta.len() as usize);
    let limited = std::io::Read::take(&mut file, APPLY_MAX_INPUT_BYTES + 1);
    let n = std::io::BufReader::new(limited)
        .read_to_string(&mut buf)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
    if n as u64 > APPLY_MAX_INPUT_BYTES {
        return Err(CtlError::usage(format!(
            "{}: file exceeded {} byte cap mid-read (TOCTOU growth?)",
            path.display(),
            APPLY_MAX_INPUT_BYTES,
        )));
    }
    Ok(buf)
}

#[cfg(test)]
mod tests {
    //! Red-team wave 2 hostile-input tests for `cellctl apply` source files.
    use super::*;
    use std::io::Write;

    /// Helper: a tempdir-scoped writable file.
    fn write_temp(name: &str, contents: &[u8]) -> std::path::PathBuf {
        let dir =
            std::env::temp_dir().join(format!("cellctl-apply-rt2-{}-{}", name, std::process::id()));
        std::fs::create_dir_all(&dir).expect("mkdir tempdir");
        let p = dir.join(format!("{name}.yaml"));
        let mut f = std::fs::File::create(&p).expect("create temp file");
        f.write_all(contents).expect("write temp file");
        p
    }

    /// Red-team W2C-M2 / W2C-H1: a 1 MiB YAML file exceeds the
    /// `APPLY_MAX_INPUT_BYTES` cap and must be refused with a `usage` error
    /// before `serde_yaml` is invoked. This defends against the
    /// serde_yaml billion-laughs vector by capping the *source*: a 256 KiB
    /// alias bomb cannot inflate past a few hundred MiB even at worst case,
    /// far below "OOM the operator's laptop".
    #[test]
    fn rejects_oversized_input_before_parse() {
        let big = vec![b'a'; 1024 * 1024];
        let p = write_temp("oversized", &big);
        let err = read_apply_input(&p).expect_err("oversized must fail");
        assert!(
            err.message.contains("maximum is")
                || err.message.contains("byte cap")
                || err.message.contains("billion-laughs"),
            "expected size-cap error, got {err:?}",
        );
        let _ = std::fs::remove_file(&p);
    }

    /// Red-team W2C-L1: a symlink as the final path component is refused so
    /// `cellctl apply -f /tmp/spec.yaml` cannot be redirected to `/etc/shadow`
    /// or `/dev/zero` by an attacker who swapped the symlink in between
    /// `argv` parsing and the open.
    #[cfg(unix)]
    #[test]
    fn rejects_symlink_at_final_component() {
        let target = write_temp("symlink-target", b"name: t\n");
        let link_dir = target.parent().unwrap().join("link-rt2");
        std::fs::create_dir_all(&link_dir).expect("mkdir link dir");
        let link = link_dir.join("link.yaml");
        let _ = std::fs::remove_file(&link);
        std::os::unix::fs::symlink(&target, &link).expect("symlink");
        let err = read_apply_input(&link).expect_err("symlink must be refused");
        assert!(
            err.message.contains("symlink"),
            "expected symlink-refusal, got {err:?}",
        );
        let _ = std::fs::remove_file(&link);
        let _ = std::fs::remove_file(&target);
    }

    /// Red-team: a tiny well-formed YAML still parses (regression guard).
    #[test]
    fn accepts_small_well_formed_yaml() {
        let p = write_temp(
            "small",
            b"name: demo\ncoordinator: coord\nmembers:\n  - id: coord\n",
        );
        let s = read_apply_input(&p).expect("small file ok");
        assert!(s.contains("coord"));
        let _ = std::fs::remove_file(&p);
    }

    /// Red-team: `validate_formation_spec` must reject a `kind` that is the
    /// right case-folded shape but the wrong literal value. We accept exact
    /// "Formation" only — case-tweaked variants ("formation", "FORMATION") are
    /// rejected because an admission-gate that accepts case variants is one
    /// step from accepting Unicode lookalikes.
    #[test]
    fn validate_rejects_wrong_kind_case() {
        let v: Value = serde_yaml::from_str(
            "kind: formation\nname: demo\ncoordinator: coord\nmembers:\n  - id: coord\n",
        )
        .unwrap();
        let err = validate_formation_spec(&v, std::path::Path::new("/dev/null"))
            .expect_err("wrong kind must fail");
        assert!(
            err.message.contains("kind"),
            "expected kind-mismatch, got {err:?}",
        );
    }

    /// Red-team W2C-M2 (named-pipe variant): /dev/zero must fail fast with
    /// "not a regular file" rather than spinning forever inside
    /// `read_to_string`. On non-unix this test is skipped because the path
    /// likely does not exist.
    #[cfg(unix)]
    #[test]
    fn rejects_dev_zero() {
        let p = std::path::Path::new("/dev/zero");
        if !p.exists() {
            return;
        }
        let err = read_apply_input(p).expect_err("/dev/zero must be refused");
        assert!(
            err.message.contains("not a regular file") || err.message.contains("symlink"),
            "expected non-regular-file refusal, got {err:?}",
        );
    }
}