ebman 0.29.0

k9s-style TUI for AWS Elastic Beanstalk
Documentation
//! App-specific path helpers for ebman. The generic bits
//! (`parse_bool`, `write_atomic`) live in `tui-common::util` and are
//! re-exported here so existing `crate::util::*` call sites keep
//! working unchanged.

use std::path::PathBuf;

pub use tui_common::util::{parse_bool, write_atomic};

/// XDG-style user config directory for ebman: `~/.config/ebman/`.
/// Falls back to the current working directory when `$HOME` is
/// unset (rare; mostly affects sandboxed test environments).
pub fn config_dir() -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        let mut p = PathBuf::from(home);
        p.push(".config/ebman");
        return p;
    }
    PathBuf::from(".")
}

/// XDG-style user cache directory for ebman: `~/.cache/ebman/`.
/// Used for the application log, audit log, crash reports, and the
/// cost-explorer cache. Same fallback shape as `config_dir`.
pub fn cache_dir() -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        let mut p = PathBuf::from(home);
        p.push(".cache/ebman");
        return p;
    }
    PathBuf::from(".")
}

/// Convenience: `config_dir().join(name)`.
pub fn config_file(name: &str) -> PathBuf {
    config_dir().join(name)
}

/// Escape a string per the JSON string-escape spec. Returns the
/// escaped INNER content (no surrounding `"`) so callers can embed
/// the result inside a larger hand-rolled JSON body. Pair with
/// [`json_string`] when you want the value wrapped + escaped in
/// one call.
///
/// One canonical helper for the whole crate (lib + bin). Pre-0.16
/// there were six near-identical variants scattered across
/// `audit.rs` / `cli/mod.rs` / `lint.rs` / `app.rs` / `llm.rs`;
/// they're all routed through this now.
pub fn json_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                use std::fmt::Write;
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out
}

/// Escape + wrap in `"..."` for use as a complete JSON string
/// literal. Same escape semantics as [`json_escape`]; convenience
/// wrapper that adds the surrounding quotes.
pub fn json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    out.push_str(&json_escape(s));
    out.push('"');
    out
}

/// Redaction policy shared by the MCP `get_option_settings`/`drift`/
/// `audit_log` tools, `ebman drift`, and the TUI `:drift` overlay:
/// env-var VALUES are secrets (`aws:elasticbeanstalk:application:
/// environment` carries DB URLs, API keys); keys stay visible so
/// config shape is inspectable. `DBPassword` matches the `:rds`
/// precedent. Everything else passes through.
pub fn redact_option_value(namespace: &str, name: &str, value: &str, redact: bool) -> String {
    if !redact {
        return value.to_string();
    }
    if namespace == "aws:elasticbeanstalk:application:environment"
        || name.eq_ignore_ascii_case("DBPassword")
    {
        return "(redacted)".to_string();
    }
    value.to_string()
}

/// Open (create-if-missing) a file for appending with 0600 perms —
/// operator-only. The cache artifacts this guards (audit.log with SSM
/// command strings, ebman.log, crash reports, explain cache) were
/// previously created with the umask default (usually 0644,
/// world-readable). Unix-only mode; other platforms get the default.
pub fn open_append_secure(path: &std::path::Path) -> std::io::Result<std::fs::File> {
    let mut opts = std::fs::OpenOptions::new();
    opts.create(true).append(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    let f = opts.open(path)?;
    // `mode()` applies only on CREATE — a pre-0.27 install's existing
    // 0644 file would stay world-readable forever without this
    // migration chmod (verified live on this machine's ebman.log).
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
    }
    Ok(f)
}

/// `std::fs::write` with 0600 perms on create (see
/// [`open_append_secure`]).
pub fn write_secure(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
    use std::io::Write;
    let mut opts = std::fs::OpenOptions::new();
    opts.create(true).write(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    let mut f = opts.open(path)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
    }
    f.write_all(contents)
}

#[cfg(test)]
mod tests {
    use super::{json_escape, json_string};

    #[test]
    fn json_escape_escapes_quotes_backslashes_newlines_tabs() {
        assert_eq!(json_escape(""), "");
        assert_eq!(json_escape("hello"), "hello");
        assert_eq!(json_escape("with \"quotes\""), "with \\\"quotes\\\"");
        assert_eq!(json_escape("a\\b"), "a\\\\b");
        assert_eq!(json_escape("a\nb"), "a\\nb");
        assert_eq!(json_escape("a\tb"), "a\\tb");
        assert_eq!(json_escape("a\rb"), "a\\rb");
        // Sub-0x20 control chars get \uXXXX escapes.
        assert_eq!(json_escape("\x01"), "\\u0001");
        assert_eq!(json_escape("\x07"), "\\u0007");
    }

    #[test]
    fn json_string_wraps_in_quotes() {
        assert_eq!(json_string(""), "\"\"");
        assert_eq!(json_string("hello"), "\"hello\"");
        assert_eq!(json_string("with \"quotes\""), "\"with \\\"quotes\\\"\"");
    }

    #[test]
    fn json_string_round_trips_via_yaml_parser() {
        // YAML is a JSON superset; serde_yml parses both. Useful
        // cross-check that our hand-rolled escape is spec-compliant.
        let inputs = [
            "",
            "plain",
            "with \"quotes\" and \\ backslashes",
            "line1\nline2\twith tab",
            "control \x01\x02 chars",
        ];
        for input in inputs {
            let escaped = json_string(input);
            let parsed: String = serde_yml::from_str(&escaped)
                .unwrap_or_else(|e| panic!("json_string({input:?}) = {escaped} failed: {e}"));
            assert_eq!(parsed, input);
        }
    }
}