sail-rs 0.2.10

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! The `~/.sail` credential and settings store, shared by the SDK and the CLI.
//!
//! The secret API key lives in `~/.sail/auth.toml` (`0600`); non-secret settings
//! (mode, endpoint overrides, and the URL the key was validated against) live in
//! `~/.sail/config.toml`. Environment variables always win over both files. The
//! stored key is tagged with that URL (`api_key_api_url`) and is applied only
//! when the active target matches, so a key minted for one environment is never
//! sent to another. All writes are atomic (temp file + rename) with
//! `0o600`/`0o700` permissions so a crash mid-write can never brick later runs.

use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::error::SailError;

/// Non-secret settings keys accepted in `config.toml` and folded into config.
pub const SETTINGS_KEYS: &[&str] = &["mode", "api_url", "sailbox_api_url", "imagebuilder_url"];

/// The target URL the stored key was validated against. Persisted by `auth
/// login` for target matching, not user-settable via `config set`.
pub const API_KEY_TARGET_KEY: &str = "api_key_api_url";

/// Recognized `mode` values.
pub const MODE_VALUES: &[&str] = &["prod", "dev", "staging", "local"];

const AUTH_HEADER: &str = "# Managed by `sail auth`. The SAIL_API_KEY env var overrides this.\n";
const CONFIG_HEADER: &str =
    "# Managed by `sail config` and `sail auth`. Env vars override these values.\n";

fn is_settings_key(key: &str) -> bool {
    SETTINGS_KEYS.contains(&key) || key == API_KEY_TARGET_KEY
}

// --- paths ---

/// The Sail home directory: `$SAIL_HOME` (with a leading `~` expanded) or
/// `~/.sail`.
pub fn sail_home() -> PathBuf {
    if let Ok(raw) = std::env::var("SAIL_HOME") {
        let raw = raw.trim();
        if !raw.is_empty() {
            return expand_user(raw);
        }
    }
    let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    base.join(".sail")
}

/// Expand a leading `~/` against the home directory; other paths pass through.
pub fn expand_user(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(rest);
        }
    }
    PathBuf::from(path)
}

/// Path to the secret credential file (`~/.sail/auth.toml`).
pub fn auth_path() -> PathBuf {
    sail_home().join("auth.toml")
}

/// Path to the non-secret settings file (`~/.sail/config.toml`).
pub fn config_path() -> PathBuf {
    sail_home().join("config.toml")
}

// --- reading ---

/// Load the stored API key from `auth.toml`. A missing file is `Ok(None)`; a
/// malformed file is an error so the CLI can surface it.
pub fn load_auth_key() -> Result<Option<String>, SailError> {
    let Some(table) = read_toml_table(&auth_path())? else {
        return Ok(None);
    };
    let key = table
        .get("api_key")
        .and_then(toml::Value::as_str)
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    Ok(key)
}

/// Load the recognized non-secret settings from `config.toml`. A missing file is
/// an empty map; a malformed file, or one containing an unrecognized key, is an
/// error so a typo'd setting fails loudly instead of being silently ignored.
pub fn load_settings() -> Result<BTreeMap<String, String>, SailError> {
    let Some(table) = read_toml_table(&config_path())? else {
        return Ok(BTreeMap::new());
    };
    let mut values = BTreeMap::new();
    let mut unknown = Vec::new();
    for (key, value) in table {
        if !is_settings_key(&key) {
            unknown.push(key);
            continue;
        }
        let text = match value {
            toml::Value::String(s) => s,
            toml::Value::Integer(i) => i.to_string(),
            toml::Value::Float(f) => f.to_string(),
            _ => continue,
        };
        values.insert(key, text);
    }
    if !unknown.is_empty() {
        unknown.sort();
        return Err(SailError::Config {
            message: format!(
                "{} contains unrecognized setting(s): {}. Allowed settings: {}. \
                 Edit the file or run `sail config reset`.",
                config_path().display(),
                unknown.join(", "),
                SETTINGS_KEYS.join(", "),
            ),
        });
    }
    Ok(values)
}

/// The stored key, ignoring a missing or malformed file. Used by SDK config
/// resolution, where a broken file must never crash an otherwise valid run.
pub(crate) fn auth_key_best_effort() -> Option<String> {
    load_auth_key().ok().flatten()
}

fn read_toml_table(path: &Path) -> Result<Option<toml::Table>, SailError> {
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => {
            return Err(SailError::Internal {
                message: format!("could not read {}: {err}", path.display()),
            })
        }
    };
    let table = toml::from_str(&text).map_err(|err| SailError::Config {
        message: format!(
            "could not parse {} ({err}). Edit the file to fix it, or run `sail config reset`.",
            path.display()
        ),
    })?;
    Ok(Some(table))
}

// --- writing ---

/// Write the API key to `auth.toml` atomically with `0o600` permissions.
pub fn save_auth_key(api_key: &str) -> Result<PathBuf, SailError> {
    let dir = sail_home();
    ensure_private_dir(&dir)?;
    let body = format!("{AUTH_HEADER}api_key = {}\n", toml_basic_string(api_key));
    let path = auth_path();
    atomic_write(&path, body.as_bytes())?;
    Ok(path)
}

/// Remove `auth.toml` if present. A missing file is success.
pub fn clear_auth_key() -> Result<(), SailError> {
    match fs::remove_file(auth_path()) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(SailError::Internal {
            message: format!("could not remove {}: {err}", auth_path().display()),
        }),
    }
}

/// Write the non-secret settings to `config.toml` atomically with a managed
/// header and sorted keys. An empty map removes the file.
pub fn save_settings(values: &BTreeMap<String, String>) -> Result<(), SailError> {
    let path = config_path();
    if values.is_empty() {
        return match fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(err) => Err(SailError::Internal {
                message: format!("could not remove {}: {err}", path.display()),
            }),
        };
    }
    let dir = sail_home();
    ensure_private_dir(&dir)?;
    let mut body = String::from(CONFIG_HEADER);
    for (key, value) in values {
        body.push_str(&format!("{key} = {}\n", toml_basic_string(value)));
    }
    atomic_write(&path, body.as_bytes())?;
    Ok(())
}

// --- target matching ---

/// The active public API URL given an explicit `api_url` (else the mode's
/// default), normalized without a trailing slash. Empty when the mode is
/// unrecognized and no explicit URL is set.
pub fn resolve_target_api_url(api_url: &str, mode: &str) -> String {
    let api_url = api_url.trim().trim_end_matches('/');
    if !api_url.is_empty() {
        return api_url.to_string();
    }
    super::config::api_url_for_mode(mode)
        .map(|u| u.trim_end_matches('/').to_string())
        .unwrap_or_default()
}

/// Whether the stored key's tagged target equals `effective_target`, so the key
/// is safe to apply. The target is the explicit `api_key_api_url` tag, or, if
/// untagged, the one the stored `api_url`/`mode` imply. A key with no tag and no
/// stored target hint never matches, so it is never applied (fail closed).
pub fn stored_key_matches_target(
    settings: &BTreeMap<String, String>,
    effective_target: &str,
) -> bool {
    let Some(stored) = stored_key_target(settings) else {
        return false;
    };
    let effective = effective_target.trim().trim_end_matches('/');
    !effective.is_empty() && stored == effective
}

/// The target the stored key is bound to: the explicit tag, else the one the
/// stored `api_url`/`mode` imply. `None` when nothing pins it (so it is never
/// applied implicitly).
fn stored_key_target(settings: &BTreeMap<String, String>) -> Option<String> {
    let nonempty = |key: &str| {
        settings
            .get(key)
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .map(str::to_string)
    };
    if let Some(tag) = nonempty(API_KEY_TARGET_KEY) {
        return Some(tag.trim_end_matches('/').to_string());
    }
    let api_url = nonempty("api_url");
    let mode = nonempty("mode");
    if api_url.is_none() && mode.is_none() {
        return None;
    }
    let target = resolve_target_api_url(
        api_url.as_deref().unwrap_or(""),
        mode.as_deref().unwrap_or(""),
    );
    if target.is_empty() {
        None
    } else {
        Some(target)
    }
}

// --- display ---

/// Mask a secret for display: `abcd…wxyz`, or `***` when too short to mask.
pub fn mask_secret(secret: &str) -> String {
    if secret.is_empty() {
        return String::new();
    }
    if secret.chars().count() <= 8 {
        return "***".to_string();
    }
    let chars: Vec<char> = secret.chars().collect();
    let head: String = chars[..4].iter().collect();
    let tail: String = chars[chars.len() - 4..].iter().collect();
    format!("{head}{tail}")
}

// --- filesystem helpers ---

fn ensure_private_dir(dir: &Path) -> Result<(), SailError> {
    if dir.exists() {
        return Ok(());
    }
    fs::create_dir_all(dir).map_err(|err| SailError::Internal {
        message: format!("could not create {}: {err}", dir.display()),
    })?;
    set_mode(dir, 0o700);
    Ok(())
}

fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SailError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("config");
    let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
    let io_err = |err: std::io::Error, what: &Path| SailError::Internal {
        message: format!("could not write {}: {err}", what.display()),
    };
    {
        let mut file = fs::File::create(&tmp).map_err(|err| io_err(err, &tmp))?;
        set_mode(&tmp, 0o600);
        file.write_all(bytes).map_err(|err| io_err(err, &tmp))?;
        file.flush().map_err(|err| io_err(err, &tmp))?;
    }
    fs::rename(&tmp, path).map_err(|err| {
        let _ = fs::remove_file(&tmp);
        SailError::Internal {
            message: format!("could not replace {}: {err}", path.display()),
        }
    })
}

#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
    use std::os::unix::fs::PermissionsExt;
    let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}

#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) {}

/// Render a value as a TOML basic string with control characters escaped, so a
/// load then save round-trip is stable even for hand-edited values.
fn toml_basic_string(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\u{0008}' => out.push_str("\\b"),
            '\t' => out.push_str("\\t"),
            '\n' => out.push_str("\\n"),
            '\u{000C}' => out.push_str("\\f"),
            '\r' => out.push_str("\\r"),
            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
                out.push_str(&format!("\\u{:04X}", c as u32));
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

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

    #[test]
    fn mask_secret_masks_long_keys_and_hides_short() {
        assert_eq!(mask_secret(""), "");
        assert_eq!(mask_secret("short"), "***");
        assert_eq!(mask_secret("sk_123456789"), "sk_1…6789");
    }

    #[test]
    fn toml_escaping_round_trips_control_chars() {
        assert_eq!(toml_basic_string("plain"), "\"plain\"");
        assert_eq!(toml_basic_string("a\nb"), "\"a\\nb\"");
        assert_eq!(toml_basic_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
        assert_eq!(toml_basic_string("\u{0001}"), "\"\\u0001\"");
    }

    #[test]
    fn target_match_requires_equal_nonempty_targets() {
        let mut settings = BTreeMap::new();
        settings.insert(
            API_KEY_TARGET_KEY.to_string(),
            "https://api.sailresearch.com/".to_string(),
        );
        // Trailing-slash differences do not matter.
        assert!(stored_key_matches_target(
            &settings,
            "https://api.sailresearch.com"
        ));
        // A different target never matches.
        assert!(!stored_key_matches_target(
            &settings,
            "https://dev.sailresearch.com"
        ));
        // No tag and no stored settings: nothing to match against, fail closed.
        assert!(!stored_key_matches_target(
            &BTreeMap::new(),
            "https://api.sailresearch.com"
        ));
    }

    #[test]
    fn target_match_falls_back_to_stored_mode_when_untagged() {
        let mut settings = BTreeMap::new();
        settings.insert("mode".to_string(), "dev".to_string());
        assert!(stored_key_matches_target(
            &settings,
            "https://dev.sailresearch.com"
        ));
        assert!(!stored_key_matches_target(
            &settings,
            "https://api.sailresearch.com"
        ));
    }
}