Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/shellsmith-mcp/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ pub struct CommandPlan {
pub struct JobPlan {
pub commands: Vec<CommandPlan>,
pub result: Value,
/// 期望生成的保护报告;任务成功时若文件不存在,从结果中省略。
pub report: Option<PathBuf>,
/// 自动签名等流程的临时目录,必须活到任务结束。
pub keep: Vec<TempDir>,
}
Expand Down Expand Up @@ -664,11 +666,22 @@ fn run_job(server: Arc<Server>, job: Arc<Mutex<Job>>, plan: JobPlan, done: Arc<A
&job,
JobStatus::Succeeded,
"任务完成".to_string(),
Some(plan.result),
Some(filter_job_result(plan.result, plan.report.as_deref())),
None,
);
}

fn filter_job_result(mut result: Value, report: Option<&Path>) -> 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<AtomicBool>);

impl Drop for DoneGuard {
Expand Down
54 changes: 44 additions & 10 deletions apps/shellsmith-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ pub fn tool_definitions() -> Vec<Value> {
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(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -619,6 +634,7 @@ fn dispatch(
#[derive(Debug, Deserialize)]
struct CheckPathInput {
path: String,
apksigner: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CheckIosInput {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -1222,6 +1243,7 @@ fn protect_xop(
label: "XOP 单壳加固".into(),
}],
result: json!({"output": output}),
report: None,
keep,
},
progress_token,
Expand Down Expand Up @@ -1289,6 +1311,7 @@ fn protect_ios(
label: "iOS 保护".into(),
}],
result: json!({"output": output}),
report: None,
keep: Vec::new(),
},
progress_token,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -1385,6 +1410,7 @@ fn install_apks(
label: "安装 APKS".into(),
}],
result: json!({"apks": apks}),
report: None,
keep: Vec::new(),
},
progress_token,
Expand Down Expand Up @@ -1469,6 +1495,7 @@ fn ensure_sign_certificate_binding(
input_apk: &Path,
sign: &SignInput,
requested: Option<&str>,
apksigner: Option<&str>,
) -> Result<String> {
let keystore = resolve_sign_keystore(server, sign)?;
let alias = sign.key_alias.as_deref().context("签名缺少 key_alias")?;
Expand All @@ -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 {
Expand Down
116 changes: 115 additions & 1 deletion apps/shellsmith-mcp/tests/mcp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -960,13 +1026,61 @@ 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())),
"原地签名必须把输入路径同时作为输出传给 CLI: {calls}"
);
}

#[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();
Expand Down
3 changes: 3 additions & 0 deletions apps/shield-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub(crate) enum Commands {
Sign(SignArgs),
CheckApk {
path: PathBuf,
/// 可选 apksigner JAR;用于验证签名和读取证书。
#[arg(long, value_name = "JAR")]
apksigner: Option<PathBuf>,
},
CheckAab {
path: PathBuf,
Expand Down
6 changes: 3 additions & 3 deletions apps/shield-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1430,11 +1430,11 @@ fn signing_step_message(step: SigningProgressStep) -> &'static str {
}
}

pub(crate) fn run_check_apk(path: PathBuf) -> Result<String> {
match check_apk(&path, None) {
pub(crate) fn run_check_apk(path: PathBuf, apksigner: Option<PathBuf>) -> Result<String> {
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
};
Expand Down
4 changes: 2 additions & 2 deletions apps/shield-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading