mxsh 0.1.0

Embeddable POSIX-style shell parser and runtime
Documentation
use std::env;

#[cfg(any(feature = "frontend", test))]
use crate::args::InitArgs;
use crate::ast::AndOrList;
#[cfg(any(feature = "frontend", test))]
use crate::policy::ShellIdentity;
use crate::sys;
use crate::sys::Runtime;

use super::{BackgroundMachinePayload, ShellState};

pub(super) const MAX_MACHINE_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
const MACHINE_PAYLOAD_CHILD_FD: sys::FileDescriptor = sys::FileDescriptor::new(255);

pub(super) fn encode_background_payload(
    state: &ShellState,
    and_or_list: &AndOrList,
    command_id: &str,
) -> Result<String, String> {
    let payload = BackgroundMachinePayload {
        state: state.background_snapshot(),
        and_or_list: and_or_list.clone(),
        command_id: command_id.to_string(),
    };
    let payload_text =
        serde_json::to_string(&payload).map_err(|err| format!("invalid machine payload: {err}"))?;
    validate_machine_payload_size(payload_text.len())?;
    Ok(payload_text)
}

#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
pub(super) fn parse_machine_payload(
    payload_text: &str,
) -> Result<BackgroundMachinePayload, String> {
    serde_json::from_str(payload_text).map_err(|err| format!("invalid machine payload: {err}"))
}

fn validate_machine_payload_size(len: usize) -> Result<(), String> {
    if len <= MAX_MACHINE_PAYLOAD_BYTES {
        Ok(())
    } else {
        Err(format!(
            "machine payload too large: {len} bytes exceeds limit of {MAX_MACHINE_PAYLOAD_BYTES} bytes"
        ))
    }
}

fn background_machine_env(state: &ShellState) -> Vec<(String, String)> {
    let payload_env = state.definition.identity.machine_payload_fd_env_var();
    let mut env = state.exported_env();
    env.retain(|(key, _)| key != payload_env);
    env.push((
        payload_env.to_string(),
        MACHINE_PAYLOAD_CHILD_FD.as_i32().to_string(),
    ));
    env
}

#[cfg(any(feature = "frontend", test))]
pub(crate) fn load_machine_payload_text(
    init: &InitArgs,
    identity: &ShellIdentity,
) -> Result<String, String> {
    let payload_env = identity.machine_payload_fd_env_var();
    if let Some(payload_text) = init.command_str.as_ref() {
        validate_machine_payload_size(payload_text.len())?;
        return Ok(payload_text.clone());
    }
    if let Ok(raw_fd) = env::var(payload_env) {
        let fd_num = raw_fd
            .parse::<i32>()
            .map_err(|_| format!("{}: invalid {payload_env} value: {raw_fd}", identity.name()))?;
        if fd_num < 0 {
            return Err(format!(
                "{}: invalid {payload_env} value: {fd_num}",
                identity.name()
            ));
        }
        let payload_text = sys::FileDescriptor::from(fd_num)
            .read_to_string()
            .map_err(|err| {
                format!(
                    "{}: failed to read machine payload fd {fd_num}: {err}",
                    identity.name()
                )
            })?;
        validate_machine_payload_size(payload_text.len())
            .map_err(|err| format!("{}: {err}", identity.name()))?;
        return Ok(payload_text);
    }
    Err(format!(
        "{}: --machine requires -c <payload> or {payload_env}=<fd>",
        identity.name()
    ))
}

pub(crate) fn resolve_machine_program_path(shell_name: &str) -> Option<String> {
    let cargo_bin_env = format!("CARGO_BIN_EXE_{shell_name}");
    if let Ok(path) = env::var(&cargo_bin_env) {
        return Some(path);
    }
    let current = env::current_exe().ok()?;
    if let Some(parent) = current.parent()
        && parent.file_name().is_some_and(|name| name == "deps")
        && let Some(bin_dir) = parent.parent()
    {
        let candidate = bin_dir.join(shell_name);
        if candidate.is_file() {
            return Some(candidate.to_string_lossy().into_owned());
        }
    }
    Some(current.to_string_lossy().into_owned())
}

pub(super) fn spawn_background_job<R: Runtime>(
    state: &ShellState,
    runtime: &mut R,
    and_or_list: &AndOrList,
    command_id: &str,
) -> Result<sys::SpawnedProcess, String> {
    let payload_text = encode_background_payload(state, and_or_list, command_id)?;
    let Some((program, argv)) = state
        .definition
        .background_launcher
        .machine_command(state.shell_name())
    else {
        return Err("failed to resolve machine executable path".to_string());
    };
    let payload_pipe = sys::OsPipe::new()
        .map_err(|err| format!("failed to create machine payload pipe: {err}"))?;
    let env = background_machine_env(state);
    let mut passed_fds = state.inherited_fds();
    passed_fds.push(sys::PassedFileDescriptor {
        parent_fd: payload_pipe.read_fd,
        child_fd: MACHINE_PAYLOAD_CHILD_FD,
    });
    let spawned = match runtime.spawn_external_command(
        &sys::ExternalCommand {
            program,
            argv,
            env,
            cwd: state.cwd.clone(),
            create_process_group: true,
            passed_fds,
        },
        sys::SpawnStdio {
            stdin_fd: state.stdin_fd,
            stdout_fd: state.stdout_fd,
            stderr_fd: state.stderr_fd,
        },
        &[],
        sys::SpawnMode::BackgroundJob,
    ) {
        Ok(spawned) => spawned,
        Err(err) => {
            payload_pipe.read_fd.close();
            payload_pipe.write_fd.close();
            return Err(format!("failed to spawn background job: {err}"));
        }
    };
    if let Err(err) = payload_pipe.write_fd.write_all(payload_text.as_bytes()) {
        payload_pipe.read_fd.close();
        payload_pipe.write_fd.close();
        let _ = runtime.signal_process_group(spawned.handle, sys::RuntimeSignal::Terminate);
        let _ = runtime.wait_child(spawned.handle);
        return Err(format!("failed to write machine payload: {err}"));
    }
    payload_pipe.read_fd.close();
    payload_pipe.write_fd.close();
    Ok(spawned)
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use super::*;

    static PAYLOAD_ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn load_machine_payload_text_reads_payload_from_fd_env() {
        let _guard = PAYLOAD_ENV_LOCK.lock().expect("payload env lock poisoned");
        let identity = ShellIdentity::default();
        let payload_env = identity.machine_payload_fd_env_var();
        let saved_fd = env::var(payload_env).ok();
        let pipe = sys::OsPipe::new().expect("payload pipe");
        pipe.write_fd
            .write_all(br#"{"kind":"payload"}"#)
            .expect("write payload");
        pipe.write_fd.close();
        unsafe {
            env::set_var(payload_env, pipe.read_fd.as_i32().to_string());
        }

        let init = InitArgs::default();
        let payload = load_machine_payload_text(&init, &identity).expect("load payload from fd");
        assert_eq!(payload, r#"{"kind":"payload"}"#);

        unsafe {
            if let Some(value) = saved_fd {
                env::set_var(payload_env, value);
            } else {
                env::remove_var(payload_env);
            }
        }
    }
}