podbox-cli 0.6.8

Declarative Podman-native container environment manager. Define an environment as a TOML file and let systemd own its lifecycle.
Documentation
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;

use crate::config::IntegrationConfig;
use crate::protocol::{HostMessage, write_frame};

/// Outcome of a `Hello` handshake.
pub(super) enum HelloOutcome {
    /// Handshake accepted; carries the list of granted capabilities.
    Accepted(Vec<String>),
    /// Handshake rejected (e.g. protocol version mismatch).
    Rejected,
}

/// Handle a `Hello` handshake from the guest.
///
/// On success returns the list of accepted capabilities, which the connection
/// uses to gate subsequent messages. A protocol version mismatch is a failed
/// negotiation and must not grant any capability or claim the daemon stream.
pub(super) fn handle_hello(
    stream: &mut UnixStream,
    config: &IntegrationConfig,
    idle_timeout_secs: u64,
    protocol_version: u32,
    guest_version: String,
    container: String,
    capabilities: Vec<String>,
) -> anyhow::Result<HelloOutcome> {
    if protocol_version != crate::protocol::PROTOCOL_VERSION {
        tracing::error!(
            "protocol mismatch — got v{}, expected v{}",
            protocol_version,
            crate::protocol::PROTOCOL_VERSION
        );
        write_frame(stream, &HostMessage::Shutdown)?;
        return Ok(HelloOutcome::Rejected);
    }
    tracing::info!(
        "guest hello (v{}, container: {}, caps: {:?})",
        guest_version,
        container,
        capabilities
    );
    let mut accepted = Vec::new();
    let mut rejected = Vec::new();
    for cap in capabilities {
        let enabled = match cap.as_str() {
            crate::protocol::CAP_NOTIFY => config.notify,
            crate::protocol::CAP_XDG_OPEN => config.xdg_open,
            crate::protocol::CAP_CLIPBOARD => config.clipboard,
            crate::protocol::CAP_HOST_EXEC => config.host_exec.enabled,
            _ => false,
        };
        if enabled {
            accepted.push(cap);
        } else {
            rejected.push(cap);
        }
    }
    let response = HostMessage::HelloAck {
        accepted: accepted.clone(),
        rejected,
        idle_timeout_secs,
    };
    write_frame(stream, &response)?;
    Ok(HelloOutcome::Accepted(accepted))
}

/// Handle a `Notify` message from the guest.
pub(super) fn handle_notify(
    stream: &mut UnixStream,
    summary: String,
    body: String,
    actions: Vec<crate::protocol::NotifyAction>,
) -> anyhow::Result<()> {
    if actions.is_empty() {
        let _ = notify_rust::Notification::new()
            .summary(&summary)
            .body(&body)
            .show();
    } else {
        let mut notif = notify_rust::Notification::new();
        notif.summary(&summary).body(&body);
        for action in &actions {
            notif.action(&action.key, &action.label);
        }
        let handle = match notif.show() {
            Ok(h) => h,
            Err(_) => {
                let _ = write_frame(
                    stream,
                    &HostMessage::NotifyActionResult {
                        notification_id: 0,
                        action_key: String::new(),
                    },
                );
                return Ok(());
            }
        };
        let mut chosen_key = String::new();
        handle.wait_for_action(|action| {
            chosen_key = action.to_string();
        });
        let _ = write_frame(
            stream,
            &HostMessage::NotifyActionResult {
                notification_id: 0,
                action_key: chosen_key,
            },
        );
    }
    Ok(())
}

/// Handle an `XdgOpen` message from the guest.
pub(super) fn handle_xdg_open(uri: String) -> anyhow::Result<()> {
    if let Some(validated) = validate_uri(&uri) {
        let args = [validated.into()];
        let _ =
            crate::process::spawn_interactive_timeout("xdg-open", &args, Duration::from_secs(30));
    }
    Ok(())
}

/// Handle a `ClipboardSet` message from the guest.
pub(super) fn handle_clipboard_set(text: String) -> anyhow::Result<()> {
    let mut child = std::process::Command::new("wl-copy")
        .stdin(std::process::Stdio::piped())
        .spawn()?;
    if let Some(ref mut stdin) = child.stdin {
        let _ = stdin.write_all(text.as_bytes());
    }
    drop(child.stdin.take());
    let _ = crate::process::wait_child_timeout(child, Duration::from_secs(10))?;
    Ok(())
}

