sail-rs 0.2.19

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Helpers for code running inside a sailbox guest: the identity Sail
//! injects at boot, and the metadata of mounted volumes. These read
//! only local state (environment, kernel cmdline, mount metadata files) and
//! fail with a clear error outside a guest.

use std::path::Path;

use crate::error::SailError;
use crate::sailbox::types::VolumeInfo;

/// Header naming the calling sailbox for app-name ingress allowlists.
pub const SOURCE_SAILBOX_ID_HEADER: &str = "X-Sail-Source-Sailbox-ID";
/// Header carrying the calling sailbox's ingress auth token.
pub const SOURCE_SAILBOX_AUTH_HEADER: &str = "X-Sail-Source-Sailbox-Auth";

const PROC_CMDLINE: &str = "/proc/cmdline";
const VOLUME_METADATA_FILE: &str = ".sail_volume.json";

/// Headers that authenticate this sailbox as an ingress allowlist source.
/// Use them on HTTP requests from one sailbox to another listener whose
/// allowlist contains the caller's app name. Only available inside a guest.
//
// CR-soon nbaruah: manual headers are transitional. The planned egress MITM
// proxy will inject these transparently for ingress-domain destinations, at
// which point SDK users can drop this call (it stays supported as the
// fallback for clients the MITM cannot terminate, e.g. pinned certs or custom
// trust stores inside user Docker containers). Raw-TCP listeners are
// separate: headers cannot ride a byte-transparent stream, so TCP app-name
// allowlists need the tcp-spooler short-circuit that populates
// OpenIngressRequest.source_sailbox_id/source_auth_token.
pub fn ingress_auth_headers() -> Result<Vec<(String, String)>, SailError> {
    headers_from(
        std::env::var("SAILBOX_ID").ok(),
        std::env::var("SAILBOX_TCP_SPOOLER_AUTH_TOKEN").ok(),
        Path::new(PROC_CMDLINE),
    )
}

fn headers_from(
    id_env: Option<String>,
    token_env: Option<String>,
    cmdline: &Path,
) -> Result<Vec<(String, String)>, SailError> {
    let mut sailbox_id = id_env.unwrap_or_default().trim().to_string();
    let mut auth_token = token_env.unwrap_or_default().trim().to_string();
    if sailbox_id.is_empty() || auth_token.is_empty() {
        // Guests running a saild that predates exporting these env vars to
        // exec'd processes still carry the same values on the kernel cmdline
        // (world-readable inside the guest), so fall back to that.
        let (cmdline_id, cmdline_token) = identity_from_cmdline(cmdline);
        if sailbox_id.is_empty() {
            sailbox_id = cmdline_id;
        }
        if auth_token.is_empty() {
            auth_token = cmdline_token;
        }
    }
    if sailbox_id.is_empty() || auth_token.is_empty() {
        return Err(SailError::InvalidArgument {
            message: "ingress auth headers are only available inside a sailbox".to_string(),
        });
    }
    Ok(vec![
        (SOURCE_SAILBOX_ID_HEADER.to_string(), sailbox_id),
        (SOURCE_SAILBOX_AUTH_HEADER.to_string(), auth_token),
    ])
}

/// Read (sailbox_id, auth_token) from the kernel cmdline values saild injects
/// at guest boot (`sailbox.id`, `sailbox.tcp_spooler_auth`). Empty outside a
/// guest.
fn identity_from_cmdline(path: &Path) -> (String, String) {
    let Ok(contents) = std::fs::read_to_string(path) else {
        return (String::new(), String::new());
    };
    let mut sailbox_id = String::new();
    let mut auth_token = String::new();
    for arg in contents.split_whitespace() {
        if let Some((key, value)) = arg.split_once('=') {
            match key {
                "sailbox.id" => sailbox_id = value.trim().to_string(),
                "sailbox.tcp_spooler_auth" => auth_token = value.trim().to_string(),
                _ => {}
            }
        }
    }
    (sailbox_id, auth_token)
}

/// Load the volume handle for a path mounted into this sailbox, from the
/// metadata file Sail writes at the mount root. Only available
/// inside a guest.
pub fn volume_from_mount(path: &Path) -> Result<VolumeInfo, SailError> {
    let metadata_path = path.join(VOLUME_METADATA_FILE);
    let contents =
        std::fs::read_to_string(&metadata_path).map_err(|_| SailError::InvalidArgument {
            message: format!("{} is not a Sail volume mount", path.display()),
        })?;
    let data: serde_json::Value =
        serde_json::from_str(&contents).map_err(|_| SailError::InvalidArgument {
            message: format!(
                "{} is not valid Sail volume metadata",
                metadata_path.display()
            ),
        })?;
    let backend = data
        .get("backend")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("")
        .trim()
        .to_string();
    if backend != "nfs" {
        return Err(SailError::InvalidArgument {
            message: format!(
                "{} is a {backend:?} Sail volume; only NFS volumes are supported",
                path.display()
            ),
        });
    }
    let parse_time = |key: &str| {
        data.get(key)
            .and_then(serde_json::Value::as_str)
            .and_then(|s| {
                time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
            })
    };
    Ok(VolumeInfo {
        volume_id: data
            .get("volume_id")
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .to_string(),
        name: data
            .get("name")
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .to_string(),
        backend,
        status: "ready".to_string(),
        created_at: parse_time("created_at"),
        updated_at: parse_time("updated_at"),
    })
}

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

    #[test]
    fn env_identity_wins_over_cmdline() {
        let dir = tempfile::tempdir().expect("tempdir");
        let cmdline = dir.path().join("cmdline");
        std::fs::write(
            &cmdline,
            "sailbox.id=sb-cmdline sailbox.tcp_spooler_auth=tok-cmdline\n",
        )
        .expect("write");
        let headers = headers_from(
            Some("sb-env".to_string()),
            Some("tok-env".to_string()),
            &cmdline,
        )
        .expect("headers");
        assert_eq!(headers[0].1, "sb-env");
        assert_eq!(headers[1].1, "tok-env");
    }

    #[test]
    fn cmdline_fallback_parses_boot_identity() {
        let dir = tempfile::tempdir().expect("tempdir");
        let cmdline = dir.path().join("cmdline");
        std::fs::write(
            &cmdline,
            "console=ttyS0 sailbox.id=sb-c sailbox.vm_instance_id=vm-1 \
             sailbox.tcp_spooler_auth=tok-c quiet\n",
        )
        .expect("write");
        let headers =
            headers_from(/* id_env */ None, /* token_env */ None, &cmdline).expect("headers");
        assert_eq!(
            headers,
            vec![
                (SOURCE_SAILBOX_ID_HEADER.to_string(), "sb-c".to_string()),
                (SOURCE_SAILBOX_AUTH_HEADER.to_string(), "tok-c".to_string()),
            ]
        );
    }

    #[test]
    fn outside_a_guest_errors() {
        let dir = tempfile::tempdir().expect("tempdir");
        let err = headers_from(
            /* id_env */ None,
            /* token_env */ None,
            &dir.path().join("missing"),
        )
        .expect_err("no identity outside a guest");
        assert!(err.to_string().contains("inside a sailbox"), "{err}");
    }

    #[test]
    fn volume_from_mount_reads_metadata() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            dir.path().join(VOLUME_METADATA_FILE),
            r#"{"volume_id":"vol_1","name":"shared","backend":"nfs","created_at":"2026-01-02T03:04:05Z"}"#,
        )
        .expect("write");
        let info = volume_from_mount(dir.path()).expect("volume");
        assert_eq!(info.volume_id, "vol_1");
        assert_eq!(info.status, "ready");
        assert!(info.created_at.is_some());
    }

    #[test]
    fn volume_from_mount_rejects_non_volume_dirs() {
        let dir = tempfile::tempdir().expect("tempdir");
        let err = volume_from_mount(dir.path()).expect_err("no metadata");
        assert!(err.to_string().contains("not a Sail volume mount"), "{err}");
    }
}