diff --git a/apps/shellsmith-mcp/src/jobs.rs b/apps/shellsmith-mcp/src/jobs.rs index 9fdb5ad..a425c10 100644 --- a/apps/shellsmith-mcp/src/jobs.rs +++ b/apps/shellsmith-mcp/src/jobs.rs @@ -102,6 +102,8 @@ pub struct CommandPlan { pub struct JobPlan { pub commands: Vec, pub result: Value, + /// 期望生成的保护报告;任务成功时若文件不存在,从结果中省略。 + pub report: Option, /// 自动签名等流程的临时目录,必须活到任务结束。 pub keep: Vec, } @@ -664,11 +666,22 @@ fn run_job(server: Arc, job: Arc>, plan: JobPlan, done: Arc) -> Value { + if let Some(report) = report { + if !report.is_file() { + if let Some(object) = result.as_object_mut() { + object.remove("report"); + } + } + } + result +} + struct DoneGuard(Arc); impl Drop for DoneGuard { diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index b00f32f..c53c142 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -58,7 +58,13 @@ pub fn tool_definitions() -> Vec { tool( "shellsmith_check_apk", "Inspect an APK: protection marker, signature state, certificate fingerprint and native ABIs.", - object(json!({"path": string("APK path")}), &["path"]), + object( + json!({ + "path": string("APK path"), + "apksigner": string("Optional apksigner JAR used to verify and read the certificate") + }), + &["path"], + ), read_only(), ), tool( @@ -585,8 +591,17 @@ fn dispatch( "shellsmith_check_apk" => { let input: CheckPathInput = parse(args)?; let path = server.resolve_input(&input.path, "APK")?; - let value = - server.run_sync(&["check-apk".to_string(), path.display().to_string()], &[])?; + let mut cli_args = vec!["check-apk".to_string(), path.display().to_string()]; + if let Some(apksigner) = input.apksigner.as_deref() { + cli_args.push("--apksigner".into()); + cli_args.push( + server + .resolve_input(apksigner, "apksigner")? + .display() + .to_string(), + ); + } + let value = server.run_sync(&cli_args, &[])?; Ok(ToolResult::ok(value)) } "shellsmith_check_aab" => check_aab(server, args), @@ -619,6 +634,7 @@ fn dispatch( #[derive(Debug, Deserialize)] struct CheckPathInput { path: String, + apksigner: Option, } #[derive(Debug, Deserialize)] struct CheckIosInput { @@ -757,6 +773,7 @@ fn protect_apk( &source, sign, input.runtime_cert_sha256.as_deref(), + sign.apksigner.as_deref().or(input.apksigner.as_deref()), )?) } else { input.runtime_cert_sha256.clone() @@ -901,15 +918,17 @@ fn protect_apk( label: "签名 APK".into(), }); } + let expected_report = report_path(&final_output); let job_id = server.start_job( "shellsmith_protect_apk", JobPlan { commands, result: json!({ "output": final_output, - "report": report_path(&final_output), + "report": expected_report.clone(), "signed": sign_output.is_some() }), + report: Some(expected_report.into()), keep, }, progress_token, @@ -1100,6 +1119,7 @@ fn protect_aab( input.key_password_env.as_deref(), )?; env.extend(stage_env); + let expected_report = report_path(&output); let job_id = server.start_job( "shellsmith_protect_aab", JobPlan { @@ -1108,7 +1128,8 @@ fn protect_aab( env, label: "加固 AAB".into(), }], - result: json!({"output": output, "report": report_path(&output)}), + result: json!({"output": output, "report": expected_report.clone()}), + report: Some(expected_report.into()), keep, }, progress_token, @@ -1222,6 +1243,7 @@ fn protect_xop( label: "XOP 单壳加固".into(), }], result: json!({"output": output}), + report: None, keep, }, progress_token, @@ -1289,6 +1311,7 @@ fn protect_ios( label: "iOS 保护".into(), }], result: json!({"output": output}), + report: None, keep: Vec::new(), }, progress_token, @@ -1342,6 +1365,7 @@ fn sign_apk( )?; let mut env = sign_env(&input)?; env.extend(stage_env); + let expected_report = report_path(&final_output); let job_id = server.start_job( "shellsmith_sign_apk", JobPlan { @@ -1350,7 +1374,8 @@ fn sign_apk( env, label: "签名 APK".into(), }], - result: json!({"output": final_output, "report": report_path(&final_output)}), + result: json!({"output": final_output, "report": expected_report.clone()}), + report: Some(expected_report.into()), keep, }, progress_token, @@ -1385,6 +1410,7 @@ fn install_apks( label: "安装 APKS".into(), }], result: json!({"apks": apks}), + report: None, keep: Vec::new(), }, progress_token, @@ -1469,6 +1495,7 @@ fn ensure_sign_certificate_binding( input_apk: &Path, sign: &SignInput, requested: Option<&str>, + apksigner: Option<&str>, ) -> Result { let keystore = resolve_sign_keystore(server, sign)?; let alias = sign.key_alias.as_deref().context("签名缺少 key_alias")?; @@ -1485,10 +1512,17 @@ fn ensure_sign_certificate_binding( )?; 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 mut apk_args = vec!["check-apk".to_string(), input_apk.display().to_string()]; + if let Some(apksigner) = apksigner { + apk_args.push("--apksigner".into()); + apk_args.push( + server + .resolve_input(apksigner, "apksigner")? + .display() + .to_string(), + ); + } + let apk_info = server.run_sync(&apk_args, &[])?; let input_cert = cert_fingerprint(&apk_info); if let Some(requested) = requested { diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index dc16af3..e56a017 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -208,7 +208,17 @@ case "$cmd" in echo "shield 1.10.1" ;; check-apk) - printf '{"already_protected":false,"is_signed":true,"cert_fingerprint":"%s","error":null}\n' "${FAKE_APK_CERT:-AA:BB:CC}" + cert="${FAKE_APK_CERT:-AA:BB:CC}" + has_apksigner=0 + prev="" + for arg in "$@"; do + [ "$prev" = "--apksigner" ] && has_apksigner=1 + prev="$arg" + done + if [ "${FAKE_CUSTOM_APKSIGNER:-0}" = "1" ] && [ "$has_apksigner" = "1" ]; then + cert="${FAKE_CUSTOM_APK_CERT:-CC:DD:EE}" + fi + printf '{"already_protected":false,"is_signed":true,"cert_fingerprint":"%s","error":null}\n' "$cert" ;; check-aab) sleep "${FAKE_CHECK_SLEEP:-0}" @@ -902,6 +912,62 @@ fn cancels_running_job_and_kills_cli_subprocess() { assert_eq!(job["structuredContent"]["status"], "cancelled", "{job}"); } +#[test] +fn certificate_precheck_uses_custom_apksigner() { + 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"); + let apksigner = temp.path().join("custom-apksigner.jar"); + fs::write(&apk, b"apk").unwrap(); + fs::write(&keystore, b"ks").unwrap(); + fs::write(&apksigner, b"jar").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"), + ("FAKE_CUSTOM_APKSIGNER", "1"), + ("FAKE_CUSTOM_APK_CERT", "CC:DD:EE"), + ], + ); + + let start = client.call( + "shellsmith_protect_apk", + json!({ + "input": apk.display().to_string(), + "output": output.display().to_string(), + "apksigner": apksigner.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", + "apksigner": apksigner.display().to_string() + } + }), + ); + 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}"); + + let logs = fs::read_to_string(&log).unwrap(); + assert!( + logs.lines() + .any(|line| { line.starts_with("check-apk ") && line.contains("--apksigner") }), + "证书预检没有使用自定义 apksigner: {logs}" + ); +} + #[test] fn signs_in_place_only_with_explicit_allow_in_place() { let temp = tempfile::tempdir().unwrap(); @@ -960,6 +1026,10 @@ fn signs_in_place_only_with_explicit_allow_in_place() { job["structuredContent"]["result"]["output"], canonical_apk.display().to_string() ); + assert!( + job["structuredContent"]["result"].get("report").is_none(), + "普通签名不应返回不存在的保护报告: {job}" + ); let calls = fs::read_to_string(&log).unwrap(); assert!( calls.contains(&format!("-o {}", canonical_apk.display())), @@ -967,6 +1037,50 @@ fn signs_in_place_only_with_explicit_allow_in_place() { ); } +#[test] +fn sign_returns_report_only_when_the_file_exists() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let apk = temp.path().join("app.apk"); + let output = temp.path().join("signed.apk"); + let keystore = temp.path().join("release.jks"); + let report = PathBuf::from(format!("{}.protection.json", output.display())); + fs::write(&apk, b"apk").unwrap(); + fs::write(&keystore, b"ks").unwrap(); + fs::write(&report, b"{}").unwrap(); + + let mut client = McpClient::start(&cli, &[temp.path()], &[("TEST_KS_PASS", SECRET)]); + 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", + "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}"); + let canonical_report = output + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(format!( + "{}.protection.json", + output.file_name().unwrap().to_string_lossy() + )); + assert_eq!( + job["structuredContent"]["result"]["report"], + canonical_report.display().to_string() + ); + assert!(canonical_report.is_file()); +} + #[test] fn reports_failed_jobs_and_hides_result_until_terminal() { let temp = tempfile::tempdir().unwrap(); diff --git a/apps/shield-cli/src/args.rs b/apps/shield-cli/src/args.rs index db4e968..c63866a 100644 --- a/apps/shield-cli/src/args.rs +++ b/apps/shield-cli/src/args.rs @@ -67,6 +67,9 @@ pub(crate) enum Commands { Sign(SignArgs), CheckApk { path: PathBuf, + /// 可选 apksigner JAR;用于验证签名和读取证书。 + #[arg(long, value_name = "JAR")] + apksigner: Option, }, CheckAab { path: PathBuf, diff --git a/apps/shield-cli/src/commands.rs b/apps/shield-cli/src/commands.rs index 0bf60d2..2aa30a6 100644 --- a/apps/shield-cli/src/commands.rs +++ b/apps/shield-cli/src/commands.rs @@ -1430,11 +1430,11 @@ fn signing_step_message(step: SigningProgressStep) -> &'static str { } } -pub(crate) fn run_check_apk(path: PathBuf) -> Result { - match check_apk(&path, None) { +pub(crate) fn run_check_apk(path: PathBuf, apksigner: Option) -> Result { + match check_apk(&path, apksigner.as_deref()) { Ok(result) => { let cert_fingerprint = if result.is_signed { - extract_apk_cert_fingerprint(&path, None).ok() + extract_apk_cert_fingerprint(&path, apksigner.as_deref()).ok() } else { None }; diff --git a/apps/shield-cli/src/main.rs b/apps/shield-cli/src/main.rs index 11fe1fe..0dd4dee 100644 --- a/apps/shield-cli/src/main.rs +++ b/apps/shield-cli/src/main.rs @@ -36,8 +36,8 @@ fn execute(cli: Cli) -> Result<()> { Commands::ProtectXop(args) => run_protect_xop(args)?, Commands::ProtectIos(args) => run_protect_ios(args)?, Commands::Sign(args) => run_sign(config.merge_sign(args)?)?, - Commands::CheckApk { path } => { - let result = run_check_apk(path)?; + Commands::CheckApk { path, apksigner } => { + let result = run_check_apk(path, apksigner)?; println!("{result}"); } Commands::CheckAab { diff --git a/crates/shield-core/src/signing.rs b/crates/shield-core/src/signing.rs index f771073..ba1d2ae 100644 --- a/crates/shield-core/src/signing.rs +++ b/crates/shield-core/src/signing.rs @@ -112,6 +112,17 @@ fn sign_apk_with_java( None }; + let final_report = crate::protection_report::report_path(&final_output); + let final_idsig = idsig_path(&final_output); + // 在调用 apksigner 前创建恢复目录:即使输出目录随后被改为只读, + // 已创建的恢复目录仍可写入;它位于输出目录而不是 MCP 任务暂存根目录, + // 因此回滚失败时不会被父进程清理。 + let recovery = if final_report.exists() || final_idsig.exists() { + Some(create_recovery_dir(&output_parent)?) + } else { + None + }; + let apksigner = match &opts.apksigner_path { Some(p) if p.exists() => p.clone(), Some(p) => anyhow::bail!("配置的 apksigner.jar 路径不存在: {}", p.display()), @@ -233,8 +244,6 @@ fn sign_apk_with_java( }; on_progress(SigningProgressStep::Commit).map_err(anyhow::Error::msg)?; - let final_report = crate::protection_report::report_path(&final_output); - let final_idsig = idsig_path(&final_output); let staged_idsig = opts.signing_versions.v4.then_some(staged_idsig); commit_signed_outputs( &output_parent, @@ -244,6 +253,7 @@ fn sign_apk_with_java( &final_output, &final_report, &final_idsig, + recovery, )?; drop(staging_dir); @@ -417,6 +427,14 @@ fn recovery_failure( ) } +fn create_recovery_dir(parent: &Path) -> Result { + tempfile::Builder::new() + .prefix("shield-recovery-") + .tempdir_in(parent) + .context("创建签名恢复目录失败") +} + +#[allow(clippy::too_many_arguments)] fn commit_signed_outputs( parent: &Path, staged_apk: NamedTempFile, @@ -425,12 +443,14 @@ fn commit_signed_outputs( final_output: &Path, final_report: &Path, final_idsig: &Path, + recovery: Option, ) -> Result<()> { - let parent = staging_parent(parent); - let recovery = tempfile::Builder::new() - .prefix("shield-recovery-") - .tempdir_in(&parent) - .context("创建签名恢复目录失败")?; + // 恢复副本必须位于输出目录,而不是 MCP 可回收的任务暂存根目录; + // 否则回滚失败时 CLI 虽然 keep 了副本,父进程清理后仍会把它删掉。 + let recovery = match recovery { + Some(recovery) => recovery, + None => create_recovery_dir(parent)?, + }; let report_backup = backup_attachment(final_report, recovery.path(), "report.json")?; let idsig_backup = backup_attachment(final_idsig, recovery.path(), "idsig")?; @@ -865,6 +885,19 @@ mod tests { } } + #[test] + fn 恢复目录固定在输出目录() { + let dir = tempfile::tempdir().unwrap(); + let recovery = create_recovery_dir(dir.path()).unwrap(); + assert!(recovery.path().starts_with(dir.path())); + assert!(recovery + .path() + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("shield-recovery-")); + } + #[test] fn 提交成功时同时替换_apk_报告和_v4_附件() { let dir = tempfile::tempdir().unwrap(); @@ -885,6 +918,7 @@ mod tests { &final_apk, &final_report, &final_idsig, + None, ) .unwrap(); @@ -910,6 +944,7 @@ mod tests { &final_apk, &final_report, &final_idsig, + None, ) .unwrap(); @@ -939,6 +974,7 @@ mod tests { &final_apk, &final_report, &final_idsig, + None, ) .unwrap_err(); diff --git a/docs/mcp.md b/docs/mcp.md index 14204ec..e883450 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,7 +104,7 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" | 工具 | 类型 | 说明 | |---|---|---| | `shellsmith_version` | 同步 | 返回 CLI 版本与 MCP 版本 | -| `shellsmith_check_apk` | 同步 | 检查 APK 是否已加固、签名状态、证书指纹和 ABI | +| `shellsmith_check_apk` | 同步 | 检查 APK 是否已加固、签名状态、证书指纹和 ABI;可传自定义 `apksigner` | | `shellsmith_check_aab` | 同步 | 检查 AAB 模块;可用 bundletool 校验并生成 APKS | | `shellsmith_check_ios` | 同步 | 检查 Xcode 工程、scheme 与签名配置,不修改工程 | | `shellsmith_check_keystore` | 同步 | 读取 keystore alias 的 SHA-256 证书指纹 | @@ -183,7 +183,7 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" {"name": "shellsmith_job_status", "arguments": {"job_id": "job-1760000000000-1"}} ``` -任务结束后 `structuredContent.result` 包含输出路径和报告路径: +任务结束后 `structuredContent.result` 包含输出路径,以及实际存在的报告路径: ```json { @@ -193,6 +193,8 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" } ``` +普通签名不会生成保护报告;如果产物旁没有 `*.protection.json`,结果会省略 `report` 字段,而不是返回不存在的路径。 + 如果客户端在 `tools/call` 的 `_meta.progressToken` 中提供 token,服务会发送 `notifications/progress`。 ### 读取报告