/// Handle a `ClipboardGet` message from the guest.
pub(super) fn handle_clipboard_get(stream: &mut UnixStream) -> anyhow::Result<()> {
    let output = std::process::Command::new("wl-paste").output()?;
    let text = String::from_utf8_lossy(&output.stdout);
    let response = HostMessage::ClipboardData {
        text: text.trim().to_string(),
    };
    write_frame(stream, &response)?;
    Ok(())
}

/// Handle a `HostExec` message from the guest.
pub(super) fn handle_host_exec(
    stream: &mut UnixStream,
    config: &IntegrationConfig,
    cmd: String,
    args: Vec<String>,
) -> anyhow::Result<()> {
    if !config.host_exec.enabled {
        write_frame(
            stream,
            &HostMessage::HostExecStderr {
                data: "host-exec is disabled".into(),
            },
        )?;
        write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
        return Ok(());
    }

    let resolved = match config.host_exec.resolve(&cmd) {
        Some(p) => p,
        None => {
            let allowed = config
                .host_exec
                .allowlist
                .as_ref()
                .map(|m| m.keys().cloned().collect::<Vec<_>>().join(", "))
                .unwrap_or_default();
            write_frame(
                stream,
                &HostMessage::HostExecStderr {
                    data: format!(
                        "Permission denied: '{cmd}' is not in the host-exec allowlist\nAllowed commands: {allowed}"
                    ),
                },
            )?;
            write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
            return Ok(());
        }
    };

    if let Err(msg) = validate_host_exec_args(&args) {
        write_frame(
            stream,
            &HostMessage::HostExecStderr {
                data: format!("Security violation: {msg}"),
            },
        )?;
        write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
        return Ok(());
    }

    // Canonicalize the resolved path to mitigate TOCTOU symlink swaps.
    // If the path resolves outside expected system directories (e.g.
    // /nix/store, /usr/bin, etc.), we still allow it — the important thing
    // is that it points to a real regular file right now.
    let canonical_path = match std::fs::canonicalize(resolved) {
        Ok(p) => p,
        Err(e) => {
            tracing::error!("host-exec: failed to canonicalize '{}': {e}", resolved);
            write_frame(
                stream,
                &HostMessage::HostExecStderr {
                    data: format!("Failed to resolve executable path '{resolved}': {e}"),
                },
            )?;
            write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
            return Ok(());
        }
    };
    if !canonical_path.is_file() {
        write_frame(
            stream,
            &HostMessage::HostExecStderr {
                data: format!("'{}' is not a regular file", canonical_path.display()),
            },
        )?;
        write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
        return Ok(());
    }
    tracing::info!(
        "host-exec: resolved '{}' -> {}",
        resolved,
        canonical_path.display()
    );

    match std::process::Command::new(&canonical_path)
        .args(&args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(child) => {
            let output = crate::process::wait_child_timeout(child, Duration::from_mins(1))?;
            if !output.stdout.is_empty() {
                write_frame(
                    stream,
                    &HostMessage::HostExecStdout {
                        data: String::from_utf8_lossy(&output.stdout).to_string(),
                    },
                )?;
            }
            if !output.stderr.is_empty() {
                write_frame(
                    stream,
                    &HostMessage::HostExecStderr {
                        data: String::from_utf8_lossy(&output.stderr).to_string(),
                    },
                )?;
            }
            let code = output.status.code().unwrap_or(1);
            write_frame(stream, &HostMessage::HostExecDone { exit_code: code })?;
        }
        Err(e) => {
            let msg = if e.kind() == std::io::ErrorKind::NotFound {
                format!("host-exec: '{cmd}' not found in allowlist path or host $PATH")
            } else {
                format!("host-exec: failed to execute '{cmd}': {e}")
            };
            write_frame(stream, &HostMessage::HostExecStderr { data: msg })?;
            write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
        }
    }
    Ok(())
}

