diff --git a/.github/release-notes/versions/1.8.1.md b/.github/release-notes/versions/1.8.1.md new file mode 100644 index 0000000..a95e8f8 --- /dev/null +++ b/.github/release-notes/versions/1.8.1.md @@ -0,0 +1,11 @@ +### PVM2 零转换失败诊断修复 + +- 修复“已匹配到业务类,但没有可转换的方法”时,实际跳过原因和覆盖率报告随临时目录丢失的问题。现在会显示匹配类数、方法数及分类原因,并在输出目录保留独立的失败报告。 +- APK、AAB 与命令行共用报告保留逻辑;桌面端复制诊断摘要只包含固定原因分类和数量,不包含路径、证书、密码、包名或业务方法名。 +- 修正构造器、静态初始化器及无代码方法被类级排除原因覆盖的问题;报告无法读取或保存时明确提示,避免继续指向不存在的报告。 +- 增加 Native 方法、界面类排除和寄存器超限等零转换场景的真实打包器回归,并验证 APK/AAB 失败报告保留与摘要脱敏。 + +### 使用说明 + +- 本版修复失败原因丢失和诊断不准确,不改变 PVM2 方法转换的支持范围。仍无可转换方法时会阻断交付,不会静默关闭保护。 +- 遇到同类报错,请升级后重新执行,并优先提供复制的诊断摘要。输出目录中的完整失败报告可能包含业务类和方法信息,分享前请手动脱敏。 diff --git a/Cargo.lock b/Cargo.lock index 53d706b..22188ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "mocika-shield" -version = "1.8.0" +version = "1.8.1" dependencies = [ "dunce", "reqwest 0.12.28", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "mocikashield" -version = "1.8.0" +version = "1.8.1" dependencies = [ "cc", "chacha20poly1305", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "shield-cli" -version = "1.8.0" +version = "1.8.1" dependencies = [ "anyhow", "clap", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "shield-core" -version = "1.8.0" +version = "1.8.1" dependencies = [ "anyhow", "chacha20poly1305", diff --git a/apps/shield-cli/Cargo.toml b/apps/shield-cli/Cargo.toml index ada8488..641c7c4 100644 --- a/apps/shield-cli/Cargo.toml +++ b/apps/shield-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-cli" -version = "1.8.0" +version = "1.8.1" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/apps/shield-cli/src/commands.rs b/apps/shield-cli/src/commands.rs index c1c0a00..c8d4017 100644 --- a/apps/shield-cli/src/commands.rs +++ b/apps/shield-cli/src/commands.rs @@ -448,7 +448,11 @@ pub fn run_protect_aab_with_control( progress(&step, &event.message); }, Arc::clone(&cancel), - )?; + ) + .map_err(|mut error| { + shield_core::protection_report::Pvm2Failure::preserve(&mut error, &args.output); + error + })?; } ProtectionEngineArg::Xop => { let packer = args diff --git a/apps/shield-gui/package-lock.json b/apps/shield-gui/package-lock.json index 5eaae2e..a1ed75b 100644 --- a/apps/shield-gui/package-lock.json +++ b/apps/shield-gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "mocika-shield-gui", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mocika-shield-gui", - "version": "1.8.0", + "version": "1.8.1", "license": "MIT OR Apache-2.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.19", diff --git a/apps/shield-gui/package.json b/apps/shield-gui/package.json index 126622a..e67bfa7 100644 --- a/apps/shield-gui/package.json +++ b/apps/shield-gui/package.json @@ -1,6 +1,6 @@ { "name": "mocika-shield-gui", - "version": "1.8.0", + "version": "1.8.1", "license": "MIT OR Apache-2.0", "private": true, "type": "module", diff --git a/apps/shield-gui/src-tauri/Cargo.toml b/apps/shield-gui/src-tauri/Cargo.toml index 9ee0b2f..da1cb16 100644 --- a/apps/shield-gui/src-tauri/Cargo.toml +++ b/apps/shield-gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocika-shield" -version = "1.8.0" +version = "1.8.1" edition = "2021" license = "MIT OR Apache-2.0" publish = false diff --git a/apps/shield-gui/src-tauri/src/aab_runner.rs b/apps/shield-gui/src-tauri/src/aab_runner.rs index 6d5475e..5ba6d6c 100644 --- a/apps/shield-gui/src-tauri/src/aab_runner.rs +++ b/apps/shield-gui/src-tauri/src/aab_runner.rs @@ -172,7 +172,7 @@ pub(crate) async fn execute_protect_aab( if cancel.load(Ordering::Relaxed) { ExecutionFailure::cancelled() } else { - ExecutionFailure::from(format!("{error:#}")) + ExecutionFailure::from_source(format!("{error:#}"), error.as_ref()) } })?; task_manager diff --git a/apps/shield-gui/src-tauri/src/failure_diagnostic.rs b/apps/shield-gui/src-tauri/src/failure_diagnostic.rs index 49e2e09..0084290 100644 --- a/apps/shield-gui/src-tauri/src/failure_diagnostic.rs +++ b/apps/shield-gui/src-tauri/src/failure_diagnostic.rs @@ -1,6 +1,7 @@ //! 将任务失败映射为受控诊断;不负责发送或持久化原始错误。 use serde::{Deserialize, Serialize}; use shield_core::diagnostic::{Diagnostic, FailureCode, ToolName, CLASSIFIER_VERSION}; +use shield_core::protection_report::{Pvm2Failure, Pvm2FailureSummary}; #[derive(Clone, Debug)] pub(crate) struct ExecutionFailure { @@ -9,6 +10,7 @@ pub(crate) struct ExecutionFailure { pub(crate) cancelled: bool, /// 空字段等前置输入校验不属于已执行操作失败。 pub(crate) executed: bool, + pub(crate) pvm2_failure: Option, } impl ExecutionFailure { @@ -18,6 +20,7 @@ impl ExecutionFailure { diagnostic: Diagnostic::new(FailureCode::Unknown), cancelled: true, executed: false, + pvm2_failure: None, } } pub(crate) fn diagnosed(message: String, diagnostic: Diagnostic) -> Self { @@ -26,6 +29,7 @@ impl ExecutionFailure { diagnostic, cancelled: false, executed: true, + pvm2_failure: None, } } pub(crate) fn preflight(message: String, diagnostic: Diagnostic) -> Self { @@ -34,16 +38,23 @@ impl ExecutionFailure { diagnostic, cancelled: false, executed: false, + pvm2_failure: None, } } pub(crate) fn from_shield(error: shield_core::ShieldError) -> Self { + let pvm2_failure = match &error { + shield_core::ShieldError::Other(source) => source + .downcast_ref::() + .map(|f| f.summary.clone()), + _ => None, + }; let preflight = match &error { shield_core::ShieldError::Other(source) => { shield_core::diagnostic::is_preflight_error(source) } _ => false, }; - match Diagnostic::from_shield(&error) { + let mut failure = match Diagnostic::from_shield(&error) { Some(diagnostic) if preflight => Self::preflight(error.to_string(), diagnostic), Some(diagnostic) => Self::diagnosed(error.to_string(), diagnostic), None => Self { @@ -51,8 +62,34 @@ impl ExecutionFailure { diagnostic: Diagnostic::new(FailureCode::Unknown), cancelled: true, executed: false, + pvm2_failure: None, }, + }; + failure.pvm2_failure = pvm2_failure; + failure + } + + pub(crate) fn from_source(message: String, error: &(dyn std::error::Error + 'static)) -> Self { + let mut failure = Self::from(message); + let mut cause = Some(error); + while let Some(value) = cause { + if let Some(pvm2) = value.downcast_ref::() { + failure.pvm2_failure = Some(pvm2.summary.clone()); + break; + } + if let Some(shield_core::ShieldError::Other(source)) = + value.downcast_ref::() + { + failure.pvm2_failure = source + .downcast_ref::() + .map(|f| f.summary.clone()); + if failure.pvm2_failure.is_some() { + break; + } + } + cause = value.source(); } + failure } } impl From for ExecutionFailure { @@ -62,6 +99,7 @@ impl From for ExecutionFailure { diagnostic: Diagnostic::new(FailureCode::Unknown), cancelled: false, executed: true, + pvm2_failure: None, } } } diff --git a/apps/shield-gui/src-tauri/src/ios_runner.rs b/apps/shield-gui/src-tauri/src/ios_runner.rs index 1bb2198..3336b14 100644 --- a/apps/shield-gui/src-tauri/src/ios_runner.rs +++ b/apps/shield-gui/src-tauri/src/ios_runner.rs @@ -93,7 +93,13 @@ pub(crate) async fn protect_ios_project( Err(message) if message.contains("已取消") => TaskStatus::Cancelled, Err(_) => TaskStatus::Failed, }; - let _ = task_manager.finish(&window, &options.1, status, result.as_ref().err().cloned()); + let _ = task_manager.finish( + &window, + &options.1, + status, + result.as_ref().err().cloned(), + None, + ); result } @@ -146,7 +152,13 @@ pub(crate) async fn prepare_ios_sdk( Err(message) if message.contains("已取消") => TaskStatus::Cancelled, Err(_) => TaskStatus::Failed, }; - let _ = task_manager.finish(&window, &task_id, status, result.as_ref().err().cloned()); + let _ = task_manager.finish( + &window, + &task_id, + status, + result.as_ref().err().cloned(), + None, + ); result } diff --git a/apps/shield-gui/src-tauri/src/task_completion.rs b/apps/shield-gui/src-tauri/src/task_completion.rs index ba76316..b866f8b 100644 --- a/apps/shield-gui/src-tauri/src/task_completion.rs +++ b/apps/shield-gui/src-tauri/src/task_completion.rs @@ -28,6 +28,10 @@ pub(crate) fn finish_task( task_id, status, result.as_ref().err().map(|error| error.message.clone()), + result + .as_ref() + .err() + .and_then(|error| error.pvm2_failure.clone()), )? { window .state::() diff --git a/apps/shield-gui/src-tauri/src/task_manager.rs b/apps/shield-gui/src-tauri/src/task_manager.rs index a268bc7..4ade92a 100644 --- a/apps/shield-gui/src-tauri/src/task_manager.rs +++ b/apps/shield-gui/src-tauri/src/task_manager.rs @@ -44,6 +44,7 @@ pub(crate) struct TaskSnapshot { pub(crate) finished_at_ms: Option, pub(crate) logs: Vec, pub(crate) error: Option, + pub(crate) pvm2_failure: Option, #[serde(skip)] pub(crate) protect_succeeded: bool, #[serde(skip)] @@ -103,6 +104,7 @@ impl TaskManager { message: "任务已开始".to_string(), }], error: None, + pvm2_failure: None, protect_succeeded: false, signing_started: false, }; @@ -158,6 +160,7 @@ impl TaskManager { task_id: &str, status: TaskStatus, error: Option, + pvm2_failure: Option, ) -> Result { let mut tasks = self.0.lock().map_err(|_| "任务状态锁已损坏".to_string())?; let task = tasks @@ -169,6 +172,7 @@ impl TaskManager { } task.finished_at_ms = Some(now); task.error = error.clone(); + task.pvm2_failure = pvm2_failure; task.logs.push(TaskLog { timestamp_ms: now, step: task.current_step.clone(), diff --git a/apps/shield-gui/src-tauri/src/updates.rs b/apps/shield-gui/src-tauri/src/updates.rs index 361dd8e..b0d6767 100644 --- a/apps/shield-gui/src-tauri/src/updates.rs +++ b/apps/shield-gui/src-tauri/src/updates.rs @@ -357,7 +357,13 @@ pub(crate) async fn install_update( Ok(()) } Err(error) => { - let _ = tasks.finish(&window, &task_id, TaskStatus::Failed, Some(error.clone())); + let _ = tasks.finish( + &window, + &task_id, + TaskStatus::Failed, + Some(error.clone()), + None, + ); Err(error) } } diff --git a/apps/shield-gui/src-tauri/tauri.conf.json b/apps/shield-gui/src-tauri/tauri.conf.json index aad930d..1ba2166 100644 --- a/apps/shield-gui/src-tauri/tauri.conf.json +++ b/apps/shield-gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Shellsmith", - "version": "1.8.0", + "version": "1.8.1", "identifier": "dev.mocika.shield-gui", "build": { "beforeDevCommand": "npm run dev -- --host 127.0.0.1", diff --git a/apps/shield-gui/src/components/app/common.tsx b/apps/shield-gui/src/components/app/common.tsx index ec438c1..911f516 100644 --- a/apps/shield-gui/src/components/app/common.tsx +++ b/apps/shield-gui/src/components/app/common.tsx @@ -142,7 +142,7 @@ export function StatusMessage({ )} /> )} -
{children}
+
{children}
{action} diff --git a/apps/shield-gui/src/hooks/use-about-page.ts b/apps/shield-gui/src/hooks/use-about-page.ts index 9aef590..92a2ed7 100644 --- a/apps/shield-gui/src/hooks/use-about-page.ts +++ b/apps/shield-gui/src/hooks/use-about-page.ts @@ -9,7 +9,7 @@ type AppInfo = { }; const defaultAppInfo: AppInfo = { - version: "1.8.0", + version: "1.8.1", git_hash: "dev", build_date: "unknown", }; diff --git a/apps/shield-gui/src/hooks/use-protect-workflow.ts b/apps/shield-gui/src/hooks/use-protect-workflow.ts index ef5eec2..634e9e7 100644 --- a/apps/shield-gui/src/hooks/use-protect-workflow.ts +++ b/apps/shield-gui/src/hooks/use-protect-workflow.ts @@ -54,6 +54,7 @@ export function useProtectWorkflow({ const [dragActive, setDragActive] = useState(false); const [warning, setWarning] = useState(""); const [error, setError] = useState(""); + const [pvm2Failure, setPvm2Failure] = useState(null); const [precheck, setPrecheck] = useState(""); const [preflight, setPreflight] = useState(null); const [runtimeUi, setRuntimeUi] = useState(defaults.runtime_ui ?? defaultRuntimeUi); @@ -119,6 +120,7 @@ export function useProtectWorkflow({ setFilenameEdited(false); setState("idle"); setError(""); + setPvm2Failure(null); setPrecheck(""); setPreflight(null); setWarning(""); @@ -157,6 +159,7 @@ export function useProtectWorkflow({ } setWarning(""); setError(""); + setPvm2Failure(null); setPrecheck(""); setPreflight(null); setCurrentStep(""); @@ -219,6 +222,7 @@ export function useProtectWorkflow({ onTauriEvent("task-state", (payload) => { if (payload.kind !== "protect" || payload.task_id !== taskId.current) return; setCurrentStep(payload.current_step); + setPvm2Failure(payload.pvm2_failure ?? null); setCurrentDetail(payload.logs.at(-1)?.message ?? ""); setStartedAt(payload.started_at_ms); setFinishedAt(payload.finished_at_ms ?? null); @@ -292,6 +296,7 @@ export function useProtectWorkflow({ started = true; setExcludedAbis(exclusions); setError(""); + setPvm2Failure(null); setCurrentStep("CheckTools"); setCurrentDetail(""); setStartedAt(Date.now()); @@ -365,6 +370,7 @@ export function useProtectWorkflow({ dragActive, warning, error, + pvm2Failure, precheck, preflight, runtimeMode, diff --git a/apps/shield-gui/src/lib/protect-diagnostic.ts b/apps/shield-gui/src/lib/protect-diagnostic.ts index 8353205..5d41681 100644 --- a/apps/shield-gui/src/lib/protect-diagnostic.ts +++ b/apps/shield-gui/src/lib/protect-diagnostic.ts @@ -1,4 +1,4 @@ -import type { ApkCheckResult } from "@/lib/tauri"; +import type { ApkCheckResult, Pvm2FailureSummary } from "@/lib/tauri"; import type { Locale } from "@/lib/i18n"; export function buildProtectDiagnostic({ @@ -11,6 +11,7 @@ export function buildProtectDiagnostic({ xopPvm2Configured, currentStep, failed, + pvm2Failure, }: { preflight: ApkCheckResult | null; locale: Locale; @@ -21,6 +22,7 @@ export function buildProtectDiagnostic({ xopPvm2Configured: boolean; currentStep: string; failed: boolean; + pvm2Failure?: Pvm2FailureSummary | null; }) { const checks = preflight?.checks .filter((check) => check.severity !== "ready") @@ -38,9 +40,47 @@ export function buildProtectDiagnostic({ `${english ? "Current stage" : "当前阶段"}: ${currentStep || (english ? "not started" : "未开始")}`, `${english ? "Preflight verdict" : "预检结论"}: ${verdictLabel(locale, preflight?.verdict)}`, checks ? `${english ? "Risk items" : "风险项"}:\n${checks}` : `${english ? "Risk items" : "风险项"}: ${english ? "none" : "无"}`, + ...(failed && pvm2Failure ? [buildPvm2FailureDiagnostic(pvm2Failure, locale)] : []), english - ? "Note: This summary excludes APK and output paths, certificates, passwords, package names, business class names, and raw error text. Also include diagnostic information from the About page. Manually redact any logs before sharing." - : "说明: 本摘要不包含 APK 路径、输出路径、证书、密码、包名、业务类名或原始错误文本。请同时附上“关于”页面的诊断信息;如需提供日志,请先手动脱敏。", + ? "Note: This summary excludes paths, certificates, passwords, package names, business class names, and raw error text. Include your app version; Settings → Feedback attaches environment diagnostics. Method reports contain business identifiers; redact before sharing." + : "说明: 本摘要不包含路径、证书、密码、包名、业务类名或原始错误文本。反馈时请补充软件版本;设置 → 问题反馈可附带环境诊断。逐方法报告含业务标识,分享前请手动脱敏。", + ].join("\n"); +} + +export function buildPvm2FailureDiagnostic(summary: Pvm2FailureSummary, locale: Locale) { + const english = locale === "en"; + const labels: Record = { + no_code: ["无 DEX 方法体(Native/抽象方法)", "No DEX body (native/abstract)"], + initializer: ["构造器/静态初始化器", "Initializer"], + ui_runtime_glue: ["界面/生命周期兼容规则", "UI/lifecycle compatibility"], + data_resource_runtime_glue: ["数据/资源框架兼容规则", "Data/resource compatibility"], + compiler_generated_bridge: ["编译器生成桥接类", "Compiler-generated bridge"], + playback_compatibility: ["媒体/辅助类兼容规则", "Media/helper compatibility"], + nullable_receiver: ["接收者非空语义无法证明", "Nullable receiver"], + unsupported_opcode: ["不支持的指令", "Unsupported opcode"], + register_limit: ["寄存器超限", "Register limit"], + code_size_limit: ["方法大小超限", "Code size limit"], + exception_mapping: ["异常处理映射不支持", "Exception mapping"], + control_flow: ["控制流不支持", "Control flow"], + type: ["类型不支持", "Unsupported type"], + not_attempted: ["未进入转换", "Not attempted"], + other: ["其他原因", "Other"], + }; + const code = `${english ? "Failure code" : "失败代码"}: PVM2_ZERO_METHODS`; + const counts = [summary.selected_classes, summary.selected_methods]; + const reasons = Object.keys(labels) as (keyof typeof labels)[]; + // 白名单重建分享文本;不能把运行日志、报告路径或未知原因字符串拼进摘要。 + const values = reasons.map((reason) => summary.reasons?.[reason] ?? 0); + if (![...counts, ...values].every((n) => Number.isSafeInteger(n) && n >= 0) + || summary.selected_classes > summary.selected_methods + || values.reduce((a, b) => a + b, 0) !== summary.selected_methods) { + return `${code}\n${english ? "Reason counts unavailable" : "原因统计无效,无法展示数量"}`; + } + return [code, + `${english ? "Classes in method inventory" : "方法清单涉及类数"}: ${summary.selected_classes}`, + `${english ? "Selected methods" : "所选方法数"}: ${summary.selected_methods}`, + `${english ? "Transformed methods" : "转换成功方法数"}: 0`, + ...reasons.flatMap((reason, i) => values[i] > 0 ? [`- ${reason} (${labels[reason][english ? 1 : 0]}): ${values[i]}`] : []), ].join("\n"); } diff --git a/apps/shield-gui/src/lib/tauri.ts b/apps/shield-gui/src/lib/tauri.ts index be116d3..86b46f7 100644 --- a/apps/shield-gui/src/lib/tauri.ts +++ b/apps/shield-gui/src/lib/tauri.ts @@ -337,6 +337,13 @@ export type TaskSnapshot = { finished_at_ms?: number | null; logs: TaskLog[]; error?: string | null; + pvm2_failure?: Pvm2FailureSummary | null; +}; + +export type Pvm2FailureSummary = { + selected_classes: number; + selected_methods: number; + reasons: Partial>; }; export type DragDropPayload = { diff --git a/apps/shield-gui/src/pages/aab-protect-page.tsx b/apps/shield-gui/src/pages/aab-protect-page.tsx index c034aad..76caac3 100644 --- a/apps/shield-gui/src/pages/aab-protect-page.tsx +++ b/apps/shield-gui/src/pages/aab-protect-page.tsx @@ -2,9 +2,11 @@ import { RuntimeUiSettings } from "@/components/app/runtime-ui-settings"; import { defaultRuntimeUi } from "@/lib/tauri"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FolderOpen, RotateCcw, Square } from "lucide-react"; +import { Clipboard, FolderOpen, RotateCcw, Square } from "lucide-react"; import { AppButton, DropZone, SelectedApkCard, SelectInput, StatusMessage, TextInput } from "@/components/app/common"; import { ProtectProgressPanel } from "@/components/app/protect-progress-panel"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { buildPvm2FailureDiagnostic } from "@/lib/protect-diagnostic"; import { notifyError, notifySuccess } from "@/lib/notify"; import { dirname, @@ -55,6 +57,8 @@ export function AabProtectPage({ const [inspection, setInspection] = useState(null); const [state, setState] = useState("idle"); const [error, setError] = useState(""); + const [pvm2Failure, setPvm2Failure] = useState(null); + const { copy } = useClipboard(locale); const [warning, setWarning] = useState(""); const [dragActive, setDragActive] = useState(false); const [selectedCertificateId, setSelectedCertificateId] = useState(""); @@ -107,6 +111,7 @@ export function AabProtectPage({ setState("prechecking"); setInspection(null); setError(""); + setPvm2Failure(null); try { const result = await api.checkAab(path); if (sequence !== checkSequence.current) return; @@ -139,6 +144,7 @@ export function AabProtectPage({ setInspection(null); setWarning(""); setError(""); + setPvm2Failure(null); setPrefixesText(""); setPvm2RequiredMethods(""); setCurrentStep(""); @@ -171,6 +177,7 @@ export function AabProtectPage({ const unlisten = onTauriEvent("task-state", (task) => { if (task.kind !== "protect" || task.task_id !== taskId.current) return; setCurrentStep(task.current_step); + setPvm2Failure(task.pvm2_failure ?? null); setCurrentDetail(task.logs.at(-1)?.message ?? ""); setStartedAt(task.started_at_ms); setFinishedAt(task.finished_at_ms ?? null); @@ -194,6 +201,7 @@ export function AabProtectPage({ taskId.current = crypto.randomUUID(); setState("running"); setError(""); + setPvm2Failure(null); setCurrentStep("CheckTools"); setCurrentDetail(""); setStartedAt(Date.now()); @@ -236,6 +244,7 @@ export function AabProtectPage({ setInspection(null); setState("idle"); setError(""); + setPvm2Failure(null); setWarning(""); setPrefixesText(""); setPvm2RequiredMethods(""); @@ -301,7 +310,7 @@ export function AabProtectPage({ )} {state === "done" && void api.showInFolder(output)}>{t(locale, "showInFolder")}}>{t(locale, "aabProtectCompleted")}} - {error && {t(locale, "errorDetail")}:{error}} + {error && void copy(buildPvm2FailureDiagnostic(pvm2Failure, locale))}>{t(locale, "copyDiagnosticSummary")}}>{t(locale, "errorDetail")}:{error}} {state === "running" || state === "done" ? ( diff --git a/apps/shield-gui/src/pages/protect-page.tsx b/apps/shield-gui/src/pages/protect-page.tsx index c32cbef..d911e45 100644 --- a/apps/shield-gui/src/pages/protect-page.tsx +++ b/apps/shield-gui/src/pages/protect-page.tsx @@ -148,6 +148,7 @@ export function ProtectPage({ xopPvm2Configured: pvmReady, currentStep: workflow.currentStep, failed: workflow.state === "failed", + pvm2Failure: workflow.pvm2Failure, })); const diagnosticCopyLabel = copiedLabel === t(locale, "copied") ? copiedLabel : t(locale, "copyDiagnosticSummary"); const minSdk = workflow.preflight?.facts.min_sdk; diff --git a/apps/shield-gui/tests/protect-diagnostic.test.mjs b/apps/shield-gui/tests/protect-diagnostic.test.mjs new file mode 100644 index 0000000..22d3838 --- /dev/null +++ b/apps/shield-gui/tests/protect-diagnostic.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { createServer } from "vite"; + +const server = await createServer({ server: { middlewareMode: true }, appType: "custom", optimizeDeps: { noDiscovery: true, include: [] } }); +after(() => server.close()); +const { buildProtectDiagnostic, buildPvm2FailureDiagnostic } = await server.ssrLoadModule("/src/lib/protect-diagnostic.ts"); +const failure = { + selected_classes: 2, + selected_methods: 4, + reasons: { no_code: 1, initializer: 1, ui_runtime_glue: 1, register_limit: 1 }, +}; + +test("失败摘要显示实际跳过统计且不复制业务标识或未知字段", () => { + const summary = buildProtectDiagnostic({ + preflight: null, locale: "zh", runtimeMode: "standard", environmentPolicy: "strict", + protectionProfile: "strict", aiResistance: "high", xopPvm2Configured: true, + currentStep: "ProcessDex", failed: true, + pvm2Failure: { ...failure, path: "/Users/private/report.json", descriptor: "Lprivate/Business;", + reasons: { ...failure.reasons, "private password=secret": 1 } }, + }); + assert.match(summary, /PVM2_ZERO_METHODS/); + assert.match(summary, /所选方法数: 4/); + assert.match(summary, /转换成功方法数: 0/); + assert.match(summary, /ui_runtime_glue .*: 1/); + assert.match(summary, /register_limit .*: 1/); + assert.doesNotMatch(summary, /private|Business|password|secret|report\.json/); + assert.doesNotMatch(summary, /关于.*诊断/); +}); + +test("AAB 与 APK 复用相同脱敏格式,异常计数不进入分享内容", () => { + const english = buildPvm2FailureDiagnostic(failure, "en"); + assert.match(english, /Selected methods: 4/); + assert.match(english, /Transformed methods: 0/); + for (const count of [-1, NaN, Infinity, "private", Number.MAX_SAFE_INTEGER + 1]) { + const text = buildPvm2FailureDiagnostic({ ...failure, selected_methods: count }, "zh"); + assert.match(text, /原因统计无效/); + assert.doesNotMatch(text, /private|NaN|Infinity/); + } + assert.match(buildPvm2FailureDiagnostic({ ...failure, selected_methods: 5 }, "zh"), /原因统计无效/); +}); + +test("普通预检不混入上一任务的失败统计", () => { + const text = buildProtectDiagnostic({ preflight: null, locale: "zh", runtimeMode: "standard", + environmentPolicy: "compatible", protectionProfile: "balanced", aiResistance: "balanced", + xopPvm2Configured: false, currentStep: "", failed: false, pvm2Failure: failure }); + assert.doesNotMatch(text, /PVM2_ZERO_METHODS/); +}); diff --git a/crates/shield-core/Cargo.toml b/crates/shield-core/Cargo.toml index 116ad21..2deea88 100644 --- a/crates/shield-core/Cargo.toml +++ b/crates/shield-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-core" -version = "1.8.0" +version = "1.8.1" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/crates/shield-core/src/protect/xop_pvm2.rs b/crates/shield-core/src/protect/xop_pvm2.rs index d7f6efb..10d091d 100644 --- a/crates/shield-core/src/protect/xop_pvm2.rs +++ b/crates/shield-core/src/protect/xop_pvm2.rs @@ -586,6 +586,13 @@ pub(crate) fn transform( }, )?; if !output.status.success() { + if output.stdout.contains("selected zero methods") + || output.stderr.contains("selected zero methods") + { + if let Ok(failure) = crate::protection_report::Pvm2Failure::zero_methods(&report_path) { + return Err(failure.into()); + } + } anyhow::bail!(transform_failure_message(&output.stdout, &output.stderr)); } let code = fs::read(&code_out).context("Xop 未生成 PVM2 code.bin")?; @@ -919,7 +926,7 @@ fn optional_usize(values: &BTreeMap, key: &str) -> Result fn transform_failure_message(stdout: &str, stderr: &str) -> String { if stdout.contains("selected zero methods") || stderr.contains("selected zero methods") { - return "Xop PVM2 已匹配到业务类,但没有可转换的方法。构造器和静态初始化器不会转换;含当前不支持的 invoke-custom/polymorphic 指令、超过寄存器/代码大小上限或异常处理表无法映射的方法会被跳过。请改选包含普通自有业务逻辑的类前缀,或查看 PVM2 覆盖率报告" + return "PVM2_ZERO_METHODS:没有可转换的方法,且未取得有效的逐方法失败报告,无法确定具体跳过原因。请确认使用配套的内置打包器;当前信息不能认定是类前缀或指令问题" .to_string(); } @@ -1102,11 +1109,86 @@ mod tests { "java.lang.IllegalStateException: pvm2-transform selected zero methods; provide a matching --true-vmp-prefix\n\tat com.example.Packer.main(Packer.java:1)", ); assert!(message.contains("没有可转换的方法")); - assert!(message.contains("覆盖率报告")); + assert!(message.contains("未取得有效的逐方法失败报告")); assert!(!message.contains("java.lang")); assert!(!message.contains("Packer.java")); } + #[test] + #[ignore = "需要 JDK 17 与从当前源码构建的 tools/xop-pvm2-packer.jar"] + fn 真实打包器零转换经共享核心返回分类并保留失败报告() { + use crate::protection_report::{Pvm2Failure, Pvm2SkipReason}; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let jar = root + .join("tools/xop-pvm2-packer.jar") + .canonicalize() + .unwrap(); + let work = tempfile::tempdir().unwrap(); + let classes = work.path().join("classes"); + fs::create_dir(&classes).unwrap(); + assert!(Command::new("javac") + .args(["--release", "17", "-cp"]) + .arg(&jar) + .arg("-d") + .arg(&classes) + .arg(root.join("tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java")) + .output() + .unwrap() + .status + .success()); + let classpath = std::env::join_paths([classes, jar.clone()]).unwrap(); + let generated = Command::new("java") + .args(["-ea", "-cp"]) + .arg(classpath) + .arg("com.yqsh.protector.packer.Pvm2ProtectionCheck") + .arg(work.path()) + .output() + .unwrap(); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + for (kind, expected, count) in [ + ("native", Pvm2SkipReason::NoCode, 2), + ("ui", Pvm2SkipReason::UiRuntimeGlue, 3), + ("registers", Pvm2SkipReason::RegisterLimit, 3), + ] { + let dex = work.path().join(format!("zero-{kind}/dex")); + fs::write( + dex.join("AndroidManifest.xml"), + "", + ) + .unwrap(); + let error = transform( + Path::new("java"), + &jar, + &dex, + &[1; 32], + &"a".repeat(64), + ProtectionProfile::Strict, + &["Lfixture/".into()], + &Default::default(), + &BTreeMap::from([("classes.dex".into(), "base".into())]), + "fixture.Bridge", + "interpret", + &AtomicBool::new(false), + &|_| {}, + ) + .unwrap_err(); + let failure = error + .downcast_ref::() + .expect("失败统计不能被字符串化丢失"); + assert_eq!(failure.summary.selected_methods, count); + assert_eq!(failure.summary.reasons[&expected], 1); + let mut error = crate::ShieldError::from(error); + Pvm2Failure::preserve(&mut error, &work.path().join(format!("{kind}.apk"))); + assert!(error.to_string().contains("本地逐方法失败报告")); + assert!(!error.to_string().contains("Lfixture/")); + assert!(!work.path().join(format!("{kind}.apk")).exists()); + } + } + #[test] fn parses_coverage_and_morph_report() { let report = parse_transform_report( diff --git a/crates/shield-core/src/protect_api.rs b/crates/shield-core/src/protect_api.rs index 904ce63..547a014 100644 --- a/crates/shield-core/src/protect_api.rs +++ b/crates/shield-core/src/protect_api.rs @@ -161,7 +161,10 @@ pub fn protect_apk( .map_err(ShieldError::from)?; let mut working = opts.clone(); working.output = staged.path().to_owned(); - let report = protect_apk_inner(&working, on_progress, cancel)?; + let report = protect_apk_inner(&working, on_progress, cancel).map_err(|mut error| { + crate::protection_report::Pvm2Failure::preserve(&mut error, &opts.output); + error + })?; let has_report = report.is_some(); if let Some(report) = report { report diff --git a/crates/shield-core/src/protection_report.rs b/crates/shield-core/src/protection_report.rs index dac232f..f27fbc5 100644 --- a/crates/shield-core/src/protection_report.rs +++ b/crates/shield-core/src/protection_report.rs @@ -49,6 +49,256 @@ pub(crate) struct MethodReport { pub native_vmp: Vec, } +/// 仅允许固定分类和数量进入可分享摘要,原始原因只保存在本地逐方法报告。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Pvm2SkipReason { + NoCode, + Initializer, + UiRuntimeGlue, + DataResourceRuntimeGlue, + CompilerGeneratedBridge, + PlaybackCompatibility, + NullableReceiver, + UnsupportedOpcode, + RegisterLimit, + CodeSizeLimit, + ExceptionMapping, + ControlFlow, + Type, + NotAttempted, + Other, +} + +impl Pvm2SkipReason { + fn classify(reason: &str) -> Self { + match reason { + "no_code" => Self::NoCode, + "initializer" => Self::Initializer, + "ui_runtime_glue" => Self::UiRuntimeGlue, + "data_resource_runtime_glue" => Self::DataResourceRuntimeGlue, + "compiler_generated_bridge" => Self::CompilerGeneratedBridge, + "playback_compatibility" => Self::PlaybackCompatibility, + "not_attempted" => Self::NotAttempted, + "too large" => Self::CodeSizeLimit, + r if r.contains("unsupported opcode 0x") => Self::UnsupportedOpcode, + r if r.contains("too many regs") => Self::RegisterLimit, + r if r.contains("nullable receiver") || r.contains("receiver register") => { + Self::NullableReceiver + } + r if r.contains("branch") => Self::ControlFlow, + r if r.contains("try ") || r.contains("tries") || r.contains("handler") => { + Self::ExceptionMapping + } + r if r.contains("unsupported return") || r.contains("argument type") => Self::Type, + _ => Self::Other, + } + } + + fn label(self) -> &'static str { + match self { + Self::NoCode => "无 DEX 方法体(Native/抽象方法)", + Self::Initializer => "构造器/静态初始化器", + Self::UiRuntimeGlue => "界面/生命周期兼容规则", + Self::DataResourceRuntimeGlue => "数据/资源框架兼容规则", + Self::CompilerGeneratedBridge => "编译器生成桥接类", + Self::PlaybackCompatibility => "媒体/辅助类兼容规则", + Self::NullableReceiver => "接收者非空语义无法证明", + Self::UnsupportedOpcode => "不支持的指令", + Self::RegisterLimit => "寄存器超限", + Self::CodeSizeLimit => "方法大小超限", + Self::ExceptionMapping => "异常处理映射不支持", + Self::ControlFlow => "控制流不支持", + Self::Type => "类型不支持", + Self::NotAttempted => "未进入转换", + Self::Other => "其他原因(详见本地报告)", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Pvm2FailureSummary { + /// 逐方法清单涉及的类,不计没有任何方法的空类。 + pub selected_classes: usize, + pub selected_methods: usize, + pub reasons: BTreeMap, +} + +#[derive(Debug)] +pub struct Pvm2Failure { + pub summary: Pvm2FailureSummary, + report: MethodReport, + report_path: Option, + report_save_failed: bool, +} + +impl Pvm2Failure { + pub(crate) fn zero_methods(path: &Path) -> Result { + ensure!( + fs::metadata(path)?.len() <= 64 * 1024 * 1024, + "方法失败报告超过 64 MiB 限制" + ); + let report = MethodReport::read(path)?; + ensure!( + report.schema_version == 2 + && report.code_version == 4 + && matches!(report.image_version, 7 | 8), + "方法失败报告协议无效" + ); + let mut classes = BTreeSet::new(); + let mut methods = BTreeSet::new(); + let mut reasons = BTreeMap::new(); + for row in &report.methods { + ensure!( + valid_signature(&row.descriptor) && methods.insert((&row.module, &row.descriptor)), + "方法失败报告包含无效或重复方法" + ); + ensure!( + row.transform_status == "skipped" + && !row.artifact_verified + && row.final_method_index.is_none() + && row.final_dex_index.is_none(), + "零转换失败报告包含成功转换记录" + ); + ensure!( + report + .dexes + .get(&row.input_dex) + .is_some_and(|dex| dex.module == row.module), + "方法失败报告模块映射不一致" + ); + let reason = row + .reason + .as_deref() + .filter(|r| !r.is_empty()) + .context("方法失败报告缺少跳过原因")?; + classes.insert((&row.module, row.descriptor.split_once("->").unwrap().0)); + *reasons.entry(Pvm2SkipReason::classify(reason)).or_insert(0) += 1; + } + let summary = Pvm2FailureSummary { + selected_classes: classes.len(), + selected_methods: methods.len(), + reasons, + }; + Ok(Self { + summary, + report, + report_path: None, + report_save_failed: false, + }) + } + + /// APK/AAB 外层知道最终输出目录;在临时工作目录销毁前分别保留失败报告。 + pub fn preserve(error: &mut crate::ShieldError, artifact: &Path) { + let crate::ShieldError::Other(source) = error else { + return; + }; + let Some(failure) = source.downcast_mut::() else { + return; + }; + failure.report_path = None; + match failure.save_report(artifact) { + Ok(path) => { + failure.report_path = Some(path); + failure.report_save_failed = false; + } + Err(_) => failure.report_save_failed = true, + } + } + + fn save_report(&mut self, artifact: &Path) -> Result { + let parent = artifact + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + self.report.build.insert("status".into(), "failed".into()); + self.report + .build + .insert("failure_code".into(), "PVM2_ZERO_METHODS".into()); + self.report.build.insert( + "failure_summary".into(), + serde_json::to_value(&self.summary)?, + ); + // 每次失败独立留存,不覆盖之前的成功报告或失败证据;临时文件默认仅本机用户可读。 + let mut file = tempfile::Builder::new() + .prefix("pvm2-failure-") + .suffix(".json") + .tempfile_in(parent)?; + serde_json::to_writer_pretty(&mut file, &self.report)?; + let (_, path) = file.keep()?; + Ok(path) + } +} + +impl std::fmt::Display for Pvm2Failure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "PVM2_ZERO_METHODS:没有可转换的方法。逐方法清单涉及 {} 个类、{} 个方法,成功转换 0。", + self.summary.selected_classes, self.summary.selected_methods + )?; + for (reason, count) in &self.summary.reasons { + write!(f, "\n{}:{}", reason.label(), count)?; + } + if self.summary.selected_methods == 0 { + write!( + f, + "\n所选范围没有方法记录,请核对 APK 中的实际类名及选择范围。" + )?; + } else if self + .summary + .reasons + .keys() + .all(|r| matches!(r, Pvm2SkipReason::NoCode | Pvm2SkipReason::Initializer)) + { + write!(f, "\n所选范围只有无 DEX 方法体或初始化方法,请选择含普通 DEX 方法体的业务范围;SO 内部函数需另走 Native 保护链路。")?; + } else { + if self.summary.reasons.keys().any(|r| { + matches!( + r, + Pvm2SkipReason::UiRuntimeGlue + | Pvm2SkipReason::DataResourceRuntimeGlue + | Pvm2SkipReason::CompilerGeneratedBridge + | Pvm2SkipReason::PlaybackCompatibility + ) + }) { + write!(f, "\n部分方法被兼容规则排除,请根据本地报告确认要保护的业务范围;不应强行绕过兼容检查。")?; + } + if self.summary.reasons.keys().any(|r| { + matches!( + r, + Pvm2SkipReason::NullableReceiver + | Pvm2SkipReason::UnsupportedOpcode + | Pvm2SkipReason::RegisterLimit + | Pvm2SkipReason::CodeSizeLimit + | Pvm2SkipReason::ExceptionMapping + | Pvm2SkipReason::ControlFlow + | Pvm2SkipReason::Type + | Pvm2SkipReason::NotAttempted + | Pvm2SkipReason::Other + ) + }) { + write!(f, "\n存在转换能力或语义检查限制,请携带原因统计反馈,不能仅靠修改类前缀认定已解决。")?; + } + } + if let Some(path) = &self.report_path { + write!( + f, + "\n本地逐方法失败报告(含业务标识,请勿直接公开):{}", + path.display() + )?; + } else if self.report_save_failed { + write!( + f, + "\n逐方法报告保存失败,请检查输出目录权限或磁盘空间;上述原因统计仍可复制。" + )?; + } + Ok(()) + } +} + +impl std::error::Error for Pvm2Failure {} + pub(crate) fn sha256(data: &[u8]) -> String { format!("{:x}", Sha256::digest(data)) } @@ -447,6 +697,125 @@ mod tests { })).unwrap() } + #[test] + fn 零转换报告分类脱敏并在临时目录销毁后保留到实际输出目录() { + let temporary = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + let path = temporary.path().join("methods.json"); + let mut detail = report(); + detail.dexes.insert( + "classes.dex".into(), + DexRecord { + module: "base".into(), + input_sha256: "input".into(), + output_sha256: "output".into(), + }, + ); + let cases = [ + ("no_code", Pvm2SkipReason::NoCode), + ("initializer", Pvm2SkipReason::Initializer), + ("ui_runtime_glue", Pvm2SkipReason::UiRuntimeGlue), + ( + "data_resource_runtime_glue", + Pvm2SkipReason::DataResourceRuntimeGlue, + ), + ( + "compiler_generated_bridge", + Pvm2SkipReason::CompilerGeneratedBridge, + ), + ( + "playback_compatibility", + Pvm2SkipReason::PlaybackCompatibility, + ), + ( + "nullable receiver at register 3", + Pvm2SkipReason::NullableReceiver, + ), + ("unsupported opcode 0xfc", Pvm2SkipReason::UnsupportedOpcode), + ("too many regs", Pvm2SkipReason::RegisterLimit), + ("too large", Pvm2SkipReason::CodeSizeLimit), + ("handler cannot map", Pvm2SkipReason::ExceptionMapping), + ("bad branch target", Pvm2SkipReason::ControlFlow), + ("unsupported return Lprivate/Secret;", Pvm2SkipReason::Type), + ("not_attempted", Pvm2SkipReason::NotAttempted), + ( + "private-customer /Users/private/project password=secret", + Pvm2SkipReason::Other, + ), + ]; + for (i, (reason, _)) in cases.iter().enumerate() { + detail.methods.push(serde_json::from_value(serde_json::json!({ + "module": "base", "descriptor": format!("Lprivate/Secret;->method{i}()V"), "required": false, + "input_instruction_bytes": 0, "input_dex": "classes.dex", "transform_status": "skipped", + "reason": reason, "artifact_verified": false, "plaintext_policy": null + })).unwrap()); + } + detail.write(&path).unwrap(); + let failure = Pvm2Failure::zero_methods(&path).unwrap(); + assert_eq!(failure.summary.selected_classes, 1); + assert_eq!(failure.summary.selected_methods, cases.len()); + for (_, reason) in cases { + assert_eq!(failure.summary.reasons[&reason], 1); + } + let safe = serde_json::to_string(&failure.summary).unwrap(); + for secret in ["private", "Secret", "method0", "password", "/Users/"] { + assert!(!safe.contains(secret)); + } + assert!(!failure.to_string().contains("private")); + let mut error = crate::ShieldError::from(anyhow::Error::new(failure)); + Pvm2Failure::preserve(&mut error, &temporary.path().join("intermediate.apk")); + // AAB 复用 APK 核心时,必须重新保存到最终输出目录,不能留下将被删除的临时路径。 + Pvm2Failure::preserve(&mut error, &output.path().join("final.aab")); + drop(temporary); + let crate::ShieldError::Other(source) = &error else { + panic!() + }; + let failure = source.downcast_ref::().unwrap(); + let saved = failure.report_path.clone().unwrap(); + assert!(saved.is_file() && saved.starts_with(output.path())); + let stored: serde_json::Value = serde_json::from_slice(&fs::read(&saved).unwrap()).unwrap(); + assert_eq!(stored["build"]["status"], "failed"); + assert_eq!(stored["build"]["failure_code"], "PVM2_ZERO_METHODS"); + assert_eq!(stored["methods"].as_array().unwrap().len(), cases.len()); + assert!(!output.path().join("final.aab").exists()); + assert!(!report_path(&output.path().join("final.aab")).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&saved).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + Pvm2Failure::preserve(&mut error, &output.path().join("final.aab")); + assert!(saved.is_file()); + Pvm2Failure::preserve(&mut error, &output.path().join("missing/final.aab")); + assert!(error.to_string().contains("报告保存失败")); + assert!(error.to_string().contains("成功转换 0")); + } + + #[test] + fn 零转换诊断拒绝缺失损坏或夹带成功记录的报告() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("methods.json"); + assert!(Pvm2Failure::zero_methods(&path).is_err()); + fs::write(&path, b"broken").unwrap(); + assert!(Pvm2Failure::zero_methods(&path).is_err()); + let mut detail = report(); + detail.write(&path).unwrap(); + assert!(Pvm2Failure::zero_methods(&path) + .unwrap() + .to_string() + .contains("没有方法记录")); + detail.methods.push(serde_json::from_value(serde_json::json!({ + "module": "base", "descriptor": "La/A;->value()I", "required": false, "input_instruction_bytes": 6, + "input_dex": "classes.dex", "transform_status": "transformed", "reason": null, + "artifact_verified": true, "plaintext_policy": "bounded_cache", "final_dex_index": 0, "final_method_index": 0 + })).unwrap()); + detail.write(&path).unwrap(); + assert!(Pvm2Failure::zero_methods(&path).is_err()); + } + #[test] fn 清单严格区分模块重载并拒绝无效描述符() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/process/support.md b/docs/process/support.md index de66618..d5381aa 100644 --- a/docs/process/support.md +++ b/docs/process/support.md @@ -29,7 +29,7 @@ 反馈与手动提交的 issue 一样会在 GitHub 上创建**公开** issue,反馈正文(Bug 反馈还包括自动附带的诊断信息)任何访客都能看到,请只填写可以公开的内容。服务端不可用时,软件会提供在浏览器打开预填 issue 页面的入口,反馈正文与诊断信息会预填到表单中,仍需在 GitHub 上确认后提交。 -加固页出现预检风险或任务失败时,可点击“复制诊断摘要”。该摘要只包含预检结论、风险代码、运行策略和失败阶段;不复制原始错误文本,避免路径、包名或业务类名随反馈扩散。需要补充错误或日志时,应先由用户手动脱敏。 +加固页出现预检风险或任务失败时,可点击“复制诊断摘要”。该摘要包含预检结论、风险代码、运行策略和失败阶段;PVM2 零方法转换失败还包含方法数量与固定分类的跳过统计,不复制原始错误文本、路径、包名或业务类名。AAB 的零转换错误也提供该复制入口。输出目录的 `pvm2-failure-*.json` 为本地逐方法失败报告,含业务标识,不能直接公开。需要补充该报告、错误或日志时,应先由用户手动脱敏。 诊断信息包含: diff --git a/docs/usage.md b/docs/usage.md index 8322ca9..0c0aadb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -231,6 +231,8 @@ v8 新增经认证的声明参数类型表,修复把 `Object` 传入的 `Integ GUI 的系统推荐默认值是“标准运行时 + 兼容环境保护 + balanced 代码保护 + balanced AI”,无需配置 PVM2 即可直接加固。高级设置会持久化;若上次保存了尚未配置完整的 strict 设置,主卡片会显示“使用推荐默认值(可直接加固)”,点击后恢复可用的推荐方案。该操作是显式切换,不会把 strict 静默降级。 +如果匹配到类但 PVM2 转换成功数为 0,任务仍会失败,并显示 `PVM2_ZERO_METHODS`、逐方法清单涉及的类数、方法数及实际跳过原因统计。无 DEX 方法体、初始化器、兼容排除、指令/寄存器等限制分别计数,未知原因不会猜测成指令问题。APK/AAB 输出目录会保留独立的 `pvm2-failure-*.json`,明确标记 `failed`,不覆盖成功产物或成功报告。该本地报告包含业务方法标识;“复制诊断摘要”只复制固定原因分类和数量。报告缺失、损坏或保存失败时会明确说明,不把零转换当作成功,也不自动关闭保护。 + “第几代”没有统一行业标准。当前能力应按实际范围描述:基础 DEX 加密封装、成功转换方法的 PVM2、构建级多态、资源/asset 与业务 ELF 静态保护,以及可选的源码级标量语义 Native VMP。不能把这些组件的存在等同于整个 APK 达到“3.5 代”。[代码保护升级方案](design/android-code-protection-upgrade.md)把“3.5 代”作为待验收的内部目标,要求关键方法清单、最终产物核验与运行时/性能证据;关键方法清单、产物核验、运行时清理和 Native 构建证据接入已实现,完整设备语义与性能矩阵仍待完成。Native VMP 的通用 C/C++ 语义、Play 内测也未完成,不能把本地结构验证算成真实设备或 Play 证据。 AAB 预检不会把 APK 解包器误用于 AAB: diff --git a/shield-stub/compat/api19-rust/Cargo.lock b/shield-stub/compat/api19-rust/Cargo.lock index af2eafa..4dcd226 100644 --- a/shield-stub/compat/api19-rust/Cargo.lock +++ b/shield-stub/compat/api19-rust/Cargo.lock @@ -242,7 +242,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mocikashield-api19" -version = "1.8.0" +version = "1.8.1" dependencies = [ "cc", "chacha20poly1305", diff --git a/shield-stub/compat/api19-rust/Cargo.toml b/shield-stub/compat/api19-rust/Cargo.toml index c48b675..7f09ba4 100644 --- a/shield-stub/compat/api19-rust/Cargo.toml +++ b/shield-stub/compat/api19-rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocikashield-api19" -version = "1.8.0" +version = "1.8.1" edition = "2021" license = "MIT OR Apache-2.0" build = "../../src/main/rust/build.rs" diff --git a/shield-stub/src/main/rust/Cargo.toml b/shield-stub/src/main/rust/Cargo.toml index 2e70c08..63de6a8 100644 --- a/shield-stub/src/main/rust/Cargo.toml +++ b/shield-stub/src/main/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocikashield" -version = "1.8.0" +version = "1.8.1" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java index cb686de..d4a2dec 100644 --- a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java +++ b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java @@ -99,6 +99,7 @@ public static void main(String[] args) throws Exception { } assert Pvm2Safety.check(new short[]{0x0238, 4, 0x1020, 0, 0x0038, 5, 0x106e, 0, 1, 0x000e}, 3, 2, true, false) != null; Path root = Path.of(args[0]); + checkZeroMethodReports(root); Path dex = Files.createDirectory(root.resolve("dex")); fixture(dex.resolve("classes.dex")); fixture(dex.resolve("classes3.dex")); @@ -187,13 +188,57 @@ public static void main(String[] args) throws Exception { } private static void run(Path dex, Path config, Path code, Path report, boolean prefixes) throws Exception { + run(dex, config, code, report, prefixes ? "Lfixture/" : null); + } + + private static void run(Path dex, Path config, Path code, Path report, String prefix) throws Exception { List args = new ArrayList<>(List.of("pvm2-transform", "--dex-dir", dex.toString(), "--code-out", code.toString(), "--insn-key-hex", KEY, "--package-name", "fixture.unrelated", "--vm-bridge", BRIDGE, "--report-config", config.toString(), "--report-out", report.toString(), "--no-payment-auto-vmp", "--no-industry-auto-vmp")); - if (prefixes) args.addAll(List.of("--true-vmp-prefix", "Lfixture/")); + if (prefix != null) args.addAll(List.of("--true-vmp-prefix", prefix)); Method method = PackerMain.class.getDeclaredMethod("runPvm2TransformCli", String[].class); method.setAccessible(true); method.invoke(null, (Object) args.toArray(String[]::new)); } + private static void checkZeroMethodReports(Path root) throws Exception { + // 三种真实零转换原因:无 DEX 方法体、类级兼容排除、编译器寄存器上限。 + for (String kind : List.of("native", "ui", "registers")) { + Path work = Files.createDirectory(root.resolve("zero-" + kind)); + Path dex = Files.createDirectory(work.resolve("dex")); + String owner = kind.equals("ui") ? "Lfixture/MainActivity;" : "Lfixture/Rules;"; + List methods = new ArrayList<>(); + methods.add(new ImmutableMethod(owner, "nativeCall", List.of(), "I", 0x109, Set.of(), Set.of(), null)); + methods.add(new ImmutableMethod(owner, "", List.of(), "V", 0x10008, Set.of(), Set.of(), + new ImmutableMethodImplementation(0, List.of(new ImmutableInstruction10x(Opcode.RETURN_VOID)), List.of(), List.of()))); + if (!kind.equals("native")) methods.add(new ImmutableMethod(owner, "value", List.of(), "I", 9, Set.of(), Set.of(), + new ImmutableMethodImplementation(kind.equals("registers") ? 512 : 1, + List.of(new ImmutableInstruction31i(Opcode.CONST, 0, 42), new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of()))); + DexPool pool = new DexPool(Opcodes.getDefault()); + pool.internClass(new ImmutableClassDef(owner, 1, "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), methods)); + FileDataStore store = new FileDataStore(dex.resolve("classes.dex").toFile()); + try { pool.writeTo(store); } finally { store.close(); } + Path config = work.resolve("config.json"), report = work.resolve("report.json"); + Files.writeString(config, new JSONObject().put("schema_version", 2) + .put("modules", new JSONObject().put("classes.dex", "base")).put("required", new JSONArray()).toString()); + try { + run(dex, config, work.resolve("code.bin"), report, "Lfixture/"); + throw new AssertionError("零方法转换被误判为成功"); + } catch (InvocationTargetException expected) { + assert expected.getCause().getMessage().contains("selected zero methods"); + } + JSONObject result = new JSONObject(Files.readString(report)); + Map reasons = new TreeMap<>(); + for (Object value : result.getJSONArray("methods")) { + JSONObject row = (JSONObject) value; + assert row.getString("transform_status").equals("skipped") && !row.getBoolean("artifact_verified"); + reasons.merge(row.getString("reason"), 1, Integer::sum); + } + assert reasons.get("no_code") == 1 && reasons.get("initializer") == 1 : reasons; + if (kind.equals("ui")) assert reasons.get("ui_runtime_glue") == 1 : reasons; + if (kind.equals("registers")) assert reasons.get("too many regs") == 1 : reasons; + System.out.println("零方法失败报告通过:" + kind + " " + reasons); + } + } + private static void fixture(Path path) throws Exception { List methods = new ArrayList<>(); for (boolean parameter : List.of(false, true)) { diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java index f6d7e9a..b92f7b6 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java @@ -68,7 +68,8 @@ void inventory(List files, Predicate selectedType) throws Exceptio if (impl != null) for (var instruction : impl.getInstructions()) size += instruction.getCodeUnits() * 2; String reason = impl == null ? "no_code" : method.getName().startsWith("<") ? "initializer" : "not_attempted"; String exclusion = AutoVmpPolicy.exclusionReason(cls.getType()); - if (!required.contains(id) && exclusion != null) reason = exclusion; + // 没有方法体和初始化器先按实际结构计数,避免被类级兼容规则掩盖。 + if (reason.equals("not_attempted") && !required.contains(id) && exclusion != null) reason = exclusion; JSONObject row = new JSONObject().put("module", module).put("descriptor", signature) .put("required", required.contains(id)).put("input_instruction_bytes", size) .put("input_dex", file.getName()).put("transform_status", "skipped") diff --git a/tools/xop-pvm2-packer.jar b/tools/xop-pvm2-packer.jar index 3d5340b..e734323 100644 Binary files a/tools/xop-pvm2-packer.jar and b/tools/xop-pvm2-packer.jar differ