diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index c7f7eb4..177635a 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -162,59 +162,7 @@ jobs: cp tools/licenses/XopProtector-LICENSE.txt bundle/licenses/ cp tools/licenses/XopProtector-NOTICE.txt bundle/licenses/ - cat > bundle/README.md <, child: Option, progress_token: Option, + done: Arc, } impl Job { @@ -104,6 +112,8 @@ pub struct Server { pub out: Arc>>, jobs: Mutex>>>, next_id: AtomicU64, + shutting_down: AtomicBool, + sync_active: AtomicUsize, } impl Server { @@ -114,6 +124,8 @@ impl Server { out: Arc::new(Mutex::new(BufWriter::new(std::io::stdout()))), jobs: Mutex::new(BTreeMap::new()), next_id: AtomicU64::new(1), + shutting_down: AtomicBool::new(false), + sync_active: AtomicUsize::new(0), } } @@ -132,12 +144,16 @@ impl Server { tool: impl Into, plan: JobPlan, progress_token: Option, - ) -> String { + ) -> Result { + if self.shutting_down.load(Ordering::SeqCst) { + anyhow::bail!("MCP 服务正在关闭,无法创建新任务"); + } let id = format!( "job-{}-{}", now_ms(), self.next_id.fetch_add(1, Ordering::Relaxed) ); + let done = Arc::new(AtomicBool::new(false)); let job = Arc::new(Mutex::new(Job { id: id.clone(), tool: tool.into(), @@ -152,26 +168,33 @@ impl Server { cancel: Arc::new(AtomicBool::new(false)), child: None, progress_token, + done: Arc::clone(&done), })); - if let Ok(mut jobs) = self.jobs.lock() { - if jobs.len() >= MAX_JOBS { - let finished = jobs - .iter() - .find(|(_, job)| { - job.lock() - .map(|job| job.status != JobStatus::Running) - .unwrap_or(true) - }) - .map(|(id, _)| id.clone()); - if let Some(id) = finished { - jobs.remove(&id); - } + let mut jobs = self + .jobs + .lock() + .map_err(|_| anyhow::anyhow!("任务表锁不可用"))?; + if self.shutting_down.load(Ordering::SeqCst) { + anyhow::bail!("MCP 服务正在关闭,无法创建新任务"); + } + if jobs.len() >= MAX_JOBS { + let finished = jobs + .iter() + .find(|(_, job)| { + job.lock() + .map(|job| job.status != JobStatus::Running) + .unwrap_or(true) + }) + .map(|(id, _)| id.clone()); + if let Some(id) = finished { + jobs.remove(&id); } - jobs.insert(id.clone(), Arc::clone(&job)); } + jobs.insert(id.clone(), Arc::clone(&job)); + drop(jobs); let server = Arc::clone(self); - thread::spawn(move || run_job(server, job, plan)); - id + thread::spawn(move || run_job(server, job, plan, done)); + Ok(id) } pub fn job_status(&self, id: &str) -> Option { @@ -211,21 +234,142 @@ impl Server { Some(job.snapshot()) } + /// 停止接收新任务,取消并回收所有活动任务与同步检查。 + pub fn shutdown(&self) { + self.shutting_down.store(true, Ordering::SeqCst); + let jobs = self + .jobs + .lock() + .map(|jobs| jobs.values().cloned().collect::>()) + .unwrap_or_default(); + for job in &jobs { + if let Ok(mut job) = job.lock() { + if job.status == JobStatus::Running { + job.cancel.store(true, Ordering::SeqCst); + job.message = "MCP 服务关闭,取消任务".to_string(); + if let Some(child) = job.child.as_mut() { + kill_tree(child); + } + } + } + } + let deadline = Instant::now() + SHUTDOWN_TIMEOUT; + loop { + let jobs_done = jobs.iter().all(|job| { + job.lock() + .map(|job| job.done.load(Ordering::SeqCst)) + .unwrap_or(true) + }); + if jobs_done && self.sync_active.load(Ordering::SeqCst) == 0 { + break; + } + if Instant::now() >= deadline { + for job in &jobs { + if let Ok(mut job) = job.lock() { + if let Some(child) = job.child.as_mut() { + kill_tree(child); + } + } + } + break; + } + thread::sleep(Duration::from_millis(50)); + } + } + pub fn run_sync(&self, args: &[String], env: &[(String, String)]) -> Result { - let output = Command::new(&self.cli) + if self.shutting_down.load(Ordering::SeqCst) { + anyhow::bail!("MCP 服务正在关闭"); + } + let _active = SyncActiveGuard::new(&self.sync_active); + let sync_temp = tempfile::Builder::new() + .prefix("shellsmith-mcp-sync-") + .tempdir() + .context("创建 MCP 同步检查临时目录失败")?; + let mut process = Command::new(&self.cli); + process .args(args) - .envs(env.iter().cloned()) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() + .stderr(Stdio::piped()); + set_cli_working_dir(&mut process, &self.cli); + apply_sanitized_env(&mut process, env); + add_temp_env(&mut process, sync_temp.path()); + let mut child = process + .spawn() .with_context(|| format!("启动 Shellsmith CLI 失败: {}", self.cli.display()))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = child + .stdout + .take() + .context("Shellsmith CLI stdout 不可用")?; + let stderr = child + .stderr + .take() + .context("Shellsmith CLI stderr 不可用")?; + let stdout_text = Arc::new(Mutex::new(String::new())); + let stderr_text = Arc::new(Mutex::new(String::new())); + let stdout_done = Arc::new(AtomicBool::new(false)); + let stderr_done = Arc::new(AtomicBool::new(false)); + let stdout_buffer = Arc::clone(&stdout_text); + let stdout_flag = Arc::clone(&stdout_done); + thread::spawn(move || { + let mut text = String::new(); + let mut reader = BufReader::new(stdout); + let _ = reader.read_to_string(&mut text); + if let Ok(mut buffer) = stdout_buffer.lock() { + *buffer = text; + } + stdout_flag.store(true, Ordering::SeqCst); + }); + let stderr_buffer = Arc::clone(&stderr_text); + let stderr_flag = Arc::clone(&stderr_done); + thread::spawn(move || { + let mut text = String::new(); + let mut reader = BufReader::new(stderr); + let _ = reader.read_to_string(&mut text); + if let Ok(mut buffer) = stderr_buffer.lock() { + *buffer = text; + } + stderr_flag.store(true, Ordering::SeqCst); + }); + let timeout = sync_timeout(); + let deadline = Instant::now() + timeout; + let status = loop { + if self.shutting_down.load(Ordering::SeqCst) { + kill_tree(&mut child); + let _ = child.wait(); + anyhow::bail!("MCP 服务正在关闭,已终止 Shellsmith CLI"); + } + match child.try_wait().context("等待 Shellsmith CLI 失败")? { + Some(status) => break status, + None => { + if Instant::now() >= deadline { + kill_tree(&mut child); + let _ = child.wait(); + anyhow::bail!("Shellsmith CLI 检查超时({} 秒)", timeout.as_secs()); + } + thread::sleep(Duration::from_millis(50)); + } + } + }; + let drain_deadline = Instant::now() + Duration::from_millis(500); + while (!stdout_done.load(Ordering::SeqCst) || !stderr_done.load(Ordering::SeqCst)) + && Instant::now() < drain_deadline + { + thread::sleep(Duration::from_millis(10)); + } + let stdout = stdout_text + .lock() + .map(|text| text.clone()) + .unwrap_or_default(); + let stderr = stderr_text + .lock() + .map(|text| text.clone()) + .unwrap_or_default(); + if !status.success() { anyhow::bail!( "Shellsmith CLI 退出码 {}:{}", - output.status.code().unwrap_or(-1), + status.code().unwrap_or(-1), if stderr.trim().is_empty() { stdout.trim() } else { @@ -233,7 +377,7 @@ impl Server { } ); } - parse_json_output(&output.stdout) + parse_json_output(stdout.as_bytes()) } pub fn resolve_input(&self, value: &str, label: &str) -> Result { @@ -285,8 +429,26 @@ impl Server { } } -fn run_job(server: Arc, job: Arc>, plan: JobPlan) { +fn run_job(server: Arc, job: Arc>, plan: JobPlan, done: Arc) { + let _done = DoneGuard(done); let _keep = plan.keep; + // MCP 持有任务私有 TMPDIR;取消时即使用 SIGKILL,也能在回收后删除 CLI 的口令临时目录。 + let job_temp = match tempfile::Builder::new() + .prefix("shellsmith-mcp-job-") + .tempdir() + { + Ok(temp) => temp, + Err(error) => { + finish_job( + &job, + JobStatus::Failed, + format!("创建 MCP 任务临时目录失败: {error}"), + None, + Some(error.to_string()), + ); + return; + } + }; for command in plan.commands { if job .lock() @@ -310,10 +472,12 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { let mut process = Command::new(&server.cli); process .args(&command.args) - .envs(command.env.iter().cloned()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + set_cli_working_dir(&mut process, &server.cli); + apply_sanitized_env(&mut process, &command.env); + add_temp_env(&mut process, job_temp.path()); #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -374,6 +538,7 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { } }); let mut last_error = None; + let mut exited_status = None; loop { match line_rx.recv_timeout(Duration::from_millis(200)) { Ok(line) => record_output_line(&server, &job, &line, &mut last_error), @@ -390,11 +555,19 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { break; }; match guard.child.as_mut() { - Some(child) => child.try_wait().ok().flatten(), + Some(child) => match child.try_wait() { + Ok(Some(status)) => { + guard.child.take(); + Some(status) + } + Ok(None) => None, + Err(_) => None, + }, None => None, } }; - if exited.is_some() { + if let Some(status) = exited { + exited_status = Some(status); // 孙子进程可能仍持有 stdout;给已退出 CLI 的尾部输出留出短暂排空窗口。 while let Ok(line) = line_rx.recv_timeout(Duration::from_millis(50)) { record_output_line(&server, &job, &line, &mut last_error); @@ -405,26 +578,6 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { Err(mpsc::RecvTimeoutError::Disconnected) => break, } } - let cancelled = job - .lock() - .map(|job| job.cancel.load(Ordering::SeqCst)) - .unwrap_or(false); - if cancelled { - if let Ok(mut guard) = job.lock() { - if let Some(mut child) = guard.child.take() { - kill_tree(&mut child); - let _ = child.wait(); - } - } - finish_job( - &job, - JobStatus::Cancelled, - "任务已取消".to_string(), - None, - None, - ); - return; - } thread::sleep(Duration::from_millis(50)); let stderr_text = stderr_output .lock() @@ -433,39 +586,35 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { if !stderr_text.trim().is_empty() { update_job(&job, |job| job.push_log(stderr_text.trim().to_string())); } - let status = { - let mut guard = match job.lock() { - Ok(guard) => guard, - Err(_) => return, - }; - match guard.child.take() { - Some(mut child) => child.wait(), - None => { - drop(guard); + let status = if let Some(status) = exited_status { + status + } else { + match wait_for_child(&job) { + Ok(ChildOutcome::Exited(status)) => status, + Ok(ChildOutcome::Cancelled) => { + cancel_and_reap(&job); + finish_job( + &job, + JobStatus::Cancelled, + "任务已取消".to_string(), + None, + None, + ); + return; + } + Err(error) => { + cancel_and_reap(&job); finish_job( &job, JobStatus::Failed, - "Shellsmith CLI 子进程状态丢失".to_string(), + format!("等待 Shellsmith CLI 失败: {error}"), None, - Some("child missing".to_string()), + Some(error.to_string()), ); return; } } }; - let status = match status { - Ok(status) => status, - Err(error) => { - finish_job( - &job, - JobStatus::Failed, - format!("等待 Shellsmith CLI 失败: {error}"), - None, - Some(error.to_string()), - ); - return; - } - }; let cancelled = job .lock() .map(|job| job.cancel.load(Ordering::SeqCst)) @@ -520,6 +669,99 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan) { ); } +struct DoneGuard(Arc); + +impl Drop for DoneGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +struct SyncActiveGuard<'a>(&'a AtomicUsize); + +impl<'a> SyncActiveGuard<'a> { + fn new(counter: &'a AtomicUsize) -> Self { + counter.fetch_add(1, Ordering::SeqCst); + Self(counter) + } +} + +impl Drop for SyncActiveGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +enum ChildOutcome { + Exited(ExitStatus), + Cancelled, +} + +fn wait_for_child(job: &Arc>) -> Result { + loop { + let status = { + let mut guard = job + .lock() + .map_err(|_| anyhow::anyhow!("任务状态锁不可用"))?; + if guard.cancel.load(Ordering::SeqCst) { + return Ok(ChildOutcome::Cancelled); + } + match guard.child.as_mut() { + Some(child) => match child.try_wait().context("等待 Shellsmith CLI 失败")? { + Some(status) => { + guard.child.take(); + Some(status) + } + None => None, + }, + None => anyhow::bail!("Shellsmith CLI 子进程状态丢失"), + } + }; + if let Some(status) = status { + return Ok(ChildOutcome::Exited(status)); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn cancel_and_reap(job: &Arc>) { + let child = job.lock().ok().and_then(|mut job| job.child.take()); + if let Some(mut child) = child { + kill_tree(&mut child); + let _ = child.wait(); + } +} + +fn apply_sanitized_env(process: &mut Command, env: &[(String, String)]) { + for key in SENSITIVE_ENV { + process.env_remove(key); + } + process.envs(env.iter().cloned()); +} + +fn set_cli_working_dir(process: &mut Command, cli: &Path) { + if let Some(parent) = cli.parent().filter(|parent| !parent.as_os_str().is_empty()) { + process.current_dir(parent); + } +} + +fn add_temp_env(process: &mut Command, root: &Path) { + let root = root.to_string_lossy().into_owned(); + process + .env("TMPDIR", &root) + .env("TMP", &root) + .env("TEMP", &root); +} + +fn sync_timeout() -> Duration { + std::env::var("SHELLSMITH_MCP_SYNC_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(DEFAULT_SYNC_TIMEOUT_SECS)) +} + fn kill_tree(child: &mut Child) { #[cfg(unix)] { @@ -531,6 +773,18 @@ fn kill_tree(child: &mut Child) { } } } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let pid = child.id().to_string(); + let _ = Command::new("taskkill") + .args(["/PID", pid.as_str(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .status(); + } let _ = child.kill(); } diff --git a/apps/shellsmith-mcp/src/protocol.rs b/apps/shellsmith-mcp/src/protocol.rs index c1b64e0..239175d 100644 --- a/apps/shellsmith-mcp/src/protocol.rs +++ b/apps/shellsmith-mcp/src/protocol.rs @@ -4,6 +4,7 @@ use anyhow::Result; use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; use std::sync::Arc; +use std::thread; const LATEST_PROTOCOL_VERSION: &str = "2025-06-18"; const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05", "2025-03-26", "2025-06-18"]; @@ -24,7 +25,13 @@ pub fn serve(server: Arc) -> Result<()> { let mut line = String::new(); loop { line.clear(); - let read = reader.read_line(&mut line)?; + let read = match reader.read_line(&mut line) { + Ok(read) => read, + Err(error) => { + server.shutdown(); + return Err(error.into()); + } + }; if read == 0 { break; } @@ -51,6 +58,7 @@ pub fn serve(server: Arc) -> Result<()> { handle_message(&server, &message); } } + server.shutdown(); Ok(()) } @@ -114,10 +122,25 @@ fn handle_message(server: &Arc, message: &Value) { .get("_meta") .and_then(|meta| meta.get("progressToken")) .cloned(); - let result = tools::call(server, name, arguments, progress_token); - serde_json::to_value(result).map_err(|error| anyhow::anyhow!(error.to_string())) + let server = Arc::clone(server); + let name = name.to_string(); + thread::spawn(move || { + let result = tools::call(&server, &name, arguments, progress_token); + match serde_json::to_value(result) { + Ok(value) => server.write_json(&success_response(id, value)), + Err(error) => server.write_json(&error_response( + id, + -32603, + format!("序列化工具结果失败: {error}"), + )), + } + }); + return; + } + "shutdown" => { + server.shutdown(); + Ok(Value::Null) } - "shutdown" => Ok(Value::Null), _ => Err(anyhow::anyhow!("未支持的方法: {method}")), }; @@ -140,6 +163,7 @@ fn handle_notification(server: &Arc, method: &str, _params: Value) { | "notifications/cancelled" | "notifications/roots/list_changed" => {} "exit" => { + server.shutdown(); let _ = std::io::stdout().flush(); std::process::exit(0); } diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index d2cb9b7..b00f32f 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; use serde_json::{json, Value}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Clone, Debug, serde::Serialize)] @@ -751,16 +751,30 @@ fn protect_apk( anyhow::bail!("原地加固必须同时提供 sign 配置:先加固到临时文件,再签名覆盖原文件"); } } + let runtime_cert_sha256 = if let Some(sign) = input.sign.as_ref() { + Some(ensure_sign_certificate_binding( + server, + &source, + sign, + input.runtime_cert_sha256.as_deref(), + )?) + } else { + input.runtime_cert_sha256.clone() + }; let parent = final_output.parent().unwrap_or(Path::new(".")); let mut keep = Vec::new(); + let stage = tempfile::Builder::new() + .prefix("shellsmith-mcp-stage-") + .tempdir_in(parent) + .context("创建 MCP 任务暂存目录失败")?; + let stage_root = stage.path().to_path_buf(); + keep.push(stage); + let stage_env = vec![( + "SHELLSMITH_STAGE_ROOT".to_string(), + stage_root.display().to_string(), + )]; let (protect_output, sign_output) = if input.sign.is_some() { - let temp = tempfile::Builder::new() - .prefix("shellsmith-mcp-") - .tempdir_in(parent) - .context("创建 MCP 自动签名临时目录失败")?; - let unsigned = temp.path().join("unsigned.apk"); - keep.push(temp); - (unsigned, Some(final_output.clone())) + (stage_root.join("unsigned.apk"), Some(final_output.clone())) } else { (final_output.clone(), None) }; @@ -787,7 +801,7 @@ fn protect_apk( push_opt( &mut protect_args, "--runtime-cert-sha256", - input.runtime_cert_sha256.as_deref(), + runtime_cert_sha256.as_deref(), ); push_path_opt( server, @@ -844,7 +858,7 @@ fn protect_apk( protect_args.push(input.exclude_abis.join(",")); } push_runtime_ui(server, &mut protect_args, input.runtime_ui.as_ref())?; - let mut env = Vec::new(); + let mut env = stage_env.clone(); if let Some(endpoint) = input.byos_endpoint.as_deref() { protect_args.push("--byos-endpoint".into()); protect_args.push(endpoint.to_string()); @@ -879,7 +893,8 @@ fn protect_apk( .as_deref(), input.allow_in_place, )?; - let sign_env = sign_env(&sign)?; + let mut sign_env = sign_env(&sign)?; + sign_env.extend(stage_env); commands.push(CommandPlan { args: sign_args, env: sign_env, @@ -898,7 +913,7 @@ fn protect_apk( keep, }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -919,6 +934,16 @@ fn protect_aab( if source == output { anyhow::bail!("AAB 输出不能等于输入"); } + let mut keep = Vec::new(); + let stage = tempfile::Builder::new() + .prefix("shellsmith-mcp-stage-") + .tempdir_in(output.parent().unwrap_or(Path::new("."))) + .context("创建 MCP 任务暂存目录失败")?; + let stage_env = vec![( + "SHELLSMITH_STAGE_ROOT".to_string(), + stage.path().display().to_string(), + )]; + keep.push(stage); let mut cli_args = vec![ "protect-aab".to_string(), "-i".into(), @@ -1070,10 +1095,11 @@ fn protect_aab( input.smoke_package.as_deref(), ); push_runtime_ui(server, &mut cli_args, input.runtime_ui.as_ref())?; - let env = password_envs( + let mut env = password_envs( input.password_env.as_deref(), input.key_password_env.as_deref(), )?; + env.extend(stage_env); let job_id = server.start_job( "shellsmith_protect_aab", JobPlan { @@ -1083,10 +1109,10 @@ fn protect_aab( label: "加固 AAB".into(), }], result: json!({"output": output, "report": report_path(&output)}), - keep: Vec::new(), + keep, }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -1106,6 +1132,16 @@ fn protect_xop( if source == output { anyhow::bail!("输出不能等于输入;XOP 加固不支持原地覆盖"); } + let mut keep = Vec::new(); + let stage = tempfile::Builder::new() + .prefix("shellsmith-mcp-stage-") + .tempdir_in(output.parent().unwrap_or(Path::new("."))) + .context("创建 MCP 任务暂存目录失败")?; + let stage_env = vec![( + "SHELLSMITH_STAGE_ROOT".to_string(), + stage.path().display().to_string(), + )]; + keep.push(stage); let mut cli_args = vec![ "protect-xop".to_string(), "-i".into(), @@ -1162,7 +1198,7 @@ fn protect_xop( cli_args.push("--rasp-action".into()); cli_args.push(action.to_string()); } - let env = if input.keystore.is_some() { + let mut env = if input.keystore.is_some() { if input.key_alias.as_deref().unwrap_or_default().is_empty() { anyhow::bail!("提供 keystore 时必须提供 key_alias"); } @@ -1176,6 +1212,7 @@ fn protect_xop( } Vec::new() }; + env.extend(stage_env); let job_id = server.start_job( "shellsmith_protect_xop", JobPlan { @@ -1185,10 +1222,10 @@ fn protect_xop( label: "XOP 单壳加固".into(), }], result: json!({"output": output}), - keep: Vec::new(), + keep, }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -1255,7 +1292,7 @@ fn protect_ios( keep: Vec::new(), }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -1286,6 +1323,16 @@ fn sign_apk( } let final_output = output.clone().unwrap_or_else(|| source.clone()); let final_output_string = final_output.display().to_string(); + let mut keep = Vec::new(); + let stage = tempfile::Builder::new() + .prefix("shellsmith-mcp-stage-") + .tempdir_in(final_output.parent().unwrap_or(Path::new("."))) + .context("创建 MCP 任务暂存目录失败")?; + let stage_env = vec![( + "SHELLSMITH_STAGE_ROOT".to_string(), + stage.path().display().to_string(), + )]; + keep.push(stage); let cli_args = sign_command_args( server, &input, @@ -1293,7 +1340,8 @@ fn sign_apk( Some(&final_output_string), input.allow_in_place.unwrap_or(false), )?; - let env = sign_env(&input)?; + let mut env = sign_env(&input)?; + env.extend(stage_env); let job_id = server.start_job( "shellsmith_sign_apk", JobPlan { @@ -1303,10 +1351,10 @@ fn sign_apk( label: "签名 APK".into(), }], result: json!({"output": final_output, "report": report_path(&final_output)}), - keep: Vec::new(), + keep, }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -1340,7 +1388,7 @@ fn install_apks( keep: Vec::new(), }, progress_token, - ); + )?; Ok(ToolResult::ok( json!({"job_id": job_id, "status": "running"}), )) @@ -1395,6 +1443,74 @@ fn job_cancel(server: &Arc, args: Value) -> Result { Ok(ToolResult::ok(serde_json::to_value(job)?)) } +fn normalize_fingerprint(value: &str) -> String { + value.trim().to_uppercase().replace([':', ' ', '-'], "") +} + +fn cert_fingerprint(value: &Value) -> Option { + value + .get("cert_fingerprint") + .and_then(Value::as_str) + .map(normalize_fingerprint) + .filter(|value| !value.is_empty()) +} + +fn resolve_sign_keystore(server: &Arc, input: &SignInput) -> Result { + let keystore = input.keystore.as_deref().context("签名缺少 keystore")?; + server.resolve_input(keystore, "keystore") +} + +/// 校验运行时绑定证书与最终签名证书一致。 +/// +/// - 未显式提供 `runtime_cert_sha256` 时,CLI 会默认绑定输入 APK 证书;因此输入证书必须等于最终签名证书。 +/// - 显式提供时,绑定证书必须等于最终签名证书;这允许用户有意把运行时绑定到新证书。 +fn ensure_sign_certificate_binding( + server: &Arc, + input_apk: &Path, + sign: &SignInput, + requested: Option<&str>, +) -> Result { + let keystore = resolve_sign_keystore(server, sign)?; + let alias = sign.key_alias.as_deref().context("签名缺少 key_alias")?; + let env = sign_env(sign)?; + let keystore_info = server.run_sync( + &[ + "check-keystore".to_string(), + "--ks".into(), + keystore.display().to_string(), + "--alias".into(), + alias.to_string(), + ], + &env, + )?; + let sign_cert = cert_fingerprint(&keystore_info).context("无法从 keystore 读取签名证书指纹")?; + + let apk_info = server.run_sync( + &["check-apk".to_string(), input_apk.display().to_string()], + &[], + )?; + let input_cert = cert_fingerprint(&apk_info); + + if let Some(requested) = requested { + let requested = normalize_fingerprint(requested); + if requested != sign_cert { + anyhow::bail!( + "runtime_cert_sha256 与最终签名证书不一致:绑定 {requested},最终签名 {sign_cert}" + ); + } + } else if input_cert.as_deref() != Some(sign_cert.as_str()) { + if input_cert.is_none() { + anyhow::bail!( + "输入 APK 未签名或无法读取证书;请显式设置 runtime_cert_sha256={sign_cert} 以绑定最终签名证书" + ); + } + anyhow::bail!( + "输入 APK 证书与最终签名证书不一致;请使用同一证书,或显式设置 runtime_cert_sha256={sign_cert} 确认重绑定" + ); + } + Ok(sign_cert) +} + fn sign_command_args( server: &Arc, input: &SignInput, @@ -1402,8 +1518,7 @@ fn sign_command_args( output_path: Option<&str>, allow_in_place: bool, ) -> Result> { - let keystore = input.keystore.as_deref().context("签名缺少 keystore")?; - let keystore = server.resolve_input(keystore, "keystore")?; + let keystore = resolve_sign_keystore(server, input)?; if output_path.is_none() && !allow_in_place { anyhow::bail!("签名需要 output"); } diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 67dbcac..dc16af3 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -11,7 +11,7 @@ const SECRET: &str = "test-secret"; struct McpClient { child: Child, - stdin: ChildStdin, + stdin: Option, rx: Receiver, inbox: Vec, next_id: i64, @@ -51,7 +51,7 @@ impl McpClient { }); Self { child, - stdin, + stdin: Some(stdin), rx, inbox: Vec::new(), next_id: 1, @@ -60,8 +60,9 @@ impl McpClient { fn send(&mut self, value: Value) { let text = serde_json::to_string(&value).expect("序列化请求"); - writeln!(self.stdin, "{text}").expect("写入 stdin"); - self.stdin.flush().expect("刷新 stdin"); + let stdin = self.stdin.as_mut().expect("stdin 已关闭"); + writeln!(stdin, "{text}").expect("写入 stdin"); + stdin.flush().expect("刷新 stdin"); } fn notify(&mut self, method: &str, params: Value) { @@ -77,7 +78,12 @@ impl McpClient { } fn request(&mut self, method: &str, params: Value) -> Value { - let id = self.next_id; + let id = self.send_request(method, params); + self.response_for(&id) + } + + fn send_request(&mut self, method: &str, params: Value) -> Value { + let id = json!(self.next_id); self.next_id += 1; self.send(json!({ "jsonrpc": "2.0", @@ -85,11 +91,22 @@ impl McpClient { "method": method, "params": params })); + id + } + + fn response_for(&mut self, id: &Value) -> Value { let deadline = Instant::now() + Duration::from_secs(15); loop { + if let Some(index) = self + .inbox + .iter() + .position(|message| message.get("id") == Some(id)) + { + return self.inbox.remove(index); + } let remaining = deadline.saturating_duration_since(Instant::now()); let message = self.read_message(remaining); - if message.get("id") == Some(&json!(id)) { + if message.get("id") == Some(id) { return message; } self.inbox.push(message); @@ -150,13 +167,32 @@ impl McpClient { thread::sleep(Duration::from_millis(50)); } } + + fn close_stdin(&mut self) { + self.stdin.take(); + } + + fn wait_exit(&mut self, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + match self.child.try_wait().expect("等待 MCP 退出失败") { + Some(status) => return Some(status), + None if Instant::now() >= deadline => return None, + None => thread::sleep(Duration::from_millis(50)), + } + } + } } impl Drop for McpClient { fn drop(&mut self) { - let _ = self.stdin.flush(); - let _ = self.child.kill(); - let _ = self.child.wait(); + if let Some(stdin) = self.stdin.as_mut() { + let _ = stdin.flush(); + } + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } } } @@ -172,17 +208,37 @@ case "$cmd" in echo "shield 1.10.1" ;; check-apk) - echo '{"already_protected":false,"is_signed":true,"cert_fingerprint":"AA:BB:CC","error":null}' + printf '{"already_protected":false,"is_signed":true,"cert_fingerprint":"%s","error":null}\n' "${FAKE_APK_CERT:-AA:BB:CC}" + ;; + check-aab) + sleep "${FAKE_CHECK_SLEEP:-0}" + echo '{"has_base":true,"modules":[]}' ;; check-keystore) if [ "$MOCIKA_SHIELD_KS_PASS" != "test-secret" ]; then echo '{"type":"error","message":"bad keystore password"}' exit 1 fi - echo '{"cert_fingerprint":"CC:DD:EE","error":null}' + printf '{"cert_fingerprint":"%s","error":null}\n' "${FAKE_KS_CERT:-CC:DD:EE}" ;; protect|protect-xop|protect-aab|protect-ios|sign|install-apks) + if [ "${FAKE_REJECT_BYOS:-0}" = "1" ] && [ -n "${SHELLSMITH_BYOS_API_KEY:-}" ]; then + echo '{"type":"error","message":"byos key leaked"}' + exit 1 + fi echo '{"type":"progress","step":"Fake","message":"working"}' + if [ -n "${FAKE_TEMP_LOG:-}" ]; then + printf 'TMPDIR=%s\nSTAGE=%s\n' "${TMPDIR:-}" "${SHELLSMITH_STAGE_ROOT:-}" > "$FAKE_TEMP_LOG" + fi + if [ "${FAKE_SHELLSMITH_LEAVE_TEMP:-0}" = "1" ]; then + mkdir -p "${TMPDIR:-/tmp}/fake-pass" + echo secret > "${TMPDIR:-/tmp}/fake-pass/ks.pass" + mkdir -p "${SHELLSMITH_STAGE_ROOT:-.}/shield-signed-fake" + echo staged > "${SHELLSMITH_STAGE_ROOT:-.}/shield-signed-fake/staged.apk" + fi + if [ "${FAKE_SHELLSMITH_CLOSE_STDOUT:-0}" = "1" ]; then + exec 1>&- + fi sleep "${FAKE_SHIELD_SLEEP:-0.05}" if [ "${FAKE_SHIELD_FAIL:-0}" = "1" ]; then echo '{"type":"error","message":"fake protection failed"}' @@ -203,6 +259,7 @@ case "$cmd" in esac fi echo '{"type":"done"}' + exit 0 ;; *) echo '{"type":"error","message":"unknown command"}' @@ -427,6 +484,8 @@ fn protects_apk_asynchronously_with_progress_and_auto_sign() { ("TEST_KS_PASS", SECRET), ("TEST_KEY_PASS", SECRET), ("FAKE_SHIELD_LOG", log.to_str().unwrap()), + ("FAKE_APK_CERT", "CC:DD:EE"), + ("FAKE_KS_CERT", "CC:DD:EE"), ], ); @@ -474,10 +533,339 @@ fn protects_apk_asynchronously_with_progress_and_auto_sign() { let logs = fs::read_to_string(&log).unwrap(); assert!(logs.contains("protect"), "{logs}"); assert!(logs.contains("sign"), "{logs}"); + assert!( + logs.contains("--runtime-cert-sha256 CCDDEE"), + "自动签名必须把绑定证书固定为最终签名证书: {logs}" + ); assert!(output.exists(), "自动签名最终产物应存在"); assert!(!logs.contains(SECRET), "日志不应包含密码: {logs}"); } +#[test] +fn rejects_mismatched_signing_certificate_and_allows_explicit_rebind() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let log = temp.path().join("calls.log"); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("app-protected.apk"); + let keystore = temp.path().join("release.jks"); + fs::write(&apk, b"apk").unwrap(); + fs::write(&keystore, b"ks").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("TEST_KS_PASS", SECRET), + ("TEST_KEY_PASS", SECRET), + ("FAKE_SHIELD_LOG", log.to_str().unwrap()), + ("FAKE_APK_CERT", "AA:BB:CC"), + ("FAKE_KS_CERT", "CC:DD:EE"), + ], + ); + + let arguments = |runtime_cert: Option<&str>| { + let mut value = json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "confirm": true, + "sign": { + "keystore": keystore.display().to_string(), + "key_alias": "release", + "password_env": "TEST_KS_PASS", + "key_password_env": "TEST_KEY_PASS", + "v2": true + } + }); + if let Some(runtime_cert) = runtime_cert { + value["runtime_cert_sha256"] = json!(runtime_cert); + } + value + }; + + let rejected = client.call("shellsmith_protect_apk", arguments(None)); + assert_eq!(rejected["isError"], true, "{rejected}"); + assert!(text(&rejected).contains("不一致"), "{rejected}"); + assert!(!output.exists()); + + let conflict = client.call("shellsmith_protect_apk", arguments(Some("AA:BB:CC"))); + assert_eq!(conflict["isError"], true, "{conflict}"); + assert!(text(&conflict).contains("不一致"), "{conflict}"); + assert!(!output.exists()); + + let rebound = client.call("shellsmith_protect_apk", arguments(Some("CC:DD:EE"))); + assert_eq!(rebound["isError"], false, "{rebound}"); + let job_id = rebound["structuredContent"]["job_id"].as_str().unwrap(); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); + assert!(output.exists()); + + let logs = fs::read_to_string(&log).unwrap(); + assert!( + logs.contains("--runtime-cert-sha256 CCDDEE"), + "显式重绑定必须固定到最终签名证书: {logs}" + ); +} + +#[test] +fn sync_check_does_not_block_job_cancel() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let apk = temp.path().join("app.apk"); + let aab = temp.path().join("app.aab"); + let output = temp.path().join("protected.apk"); + fs::write(&apk, b"apk").unwrap(); + fs::write(&aab, b"aab").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[("FAKE_SHIELD_SLEEP", "5"), ("FAKE_CHECK_SLEEP", "2")], + ); + let start = client.call( + "shellsmith_protect_xop", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "packer": cli.display().to_string(), + "shell_dir": temp.path().display().to_string(), + "allow_unsigned": true, + "confirm": true + }), + ); + assert_eq!(start["isError"], false, "{start}"); + let job_id = start["structuredContent"]["job_id"].as_str().unwrap(); + + let check_id = client.send_request( + "tools/call", + json!({ + "name": "shellsmith_check_aab", + "arguments": {"path": aab.display().to_string()} + }), + ); + thread::sleep(Duration::from_millis(150)); + + let started = Instant::now(); + let cancelled = client.call("shellsmith_job_cancel", json!({"job_id": job_id})); + let elapsed = started.elapsed(); + assert_eq!(cancelled["isError"], false, "{cancelled}"); + assert!( + elapsed < Duration::from_secs(1), + "同步检查阻塞了取消请求: {elapsed:?}" + ); + + let check = client.response_for(&check_id); + assert_eq!(check["result"]["isError"], false, "{check}"); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "cancelled", "{job}"); +} + +#[test] +fn cancellation_works_after_cli_closes_stdout_early() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("closed-stdout.apk"); + fs::write(&apk, b"apk").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("FAKE_SHELLSMITH_CLOSE_STDOUT", "1"), + ("FAKE_SHIELD_SLEEP", "2"), + ], + ); + let start = client.call( + "shellsmith_protect_xop", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "packer": cli.display().to_string(), + "shell_dir": temp.path().display().to_string(), + "allow_unsigned": true, + "confirm": true + }), + ); + assert_eq!(start["isError"], false, "{start}"); + let job_id = start["structuredContent"]["job_id"].as_str().unwrap(); + thread::sleep(Duration::from_millis(300)); + + let started = Instant::now(); + let cancelled = client.call("shellsmith_job_cancel", json!({"job_id": job_id})); + let elapsed = started.elapsed(); + assert_eq!(cancelled["isError"], false, "{cancelled}"); + assert!( + elapsed < Duration::from_secs(1), + "CLI 提前关闭 stdout 后取消被阻塞: {elapsed:?}" + ); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "cancelled", "{job}"); +} + +#[test] +fn closing_stdin_cancels_running_jobs() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("late-output.apk"); + fs::write(&apk, b"apk").unwrap(); + + let mut client = McpClient::start(&cli, &[temp.path()], &[("FAKE_SHIELD_SLEEP", "2")]); + let start = client.call( + "shellsmith_protect_xop", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "packer": cli.display().to_string(), + "shell_dir": temp.path().display().to_string(), + "allow_unsigned": true, + "confirm": true + }), + ); + assert_eq!(start["isError"], false, "{start}"); + thread::sleep(Duration::from_millis(200)); + + client.close_stdin(); + let status = client.wait_exit(Duration::from_secs(5)); + assert!(status.is_some(), "MCP 未在关闭 stdin 后退出"); + thread::sleep(Duration::from_millis(2500)); + assert!( + !output.exists(), + "MCP 退出后后台 CLI 仍继续生成输出: {}", + output.display() + ); +} + +#[test] +fn sync_check_times_out_and_kills_cli() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let aab = temp.path().join("app.aab"); + fs::write(&aab, b"aab").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("FAKE_CHECK_SLEEP", "3"), + ("SHELLSMITH_MCP_SYNC_TIMEOUT_SECS", "1"), + ], + ); + let started = Instant::now(); + let result = client.call( + "shellsmith_check_aab", + json!({"path": aab.display().to_string()}), + ); + let elapsed = started.elapsed(); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("超时"), "{result}"); + assert!( + elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(3), + "同步检查超时时间不符合预期: {elapsed:?}" + ); +} + +#[test] +fn offline_protect_does_not_inherit_byos_key() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("offline.apk"); + fs::write(&apk, b"apk").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("SHELLSMITH_BYOS_API_KEY", "must-not-leak"), + ("FAKE_REJECT_BYOS", "1"), + ], + ); + let start = client.call( + "shellsmith_protect_apk", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "confirm": true + }), + ); + assert_eq!(start["isError"], false, "{start}"); + let job_id = start["structuredContent"]["job_id"].as_str().unwrap(); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); + assert!(output.exists()); +} + +#[test] +fn cancels_sign_and_cleans_temporary_directories() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let temp_log = temp.path().join("temp-paths.log"); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("signed.apk"); + let keystore = temp.path().join("release.jks"); + fs::write(&apk, b"apk").unwrap(); + fs::write(&keystore, b"ks").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("TEST_KS_PASS", SECRET), + ("TEST_KEY_PASS", SECRET), + ("FAKE_TEMP_LOG", temp_log.to_str().unwrap()), + ("FAKE_SHELLSMITH_LEAVE_TEMP", "1"), + ("FAKE_SHIELD_SLEEP", "5"), + ], + ); + let start = client.call( + "shellsmith_sign_apk", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "keystore": keystore.display().to_string(), + "key_alias": "release", + "password_env": "TEST_KS_PASS", + "key_password_env": "TEST_KEY_PASS", + "confirm": true + }), + ); + assert_eq!(start["isError"], false, "{start}"); + let job_id = start["structuredContent"]["job_id"].as_str().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(3); + while !temp_log.exists() { + assert!(Instant::now() < deadline, "fake CLI 未创建临时路径记录"); + thread::sleep(Duration::from_millis(20)); + } + let paths = fs::read_to_string(&temp_log).unwrap(); + let temp_root = paths + .lines() + .find_map(|line| line.strip_prefix("TMPDIR=")) + .expect("TMPDIR"); + let stage_root = paths + .lines() + .find_map(|line| line.strip_prefix("STAGE=")) + .expect("STAGE"); + assert!(Path::new(temp_root).exists(), "{paths}"); + assert!(Path::new(stage_root).exists(), "{paths}"); + + let cancelled = client.call("shellsmith_job_cancel", json!({"job_id": job_id})); + assert_eq!(cancelled["isError"], false, "{cancelled}"); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "cancelled", "{job}"); + + let deadline = Instant::now() + Duration::from_secs(3); + while Path::new(temp_root).exists() || Path::new(stage_root).exists() { + assert!( + Instant::now() < deadline, + "取消后临时目录未清理: TMPDIR={temp_root} STAGE={stage_root}" + ); + thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn cancels_running_job_and_kills_cli_subprocess() { let temp = tempfile::tempdir().unwrap(); diff --git a/apps/shield-cli/src/args.rs b/apps/shield-cli/src/args.rs index 3eea080..db4e968 100644 --- a/apps/shield-cli/src/args.rs +++ b/apps/shield-cli/src/args.rs @@ -140,7 +140,7 @@ impl Commands { pub(crate) struct ProtectIosArgs { /// Shellsmith iOS TOML 配置。 #[arg(long = "ios-config", value_name = "TOML")] - pub config: PathBuf, + pub ios_config: PathBuf, /// 独立输出目录;必须不存在或为空,不能位于源码目录内。 #[arg(short, long, value_name = "DIR")] pub output: PathBuf, @@ -516,6 +516,54 @@ impl RuntimeUiArgs { } } +#[cfg(test)] +mod ios_config_tests { + use super::*; + use std::path::Path; + + #[test] + fn ios_config_does_not_collide_with_global_config() { + let parsed = Cli::try_parse_from([ + "shield", + "protect-ios", + "--ios-config", + "ios.toml", + "--output", + "out", + ]) + .expect("合法的 iOS 参数应能解析"); + assert!(parsed.config.is_none()); + match parsed.command { + Commands::ProtectIos(args) => { + assert_eq!(args.ios_config, PathBuf::from("ios.toml")); + } + _ => panic!("错误命令"), + } + } + + #[test] + fn global_and_ios_config_are_independent() { + let parsed = Cli::try_parse_from([ + "shield", + "--config", + "cli.toml", + "protect-ios", + "--ios-config", + "ios.toml", + "--output", + "out", + ]) + .expect("全局配置与 iOS 配置应能同时解析"); + assert_eq!(parsed.config.as_deref(), Some(Path::new("cli.toml"))); + match parsed.command { + Commands::ProtectIos(args) => { + assert_eq!(args.ios_config, PathBuf::from("ios.toml")); + } + _ => panic!("错误命令"), + } + } +} + #[cfg(test)] mod byos_tests { use super::*; diff --git a/apps/shield-cli/src/commands.rs b/apps/shield-cli/src/commands.rs index 1d32282..0bf60d2 100644 --- a/apps/shield-cli/src/commands.rs +++ b/apps/shield-cli/src/commands.rs @@ -131,7 +131,7 @@ pub(crate) fn run_check_ios(project: PathBuf, scheme: Option<&str>) -> Result Result<()> { - let config = shield_ios::ShellsmithIosConfig::load(&args.config)?; + let config = shield_ios::ShellsmithIosConfig::load(&args.ios_config)?; let cancel = Arc::new(AtomicBool::new(false)); let json = args.json; let report = shield_ios::protect_ios_project( @@ -546,12 +546,12 @@ pub fn run_protect_aab_with_control( let staged_aab = tempfile::Builder::new() .prefix("shield-aab-") .suffix(".aab") - .tempfile_in( + .tempfile_in(shield_core::utils::staging_parent( args.output .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(std::path::Path::new(".")), - )?; + ))?; sign_aab_with_jarsigner( &unsigned_aab, staged_aab.path(), diff --git a/crates/shield-core/src/protect_api.rs b/crates/shield-core/src/protect_api.rs index 3df0278..20fef34 100644 --- a/crates/shield-core/src/protect_api.rs +++ b/crates/shield-core/src/protect_api.rs @@ -161,15 +161,16 @@ pub fn protect_apk( opts.byos_auth, ) .map_err(ShieldError::from)?; - let parent = opts - .output - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or(Path::new(".")); + let parent = crate::utils::staging_parent( + opts.output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")), + ); let staged = tempfile::Builder::new() .prefix("shield-output-") .suffix(".apk") - .tempfile_in(parent) + .tempfile_in(&parent) .map_err(anyhow::Error::from) .map_err(ShieldError::from)?; let mut working = opts.clone(); diff --git a/crates/shield-core/src/signing.rs b/crates/shield-core/src/signing.rs index e9281d0..f771073 100644 --- a/crates/shield-core/src/signing.rs +++ b/crates/shield-core/src/signing.rs @@ -5,7 +5,7 @@ use std::process::Stdio; use tempfile::{NamedTempFile, TempDir}; use crate::diagnostic::{Diagnostic, FailureCode, ToolName}; -use crate::utils::{find_java, no_window_command}; +use crate::utils::{find_java, no_window_command, staging_parent}; use crate::zipalign::align_apk; #[derive(Debug, Clone, PartialEq)] @@ -321,9 +321,10 @@ fn ensure_nonempty_file(path: &Path, message: &str) -> Result<()> { } fn create_signing_stage(parent: &Path) -> Result<(TempDir, NamedTempFile, PathBuf)> { + let parent = staging_parent(parent); let staging_dir = tempfile::Builder::new() .prefix("shield-signed-") - .tempdir_in(parent) + .tempdir_in(&parent) .with_context(|| { format!( "创建签名暂存目录失败,请确认输出目录可写: {}", @@ -343,10 +344,11 @@ fn write_staged_report( report: &crate::protection_report::MethodReport, parent: &Path, ) -> Result { + let parent = staging_parent(parent); let mut file = tempfile::Builder::new() .prefix("shield-report-") .suffix(".json") - .tempfile_in(parent) + .tempfile_in(&parent) .context("创建保护报告暂存文件失败")?; serde_json::to_writer_pretty(&mut file, report).context("写入保护报告暂存文件失败")?; file.as_file_mut() @@ -424,9 +426,10 @@ fn commit_signed_outputs( final_report: &Path, final_idsig: &Path, ) -> Result<()> { + let parent = staging_parent(parent); let recovery = tempfile::Builder::new() .prefix("shield-recovery-") - .tempdir_in(parent) + .tempdir_in(&parent) .context("创建签名恢复目录失败")?; let report_backup = backup_attachment(final_report, recovery.path(), "report.json")?; let idsig_backup = backup_attachment(final_idsig, recovery.path(), "idsig")?; diff --git a/crates/shield-core/src/utils.rs b/crates/shield-core/src/utils.rs index 4efc4cb..82e35df 100644 --- a/crates/shield-core/src/utils.rs +++ b/crates/shield-core/src/utils.rs @@ -130,6 +130,16 @@ pub fn create_temp_dir(prefix: &str) -> Result { .context("创建临时目录失败") } +/// 任务私有暂存根目录;父进程可通过 `SHELLSMITH_STAGE_ROOT` 指定。 +/// +/// 默认仍使用产物所在目录,保证同级暂存文件可以原子重命名到最终路径。 +pub fn staging_parent(default_parent: &Path) -> PathBuf { + std::env::var_os("SHELLSMITH_STAGE_ROOT") + .map(PathBuf::from) + .filter(|path| path.is_dir()) + .unwrap_or_else(|| default_parent.to_path_buf()) +} + pub fn exe_dir() -> PathBuf { std::env::current_exe() .ok() diff --git a/docs/mcp.md b/docs/mcp.md index 9d662cf..14204ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -95,6 +95,7 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" | `SHELLSMITH_BYOS_API_KEY` | BYOS 构建端 API key | | `SHELLSMITH_MCP_CLI` | 默认 `shield` 路径 | | `SHELLSMITH_MCP_ALLOWED_ROOTS` | 默认允许目录列表 | +| `SHELLSMITH_MCP_SYNC_TIMEOUT_SECS` | 同步检查超时秒数,默认 600;超时会终止 CLI | ## 工具清单 @@ -162,6 +163,11 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" } ``` +自动签名开始前会读取输入 APK 与 keystore 的证书指纹: + +- 未设置 `runtime_cert_sha256` 时,两者必须一致,否则直接拒绝; +- 如确实要把运行时绑定到新证书,设置 `runtime_cert_sha256` 为最终签名证书指纹显式授权;它与 keystore 证书不一致时仍会拒绝。 + 返回: ```json diff --git a/scripts/release-linux.sh b/scripts/release-linux.sh index e409757..c7432af 100755 --- a/scripts/release-linux.sh +++ b/scripts/release-linux.sh @@ -186,17 +186,17 @@ RUNEOF chmod +x "$CLI_PKG_DIR/shield.sh" # README - cat > "$CLI_PKG_DIR/README.md" << READMEEOF -# Shellsmith CLI v${VERSION} + cat > "$CLI_PKG_DIR/README.md" << 'READMEEOF' +# Shellsmith CLI v@VERSION@ Linux x86_64 静态链接版本,无需安装依赖。 ## 使用方法 -\`\`\`bash +```bash ./bin/shield protect -i input.apk -o protected.apk -./bin/shellsmith-mcp --allow-root "\$PWD" -\`\`\` +./bin/shellsmith-mcp --allow-root "$PWD" +``` MCP 通过 stdio 暴露全部 CLI 能力;密码只从启动环境读取,不写入工具参数。 @@ -207,8 +207,8 @@ MCP 通过 stdio 暴露全部 CLI 能力;密码只从启动环境读取,不 ## 目录结构 -\`\`\` -Shellsmith-cli-${VERSION}-linux-x86_64/ +``` +Shellsmith-cli-@VERSION@-linux-x86_64/ ├── bin/shield # 可执行文件(静态链接) ├── bin/shellsmith-mcp # MCP stdio 服务(静态链接) ├── lib/ @@ -220,8 +220,10 @@ Shellsmith-cli-${VERSION}-linux-x86_64/ │ └── resources-api19.zip # Android 4.4 ARMv7 兼容运行时 ├── licenses/ # XopProtector 许可证与声明 └── README.md -\`\`\` +``` READMEEOF + sed "s/@VERSION@/${VERSION}/g" "$CLI_PKG_DIR/README.md" > "$CLI_PKG_DIR/README.md.tmp" + mv "$CLI_PKG_DIR/README.md.tmp" "$CLI_PKG_DIR/README.md" # 打包 cd "$DIST_DIR/cli" diff --git a/scripts/release-macos.sh b/scripts/release-macos.sh index 879dc6a..af1e419 100755 --- a/scripts/release-macos.sh +++ b/scripts/release-macos.sh @@ -323,10 +323,10 @@ macOS 版本。 ## 使用方法 -\`\`\`bash +```bash ./bin/shield protect -i input.apk -o protected.apk -./bin/shellsmith-mcp --allow-root "\$PWD" -\`\`\` +./bin/shellsmith-mcp --allow-root "$PWD" +``` MCP 通过 stdio 暴露全部 CLI 能力;密码只从启动环境读取,不写入工具参数。 @@ -337,8 +337,8 @@ MCP 通过 stdio 暴露全部 CLI 能力;密码只从启动环境读取,不 ## 目录结构 -\`\`\` -Shellsmith-cli-${VERSION}-macos-${ARCH_LABEL}/ +``` +Shellsmith-cli-@VERSION@-macos-@ARCH_LABEL@/ ├── bin/shield # 可执行文件 ├── bin/shellsmith-mcp # MCP stdio 服务 ├── lib/ @@ -351,8 +351,11 @@ Shellsmith-cli-${VERSION}-macos-${ARCH_LABEL}/ ├── licenses/ # XopProtector 许可证与声明 ├── shield.sh # 快捷启动脚本 └── README.md -\`\`\` +``` READMEEOF + sed -e "s/@VERSION@/${VERSION}/g" -e "s/@ARCH_LABEL@/${ARCH_LABEL}/g" \ + "$CLI_PKG_DIR/README.md" > "$CLI_PKG_DIR/README.md.tmp" + mv "$CLI_PKG_DIR/README.md.tmp" "$CLI_PKG_DIR/README.md" cd "$DIST_DIR/cli" tar -czf "Shellsmith-cli-${VERSION}-macos-${ARCH_LABEL}.tar.gz" \ diff --git a/scripts/release-windows.ps1 b/scripts/release-windows.ps1 index e025d57..ac609ef 100644 --- a/scripts/release-windows.ps1 +++ b/scripts/release-windows.ps1 @@ -287,7 +287,7 @@ MCP 通过 stdio 暴露全部 CLI 能力;密码只从启动环境读取,不 ## 要求 - Windows 10/11 (64-bit) -- Java 8+(需完整 JDK,`java` / `keytool` 须在 PATH 中) +- Java 8+(需完整 JDK,``java`` / ``keytool`` 须在 PATH 中) ## 目录结构 diff --git a/scripts/render-mcp-release-readme.sh b/scripts/render-mcp-release-readme.sh new file mode 100644 index 0000000..6be5a46 --- /dev/null +++ b/scripts/render-mcp-release-readme.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# 生成 CLI + MCP 发布包说明。 +# 使用带引号的 heredoc,避免说明文字中的反引号或 $ 被 shell 执行。 +set -euo pipefail + +output="${1:?用法: render-mcp-release-readme.sh OUTPUT VERSION PLATFORM}" +version="${2:?缺少版本号}" +platform="${3:?缺少平台}" + +cat > "$output" <<'EOF' +# Shellsmith CLI + MCP @VERSION@ + +Platform: @PLATFORM@ + +This bundle contains the `shield` CLI and the `shellsmith-mcp` stdio server. +No artifact, certificate or password is uploaded by this package. + +## Verify + +```bash +./bin/shield --version +./bin/shellsmith-mcp --version +``` + +## MCP client configuration + +Use an absolute path to the extracted bundle: + +```json +{ + "mcpServers": { + "shellsmith": { + "command": "/absolute/path/to/bin/shellsmith-mcp", + "args": ["--allow-root", "/absolute/path/to/projects"] + } + } +} +``` + +`shellsmith-mcp` automatically finds the sibling `shield` binary. If you keep +the CLI elsewhere, pass `--cli /path/to/shield` or set +`SHELLSMITH_MCP_CLI=/path/to/shield`. + +Keystore passwords are read from the MCP server environment only: + +```bash +export MOCIKA_SHIELD_KS_PASS="..." +export MOCIKA_SHIELD_KEY_PASS="..." +``` + +Do not put a plaintext password in the MCP client JSON or tool arguments. + +## Requirements + +- Java 17+ with `java` and `keytool` available. +- AAB workflows also need `bundletool.jar` and `aapt2`; pass their paths to the tool. +- Android APK/AAB work stays on this machine; iOS archive requires macOS and Xcode. +- macOS: if Gatekeeper blocks the unsigned CLI, run + `xattr -rd com.apple.quarantine /path/to/Shellsmith-cli-*`. + +See `docs/mcp.md` in the repository for the full tool list and security model. +EOF + +sed \ + -e "s/@VERSION@/${version}/g" \ + -e "s/@PLATFORM@/${platform}/g" \ + "$output" > "$output.tmp" +mv "$output.tmp" "$output" diff --git a/scripts/tests/test_mcp_release_readme.py b/scripts/tests/test_mcp_release_readme.py new file mode 100644 index 0000000..3e2cb73 --- /dev/null +++ b/scripts/tests/test_mcp_release_readme.py @@ -0,0 +1,65 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "render-mcp-release-readme.sh" + + +class RenderMcpReleaseReadmeTests(unittest.TestCase): + def test_发布脚本的说明模板使用带引号_heredoc(self) -> None: + for name in ("release-cli.sh", "release-linux.sh", "release-macos.sh"): + path = ROOT / "scripts" / name + text = path.read_text(encoding="utf-8") + heredocs = [ + line + for line in text.splitlines() + if "README.md" in line and "<<" in line + ] + self.assertTrue(heredocs, name) + for line in heredocs: + self.assertIn("<< '", line, f"{name} 的 README heredoc 未加引号: {line}") + + def test_发布工作流使用安全的说明渲染脚本(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "cli-release.yml").read_text( + encoding="utf-8" + ) + self.assertIn("scripts/render-mcp-release-readme.sh", workflow) + self.assertNotIn("cat > bundle/README.md", workflow) + + def test_说明文字不会被命令替换执行(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + marker = root / "executed" + fake_bin = root / "bin" + fake_bin.mkdir() + shield = fake_bin / "shield" + shield.write_text( + "#!/bin/sh\n" + f"printf 'executed' > '{marker}'\n", + encoding="utf-8", + ) + shield.chmod(0o755) + + output = root / "README.md" + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + subprocess.run( + ["bash", str(SCRIPT), str(output), "1.10.2", "macos-universal"], + check=True, + env=env, + ) + + text = output.read_text(encoding="utf-8") + self.assertFalse(marker.exists(), "反引号被 shell 执行了") + self.assertIn("# Shellsmith CLI + MCP 1.10.2", text) + self.assertIn("Platform: macos-universal", text) + self.assertIn("`shield`", text) + self.assertIn("`--cli /path/to/shield`", text) + self.assertFalse((root / "README.md.tmp").exists()) + + +if __name__ == "__main__": + unittest.main()