/// Validate arguments for host-exec, rejecting shell metacharacters and
/// dangerous flag patterns that could alter the behaviour of a whitelisted
/// binary (e.g. `git --exec-path=…`).
///
/// # Security model and limitations
///
/// Arguments are validated with a substring blocklist. Commands run via
/// `execve` directly — **not** through `/bin/sh` — so metacharacters like
/// `;`, `|`, `$(` cannot cause shell injection on ordinary ELF binaries.
/// The blocklist exists to reduce misuse, not to make arbitrary allowlist
/// entries safe.
///
/// Known bypass classes that this filter **cannot** prevent:
///
/// * Versatile binaries: any program with code-execution or file-access
///   flags defeats substring filtering regardless of metacharacters —
///   e.g. `git -C /root …`, `git clone --upload-pack=…`, `find -exec …`,
///   `tar --to-command=…`, `python -c …`, `ssh -oProxyCommand=…`.
/// * Flag synonyms: only a small set of dangerous prefixes is known; an
///   allowlisted binary may expose others (`--pager`, `-c`, `--eval`, …).
///
/// Therefore:
///
/// * Allowlist only restricted binaries or dedicated wrapper scripts.
/// * Prefer wrappers that pin the arguments (e.g. a script exposing exactly
///   `systemctl --user status <unit>`), rather than raw `git`, `python`,
///   `tar`, `find`, or shells.
/// * Treat every allowlist entry as granting the guest that binary's full
///   capability surface on the host.
///
/// False positives are expected: benign messages containing `<()`, globs,
/// or parentheses are rejected. Affected users should route those commands
/// through a wrapper script instead of loosening this filter.
pub(super) fn validate_host_exec_args(args: &[String]) -> Result<(), String> {
    for arg in args {
        if arg.contains(';')
            || arg.contains('|')
            || arg.contains('&')
            || arg.contains('$')
            || arg.contains('`')
            || arg.contains('\n')
            || arg.contains('\r')
        {
            return Err(format!("argument {arg:?} contains shell metacharacters"));
        }
        if arg.contains('<') || arg.contains('>') {
            return Err(format!("argument {arg:?} contains redirection operators"));
        }
        if arg.contains('*')
            || arg.contains('?')
            || arg.contains('[')
            || arg.contains(']')
            || arg.contains('{')
            || arg.contains('}')
        {
            return Err(format!(
                "argument {arg:?} contains glob or brace characters"
            ));
        }
        if arg.contains('(') || arg.contains(')') || arg.contains('\\') {
            return Err(format!(
                "argument {arg:?} contains subshell or escape characters"
            ));
        }
        let lower = arg.to_ascii_lowercase();
        if lower.starts_with("--exec-path")
            || lower.starts_with("--config")
            || lower.starts_with("--plugin")
            || lower.starts_with("--load")
            || lower.starts_with("--module")
            || lower.starts_with("--remote=")
            || lower == "-o"
        {
            return Err(format!("argument {arg:?} uses a restricted flag pattern"));
        }
    }
    Ok(())
}

pub(super) fn validate_uri(uri: &str) -> Option<String> {
    let s = uri.trim();
    if s.is_empty() || s.starts_with('/') || s.starts_with('.') {
        return None;
    }

    match url::Url::parse(s) {
        Ok(parsed) => {
            let scheme = parsed.scheme().to_ascii_lowercase();

            // Blacklist local disk access, XSS, and command-injection vectors
            let dangerous_schemes = [
                "file",
                "javascript",
                "data",
                "ghelp",
                "help",
                "info",
                "man",
                "shell",
                "exec",
                "run",
                "local",
                "ssh",
            ];

            if dangerous_schemes.contains(&scheme.as_str()) {
                return None;
            }

            // Ensure the schema format complies with RFC 3986 standards
            let is_valid_format = scheme.starts_with(|c: char| c.is_ascii_alphabetic())
                && scheme
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-');

            if is_valid_format {
                Some(s.to_string())
            } else {
                None
            }
        }
        Err(url::ParseError::RelativeUrlWithoutBase) => {
            // Automatically wrap raw hostnames like "github.com"
            Some(format!("https://{s}"))
        }
        _ => None,
    }
}