diff --git a/.agents/skills/hapsign-hap-deploy/SKILL.md b/.agents/skills/hapsign-hap-deploy/SKILL.md index f2aa42e..69eafc3 100644 --- a/.agents/skills/hapsign-hap-deploy/SKILL.md +++ b/.agents/skills/hapsign-hap-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: hapsign-hap-deploy -description: Authenticate, sign, install, or deploy unsigned and signed HarmonyOS HAPs on an explicitly selected connected device through the hapsign CLI. Use for HAP deployment; not for building HAPs, emulator-only workflows, or manual DevEco/HDC signing. +description: Install or deploy authorized HarmonyOS HAPs on an explicitly selected connected device through the hapsign CLI. Use for device deployment; use hapsign-signing for sign-only or signing-troubleshooting workflows. --- # HapSign HAP Deploy @@ -10,29 +10,48 @@ authentication, token caching, device-bound Profiles, signing, HDC installation, and post-install bundle checks. Do not recreate those steps with DevEco, raw HDC, or skill-local scripts. -## Commands +## Safe deployment workflow -Always request JSON and pass an explicit HDC serial: +Use the CLI's read-only checks before authentication or deployment: ```bash +hapsign doctor --json +hapsign inspect --hap --json hapsign devices list --connected-only --json -hapsign auth status --json -hapsign auth --json -hapsign sign --hap --serial --json -hapsign install --hap --serial --json -hapsign deploy --hap --serial --json ``` +When deployment needs a Real Profile/system_basic, pass `--enable-capability` +to both `inspect` and `deploy` so cache compatibility is evaluated consistently. + +- Require `capabilities.device.ok`; also require `capabilities.signing.ok` for + an unsigned HAP. +- If `migration_warnings` contains an item with `destructive=true` and + `requires_user_decision=true`, explain its impact and remediation and stop + for the user's choice. Do not silently refresh or migrate signing materials. - Honor a serial supplied by the user. Otherwise select the sole `physical_candidate=true` target; ask the user if zero or multiple physical candidates remain. Do not select `likely_emulator=true` unless requested. -- Use `sign` for signing only, `install` for an already signed HAP, and `deploy` - for end-to-end signing and installation. `deploy` also accepts signed HAPs. -- Check `auth status` before an operation that may need login. If no current cache - exists and the request does not already authorize account authentication, tell - the user that browser authorization is required before running `auth`. + +Then use JSON and the explicit HDC serial. Installing an already signed HAP does +not require Huawei authentication: + +```bash +hapsign install --hap --serial --json +hapsign deploy --hap --serial --json +``` + +- Use `install` for an already signed HAP and `deploy` for end-to-end signing + and installation. `deploy` also accepts signed HAPs. +- For an unsigned HAP, `deploy` can perform authentication itself. If no current + cache exists and the request does not already authorize account authentication, + tell the user that browser authorization will be required before running it. + Use a separate `auth status`/`auth` step only when the user wants authentication + prepared independently; never authenticate for a signed-only `install`. Treat success as exit code `0` plus JSON `ok=true`. Parse stdout as JSON and treat stderr as diagnostic logs. On failure, report `error.type`, `error.message`, and the exit code; do not fall back to manual signing or raw HDC installation. Never print or copy tokens, passwords, signing keys, Profiles, or device UDIDs. +When `--enable-capability` was requested, report `capability_fallback=true` as a +successful debug install whose effective Profile is still normal, not as a +successful system_basic deployment. diff --git a/.agents/skills/hapsign-signing/SKILL.md b/.agents/skills/hapsign-signing/SKILL.md new file mode 100644 index 0000000..d34e804 --- /dev/null +++ b/.agents/skills/hapsign-signing/SKILL.md @@ -0,0 +1,168 @@ +--- +name: hapsign-signing +description: Inspect, debug-sign, and optionally install authorized HarmonyOS .hap packages with HapSign on Windows, Linux, or macOS using machine-readable JSON and user-assisted Huawei authorization. Use for local HAP debug signing and troubleshooting, not production AppGallery signing or generic OS code signing. +--- + +# HapSign HAP signing + +Use HapSign only for authorized local HarmonyOS development and debugging. It +creates Huawei debug signing materials; it does not replace AppGallery +production-release signing. + +## Locate and identify the CLI + +Prefer, in order: + +1. The path in `HAPSIGN_CLI`. +2. The complete portable package in the current HapSign repository: + `dist/HapSign/hapsign-cli.exe` on Windows or + `dist/HapSign/hapsign-cli` on Linux/macOS. +3. A `hapsign` source-install command on `PATH`. +4. A complete portable folder supplied by the user. + +Do not use a copied standalone executable without its sibling `resources/`. +Resolve the CLI, input HAP, and output HAP to absolute paths. For portable +builds, run with the CLI parent directory as the working directory so older +builds also keep caches beside the application. If no usable CLI exists, report +the paths checked and ask where HapSign is installed. + +Before processing a HAP, run: + +```text + --version + doctor --json +``` + +In PowerShell invoke a quoted executable with `&`. On POSIX, invoke the +executable directly. + +Interpret doctor capabilities separately: + +- `capabilities.signing.ok` is required to sign an unsigned HAP. +- `capabilities.device.ok` is required for installation and for obtaining a + first-time Profile UDID from a connected device. +- Top-level `ok` may be false while sign-only remains possible with valid + cached signing materials or a trusted explicit UDID. +- Read `paths.state_dir` and `paths.output_dir`; do not infer storage from the + process working directory. Report relevant entries from `breaking_changes` + when upgrading an existing installation. + +## Safe agent workflow + +1. Inspect the input without logging in, connecting a device, or changing it: + + ```text + inspect --hap --json + ``` + + If the requested signing mode needs a Real Profile/system_basic, include + `--enable-capability` in both this inspection and the later `sign` or + `deploy` command. Cache compatibility is evaluated against that mode. + + Read `migration_warnings` before continuing. If a warning has + `destructive: true` and `requires_user_decision: true`, explain its impact + and remediation, then wait for the user's choice. Never silently accept a + destructive migration. + + For `HAPSIGN-BREAKING-001`, inspect `reasons`. A sole + `capability_mode_mismatch` can be resolved by consistently matching the + cached mode, or by backing up `paths.work_dir` and accepting a refresh when + the user intends to switch modes. Offer metadata migration only when the + warning reports `migratable: true` and the user confirms the legacy Profile + type; then run one of: + + ```text + migrate-cache --hap \ + --state-dir --profile-type normal --json + migrate-cache --hap \ + --state-dir --profile-type system-basic --json + ``` + + Do not guess the legacy Profile type. The migration command only updates + metadata and legacy relative material paths, keeps a backup, and must return + `command: migrate-cache` and `ok: true` before reuse. A stale or incomplete + cache cannot be migrated; back it up and allow refresh instead. + +2. For a signing request, default to sign-only. Choose a new absolute `.hap` + output path and verify that it does not already exist: + + ```text + sign --hap \ + --output --browser system_controlled --json + ``` + + `system_controlled` uses an isolated Edge/Chrome context without the user's + cookies or saved passwords and grants the authorization page local callback + access. Use `system` only when the user explicitly prefers their normal + browser profile or controlled launch is unavailable; disclose that cached + SSO state, extensions, and local-network permissions can change the flow. + Use `playwright` only when bundled Chromium is explicitly preferred. + + If persistent locations matter, pass absolute `--state-dir` and either an + exact `--output` or `--output-dir`. CLI flags override + `HAPSIGN_SIGNING_DIR` / `HAPSIGN_SIGNED_HAPS_DIR`, which override the + application defaults. Never place signing state in shared or cloud-synced + storage. + +3. Never add `--overwrite-output` unless the user explicitly authorizes + replacing that exact file. Add `--device-udid` only for a trusted, + user-authorized 64-character UDID. Add `--enable-capability` only when the + user requests a Real Profile/system_basic capability or the task clearly + requires it. + +4. If authorization opens, tell the user to complete login, CAPTCHA, consent, + and two-factor verification manually, then wait for the process. Never + request, read, store, type, or automate credentials, CAPTCHA, one-time codes, + consent clicks, or tokens. + +5. Treat a nonzero exit code or JSON `ok: false` as failure. On success, read + `signed_hap` from JSON instead of guessing a path, then verify it: + + ```text + inspect --hap --json + ``` + + Report success only when the verification returns `signed: true`. For an + already-signed input, accept the `signed_hap` path returned by the CLI even + though no cryptographic signing step ran; do not infer it from the input. + When a Real Profile was requested, also inspect `capability_fallback` and + `capability_mode`. A fallback result is a valid debug signature but did not + satisfy system_basic; disclose that limitation instead of reporting the + requested capability as successful. + +6. Install only when the user explicitly requests device installation. Use + `deploy --hap --serial ` for signing plus installation, or + `install --hap --serial ` for an already signed HAP. + Require a connected authorized device and report the returned `installed` + value. Signing permission alone does not authorize installation. + +## Authorization diagnostics + +Keep logs at a nonsensitive level and use stage markers: + +- No `[callback]` entry: the browser did not reach the loopback callback; + check browser mode and local-network access. +- Callback POST/GET without `授权回调校验成功`: inspect the redacted CSRF or + parameter error. +- Callback validation succeeded: browser spinning or subsequent + `net::ERR_ABORTED` requests are usually page shutdown after the callback; + investigate token exchange or later pipeline stages instead. +- A Windows DPAPI cache decryption failure should trigger a fresh user login. + Linux/macOS token caches are plaintext files restricted to mode `0o600`; do + not place them in shared or cloud-synced storage. Do not delete signing state + unless the user explicitly requests cleanup. + +When diagnosing a GUI run, first identify the actual executable and its log +directory; do not assume source CLI, portable CLI, and GUI share a directory in +older builds. + +## Sensitive data and boundaries + +- `signing_files/` contains private keys, certificates, Profiles, and login + cache. Never print, commit, upload, or place it in untrusted/shared storage. +- Keep sensitive logging disabled. Do not expose full UDIDs, account identifiers, + tokens, passwords, private-key material, or complete authentication payloads. +- Network access and a verified Huawei developer account are required when + cached authorization/signing materials are unavailable. +- This skill is for HAP debug signing. Route EXE/MSI signing, certificate + issuance, and production release signing elsewhere. diff --git a/.agents/skills/hapsign-signing/agents/openai.yaml b/.agents/skills/hapsign-signing/agents/openai.yaml new file mode 100644 index 0000000..0aaf5b7 --- /dev/null +++ b/.agents/skills/hapsign-signing/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "HapSign HAP Signing" + short_description: "Inspect, debug-sign, and install HarmonyOS HAPs" + default_prompt: "Use $hapsign-signing to inspect and debug-sign this authorized HarmonyOS HAP." + +policy: + allow_implicit_invocation: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0803f09..a10e8f6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,8 +23,10 @@ body: options: - Windows 精简便携包 - Windows Chromium 兼容包 - - Python 源码 GUI - - Python CLI + - Linux 便携包 + - macOS 便携包 + - Python 源码 GUI(Windows/Linux/macOS) + - Python CLI(Windows/Linux/macOS) - 其他 validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6a005..246646a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest] - python-version: ["3.11", "3.13"] + os: [windows-latest, macos-latest, ubuntu-latest] + python-version: ["3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4f829..a82c475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,38 @@ ## Unreleased +### Breaking changes + +- `HAPSIGN-BREAKING-001`:旧签名缓存缺少或切换能力模式、包名不匹配或设备 UDID + 无效时会执行一次性材料刷新;能力模式可通过一致使用 `--enable-capability` 匹配, + 只有其余一致性校验通过时,才可显式运行 + `migrate-cache --profile-type normal|system-basic` 保留缓存。 +- `HAPSIGN-BREAKING-002`:CLI 默认浏览器从普通系统 Profile 改为隔离的 + `system_controlled`;可用 `--browser` 或 `HAPSIGN_BROWSER` 配置。 +- `HAPSIGN-BREAKING-003`:CLI 默认状态/产物从 PR #5 的 `~/.hapsign`(更早版本为 + 进程工作目录)改到应用目录;可用 `--state-dir`、`--output-dir`、 + `HAPSIGN_SIGNING_DIR` 和 `HAPSIGN_SIGNED_HAPS_DIR` 恢复原路径;`inspect` 会检测 + 可复用的 PR #5 旧状态,并在发现旧签名材料时阻止 agent 静默刷新材料。 +- `HAPSIGN-BREAKING-004`:面向人的 CLI 输出不再作为脚本协议;自动化应使用 `--json`。 +- `HAPSIGN-BREAKING-005`:CLI 改为显式子命令接口;旧的扁平参数调用需要按迁移表整改。 + 完整影响、检测方式与整改命令见 [迁移指南](docs/MIGRATIONS.md)。 + ### Added - 新增面向 Agent 的 `auth`、`devices list`、`sign`、`install`、`deploy` CLI 子命令;支持单行 JSON stdout、stderr 日志、明确退出码与输入校验。 - CLI 支持显式 HDC `--serial`、真机/模拟器候选标记、签名与安装分离,以及安装后 `bm dump` 校验;Token 可跨目标设备复用,Profile 缓存按 UDID 隔离。 -- CLI 默认状态目录改为跨平台用户主目录 `~/.hapsign`,Windows 对应 - `%USERPROFILE%\.hapsign`,不再受 Agent 当前工作目录影响。 +- Linux 源码命令行、CI、DevEco/JAVA_HOME/PATH 工具发现,以及锁定并校验的 + OpenHarmony 6.1 + Temurin 21 Linux x64 便携工具链准备流程。 +- Agent 友好的 `doctor`、`inspect`、`migrate-cache`、`--json`、显式 UDID 和 + 精确输出路径;仅签名可复用缓存 Profile 而不连接设备,便携包同时提供独立的 + `hapsign-cli[.exe]` 控制台程序。 +- 机器可读的兼容性变更目录、按 HAP 检测的迁移警告,以及带备份的旧缓存显式迁移命令。 +- 仓库级 `.agents/skills/hapsign-signing` 全平台 Codex 技能,包含受控浏览器授权、 + 能力判定、签名后复检、输出保护和脱敏回调诊断流程。 +- Linux HDC server 归属探测在缺少 `lsof` 时回退到 `/proc`,进程启动时间读取不受 + `ps` 输出语言影响。 - macOS 支持:按平台解析 DevEco JBR / hap-sign-tool / hdc 路径,可用 `hapsign` 命令行签名安装。 - 可安装的 `hapsign` 命令和标准 Python 项目元数据。 - Ruff、pytest、覆盖率、pre-commit 和 Windows CI 配置。 @@ -56,9 +80,10 @@ ### Changed -- CLI 现在必须显式使用 `auth`、`devices`、`sign`、`install` 或 `deploy` - 子命令,并为设备相关命令传入非空 `--serial`;旧的 `hapsign --hap ...` - 调用方式不再兼容。 +- CLI 现在必须显式使用 `doctor`、`inspect`、`migrate-cache`、`auth`、`devices`、 + `sign`、`install` 或 `deploy` 子命令。`deploy`/`install` 必须传入非空 + `--serial`;`sign` 可使用 `--serial`、可信的显式 `--device-udid`,或复用兼容的 + 缓存材料。旧的 `hapsign --hap ...` 调用方式不再兼容。 ### Fixed @@ -67,6 +92,16 @@ 输出误报为空设备列表。 - CLI 不再把缺少 HDC 可执行文件归类为输入错误;`devices`、`install` 等运行时 HDC 失败现在返回 `operation_failed` 和退出码 1。 +- CLI 默认使用隔离的 `system_controlled` 浏览器,并把签名缓存与默认输出绑定到应用 + 目录/配置,避免复用普通浏览器 Profile 或因调用工作目录不同而产生多份缓存。 +- 修复 CLI 初始化失败时把 `sign-install` 误报成 `sign`;基础安装现在声明默认受控 + 浏览器所需的 Playwright 运行时依赖;JSON 协议使用 ASCII 转义,避免 Windows + 本地代码页导致 agent 无法解码中文错误。 +- 签名材料缓存会校验包名、能力模式、已知设备 UDID 和材料文件类型,避免跨设备或 + 跨 Profile 模式误用;显式输出的默认不覆盖门禁也覆盖并发发布竞争。 +- 缓存分别记录请求与实际能力模式,Real Profile 回退到 Test Profile 后可稳定复用; + CLI JSON 会显式返回实际模式及 `capability_fallback`,避免 agent 误报 system_basic。 +- 输入已经签名时,`--output` 仍会按不覆盖策略原子发布到指定路径,不再静默忽略。 - HTTP 客户端正确发送 `User-Agent` / `Accept-Language` 请求头。 - Token 缓存缺少 `jwt_token` 时不再复用,避免后续刷新失败。 - 设备注册将业务层重复错误码视为成功,并保留 HTTP 错误信息中的兼容判定。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 902f098..279ec03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ Profile、设备 UDID 或签名后的 HAP 加入仓库。 ## 开发环境 -项目要求 Windows 或 macOS,以及 Python 3.11 或更高版本。创建并激活虚拟环境后安装开发依赖: +项目支持 Windows、Linux 和 macOS,要求 Python 3.11 或更高版本。创建并激活 +虚拟环境后安装开发依赖: ```bash python -m pip install -r requirements-dev.txt diff --git a/PORTABLE.md b/PORTABLE.md index da58fa9..a0438e4 100644 --- a/PORTABLE.md +++ b/PORTABLE.md @@ -1,6 +1,7 @@ # HapSign 便携版 -解压 ZIP 后双击 `HapSign.exe`(macOS/Linux 使用对应平台可执行文件)。 +解压下载归档后双击 `HapSign.exe`(macOS/Linux 使用对应平台的 `HapSign`)。目录中 +另有 `hapsign-cli.exe`/`hapsign-cli`,供脚本和 agent 使用机器可读签名协议。 ## 使用方法 @@ -11,6 +12,17 @@ 5. 如果 HAP 尚未签名,程序会控制系统 Edge(其次 Chrome)打开华为登录页; 完成登录后程序会继续。 +仅签名或自动化辅助可在终端运行: + +```bash +./hapsign-cli doctor --json +./hapsign-cli sign --hap app.hap --output app-signed.hap \ + --browser system_controlled --json +``` + +Windows PowerShell 使用 `.\hapsign-cli.exe`。完整约定见 `AGENT_SIGNING.md`;升级 +已有安装前请查看 `MIGRATIONS.md`,或读取 `doctor --json` 的机器可读变更目录。 + 进度条按当前实际流程阶段推进。任务执行期间可以点击“取消”;如果直接关闭窗口, 程序会询问是否中断当前任务。确认后会先结束登录等待或外部工具、清理本次启动的 HDC 服务,再退出。取消不会复用未完成任务的运行状态,可直接重新开始。 @@ -48,8 +60,8 @@ Java、keytool 和 HDC 等外部命令会在后台执行,不会弹出命令行 ## 构建 构建机需要 Python 3.11+。目标电脑不需要安装 Python 或 DevEco Studio;默认 -精简包要求目标 Windows 已安装 Edge 或 Chrome。正式 Windows 构建先准备锁定的 -OpenHarmony/Temurin 工具链: +精简包要求目标系统已安装 Edge 或 Chrome。正式 Windows/Linux x64 构建先在目标 +平台准备锁定的 OpenHarmony/Temurin 工具链: ```bash python -m pip install -e ".[gui,bundle]" @@ -65,9 +77,9 @@ python -m playwright install --no-shell chromium python scripts/build_portable.py --keep-bundled-browser ``` -构建结果位于 `dist/HapSign-portable-.zip`,同目录会生成可用于发布校验的 -`.zip.sha256` 文件。 -兼容包位于 `dist/HapSign-portable--compat.zip`。 +Windows/macOS 构建结果位于 `dist/HapSign-portable-.zip`;Linux 使用 +`dist/HapSign-portable-linux.tar.gz` 以保留可执行位。同目录会生成对应的 +`.sha256` 校验文件。兼容包沿用相同平台格式,并在文件名中增加 `-compat`。 准备脚本使用 `jlink` 生成精简 Temurin 运行时,构建脚本会自动执行 Java、 keytool、hap-sign-tool、HDC 和冻结程序自检。 @@ -77,14 +89,15 @@ keytool、hap-sign-tool、HDC 和冻结程序自检。 python scripts/build_portable.py --skip-toolchain ``` -该 GUI-only 包不能在没有外部工具链的电脑上完成签名和安装。完整的构建环境、 +该无工具链 GUI/CLI 包不能在没有外部工具链的电脑上完成签名和安装。完整的构建环境、 资源发现顺序、目录结构、验证方法和发布清单见 `docs/PACKAGING.md`;生成的便携 目录中也会包含一份 `BUILDING.md`。 PyInstaller 产物与当前操作系统绑定,因此 Windows、macOS、Linux 需要分别构建。 -锁文件会记录公共 SDK、Temurin 和核心文件哈希;发布包也包含生成时的 +锁文件当前包含 Windows x64 和 Linux x64 的公共 SDK、Temurin 和核心文件哈希; +发布包也包含生成时的 `PROVENANCE.txt`、完整 OpenHarmony NOTICE、Temurin legal 目录,以及 -`libusb_shared.dll` 对应的 OpenHarmony 源码快照。若使用 +`libusb_shared.dll`/`libusb_shared.so` 对应的 OpenHarmony 源码快照。若使用 `--allow-deveco-toolchain` 回退,本次产物只用于本机排障,不得公开发布。 发布包根目录会包含 HapSign 的 `LICENSE`、`PRIVACY.md`、 diff --git a/PRIVACY.md b/PRIVACY.md index 581c275..6e9cbc1 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -26,12 +26,12 @@ Profile。重复 UDID 会复用已有设备记录。用户应确认自己有权 ## 本地保存 -根据入口和设置,以下文件保存在 CLI 用户主目录、程序目录、用户 Local AppData -或用户选择的目录: +根据入口和设置,以下文件保存在程序目录、用户 Local AppData 或用户选择的目录; +CLI 默认遵循应用配置,也可用参数或专用环境变量覆盖: -- CLI 的 `~/.hapsign/.token_cache.json`(桌面/便携版为 - `signing_files/.token_cache.json`):访问令牌、刷新令牌、JWT 和账号基本字段; -- CLI 的 `~/.hapsign//`(桌面/便携版为 `signing_files//`): +- `/.token_cache.json`(默认 `signing_files/.token_cache.json`): + 访问令牌、刷新令牌、JWT 和账号基本字段; +- `//`(默认 `signing_files//`): 私钥密钥库、CSR、证书、Profile 和缓存元数据; - `signed_haps/`:可选保留的最后一个已签名 HAP; - `logs/hapsign.log*`:诊断日志; diff --git a/README.md b/README.md index bfe25d9..f851c74 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ ``` 检测 HAP 是否已签名 - ├─ 已签名 → hdc install 直接安装 + ├─ 已签名 → 按请求直接返回或 hdc install 安装 └─ 未签名 → - Playwright 控制系统 Edge/Chrome 打开登录页(用户手动登录) + 浏览器打开登录页(用户手动登录) → 拿 tempToken → 换 accessToken → 调华为云签名 API 生成 .cer / .p7b → hap-sign-tool 签名 hap - → hdc install 安装到设备 + → 按请求返回签名 HAP 或 hdc install 安装到设备 ``` ## 前置条件 @@ -26,10 +26,13 @@ 1. **签名/设备工具链**:便携版已内置;源码运行可使用已准备的公开工具链或 DevEco Studio 2. **Python 3.11+**(推荐使用 conda 或 venv 隔离环境) -3. **HarmonyOS 设备**已通过 USB 连接并开启 USB 调试模式 +3. **HarmonyOS 设备**已通过 USB 连接并开启 USB 调试模式(安装或首次自动读取 + UDID 时需要;仅签名也可显式提供 UDID) 4. **华为开发者账号**(需要已完成实名认证) -命令行支持 **Windows** 与 **macOS**;当前正式便携版为 Windows。 +源码命令行支持 **Windows、Linux 与 macOS**。锁定的公开便携工具链目前覆盖 +Windows x64 与 Linux x64;macOS 可使用本机 DevEco Studio 工具链构建,正式发布 +前仍需补充独立的公开锁定项、代码签名和 notarization。 ## 直接使用 Windows 便携版(推荐) @@ -47,6 +50,9 @@ Get-Content .\HapSign-portable-windows.zip.sha256 `HapSign-portable-windows-compat.zip`,它包含内置 Chromium,适合没有可用系统 浏览器的电脑。 +便携目录同时包含 agent 可调用的 `hapsign-cli.exe`(Linux/macOS 为 +`hapsign-cli`);它与源码安装后的 `hapsign` 使用同一组参数和 JSON 协议。 + 首次使用时连接已开启 USB 调试的 HarmonyOS 设备,并在设备上确认调试授权;在窗口中 点击“检测设备”,然后拖入或选择 `.hap`,点击“开始签名并安装”。未签名 HAP 会打开 受控浏览器完成华为账号登录和验证码/二次验证,之后自动申请材料、签名并安装;已签名 @@ -84,17 +90,22 @@ python -m playwright install --no-shell chromium 正式便携版不需要 DevEco Studio。源码运行时程序会优先使用已准备的公开工具链,只有 在排查特定 DevEco 版本兼容性时才需要本机 DevEco 回退。程序会查找系统常见安装目录 -和 `D:\Program Files\Huawei\DevEco Studio`;如果安装在其他位置,设置环境变量: +和各平台常见目录: - Windows: `D:\Program Files\Huawei\DevEco Studio` - macOS: `/Applications/DevEco-Studio.app/Contents` +- Linux: `/opt/DevEco-Studio`、`/opt/Huawei/DevEco-Studio`、 + `~/DevEco-Studio` 或 `~/Huawei/DevEco-Studio` 若安装在其他位置,设置环境变量: ```bash -# macOS / Linux shell +# macOS shell export DEVECO_HOME="/Applications/DevEco-Studio.app/Contents" +# Linux shell +export DEVECO_HOME="$HOME/DevEco-Studio" + # Windows CMD set DEVECO_HOME=E:\DevEco Studio @@ -102,6 +113,19 @@ set DEVECO_HOME=E:\DevEco Studio $env:DEVECO_HOME = "E:\DevEco Studio" ``` +也可以不安装 DevEco Studio,把 Java/keytool 放进 `JAVA_HOME` 或 `PATH`,把 HDC +放进 `PATH`,并显式给出签名器: + +```bash +export JAVA_HOME="/opt/jdk-21" +export HAPSIGN_HAP_SIGN_TOOL="/opt/ohos-sdk/toolchains/lib/hap-sign-tool.jar" +export HAPSIGN_HDC="/opt/ohos-sdk/toolchains/hdc" +hapsign doctor +``` + +`HAPSIGN_JAVA`、`HAPSIGN_KEYTOOL`、`HAPSIGN_HAP_SIGN_TOOL` 和 `HAPSIGN_HDC` +均可逐项覆盖自动发现结果。 + 调试密钥库默认使用兼容 DevEco 调试流程的固定密码。如需覆盖,请设置: ```bat @@ -157,10 +181,10 @@ hapsign-app 签名目录或日志目录。诊断日志默认写到程序目录的 `logs/hapsign.log`,单个文件 最多 4 MiB,保留 3 份轮转备份。敏感诊断默认关闭;主动开启并选择 DEBUG 后, 日志可能包含 token、用户标识和完整 API 请求/响应,但始终不会记录密钥库密码。 -“保留最新一个签名后的 HAP”默认开启:最终 HAP 固定写到程序目录的 -`signed_haps/`,新文件成功生成后只清理 HapSign 清单记录的旧产物,不会删除 -目录中未记录的用户 HAP;当前输入文件也会受到保护。关闭后程序使用临时文件安装, -任务结束即删除。 +“保留最新一个签名后的 HAP”默认开启:最终 HAP 默认写到程序目录的 +`signed_haps/`(可用 `HAPSIGN_SIGNED_HAPS_DIR` 覆盖),新文件成功生成后只清理 +HapSign 清单记录的旧产物,不会删除目录中未记录的用户 HAP;当前输入文件也会受到 +保护。关闭后程序使用临时文件安装,任务结束即删除。 如果 HDC server 原本未运行,程序会在本次任务结束时关闭自己拉起的后台服务; 如果 DevEco Studio 或其他工具已经启动 HDC server,则会保留该既有服务。 @@ -175,7 +199,7 @@ set HAPSIGN_SERIAL=5XQ0225613000233 sign_install.bat path\to\app-unsigned.hap 5XQ0225613000233 ``` -### 方式三:命令行(Windows / macOS) +### 方式三:命令行(Windows / Linux / macOS) ```bash hapsign devices list --connected-only --json @@ -201,11 +225,49 @@ hapsign deploy --hap path/to/app-unsigned.hap --serial --json 多台运行 HapSign 的电脑之间自动同步,也不建议手工复制缓存。`auth status` 只检查 本地当日缓存,因此 JSON 中 `online_verified` 固定为 `false`。 +### Agent / 半自动仅签名 + +CLI 的标准输出可以保持为单个 JSON 文档,普通运行日志写入标准错误。agent 可按 +“诊断 → 检查 HAP → 发起签名 → 等待用户完成浏览器授权 → 读取产物路径”的顺序调用: + +```bash +hapsign doctor --json +hapsign inspect --hap app.hap --json +hapsign sign --hap app.hap --output artifacts/app-signed.hap \ + --state-dir .hapsign-state --browser system_controlled --json +``` + +首次为某个应用申请 Profile 时仍需要设备 UDID。默认会通过 HDC 从已连接设备读取; +如果 agent 已从可信来源获得 UDID,可以跳过本机设备探测: + +```bash +hapsign sign --hap app.hap --device-udid <64位十六进制UDID> \ + --browser system_controlled --json +``` + +同一天已有与包名、能力模式及已知设备匹配的可用签名材料缓存时,仅签名模式不要求 +设备连接。`--output` 默认拒绝覆盖已有文件;只有调用方明确传入 +`--overwrite-output` 才会原子替换。输入已经签名时不会重复签名;传入 `--output` 时 +仍会发布到指定路径,否则返回原 HAP 路径。完整的 agent 调用约定和 JSON 字段见 +[Agent 签名协议](docs/AGENT_SIGNING.md)。 + +`doctor --json` 会同时返回本次解析出的 `paths` 和带稳定编号的 +`breaking_changes`;`inspect --json` 会把当前 HAP 适用的项目放入 +`migration_warnings`。升级已有安装前请按[迁移指南](docs/MIGRATIONS.md)处理。 + +Codex 从本仓库运行时会自动发现 +[hapsign-signing 技能](.agents/skills/hapsign-signing/SKILL.md)。该技能覆盖三平台 +CLI 定位、受控浏览器授权、JSON 判定、签名后复检和脱敏故障诊断;仓库副本是个人 +安装版本的权威来源。 + +便携版把上述命令名替换为 Windows 的 `hapsign-cli.exe` 或 Linux/macOS 的 +`./hapsign-cli`,无需目标机器安装 Python。 + ### 构建便携版 便携版是一个可直接解压运行的目录,不要求目标电脑安装 Python 或 DevEco Studio。 -Windows 正式包使用锁定并校验的 OpenHarmony 6.1 公共工具链和 Eclipse Temurin -21。首次构建先准备工具链,再打包: +Windows/Linux 正式包使用锁定并校验的 OpenHarmony 6.1 公共工具链和 Eclipse +Temurin 21。首次构建先在目标平台准备工具链,再打包: ```bash python -m pip install -e ".[gui,bundle]" @@ -215,7 +277,8 @@ python scripts/build_portable.py `prepare_toolchain.py` 会校验 `toolchain.lock.json` 中的大小和 SHA-256,只从 OpenHarmony 公共 SDK 提取 HDC、libusb、hap-sign-tool 和 NOTICE,再用 Temurin -JDK 的 `jlink` 生成精简 Java 运行时。公共 SDK 下载约 2.5 GB,但只在构建缓存中 +JDK 的 `jlink` 生成精简 Java 运行时。Linux JDK 的 tar.gz 权限和链接会经过安全 +提取,并确保 HDC 保留可执行位。公共 SDK 下载约 2.5 GB,但只在构建缓存中 保留;最终工具链约 66 MiB。已有下载可用 `--sdk-archive` 和 `--jdk-archive` 传入,仍会执行相同校验。 @@ -228,8 +291,9 @@ python -m playwright install --no-shell chromium python scripts/build_portable.py --keep-bundled-browser ``` -输出文件为 `dist/HapSign-portable-.zip`。Windows、macOS 和 Linux -产物需要在各自平台分别构建。便携版使用说明见 [PORTABLE.md](PORTABLE.md), +Windows/macOS 输出 `dist/HapSign-portable-.zip`,Linux 输出 +`dist/HapSign-portable-linux.tar.gz` 以保留可执行位。三个目标都需要在各自平台 +分别构建,并同时包含 GUI 与 agent CLI。便携版使用说明见 [PORTABLE.md](PORTABLE.md), 完整构建步骤见 [docs/PACKAGING.md](docs/PACKAGING.md)。 > [!NOTE] @@ -242,9 +306,12 @@ python scripts/build_portable.py --keep-bundled-browser ### Agent CLI 接口 ``` +hapsign doctor [--state-dir DIR] [--output-dir DIR] [--json] +hapsign inspect --hap HAP [--bundle-name NAME] [--state-dir DIR] [--enable-capability] [--json] +hapsign migrate-cache --hap HAP --profile-type normal|system-basic [--state-dir DIR] [--json] hapsign auth [login|status] [--refresh] [--state-dir DIR] [--json] hapsign devices [list] [--connected-only] [--json] -hapsign sign --hap HAP --serial SERIAL [签名选项] [--json] +hapsign sign --hap HAP [--serial SERIAL | --device-udid UDID] [签名选项] [--json] hapsign install --hap SIGNED_HAP --serial SERIAL [--bundle-name NAME] [--json] hapsign deploy --hap HAP --serial SERIAL [签名选项] [--json] @@ -252,9 +319,11 @@ sign / deploy 签名选项: --bundle-name NAME 覆盖 HAP 中的包名 --country CODE 华为账号国家码,默认 CN --device-type TYPE 签名平台注册的设备类型码,默认 4 - --state-dir DIR Token 与默认签名材料根目录,默认 ~/.hapsign + --state-dir DIR Token 与默认签名材料根目录 --work-dir DIR 当前 bundle 签名材料目录,默认 / - --output-dir DIR 签名 HAP 输出目录,默认与 work-dir 相同 + --output-dir DIR 未指定 --output 时的签名产物目录 + --output FILE 签名 HAP 的精确输出路径 + --overwrite-output 允许覆盖 --output 指定的已有文件 --browser MODE system、system_controlled 或 playwright --enable-capability 使用 Real Profile(APL=system_basic) --refresh-token 强制浏览器认证,同时刷新签名材料 @@ -276,8 +345,9 @@ sign / deploy 签名选项: ``` 成功 JSON 至少包含 `ok=true` 和 `command`。`sign` / `deploy` 还包含 `input_hap`、 -`signed_hap`、`bundle_name`、`serial`、`input_signed` 和 `installed`;`devices list` -包含 `count`、`connected_count` 与 `targets`。失败 JSON 使用 +`signed_hap`、`bundle_name`、`serial`、`input_signed`、`installed`、请求/实际能力模式 +和 `capability_fallback`;`devices list` 包含 `count`、`connected_count` 与 +`targets`。失败 JSON 使用 `{"ok":false,"command":"...","error":{"type":"...","message":"..."}}`。 完整、随版本同步的帮助以 `hapsign --help` 和各子命令 `--help` 为准。 @@ -297,6 +367,10 @@ hapsign deploy --hap app.hap --serial --enable-capability 此模式通过 `add.real.provision` API 创建 Real Profile(provisionType=1),对应 DevEco Studio 6.1+ 的 `enableCapability` 路径。需要应用已在 AGC(AppGallery Connect)注册且当前账号有访问权限,否则自动回退到 Test Profile。 +CLI 会在结果中分别返回 `requested_capability_mode` 和 `capability_mode`。如果发生回退, +`capability_fallback=true`;相同请求会复用该 Test Profile,显式传入 +`--refresh-signing` 才会重新尝试申请 Real Profile。 + ### 签名文件和缓存位置 桌面版和便携版默认把签名材料保存在程序目录旁,解压目录可以整体移动: @@ -310,23 +384,27 @@ HapSign/ └── signed_haps/ # 最新一个签名 HAP(可在设置中关闭) ``` -源码 CLI 默认保存在用户主目录的 `~/.hapsign//`;Windows 对应 -`%USERPROFILE%\.hapsign\\`。该默认值不依赖启动命令时的工作目录。 -传入 `--state-dir`、`--work-dir` 和 `--output-dir` 可以分别指定 Token/默认材料 -根目录、当前 bundle 材料目录和签名 HAP 输出目录。 +源码 CLI 默认保存在应用目录(源码仓库根目录)的 +`signing_files//`,不依赖启动命令时所在目录。`--state-dir` 可配置 token +与全部 bundle 缓存的根目录;`--work-dir` 只覆盖本次 HAP 的材料目录; +`--output-dir` 配置默认产物目录,`--output` 则指定精确文件。对应环境变量是 +`HAPSIGN_SIGNING_DIR` 与 `HAPSIGN_SIGNED_HAPS_DIR`,命令行参数优先。 程序目录必须可写,不建议把便携版解压到 `Program Files` 等受保护目录。 桌面版“设置”中还可以改为用户 `AppData Local` 或任意自定义目录。 -成功完成一次未签名 HAP 的签名后,目录内容如下: +成功完成一次未签名 HAP 的签名后,默认目录内容如下: ``` -<签名目录>/com.example.myapp/ +signing_files/com.example.myapp/ ├── auto_debug_com.example.myapp.p12 # 密钥库 ├── auto_debug_com.example.myapp.csr # CSR ├── auto_debug_com.example.myapp.cer # 调试证书 ├── auto_debug_com.example.myapp.p7b # 调试 Profile -├── metadata.json # 缓存元数据 -└── entry-default-unsigned_signed.hap # 签名后的 hap +└── metadata.json # 缓存元数据 + +signed_haps/ +├── .hapsign-signed-haps.json # HapSign 生成产物清单 +└── entry-default-unsigned_signed.hap # 签名后的 HAP ``` 如果输入 HAP 本身已经签名,程序会直接安装原文件,不会生成上述签名材料。 @@ -335,22 +413,26 @@ HapSign/ 同一天内不会重复登录;签名文件只在 bundle 和目标 UDID 都相同时复用: -- **Token 缓存**:`~/.hapsign/.token_cache.json`,当天可跨目标设备复用 -- **签名文件缓存**:`~/.hapsign/{bundle_name}/metadata.json`,当天仅为匹配的 +- **Token 缓存**:`/.token_cache.json`,当天可跨目标设备复用 +- **签名文件缓存**:`/{bundle_name}/metadata.json`,当天仅为匹配的 bundle 和设备 UDID 复用 - 跨天自动失效,重新走完整流程 - Token 失效时自动刷新,刷新失败才回退到重新登录 -Windows 使用当前用户 DPAPI 加密 Token;macOS/Linux 以权限 `0600` 的明文保存。 -签名材料与缓存都不要上传、分享或放入云同步目录。共享电脑使用完毕后应删除 -`~/.hapsign/`。详细说明见 [SECURITY.md](SECURITY.md)。 +这些文件包含敏感信息。Windows token 缓存使用当前用户 DPAPI 保护;Linux/macOS +token 缓存是权限限制为 `0o600` 的明文文件,签名私钥等材料仍需按敏感文件保护。 +不要上传、分享或放入云同步目录。共享电脑使用完毕后应删除 `signing_files/`。详细 +说明见 [SECURITY.md](SECURITY.md)。 ## 限制 - 登录验证码 / 二次验证需要用户在浏览器中手动处理 -- 拖拽安装脚本仅支持 Windows(`sign_install.bat`);macOS 请使用 `hapsign` 命令行 -- 当前提供并完整验证的是 Windows 便携版;macOS/Linux 已预留运行时路径, - 仍需分别增加锁定的公开工具包并在对应系统构建、实机验证 +- 拖拽安装脚本仅支持 Windows(`sign_install.bat`);Linux/macOS 请使用 + `hapsign` 命令行 +- Windows 便携版已经过完整构建和实机流程验证;Linux x64 已有锁定公开工具链、 + CI 和构建路径,但发布前仍应在目标发行版执行 GUI、USB/udev 和真实设备安装回归 +- macOS 源码命令行可使用 DevEco Studio;正式便携发布仍需锁定公开工具链并完成 + 应用签名/notarization - 签名流程依赖华为云 API,需要有网络连接和华为开发者账号 ## 开发与贡献 diff --git a/SECURITY.md b/SECURITY.md index cc82819..82608ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,10 @@ vulnerability reporting;启用后请使用仓库 Security 页的“Report a vu ## 本地敏感数据 -桌面/便携版默认会在程序目录的 `signing_files/` 中保存当日 token 缓存、调试证书、 -Profile 和 `.p12` 密钥库;CLI 默认使用用户主目录的 `~/.hapsign/`(Windows 为 -`%USERPROFILE%\.hapsign\`)。这些文件已被 `.gitignore` 排除,但仍是本机敏感 +桌面、便携版和 CLI 默认会按应用配置在程序目录的 `signing_files/` 中保存当日 token +缓存、调试证书、Profile 和 `.p12` 密钥库;CLI 可用 `--state-dir` 或 +`HAPSIGN_SIGNING_DIR` 覆盖。此默认值不依赖启动命令时的工作目录。这些文件已被 +`.gitignore` 排除,但仍是本机敏感 数据。Windows 上 token 缓存通过当前用户作用域的 DPAPI (CryptProtectData)静态加密后落盘,其他平台退化为受限权限(仅当前用户可读) 的明文存储,并在首次保存时打印告警;请勿把缓存目录放入云同步目录,在共享电脑 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 83a58d1..4569e4d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,7 +19,7 @@ HapSign 自身的源代码使用 [MIT License](LICENSE)。本文件说明源码 | idna | 国际化域名处理 | BSD-3-Clause | | PyInstaller bootloader | 生成可执行文件 | GPL-2.0-or-later,带允许分发生成程序的特殊例外 | -Windows 便携版以独立 DLL 的方式携带未修改的 Qt/PySide6 运行库,选择 LGPL-3.0 +便携版以独立动态库的方式携带未修改的 Qt/PySide6 运行库,选择 LGPL-3.0 路径进行分发。接收者可以用 ABI 兼容的修改版动态库替换 `_internal/PySide6/` 中相应文件;HapSign 不对调试这种修改施加额外限制。Qt 的 版权、商标和许可仍归各权利人所有。 @@ -31,7 +31,7 @@ Chromium,还必须保留该目录内 Playwright/Chromium 的第三方声明。 ## 便携工具链 -Windows 正式便携版从 `toolchain.lock.json` 锁定的公开上游准备: +Windows/Linux x64 正式便携版从 `toolchain.lock.json` 锁定的公开上游准备: | 组件 | 当前版本/来源 | 许可 | | --- | --- | --- | @@ -45,7 +45,7 @@ Windows 正式便携版从 `toolchain.lock.json` 锁定的公开上游准备: - `resources/toolchain//runtime/legal/`; - OpenHarmony 公共 SDK 的完整 `NOTICE.txt`; - 下载地址、版本及 SHA-256 的 `PROVENANCE.txt` 和 `toolchain.lock.json`; -- `licenses/libusb-source/` 中与 DLL 对应的 OpenHarmony libusb 完整源码快照、 +- `licenses/libusb-source/` 中与 libusb 动态库对应的 OpenHarmony libusb 完整源码快照、 补丁、构建配置和 LGPL 文本。 HapSign 的 MIT 许可只覆盖本项目自身代码,不覆盖这些独立组件。接收者可以按各组件 diff --git a/bundle/hapsign.spec b/bundle/hapsign.spec index a736778..10828c7 100644 --- a/bundle/hapsign.spec +++ b/bundle/hapsign.spec @@ -43,6 +43,24 @@ analysis = Analysis( noarchive=False, optimize=1, ) + +# Qt6Core imports the unsuffixed ``icuuc.dll`` on Windows. PyInstaller resolves +# DLL imports through the build machine's PATH; a PATH entry such as the bundled +# Poppler runtime can therefore inject an incompatible ICU DLL into the package +# (the Qt binary expects the Windows/system ICU ABI). Keep only an ICU DLL that +# is actually shipped by the PySide6 package, and never freeze an unrelated PATH +# copy. This also makes the result independent of the Codex/PDF runtime PATH. +_pyside_root = Path(__import__("PySide6").__file__).resolve().parent +_icu_names = {"icuuc.dll", "icuin.dll", "icudt.dll", "icudt78.dll"} +analysis.binaries = [ + item + for item in analysis.binaries + if not ( + Path(item[0]).name.lower() in _icu_names + and _pyside_root not in Path(item[1]).resolve().parents + ) +] + pyz = PYZ(analysis.pure) exe = EXE( diff --git a/docs/AGENT_SIGNING.md b/docs/AGENT_SIGNING.md new file mode 100644 index 0000000..4d94ebc --- /dev/null +++ b/docs/AGENT_SIGNING.md @@ -0,0 +1,142 @@ +# HapSign Agent 签名协议 + +本文描述 agent 如何在 Windows、Linux 和 macOS 上半自动完成 HAP 调试签名。账号 +密码、验证码和二次验证始终由用户在浏览器中完成;agent 不需要也不应读取这些信息。 +源码安装后的命令名是 `hapsign`;便携版使用 `hapsign-cli.exe`(Windows)或 +`./hapsign-cli`(Linux/macOS),其余参数和结果完全相同。 + +## 稳定调用顺序 + +### 1. 诊断运行环境 + +```bash +hapsign doctor --json +``` + +标准输出是单个 JSON 文档。`capabilities.signing.ok` 表示 Java、keytool 和 +hap-sign-tool 可用,`capabilities.device.ok` 表示 HDC 可用;顶层 `ok` 只有两组 +都可用时才为 `true`。仅签名且已有缓存 Profile 或显式提供 UDID 时,可以不要求 +device capability。 + +先读取 `paths` 和 `breaking_changes`。`paths.state_dir`、`paths.output_dir` 是本次 +实际使用的绝对目录;兼容性变更用稳定 `id` 标识,整改方式见 +[迁移指南](MIGRATIONS.md)。 + +工具发现顺序为:当前平台便携资源、`DEVECO_HOME`/常见 DevEco Studio 目录、 +`JAVA_HOME`/`PATH`,最后应用 `HAPSIGN_JAVA`、`HAPSIGN_KEYTOOL`、 +`HAPSIGN_HAP_SIGN_TOOL` 和 `HAPSIGN_HDC` 覆盖。 + +### 2. 只读检查输入 + +```bash +hapsign inspect --hap app.hap --json +``` + +如果任务需要 Real Profile/system_basic,检查时就传入 `--enable-capability`,并在后续 +`sign`/`deploy` 中保持一致。这样 `migration_warnings` 才能按实际目标模式判断缓存。 + +成功结果包含绝对 `hap` 路径、`bundle_name`、布尔值 `signed`、`paths` 和 +`migration_warnings`,不会登录、连接设备、创建签名材料或修改 HAP。若警告中 +`destructive: true` 且 `requires_user_decision: true`,agent 必须先向用户说明影响和 +整改选项,不能直接继续签名。 + +对于 `HAPSIGN-BREAKING-001`,先读取 `reasons`。能力模式不一致时可以让检查和签名 +使用与缓存一致的 `--enable-capability` 配置;确实要切换模式时,用户可以备份 +`paths.work_dir` 后接受一次性刷新。只有警告同时返回 `migratable: true`,并且用户 +确认旧 Profile 类型后,才可执行: + +```bash +hapsign migrate-cache --hap app.hap --state-dir /absolute/signing_files \ + --profile-type normal --json +``` + +Real Profile 使用 `system-basic`。不能从旧元数据可靠推断类型,不得替用户猜测。 + +### 3. 发起仅签名流程 + +```bash +hapsign sign \ + --hap app.hap \ + --output artifacts/app-signed.hap \ + --browser system_controlled \ + --json +``` + +普通日志只写入标准错误,最终标准输出仍是单个 JSON 文档。没有当日登录/签名材料 +缓存时,命令会打开浏览器;agent 应提示用户在该浏览器中完成授权,然后继续等待 +进程退出。成功结果的 `signed_hap` 是可交付产物的绝对路径。 + +Agent 默认应显式使用 `system_controlled`:它调用系统 Edge/Chrome,但使用隔离的临时 +上下文,不复用用户 cookie、已保存密码或旧 SSO 状态,并可预授予登录页访问 loopback +回调的权限。`system` 会打开用户的普通默认浏览器 Profile,仅在用户明确希望复用登录 +状态或受控浏览器不可用时使用;浏览器扩展、旧 cookie 和本地网络权限可能改变授权流程。 + +首次申请调试 Profile 需要设备 UDID。可以连接一台已授权调试设备让 HDC 自动读取, +也可以由调用方提供已核实的值: + +```bash +hapsign sign --hap app.hap --device-udid <64位十六进制UDID> \ + --browser system_controlled --json +``` + +当日 `metadata.json` 中的 `.p12/.cer/.p7b` 仍有效,且包名、能力模式与已知设备 +UDID 匹配时,HapSign 会直接复用这些材料,无需连接设备或重新登录。 + +### 4. 可选安装 + +安装必须显式传入 HDC serial。未签名输入使用 `deploy`,已经签名的输入可直接使用 +`install`: + +```bash +hapsign deploy --hap app.hap --serial --json +hapsign install --hap app-signed.hap --serial --json +``` + +## 结果与退出码 + +- `0`:操作成功;JSON 的 `ok` 为 `true`。 +- `1`:诊断不完整或登录/签名/HDC/安装运行失败;JSON 的 `ok` 为 `false`, + `error` 给出脱敏后的阶段错误。 +- `2`:命令行参数组合或输入 HAP 无效。 + +签名成功结果包含 `command`、`input_hap`、`bundle_name`、`browser_mode`、`paths`、 +`signed_hap`、`installed`、`requested_capability_mode`、`capability_mode` 和 +`capability_fallback`。请求 Real Profile 时如果 `capability_fallback: true`,HAP 已 +完成调试签名,但实际 Profile 仍是 `normal`;agent 必须明确报告能力未满足,不能把它 +描述为 system_basic 成功。读取 `signed_hap` 后应再次执行 +`inspect --hap --json`,只有确认 `signed: true` 才报告签名成功。 +`inspect` 和 `doctor` 使用各自的只读结果字段。 + +## 输出安全约定 + +- `--output` 必须以 `.hap` 结尾,且不能与输入 HAP 是同一路径。 +- 指定输出已存在时默认失败;只有明确传入 `--overwrite-output` 才会原子替换。 +- 未指定 `--output` 时写入应用目录的 `signed_haps/`,使用 `<输入名>_signed.hap` 命名。 +- `--state-dir` 配置 token/签名缓存根目录;`--output-dir` 配置默认产物目录。 + 对应环境变量为 `HAPSIGN_SIGNING_DIR` 和 `HAPSIGN_SIGNED_HAPS_DIR`,命令行参数优先。 +- `sign` 输入已经签名时不重复签名;传入 `--output` 时仍会发布到该路径,否则 + `signed_hap` 指向原输入。 +- JSON 和默认日志不会包含 token、密钥库密码或完整账号认证载荷。 +- `signing_files/` 包含私钥、证书、Profile 和登录缓存,不应上传、提交或放入不受信任 + 的共享目录。 + +## 授权诊断 + +- 没有任何 `[callback]`:浏览器没有访问 loopback,检查浏览器模式和本地网络权限。 +- 收到 POST/GET 但没有“授权回调校验成功”:检查脱敏后的 CSRF 或参数错误。 +- 已校验成功:浏览器继续转圈或随后出现 `net::ERR_ABORTED` 通常是回调后关闭页面产生; + 应继续检查 token 交换和后续签名阶段。 +- Windows DPAPI 缓存解密失败会自动触发重新登录;Linux/macOS token 缓存是 + 权限限制为 `0o600` 的明文文件。不要擅自删除签名材料,也不要把缓存放进共享或 + 云同步目录。 + +CLI 默认把缓存和签名产物绑定到应用目录及其配置,不依赖调用者当前工作目录。诊断旧版 +GUI/CLI 时仍应先记录实际可执行文件路径、版本、浏览器模式和日志目录,避免读取错实例。 + +## Linux 提示 + +Linux 源码运行既可以使用 DevEco Studio,也可以组合系统 JDK、OpenHarmony SDK 和 +显式环境变量。先运行 `hapsign doctor --json`,再用 `hapsign devices list --json` +验证 USB +设备访问;如果普通用户看不到设备,应按所用发行版配置对应 udev 规则后重新插拔并 +授权设备。HapSign 的 HDC server 检测在没有 `lsof` 时会回退到 `/proc`。 diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md new file mode 100644 index 0000000..dd43209 --- /dev/null +++ b/docs/MIGRATIONS.md @@ -0,0 +1,155 @@ +# HapSign 兼容性变更与迁移 + +本页集中记录会改变既有脚本、缓存或默认行为的变更。每项都有稳定编号;相同信息也会 +出现在 `hapsign doctor --json` 的 `breaking_changes` 中。针对具体 HAP 运行 +`hapsign inspect --hap app.hap --json` 时,当前确实适用的项目会出现在 +`migration_warnings` 中。 + +自动化程序应按编号处理,不要匹配可能调整措辞的中文消息。`introduced_in` 在版本发布 +前为 `unreleased`,发布时替换为正式版本号。 + +当前兼容策略和产品结论如下;这些字段也由 `doctor --json` 以 `decision`、 +`compatibility_strategy` 和 `compatibility_options` 返回: + +| 编号 | 兼容策略 | 明确结论 | +| --- | --- | --- | +| 001 | 配置匹配、显式迁移或备份后刷新 | 接受新的缓存一致性校验 | +| 002 | 可配置恢复普通系统浏览器 | 接受 `system_controlled` 新默认值 | +| 003 | 可配置恢复旧状态与产物目录 | 接受应用目录新默认值 | +| 004 | 仅迁移调用方,不提供混合输出模式 | 保留 stdout 结果与 stderr 日志分离 | +| 005 | 仅迁移调用方,不提供扁平参数兼容层 | 保留显式子命令接口 | + +## HAPSIGN-BREAKING-001 + +旧版 `metadata.json` 没有 `enable_capability`。新版还会核对目标能力模式、包名、64 位 +设备 UDID 和材料文件,避免把 `normal` Test Profile 与 `system_basic` Real Profile +混用或跨目标复用。未通过一致性校验时,下一次签名会重新申请材料;生成同名密钥库时 +会替换本地 `.p12`,申请证书时可能删除并替换账号下同名的远端调试证书。 + +先执行只读检查: + +```bash +hapsign inspect --hap app.hap --json +``` + +如果后续签名需要 Real Profile,检查时也必须传入同一个选项: + +```bash +hapsign inspect --hap app.hap --enable-capability --json +``` + +若 `migration_warnings` 包含本编号,先读取其中的 `reasons`、`migratable`、 +`cached_capability_mode`、`cached_effective_capability_mode` 和 +`expected_capability_mode`: + +- `capability_mode_mismatch`:如果要复用现有模式,让 `inspect` 与 `sign/deploy` 一致地 + 传入或省略 `--enable-capability`;如果确实要切换模式,使用下面第 1 种刷新方式。 +- `missing_capability_mode`:仅当 `migratable: true` 时可选择下面第 2 或第 3 种迁移。 +- 包名或 UDID 也不一致时不可迁移,只能选择第 1 种刷新方式。 + +新版元数据分别保存“调用方请求模式”和“服务端实际生成模式”。请求 Real Profile 但 +应用条件不足而回退到 Test Profile 时,相同请求会复用该回退结果,不会每天重复刷新; +如需重新尝试 Real Profile,显式使用 `--refresh-signing`。签名结果 JSON 的 +`capability_fallback` 会说明本次是否发生了这种回退。 + +整改方式: + +1. 不需要保留旧材料:先备份 JSON 中 `paths.work_dir` 指向的整个目录,再正常签名, + 接受一次性刷新。 +2. 确认旧材料是默认的 `normal` Test Profile: + + ```bash + hapsign migrate-cache --hap app.hap --state-dir /absolute/signing_files \ + --profile-type normal --json + ``` + +3. 确认旧材料是 `system_basic` Real Profile: + + ```bash + hapsign migrate-cache --hap app.hap --state-dir /absolute/signing_files \ + --profile-type system-basic --json + ``` + +迁移命令不登录、不联网、不申请或删除证书,只原子补充能力模式并把已存在的材料路径 +规范为绝对路径;首次修改前会在同目录保留 +`metadata.json.pre-capability-migration.bak`。如果缓存不是当天创建、材料缺失、已声明 +不同模式、包名不匹配或 UDID 无效,命令会失败而不会覆盖。无法确认旧 Profile 类型时 +不要猜,使用第一种刷新方式。 + +## HAPSIGN-BREAKING-002 + +CLI 登录浏览器默认值由 `system` 改为 `system_controlled`。新默认使用隔离的临时 +Edge/Chrome 上下文,不带用户默认浏览器中的 cookie、保存密码、扩展和旧 SSO 状态。 + +需要旧行为时显式配置: + +```bash +hapsign sign --hap app.hap --browser system +``` + +或设置 `HAPSIGN_BROWSER=system`。命令行参数优先于环境变量。复用普通浏览器 Profile +可能让登录页表现不同,也可能受扩展或本地网络权限设置影响。 + +## HAPSIGN-BREAKING-003 + +最新 master 的 PR #5 默认把 CLI 状态和签名产物放在 +`~/.hapsign//`;更早的源码 CLI 则使用进程当前工作目录下的相对 +`signing_files/`。新版默认使用应用目录(冻结版本为可执行文件所在目录,源码运行时为 +仓库根目录),并把默认产物放在应用目录的 `signed_haps/`。旧目录不会自动搬迁。 +如果新目录尚无对应状态,而 PR #5 的 `~/.hapsign` 中仍有 Token 或当日可用材料, +`inspect` 会返回本编号。仅发现 Token 时这是非破坏性迁移提示;发现可复用签名材料时 +才会标记 `destructive: true` 和 `requires_user_decision: true`。忽略后者继续签名可能 +重新生成本地密钥,并删除、替换账号下的同名远端调试证书。 + +配置优先级为“命令行参数 > 专用环境变量 > 现有应用配置/应用目录默认值”: + +| 用途 | 命令行 | 环境变量 | 含义 | +| --- | --- | --- | --- | +| 全局签名状态 | `--state-dir` | `HAPSIGN_SIGNING_DIR` | token 与各 bundle 的签名缓存根目录 | +| 单个 bundle 工作目录 | `--work-dir` | 无 | 只覆盖当前 HAP 的材料目录 | +| 默认产物目录 | `--output-dir` | `HAPSIGN_SIGNED_HAPS_DIR` | 未传 `--output` 时使用 | +| 精确产物路径 | `--output` | 无 | 优先使用单个文件路径;可与 `--output-dir` 同时传 | + +`HAPSIGN_DATA_DIR` 保持兼容:它仍表示应用数据根目录,签名状态位于其 +`signing_files/` 子目录。`doctor --json` 的 `paths` 会给出本次解析后的绝对路径。 + +若要恢复 PR #5 的用户主目录方案,显式传入原状态和产物路径即可: + +```bash +hapsign sign --hap app.hap --state-dir ~/.hapsign \ + --output-dir ~/.hapsign/ --json +``` + +若要继续复用更早版本的工作目录,也可显式传入旧路径: + +```bash +hapsign sign --hap app.hap --state-dir /old/cwd/signing_files \ + --output-dir /old/cwd/signed_haps --json +``` + +## HAPSIGN-BREAKING-004 + +该行为由 master 的 PR #5(合入提交 `37c4c3`)引入。 + +CLI 普通输出与日志流已分离,且面向人的文本不作为稳定协议。依赖旧标准输出日志文本的 +脚本应改用 `--json`,只按退出码、`ok`、`command`、`signed_hap`、`installed`、 +`paths` 和稳定编号字段判断。普通日志写入标准错误。产品结论是保留分离,不提供把日志 +重新混入 stdout 的兼容开关。 + +## HAPSIGN-BREAKING-005 + +该行为由 master 的 PR #5(合入提交 `37c4c3`)引入。 + +CLI 改为显式子命令接口。旧调用不会被静默猜测或兼容转发,以免 agent 把本应只读的 +检查误执行为签名或安装。产品结论是保留子命令,不提供旧扁平参数兼容层。常见映射如下: + +| 旧调用 | 新调用 | +| --- | --- | +| `hapsign --doctor --json` | `hapsign doctor --json` | +| `hapsign --hap app.hap --inspect --json` | `hapsign inspect --hap app.hap --json` | +| `hapsign --hap app.hap --sign-only --json` | `hapsign sign --hap app.hap --json` | +| `hapsign --hap app.hap --json` | `hapsign deploy --hap app.hap --serial --json` | + +缓存迁移使用 `migrate-cache`,已签名 HAP 的安装使用 `install`。`deploy` 和 `install` +必须显式传入非空 `--serial`;`sign` 可以传 `--serial`、可信的 `--device-udid`,或在 +不连接设备时复用包含有效 UDID 的兼容缓存材料。 diff --git a/docs/OPEN_SOURCE_RELEASE.md b/docs/OPEN_SOURCE_RELEASE.md index 3b4b854..c1bdd4f 100644 --- a/docs/OPEN_SOURCE_RELEASE.md +++ b/docs/OPEN_SOURCE_RELEASE.md @@ -36,14 +36,15 @@ - 兼容包保留 Chromium/Playwright 的第三方许可文件; - 解压目录不含 `hapsign-config.json`、`logs/`、`signing_files/`、`signed_haps/` 或任何构建者/测试者数据; -- 对最终 ZIP 运行恶意软件扫描、签名(若有代码签名证书)和 SHA-256 校验。 +- 对最终发布归档(Windows/macOS ZIP 或 Linux tar.gz)运行恶意软件扫描、签名(若有 + 代码签名证书)和 SHA-256 校验。 -Windows 正式构建必须先运行 `python scripts/prepare_toolchain.py`,再运行 +Windows/Linux x64 正式构建必须先运行 `python scripts/prepare_toolchain.py`,再运行 `python scripts/build_portable.py`。准备脚本锁定并校验 OpenHarmony 公共 SDK 与 Eclipse Temurin,只提取运行所需文件,并附带 libusb 对应源代码。 使用 `--allow-deveco-toolchain` 生成的是本机兼容/排障包,不通过公开发布门禁; -使用 `--skip-toolchain` 生成的是 GUI-only 包,也不能作为完整便携版发布。 +使用 `--skip-toolchain` 生成的是无工具链 GUI/CLI 包,也不能作为完整便携版发布。 ## GitHub 发布建议 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index c787a3d..a46c987 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -7,7 +7,8 @@ Java、keytool、`hap-sign-tool.jar` 和 HDC 一并复制后压缩成 ZIP。目 ## 1. 构建模型 - Windows、macOS、Linux 产物必须分别在对应操作系统构建,不能交叉生成。 -- 当前经过完整构建和启动验证的是 64 位 Windows 版本。 +- 当前经过完整构建、启动和真实设备验证的是 64 位 Windows 版本。Linux x64 已有 + 锁定工具链、准备脚本、CI 和打包路径,发布前仍需在目标发行版完成 GUI/USB 实测。 - 仓库不保存大型 SDK/JDK 二进制;`toolchain.lock.json` 固定公共上游地址、版本、 大小和 SHA-256,准备结果放在被 Git 忽略的 `build/`。 - 默认由 Playwright 控制系统 Edge/Chrome,因此继续拥有本地网络权限和回调控制, @@ -20,10 +21,10 @@ Java、keytool、`hap-sign-tool.jar` 和 HDC 一并复制后压缩成 ZIP。目 完整便携包需要: 1. Python 3.11 或更高版本。 -2. Windows 正式构建需要网络下载锁定的 OpenHarmony 公共 SDK(约 2.5 GB)和 - Temurin JDK(约 205 MB),或由构建者提供已下载的相同文件。 -3. 首次准备建议至少 6 GB 可用磁盘空间;工具链生成后可删除研究/下载副本, - `build/toolchain-prepared/windows` 约 66 MiB。 +2. Windows/Linux x64 正式构建需要网络下载锁定的 OpenHarmony 公共 SDK + (约 2.5 GB)和当前平台 Temurin JDK(约 200 MB),或由构建者提供已下载的 + 相同文件。 +3. 首次准备建议至少 6 GB 可用磁盘空间;工具链生成后可删除研究/下载副本。 DevEco Studio 不是正式便携构建的依赖。它仍可用于源码运行和兼容排障;只有显式 传入 `--allow-deveco-toolchain` 时,构建脚本才会从本机发现并复制它的工具。 @@ -57,7 +58,7 @@ python scripts/prepare_toolchain.py 1. 校验 `toolchain.lock.json`; 2. 下载并校验 OpenHarmony 6.1 公共 SDK 和 Eclipse Temurin 21; -3. 只提取 Windows HDC、配套 libusb、hap-sign-tool 及完整 NOTICE; +3. 只提取当前平台 HDC、配套 libusb、hap-sign-tool 及完整 NOTICE; 4. 用 `jlink` 生成只含签名器和 keytool 所需模块的 Java runtime; 5. 附带 libusb 1.0.28 对应的 OpenHarmony 源码快照; 6. 实际运行 Java、EC keytool、hap-sign-tool 和 HDC 版本自检。 @@ -70,8 +71,8 @@ python scripts/prepare_toolchain.py ` --jdk-archive D:\cache\OpenJDK21U-jdk_x64_windows_hotspot_21.0.12_8.zip ``` -默认输出是 `build/toolchain-prepared/windows/`。要重建现有输出,显式添加 -`--force`。 +默认输出是 `build/toolchain-prepared//`。要重建现有输出,显式添加 +`--force`。Linux JDK 使用锁定的 tar.gz,脚本会安全保留 JDK/HDC 的可执行权限。 ## 5. 生成完整便携包 @@ -84,15 +85,17 @@ python scripts/build_portable.py 脚本会依次: 1. 检查构建机系统 Edge/Chrome 可由 Playwright 正常控制。 -2. 使用 `bundle/hapsign.spec` 构建 `dist/HapSign/`;默认不收集 Chromium。 -3. 复制已校验的公开工具链及其许可、来源和对应源码。 -4. 复制便携版使用说明和本打包指南。 -5. 运行冻结应用自检,并清除自检生成的日志、配置和运行数据。 -6. 生成 `dist/HapSign-portable-.zip`。 - -Windows 正式构建使用 Temurin `jlink` runtime 并复用系统 Edge/Chrome。每次构建 -都会实际启动冻结版受控系统浏览器,并运行 Java、hap-sign-tool 和 keytool 密钥 -生成自检;任一失败便终止构建。 +2. 使用 `bundle/hapsign.spec` 构建 `dist/HapSign/` GUI;默认不收集 Chromium。 +3. 额外生成同目录的单文件控制台程序 `hapsign-cli[.exe]`,供 agent/脚本调用。 +4. 复制已校验的公开工具链及其许可、来源和对应源码。 +5. 复制便携版使用说明和本打包指南。 +6. 运行 GUI、CLI 和工具链自检,并清除自检生成的日志、配置和运行数据。 +7. Windows/macOS 生成 `dist/HapSign-portable-.zip`;Linux 生成 + `dist/HapSign-portable-linux.tar.gz`,避免解压时丢失可执行位。 + +Windows/Linux 正式构建使用 Temurin `jlink` runtime 并复用系统 Edge/Chrome。 +每次构建都会实际启动冻结版受控系统浏览器,并运行 Java、HDC、hap-sign-tool 和 +keytool 密钥生成自检;任一失败便终止构建。 仅在排查新版 DevEco 兼容性时可使用本机回退: @@ -117,6 +120,7 @@ Windows 的主要输出结构: dist/ ├── HapSign/ │ ├── HapSign.exe +│ ├── hapsign-cli.exe # agent/脚本控制台入口 │ ├── _internal/ # Python、Qt 和应用依赖 │ │ └── playwright/ # 浏览器控制驱动 │ ├── resources/toolchain/windows/ @@ -132,7 +136,7 @@ dist/ └── HapSign-portable-windows.zip ``` -## 6. GUI-only 快速构建 +## 6. 无工具链 GUI/CLI 快速构建 仅验证界面和 PyInstaller 配置时可以跳过约数百 MiB 的工具链复制: @@ -140,7 +144,7 @@ dist/ python scripts/build_portable.py --skip-toolchain ``` -GUI-only 包仍能启动,但目标电脑若没有可发现的外部工具链,就不能检测设备、 +GUI/CLI 仍能启动,但目标电脑若没有可发现的外部工具链,就不能检测设备、 签名或安装。不要把它当作正式便携版发布。 ## 7. 构建后验证 @@ -204,29 +208,31 @@ HapSign/ └── signed_haps/ # 最新一个签名 HAP,与材料目录设置无关 ``` -因此便携目录必须可写。源码 GUI 默认使用项目根目录,源码 CLI 默认使用用户主目录 -下的 `~/.hapsign//`。可以用 `HAPSIGN_DATA_DIR` 覆盖桌面版 -数据根目录,CLI 则可使用 `--state-dir`、`--work-dir` 和 `--output-dir`。已签名 -HAP 会跳过签名流程,因此不会 -产生新的 `.p12`、`.cer`、`.p7b` 或签名后 HAP。 +因此便携目录必须可写。源码 GUI 和源码 CLI 默认使用项目根目录下的应用配置目录, +不依赖启动命令时的工作目录。可以用 `HAPSIGN_DATA_DIR` 覆盖应用数据根目录,CLI +还可使用 `--state-dir`、`--work-dir`、`--output-dir` 和 `--output`。已签名 +HAP 会跳过密码学签名,因此不会产生新的 `.p12`、`.cer` 或 `.p7b`;显式传入 +`--output` 时仍会把已签名输入发布到指定路径。 GUI 设置也可选择用户 `AppData Local` 或自定义签名目录。程序目录下的 `hapsign-config.json` 保存这些设置,`logs/hapsign.log` 保存滚动诊断日志; 程序目录不可写时日志回退到用户本地数据目录。敏感诊断默认关闭,开启后配合 DEBUG 级别可记录 token、用户标识和完整 API 请求/响应,但密钥库密码永不记录。 -最终签名 HAP 固定使用程序目录的 `signed_haps/`,默认只保留最新一个 HapSign -清单记录的产物,不会清理未记录的用户 HAP,当前输入文件也会受到保护;设置关闭 -保留后,任务使用系统临时目录并在结束时清理。 +最终签名 HAP 默认使用程序目录的 `signed_haps/`,也可用 CLI 参数或 +`HAPSIGN_SIGNED_HAPS_DIR` 覆盖。默认目录只保留最新一个 HapSign 清单记录的产物, +不会清理未记录的用户 HAP,当前输入文件也会受到保护;设置关闭保留后,任务使用 +系统临时目录并在结束时清理。 ## 9. 跨平台注意事项 -- `runtime.py` 已集中处理平台目录和可执行文件名;`toolchain.lock.json` 当前只 - 锁定并实测 Windows x64。 +- `runtime.py` 已集中处理平台目录、可执行文件名、`JAVA_HOME`/`PATH` 和显式环境 + 变量覆盖;`toolchain.lock.json` 当前锁定 Windows x64 与 Linux x64。 - 新平台应增加独立锁定项、对应平台 Temurin 归档、OpenHarmony 工具包与真实设备 测试,不能复用 Windows 二进制。 - macOS 发布通常还需要应用包、代码签名和 notarization;当前脚本只生成目录 和 ZIP。 -- Linux 需要在目标发行版或兼容的较旧发行版构建,并验证 Qt、USB 权限和 HDC。 +- Linux 应在目标发行版或兼容的较旧发行版构建,并验证 Qt、USB/udev 权限和 HDC; + 没有 `lsof` 时运行时会使用 `/proc` 识别本次启动的 HDC server。 - 不要把一个平台的 Java runtime 或 HDC 复制进另一个平台的产物。 ## 10. 发布前清单 diff --git a/hapsign/cli.py b/hapsign/cli.py index 2c238c4..12648c8 100644 --- a/hapsign/cli.py +++ b/hapsign/cli.py @@ -8,6 +8,7 @@ import json import logging import os +import platform import sys import zipfile from collections.abc import Sequence @@ -17,12 +18,48 @@ from hapsign.cancellation import OperationCancelled from hapsign.config import DEVICE_TYPE_PHONE from hapsign.diagnostics import redact_sensitive_text -from hapsign.pipeline import SignPipeline, default_state_dir +from hapsign.migrations import ( + breaking_changes, + cache_compatibility_warning, + legacy_state_warning, + migrate_legacy_cache, +) +from hapsign.pipeline import SignPipeline +from hapsign.runtime import ( + ToolchainPaths, + application_dir, + discover_toolchain, + platform_tag, +) +from hapsign.settings import ( + config_file_path, + load_settings, + signed_haps_dir, + signing_files_dir, +) from hapsign.signing.hap_inspect import is_hap_signed from hapsign.signing.installer import Installer from hapsign.token import secure_token_cache -COMMANDS = {"auth", "devices", "sign", "install", "deploy"} +_BROWSER_MODES = ("system", "system_controlled", "playwright") + + +def _default_browser_mode() -> str: + """返回可复现的 CLI 浏览器默认值,并允许显式环境变量覆盖。""" + configured = os.environ.get("HAPSIGN_BROWSER", "system_controlled").lower() + return configured if configured in _BROWSER_MODES else "system_controlled" + + +COMMANDS = { + "auth", + "deploy", + "devices", + "doctor", + "inspect", + "install", + "migrate-cache", + "sign", +} EXIT_OK = 0 EXIT_OPERATION_FAILED = 1 EXIT_USAGE = 2 @@ -59,11 +96,9 @@ def _add_output_options(parser: argparse.ArgumentParser) -> None: def _add_state_option(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--state-dir", - default=default_state_dir(), + default=str(signing_files_dir(load_settings())), help=( - "Token 与默认签名材料根目录;默认用户主目录 ~/.hapsign" - "(Windows 为 %%USERPROFILE%%\\.hapsign)。" - "Token 不会出现在 JSON 输出中" + "Token 与默认签名材料根目录;默认按应用配置;环境变量 HAPSIGN_SIGNING_DIR" ), ) @@ -85,20 +120,11 @@ def _add_hap_identity_options(parser: argparse.ArgumentParser) -> None: ) -def _add_signing_options(parser: argparse.ArgumentParser) -> None: - _add_hap_identity_options(parser) - parser.add_argument( - "--serial", - required=True, - type=_nonempty_serial, - help="hdc list targets 返回的目标序列号;Profile 将绑定该设备", - ) - parser.add_argument("--country", default="CN", help="华为账号国家码;默认 CN") - parser.add_argument( - "--device-type", - default=DEVICE_TYPE_PHONE, - help="签名平台注册的设备类型码;默认 4(手机/平板/2in1)", - ) +def _add_path_options( + parser: argparse.ArgumentParser, + *, + include_exact_output: bool, +) -> None: _add_state_option(parser) parser.add_argument( "--work-dir", @@ -108,19 +134,73 @@ def _add_signing_options(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--output-dir", default="", - help="签名 HAP 输出目录;默认与当前 bundle 的签名材料目录相同", - ) - parser.add_argument( - "--browser", - choices=("system", "system_controlled", "playwright"), - default="system", - help="首次认证使用的浏览器模式;CLI 默认 system", + help=( + "默认签名 HAP 输出目录;默认应用目录 signed_haps/;" + "环境变量 HAPSIGN_SIGNED_HAPS_DIR" + ), ) + if include_exact_output: + parser.add_argument( + "--output", + default="", + help="签名 HAP 的精确输出路径;优先于 --output-dir", + ) + parser.add_argument( + "--overwrite-output", + action="store_true", + help="允许覆盖 --output 指定的已有文件", + ) + + +def _add_capability_option(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--enable-capability", action="store_true", help="尝试使用 Real Profile(APL=system_basic);需要已注册 AGC 应用", ) + + +def _add_signing_options( + parser: argparse.ArgumentParser, + *, + require_serial: bool, +) -> None: + _add_hap_identity_options(parser) + if require_serial: + parser.add_argument( + "--serial", + required=True, + type=_nonempty_serial, + help="hdc list targets 返回的目标序列号;安装时必须显式指定", + ) + parser.set_defaults(device_udid="") + else: + target = parser.add_mutually_exclusive_group() + target.add_argument( + "--serial", + type=_nonempty_serial, + default=None, + help="hdc list targets 返回的目标序列号", + ) + target.add_argument( + "--device-udid", + default="", + help="可信的 64 位设备 UDID;可在不连接本机设备时申请 Profile", + ) + parser.add_argument("--country", default="CN", help="华为账号国家码;默认 CN") + parser.add_argument( + "--device-type", + default=DEVICE_TYPE_PHONE, + help="签名平台注册的设备类型码;默认 4(手机/平板/2in1)", + ) + _add_path_options(parser, include_exact_output=True) + parser.add_argument( + "--browser", + choices=_BROWSER_MODES, + default=_default_browser_mode(), + help="首次认证使用的浏览器模式;默认 system_controlled", + ) + _add_capability_option(parser) parser.add_argument( "--refresh-token", action="store_true", @@ -145,6 +225,8 @@ def build_parser() -> argparse.ArgumentParser: formatter_class=_formatter, epilog="""\ 典型 Agent 流程: + hapsign doctor --json + hapsign inspect --hap app-unsigned.hap --json hapsign devices list --connected-only --json hapsign auth status --json hapsign auth --json @@ -162,6 +244,45 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"hapsign {__version__}") subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") + doctor = subparsers.add_parser( + "doctor", + help="检查平台、工具链、路径和兼容性变更;不修改本地状态", + formatter_class=_formatter, + ) + _add_state_option(doctor) + doctor.add_argument("--output-dir", default="", help="覆盖诊断中的默认产物目录") + _add_output_options(doctor) + + inspect = subparsers.add_parser( + "inspect", + help="只读检查 HAP、解析路径并报告适用的迁移警告", + formatter_class=_formatter, + ) + _add_hap_identity_options(inspect) + _add_path_options(inspect, include_exact_output=False) + _add_capability_option(inspect) + _add_output_options(inspect) + + migrate = subparsers.add_parser( + "migrate-cache", + help="在用户确认旧 Profile 类型后迁移当天缓存元数据", + formatter_class=_formatter, + ) + _add_hap_identity_options(migrate) + _add_state_option(migrate) + migrate.add_argument( + "--work-dir", + default="", + help="当前 bundle 的旧签名材料目录;默认 /", + ) + migrate.add_argument( + "--profile-type", + required=True, + choices=("normal", "system-basic"), + help="用户确认的旧 Profile 类型", + ) + _add_output_options(migrate) + auth = subparsers.add_parser( "auth", help="登录或查看本地 Token 缓存状态;不检测设备、不签名、不安装", @@ -187,9 +308,9 @@ def build_parser() -> argparse.ArgumentParser: auth.add_argument("--country", default="CN", help="华为账号国家码;默认 CN") auth.add_argument( "--browser", - choices=("system", "system_controlled", "playwright"), - default="system", - help="认证浏览器模式;CLI 默认 system", + choices=_BROWSER_MODES, + default=_default_browser_mode(), + help="认证浏览器模式;默认 system_controlled", ) auth.add_argument( "--refresh", @@ -228,9 +349,10 @@ def build_parser() -> argparse.ArgumentParser: sign = subparsers.add_parser( "sign", - help="为指定设备签名 HAP,但不安装", + help="签名 HAP 但不安装;可显式选择设备或复用兼容缓存", description=( - "为 --serial 对应设备生成 debug Profile 并签名。" + "为 --serial/--device-udid 对应设备生成 debug Profile 并签名;" + "未指定设备时可复用兼容缓存或从当前 HDC 目标读取 UDID。" "已有当日 Auth Token 会复用;" "同一 bundle 切换设备时会自动丢弃不匹配的 Profile 缓存。" ), @@ -241,7 +363,7 @@ def build_parser() -> argparse.ArgumentParser: hapsign sign --hap app.hap --serial --output-dir ./signed --json """, ) - _add_signing_options(sign) + _add_signing_options(sign, require_serial=False) install = subparsers.add_parser( "install", @@ -279,7 +401,7 @@ def build_parser() -> argparse.ArgumentParser: hapsign deploy --hap app-signed.hap --serial --json """, ) - _add_signing_options(deploy) + _add_signing_options(deploy, require_serial=True) return parser @@ -287,6 +409,88 @@ def _command_from_args(argv: Sequence[str]) -> str: return next((item for item in argv if item in COMMANDS), "unknown") +def _tool_status(path: Path, *, executable: bool) -> dict[str, object]: + exists = path.is_file() + can_execute = exists and ( + os.name == "nt" or not executable or os.access(path, os.X_OK) + ) + return { + "path": str(path.expanduser().absolute()), + "exists": exists, + "executable": can_execute, + } + + +def _resolved_directories( + *, + state_override: str = "", + output_override: str = "", +) -> tuple[Path, Path]: + settings = load_settings() + state = ( + Path(state_override).expanduser().resolve() + if state_override + else signing_files_dir(settings) + ) + output = ( + Path(output_override).expanduser().resolve() + if output_override + else signed_haps_dir() + ) + return state, output + + +def _work_directory(state_dir: Path, work_override: str, bundle_name: str) -> Path: + if work_override: + return Path(work_override).expanduser().resolve() + return state_dir / bundle_name + + +def doctor_report( + toolchain: ToolchainPaths | None = None, + *, + state_dir: str = "", + output_dir: str = "", +) -> dict[str, object]: + """返回适合 agent 判断下一步动作的跨平台诊断结果。""" + selected = toolchain or discover_toolchain() + resolved_state, resolved_output = _resolved_directories( + state_override=state_dir, + output_override=output_dir, + ) + signing_problems = selected.missing(require_signing=True, require_hdc=False) + hdc_problems = selected.missing(require_signing=False, require_hdc=True) + return { + "ok": not signing_problems and not hdc_problems, + "command": "doctor", + "platform": platform_tag(), + "architecture": platform.machine(), + "python": platform.python_version(), + "toolchain_source": selected.source, + "paths": { + "application_dir": str(application_dir()), + "current_working_dir": str(Path.cwd()), + "config_file": str(config_file_path()), + "state_dir": str(resolved_state), + "output_dir": str(resolved_output), + }, + "breaking_changes": breaking_changes(), + "capabilities": { + "signing": {"ok": not signing_problems, "problems": signing_problems}, + "device": {"ok": not hdc_problems, "problems": hdc_problems}, + }, + "tools": { + "java": _tool_status(selected.java, executable=True), + "keytool": _tool_status(selected.keytool, executable=True), + "hap_sign_tool": _tool_status( + selected.hap_sign_tool, + executable=False, + ), + "hdc": _tool_status(selected.hdc, executable=True), + }, + } + + def _parse_args( parser: argparse.ArgumentParser, argv: Sequence[str] ) -> argparse.Namespace | int: @@ -322,7 +526,9 @@ def _configure_logging(verbose: bool) -> None: def _emit(payload: dict[str, object], json_output: bool) -> None: if json_output: - print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + # ASCII JSON avoids Windows console code-page corruption while preserving + # the original Unicode values after parsing. + print(json.dumps(payload, ensure_ascii=True, sort_keys=True)) return if payload.get("ok"): print(payload.get("message", "操作成功")) @@ -369,6 +575,93 @@ def _resolve_hap(raw_path: str) -> Path: return path +def _resolved_bundle_paths( + args: argparse.Namespace, + bundle_name: str, +) -> tuple[Path, Path, Path]: + state_dir, output_dir = _resolved_directories( + state_override=args.state_dir, + output_override=getattr(args, "output_dir", ""), + ) + work_dir = _work_directory(state_dir, getattr(args, "work_dir", ""), bundle_name) + return state_dir, work_dir, output_dir + + +def _run_doctor(args: argparse.Namespace) -> int: + result = doctor_report(state_dir=args.state_dir, output_dir=args.output_dir) + result["message"] = "环境诊断完成" + _emit(result, args.json_output) + return EXIT_OK if result["ok"] else EXIT_OPERATION_FAILED + + +def _run_inspect(args: argparse.Namespace) -> int: + hap_path = _resolve_hap(args.hap) + bundle_name = args.bundle_name or detect_bundle_name(str(hap_path)) + state_dir, work_dir, output_dir = _resolved_bundle_paths(args, bundle_name) + signed = is_hap_signed(hap_path) + warnings: list[dict[str, object]] = [] + if not signed: + state_warning = legacy_state_warning( + state_dir, + work_dir, + bundle_name=bundle_name, + ) + if state_warning is not None: + warnings.append(state_warning) + warning = cache_compatibility_warning( + work_dir / "metadata.json", + bundle_name=bundle_name, + enable_capability=args.enable_capability, + ) + if warning is not None: + warnings.append(warning) + _emit( + { + "ok": True, + "command": "inspect", + "message": "HAP 检查完成", + "platform": platform_tag(), + "hap": str(hap_path), + "bundle_name": bundle_name, + "signed": signed, + "paths": { + "state_dir": str(state_dir), + "work_dir": str(work_dir), + "output_dir": str(output_dir), + }, + "migration_warnings": warnings, + }, + args.json_output, + ) + return EXIT_OK + + +def _run_migrate_cache(args: argparse.Namespace) -> int: + hap_path = _resolve_hap(args.hap) + bundle_name = args.bundle_name or detect_bundle_name(str(hap_path)) + state_dir = Path(args.state_dir).expanduser().resolve() + work_dir = _work_directory(state_dir, args.work_dir, bundle_name) + result = migrate_legacy_cache( + work_dir / "metadata.json", + bundle_name=bundle_name, + enable_capability=args.profile_type == "system-basic", + ) + _emit( + { + "ok": True, + "command": "migrate-cache", + "message": "旧签名缓存元数据迁移完成", + "platform": platform_tag(), + "hap": str(hap_path), + "bundle_name": bundle_name, + "capability_mode": args.profile_type, + **result, + }, + args.json_output, + ) + return EXIT_OK + + def _auth_pipeline(args: argparse.Namespace) -> SignPipeline: state_dir = str(Path(args.state_dir).expanduser().resolve()) return SignPipeline( @@ -453,6 +746,9 @@ def _build_sign_pipeline( hap_path: Path, bundle_name: str, *, + state_dir: Path, + work_dir: Path, + output_dir: Path, install_after_sign: bool, ) -> SignPipeline: return SignPipeline( @@ -460,14 +756,17 @@ def _build_sign_pipeline( bundle_name=bundle_name, country=args.country, device_type=args.device_type, - serial=args.serial, - work_dir=args.work_dir, - state_dir=args.state_dir, + serial=args.serial or "", + device_udid=args.device_udid, + work_dir=str(work_dir), + state_dir=str(state_dir), enable_capability=args.enable_capability, force_refresh_token=args.refresh_token, force_refresh_signing=args.refresh_signing, browser_mode=args.browser, - signed_output_dir=args.output_dir, + signed_output_dir=str(output_dir), + signed_output_path=args.output, + overwrite_output=args.overwrite_output, keep_signed_hap=True, install_after_sign=install_after_sign, ) @@ -482,14 +781,49 @@ def _inspect_installed_bundle(serial: str, bundle_name: str) -> dict[str, str]: def _run_sign_or_deploy(args: argparse.Namespace) -> int: + if args.overwrite_output and not args.output: + raise ValueError("--overwrite-output 必须与 --output 一起使用") hap_path = _resolve_hap(args.hap) bundle_name = args.bundle_name or detect_bundle_name(str(hap_path)) input_signed = is_hap_signed(hap_path) + if args.output: + output_path = Path(args.output).expanduser().resolve() + if output_path.exists() and not args.overwrite_output: + raise ValueError( + f"签名输出已存在:{output_path};如需覆盖请显式传入 --overwrite-output" + ) deploy = args.command == "deploy" + state_dir, work_dir, output_dir = _resolved_bundle_paths(args, bundle_name) + migration_warnings: list[dict[str, object]] = [] + if not input_signed: + candidates = ( + legacy_state_warning( + state_dir, + work_dir, + bundle_name=bundle_name, + ), + cache_compatibility_warning( + work_dir / "metadata.json", + bundle_name=bundle_name, + enable_capability=args.enable_capability, + ), + ) + for warning in candidates: + if warning is not None: + migration_warnings.append(warning) + logging.warning( + "[%s] %s;整改说明:%s", + warning["id"], + warning["summary"], + warning["remediation"], + ) pipeline = _build_sign_pipeline( args, hap_path, bundle_name, + state_dir=state_dir, + work_dir=work_dir, + output_dir=output_dir, install_after_sign=deploy, ) if not pipeline.run(): @@ -500,14 +834,33 @@ def _run_sign_or_deploy(args: argparse.Namespace) -> int: return EXIT_OPERATION_FAILED signed_hap = str(Path(pipeline.signed_hap_path).resolve()) + requested_capability_mode = None + capability_mode = None + capability_fallback = False + if not input_signed: + requested_capability_mode = ( + "system-basic" if args.enable_capability else "normal" + ) + capability_mode = "system-basic" if pipeline.enable_capability else "normal" + capability_fallback = requested_capability_mode != capability_mode payload: dict[str, object] = { "ok": True, "command": args.command, "message": "签名并安装成功" if deploy else "签名成功", "bundle_name": bundle_name, - "serial": args.serial, + "serial": args.serial or "", "input_hap": str(hap_path), "input_signed": input_signed, + "browser_mode": args.browser, + "requested_capability_mode": requested_capability_mode, + "capability_mode": capability_mode, + "capability_fallback": capability_fallback, + "migration_warnings": migration_warnings, + "paths": { + "state_dir": str(state_dir), + "work_dir": str(work_dir), + "output_dir": str(output_dir), + }, "signed_hap": signed_hap, "installed": deploy, } @@ -556,6 +909,12 @@ def main(argv: Sequence[str] | None = None) -> int: _configure_logging(args.verbose) try: + if args.command == "doctor": + return _run_doctor(args) + if args.command == "inspect": + return _run_inspect(args) + if args.command == "migrate-cache": + return _run_migrate_cache(args) if args.command == "auth": return _run_auth(args) if args.command == "devices": diff --git a/hapsign/diagnostics.py b/hapsign/diagnostics.py index b93b0ac..3a07223 100644 --- a/hapsign/diagnostics.py +++ b/hapsign/diagnostics.py @@ -31,6 +31,13 @@ _DEVICE_UDID_RE = re.compile(r"(?i)(? bool: + """返回值是否为完整的 64 位十六进制设备 UDID。""" + return ( + isinstance(value, str) and _DEVICE_UDID_RE.fullmatch(value.strip()) is not None + ) + + def set_sensitive_logging(enabled: bool) -> None: """控制是否允许诊断日志包含 token 及完整网络载荷。""" global _sensitive_logging_enabled diff --git a/hapsign/migrations.py b/hapsign/migrations.py new file mode 100644 index 0000000..1ce3539 --- /dev/null +++ b/hapsign/migrations.py @@ -0,0 +1,358 @@ +"""面向用户和 agent 的兼容性变更目录与缓存迁移工具。""" + +from __future__ import annotations + +import json +import os +import shutil +from datetime import date +from pathlib import Path + +from hapsign.diagnostics import is_valid_device_udid + +LEGACY_CACHE_CHANGE_ID = "HAPSIGN-BREAKING-001" +LEGACY_STATE_CHANGE_ID = "HAPSIGN-BREAKING-003" + +BREAKING_CHANGES: tuple[dict[str, object], ...] = ( + { + "id": LEGACY_CACHE_CHANGE_ID, + "introduced_in": "unreleased", + "destructive": True, + "decision": "accepted", + "compatibility_strategy": "configuration-and-explicit-migration", + "compatibility_options": ( + "--enable-capability", + "migrate-cache", + "backup-and-refresh", + ), + "summary": "旧签名缓存不满足新的安全一致性校验时不再自动复用", + "impact": ( + "缺少或切换能力模式、包名不匹配或设备 UDID 无效时会重新申请材料;" + "生成同名密钥库时会替换本地 .p12," + "申请证书时可能删除并替换远端同名调试证书。" + ), + "remediation": ( + "让 inspect 与 sign/deploy 使用一致的 --enable-capability;缺少能力模式时" + "可在确认旧 Profile 类型后运行 migrate-cache,否则先备份再允许刷新。" + ), + "docs": "docs/MIGRATIONS.md#hapsign-breaking-001", + }, + { + "id": "HAPSIGN-BREAKING-002", + "introduced_in": "unreleased", + "destructive": False, + "decision": "accepted", + "compatibility_strategy": "configuration", + "compatibility_options": ("--browser system", "HAPSIGN_BROWSER=system"), + "summary": "CLI 默认浏览器由 system 改为 system_controlled", + "impact": "授权使用隔离的 Edge/Chrome 上下文,不复用默认浏览器的 cookie。", + "remediation": ( + "需要原行为时传入 --browser system,或设置 HAPSIGN_BROWSER=system。" + ), + "docs": "docs/MIGRATIONS.md#hapsign-breaking-002", + }, + { + "id": "HAPSIGN-BREAKING-003", + "introduced_in": "unreleased", + "destructive": True, + "decision": "accepted", + "compatibility_strategy": "configuration", + "compatibility_options": ( + "--state-dir/--output-dir", + "HAPSIGN_SIGNING_DIR/HAPSIGN_SIGNED_HAPS_DIR", + ), + "summary": "CLI 默认状态和产物改用应用配置目录", + "impact": ( + "PR #5 使用的 ~/.hapsign 及更早版本工作目录中的缓存不会自动搬迁," + "默认产物位置也会变化;继续签名可能重新生成密钥并替换远端同名调试证书。" + ), + "remediation": ( + "使用 --state-dir/--output-dir 恢复原路径,或通过 HAPSIGN_SIGNING_DIR/" + "HAPSIGN_SIGNED_HAPS_DIR 持久配置。" + ), + "docs": "docs/MIGRATIONS.md#hapsign-breaking-003", + }, + { + "id": "HAPSIGN-BREAKING-004", + "introduced_in": "unreleased", + "destructive": False, + "decision": "accepted", + "compatibility_strategy": "migration-only", + "compatibility_options": ("--json with stderr diagnostics",), + "summary": "CLI 普通输出和日志流已分离", + "impact": "依赖旧标准输出日志文本的脚本需要调整解析方式。", + "remediation": "自动化调用应使用 --json,并按退出码及 ok 字段判断结果。", + "docs": "docs/MIGRATIONS.md#hapsign-breaking-004", + }, + { + "id": "HAPSIGN-BREAKING-005", + "introduced_in": "unreleased", + "destructive": False, + "decision": "accepted", + "compatibility_strategy": "migration-only", + "compatibility_options": ("explicit subcommands",), + "summary": "CLI 改为显式子命令接口", + "impact": ( + "旧的 hapsign --hap ...、--doctor、--inspect 和 --sign-only 调用不再解析。" + ), + "remediation": ( + "改用 doctor、inspect、migrate-cache、sign、deploy 或 install 子命令;" + "设备安装命令必须显式传入 --serial。" + ), + "docs": "docs/MIGRATIONS.md#hapsign-breaking-005", + }, +) + + +def breaking_changes() -> list[dict[str, object]]: + """返回可安全序列化且可由调用方修改的兼容性变更目录。""" + return [dict(change) for change in BREAKING_CHANGES] + + +def _existing_artifact_paths( + metadata_path: Path, metadata: dict[str, object] +) -> dict[str, Path] | None: + """解析旧元数据路径;默认材料都与 metadata.json 位于同一目录。""" + artifacts: dict[str, Path] = {} + for key in ("p12_path", "cer_path", "p7b_path"): + raw_path = metadata.get(key) + if not isinstance(raw_path, str) or not raw_path: + return None + configured = Path(raw_path).expanduser() + candidates = [configured] if configured.is_absolute() else [] + candidates.append(metadata_path.parent / configured.name) + match = next( + (candidate.resolve() for candidate in candidates if candidate.is_file()), + None, + ) + if match is None: + return None + artifacts[key] = match + return artifacts + + +def cache_compatibility_warning( + metadata_path: Path, + *, + bundle_name: str = "", + enable_capability: bool = False, +) -> dict[str, object] | None: + """检查现有元数据是否会因本次目标模式触发破坏性材料刷新。""" + if not metadata_path.is_file(): + return None + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(metadata, dict): + return None + if metadata.get("creation_date") != date.today().isoformat(): + return None + if _existing_artifact_paths(metadata_path, metadata) is None: + return None + reasons: list[str] = [] + cached_capability = metadata.get("enable_capability") + cached_request = metadata.get("requested_enable_capability", cached_capability) + if not isinstance(cached_capability, bool): + reasons.append("missing_capability_mode") + elif not isinstance(cached_request, bool): + reasons.append("missing_requested_capability_mode") + elif cached_capability and not cached_request: + reasons.append("invalid_effective_capability_mode") + elif cached_request != enable_capability: + reasons.append("capability_mode_mismatch") + if bundle_name and metadata.get("bundle_name") != bundle_name: + reasons.append("bundle_name_mismatch") + if not is_valid_device_udid(metadata.get("udid")): + reasons.append("invalid_device_udid") + if not reasons: + return None + migratable = reasons == ["missing_capability_mode"] + catalog_entry = next( + change for change in BREAKING_CHANGES if change["id"] == LEGACY_CACHE_CHANGE_ID + ) + warning = dict(catalog_entry) + warning["metadata"] = str(metadata_path.expanduser().absolute()) + warning["applicable"] = True + warning["requires_user_decision"] = True + warning["reasons"] = reasons + warning["migratable"] = migratable + warning["expected_capability_mode"] = ( + "system-basic" if enable_capability else "normal" + ) + warning["cached_capability_mode"] = ( + "system-basic" + if cached_request is True + else "normal" + if cached_request is False + else "unknown" + ) + warning["cached_effective_capability_mode"] = ( + "system-basic" + if cached_capability is True + else "normal" + if cached_capability is False + else "unknown" + ) + if reasons == ["capability_mode_mismatch"]: + matching_option = ( + "传入 --enable-capability" + if cached_request is True + else "不要传入 --enable-capability" + ) + warning["remediation"] = ( + f"现有缓存模式为 {warning['cached_capability_mode']};若要复用," + f"inspect 和 sign/deploy 应一致地{matching_option}。" + "若确实要切换模式,先备份该 bundle 的整个签名目录,再允许刷新。" + ) + elif not migratable: + warning["remediation"] = ( + "先备份该 bundle 的整个签名目录;缓存还存在请求能力模式、包名或设备 " + "UDID 一致性问题,不能通过 migrate-cache 安全复用,只能在确认后允许刷新。" + ) + return warning + + +def legacy_state_warning( + state_dir: Path, + work_dir: Path, + *, + bundle_name: str, + legacy_state_dir: Path | None = None, +) -> dict[str, object] | None: + """检测 PR #5 用户主目录状态是否会被新的应用目录默认值遗漏。""" + selected_state = state_dir.expanduser().resolve() + selected_work = work_dir.expanduser().resolve() + legacy_state = ( + legacy_state_dir.expanduser().resolve() + if legacy_state_dir is not None + else (Path.home() / ".hapsign").resolve() + ) + if selected_state == legacy_state: + return None + + found: list[str] = [] + selected_token = selected_state / ".token_cache.json" + legacy_token = legacy_state / ".token_cache.json" + if not selected_token.is_file() and legacy_token.is_file(): + found.append("token_cache") + + selected_metadata = selected_work / "metadata.json" + legacy_metadata = legacy_state / bundle_name / "metadata.json" + if not selected_metadata.is_file() and legacy_metadata.is_file(): + try: + metadata = json.loads(legacy_metadata.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + metadata = None + if ( + isinstance(metadata, dict) + and metadata.get("creation_date") == date.today().isoformat() + and metadata.get("bundle_name") == bundle_name + and _existing_artifact_paths(legacy_metadata, metadata) is not None + ): + found.append("signing_materials") + + if not found: + return None + catalog_entry = next( + change for change in BREAKING_CHANGES if change["id"] == LEGACY_STATE_CHANGE_ID + ) + warning = dict(catalog_entry) + destructive = "signing_materials" in found + warning.update( + { + "applicable": True, + "destructive": destructive, + "requires_user_decision": destructive, + "legacy_state_dir": str(legacy_state), + "selected_state_dir": str(selected_state), + "found": found, + } + ) + return warning + + +def migrate_legacy_cache( + metadata_path: Path, + *, + bundle_name: str, + enable_capability: bool, +) -> dict[str, object]: + """在用户明确选择能力模式后原子补齐旧缓存元数据。""" + resolved = metadata_path.expanduser().resolve() + try: + metadata = json.loads(resolved.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise FileNotFoundError(f"未找到旧缓存元数据:{resolved}") from exc + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"旧缓存元数据无法读取:{resolved}") from exc + if not isinstance(metadata, dict): + raise ValueError(f"旧缓存元数据不是 JSON 对象:{resolved}") + if metadata.get("bundle_name") != bundle_name: + raise ValueError("旧缓存元数据的 bundle_name 与输入 HAP 不匹配") + if metadata.get("creation_date") != date.today().isoformat(): + raise ValueError("旧缓存不是当天创建的,无法迁移复用;请备份后允许刷新") + if not is_valid_device_udid(metadata.get("udid")): + raise ValueError("旧缓存缺少有效的 64 位设备 UDID,无法迁移安全复用") + + artifacts = _existing_artifact_paths(resolved, metadata) + if artifacts is None: + raise ValueError("旧缓存签名材料缺失,无法迁移复用;请备份后允许刷新") + + existing = metadata.get("enable_capability") + if isinstance(existing, bool): + if existing != enable_capability: + raise ValueError("缓存已声明其他能力模式;为避免误用 Profile,不会自动覆盖") + requested = metadata.get("requested_enable_capability", existing) + if not isinstance(requested, bool): + raise ValueError("缓存声明的请求能力模式无效,无法迁移安全复用") + if existing and not requested: + raise ValueError("缓存的实际能力模式高于请求模式,无法迁移安全复用") + else: + requested = enable_capability + normalized_artifacts = {key: str(path) for key, path in artifacts.items()} + changed = ( + not isinstance(existing, bool) + or metadata.get("requested_enable_capability") != requested + or any( + metadata.get(key) != value for key, value in normalized_artifacts.items() + ) + ) + if not changed: + return { + "changed": False, + "metadata": str(resolved), + "backup": "", + "enable_capability": enable_capability, + } + + backup = resolved.with_name(resolved.name + ".pre-capability-migration.bak") + if not backup.exists(): + shutil.copy2(resolved, backup) + + metadata["enable_capability"] = enable_capability + metadata["requested_enable_capability"] = requested + metadata.update(normalized_artifacts) + temporary = resolved.with_name(resolved.name + ".tmp") + try: + temporary.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + try: + os.chmod(temporary, resolved.stat().st_mode) + except OSError: + pass + os.replace(temporary, resolved) + except BaseException: + try: + temporary.unlink() + except OSError: + pass + raise + return { + "changed": True, + "metadata": str(resolved), + "backup": str(backup), + "enable_capability": enable_capability, + } diff --git a/hapsign/pipeline.py b/hapsign/pipeline.py index fc68699..fa9f3c0 100644 --- a/hapsign/pipeline.py +++ b/hapsign/pipeline.py @@ -15,9 +15,11 @@ import json import logging import os +import shutil import tempfile import threading import traceback +import uuid import zipfile from collections.abc import Callable from datetime import date @@ -35,7 +37,7 @@ KEY_ALIAS, KEYSTORE_PASSWORD, ) -from hapsign.diagnostics import redact_sensitive_text +from hapsign.diagnostics import is_valid_device_udid, redact_sensitive_text from hapsign.login.browser_login import BrowserLogin from hapsign.models import AppBriefInfo, CertResult, ProvisionResult, TokenInfo from hapsign.signing.hap_inspect import is_hap_signed @@ -96,17 +98,37 @@ def __init__( progress_callback: Callable[[int, str], None] | None = None, serial: str | None = None, install_after_sign: bool = True, + *, + device_udid: str = "", + signed_output_path: str = "", + overwrite_output: bool = False, ): self.hap_path = hap_path self.bundle_name = bundle_name self.country = country self.device_type = device_type self.serial = serial - self.enable_capability = enable_capability + self.requested_enable_capability = bool(enable_capability) + self.enable_capability = self.requested_enable_capability self.force_refresh_token = force_refresh_token self.force_refresh_signing = force_refresh_signing self.browser_mode = browser_mode self.keep_signed_hap = keep_signed_hap + self.install_after_sign = install_after_sign + self.device_udid = device_udid.strip() + if self.device_udid and not is_valid_device_udid(self.device_udid): + raise ValueError("设备 UDID 必须是 64 位十六进制字符串") + self.signed_output_path = ( + os.path.expanduser(signed_output_path) if signed_output_path else "" + ) + self.overwrite_output = overwrite_output + if self.signed_output_path: + output_absolute = os.path.realpath(os.path.abspath(self.signed_output_path)) + input_absolute = os.path.realpath(os.path.abspath(self.hap_path)) + if os.path.normcase(output_absolute) == os.path.normcase(input_absolute): + raise ValueError("签名输出不能覆盖输入 HAP,请指定其他路径") + if os.path.splitext(output_absolute)[1].lower() != ".hap": + raise ValueError("签名输出路径必须以 .hap 结尾") self._temporary_signed_dir: tempfile.TemporaryDirectory | None = None self.cancel_event = cancel_event self.progress_callback = progress_callback @@ -115,7 +137,6 @@ def __init__( if state_dir else default_state_dir() ) - self.install_after_sign = install_after_sign if work_dir: self.work_dir = os.path.abspath(os.path.expanduser(work_dir)) else: @@ -138,7 +159,7 @@ def __init__( self._provision_api: ProvisionAPI | None = None self._capability_api: CapabilityAPI | None = None self._token_from_cache = False - self._udid = "" + self._udid = self.device_udid self._installer: Installer | None = None # 运行期状态:全部显式初始化,避免 hasattr 探测和未初始化属性。 @@ -166,8 +187,8 @@ def _load_token_cache(self) -> dict | None: """加载当天的 token 缓存。 条件:缓存存在、creation_date 是今天;缓存可能是 Windows DPAPI 加密格式 - 或非 Windows 平台的 0600 明文 JSON。Windows 上的旧版明文缓存首次读取时 - 安全迁移为加密格式;解密失败视为无缓存,回退重新登录。 + 或受限权限的明文 JSON。Windows 上的明文缓存首次读取时迁移为加密格式; + 其他平台继续使用 0o600 明文缓存。解密失败视为无缓存,回退重新登录。 """ if not os.path.exists(self._token_cache_path): return None @@ -215,7 +236,7 @@ def _load_token_cache(self) -> dict | None: ): return None if legacy: - # 旧版明文缓存:安全迁移为加密格式,失败不影响本次使用。 + # Windows 会迁移为 DPAPI;其他平台重写为 0o600 明文并记录安全告警。 self._write_token_cache(cache) return cache @@ -315,16 +336,36 @@ def _load_cached_metadata(self) -> dict | None: logger.info("[cache] 签名文件创建于 %s,非今日", meta.get("creation_date")) return None - cached_udid = str(meta.get("udid", "")) - if self._udid and cached_udid != self._udid: - logger.info("[cache] 签名 Profile 属于其他设备,重新申请") + if meta.get("bundle_name") != self.bundle_name: + logger.info("[cache] 签名文件包名不匹配,将重新申请") return None + cached_capability = meta.get("enable_capability") + cached_request = meta.get("requested_enable_capability", cached_capability) + if ( + not isinstance(cached_capability, bool) + or not isinstance(cached_request, bool) + or (cached_capability and not cached_request) + or cached_request != self.requested_enable_capability + ): + logger.info("[cache] 签名文件能力模式不匹配,将重新申请") + return None + + cached_udid = str(meta.get("udid", "")).strip() + if not is_valid_device_udid(cached_udid): + logger.info("[cache] 签名文件缺少有效设备 UDID,将重新申请") + return None + if self._udid and cached_udid.lower() != self._udid.lower(): + logger.info("[cache] 签名文件设备不匹配,将重新申请") + return None for key in ("p12_path", "cer_path", "p7b_path"): path = meta.get(key, "") - if not path or not os.path.exists(path): + if not path or not os.path.isfile(path): logger.info("[cache] 签名文件缺失 (%s)", key) return None + if not self._udid: + self._udid = cached_udid + self.enable_capability = cached_capability return meta def _save_metadata( @@ -347,6 +388,8 @@ def _save_metadata( "cert_object_id": cert_object_id, "udid": udid, "key_alias": KEY_ALIAS, + "requested_enable_capability": self.requested_enable_capability, + "enable_capability": self.enable_capability, } with open(self._metadata_path, "w", encoding="utf-8") as f: json.dump(meta, f, indent=2, ensure_ascii=False) @@ -379,20 +422,32 @@ def _run_pipeline(self) -> bool: Returns: 全部步骤成功返回 True,任一步骤失败返回 False。 """ - # 所有路径都先确认设备可用,避免登录、申请证书或签名完成后才发现 - # HDC 无法安装。检测得到的 UDID 也会在注册设备时直接复用。 - if not self._run_steps([("检测设备连接", self._step_check_device)]): - return False + # 兼容旧行为:签名并安装时仍在读取 HAP、登录和申请材料前确认设备可用。 + # 仅签名时可以复用缓存 Profile,或由调用方直接提供设备 UDID。 + device_checked = False + if self.install_after_sign: + if not self._run_steps([("检测设备连接", self._step_check_device)]): + return False + device_checked = True - # 已签名包:跳过登录 / 申请 / 签名,直接安装原文件 + # 已签名包跳过重签名;显式输出仍应兑现,安装时也使用发布后的路径。 if is_hap_signed(self.hap_path): logger.info("[sign] 检测到已签名 HAP,跳过签名流程") self._signed_hap_path = self.hap_path + if self.signed_output_path: + self._publish_existing_signed_hap() steps = [] if self.install_after_sign: steps.append(("安装 hap 到设备", self._step_install)) return self._run_steps(steps) + # 仅签名允许完全离线复用缓存,但显式给出 serial 时必须先读取该目标 + # 的 UDID,避免误用另一个设备的 Profile。 + if self.serial and not device_checked: + if not self._run_steps([("检测设备连接", self._step_check_device)]): + return False + device_checked = True + # 强制刷新 token 时连带刷新签名文件(仅本次运行,不改写实例属性) refresh_signing = self.force_refresh_signing if self.force_refresh_token: @@ -417,6 +472,12 @@ def _run_pipeline(self) -> bool: if self.install_after_sign: steps.append(("安装 hap 到设备", self._step_install)) else: + if ( + not self._udid + and not device_checked + and not self._run_steps([("检测设备连接", self._step_check_device)]) + ): + return False # 需要申请签名文件,先确保有 token if not self.force_refresh_token: token_cached = self._load_token_cache() @@ -435,6 +496,7 @@ def _run_pipeline(self) -> bool: except OperationCancelled: raise except Exception as e: + self._last_error = f"登录失败: {redact_sensitive_text(e)}" logger.error("x 登录失败: %s", redact_sensitive_text(e)) logger.debug( "登录失败调用栈:\n%s", @@ -813,11 +875,24 @@ def _step_create_provision(self) -> None: def _step_sign_hap(self) -> None: """用 hap-sign-tool 对 hap 包签名。""" hap_basename = os.path.splitext(os.path.basename(self.hap_path))[0] - if self.keep_signed_hap: - os.makedirs(self.signed_output_dir, exist_ok=True) - output_path = os.path.join( + explicit_output = bool(self.signed_output_path) + keep_output = self.keep_signed_hap or explicit_output + if explicit_output: + final_path = os.path.abspath(self.signed_output_path) + output_path = self._prepare_staged_output( + final_path, + operation="signing", + refuse_existing=not self.overwrite_output, + ) + elif keep_output: + final_path = os.path.join( self.signed_output_dir, - f".{hap_basename}.signing.tmp.hap", + f"{hap_basename}_signed.hap", + ) + output_path = self._prepare_staged_output( + final_path, + operation="signing", + refuse_existing=False, ) else: self._temporary_signed_dir = tempfile.TemporaryDirectory( @@ -839,26 +914,136 @@ def _step_sign_hap(self) -> None: self.keystore_password, output_path, ) - except Exception: - if self.keep_signed_hap: - try: - os.remove(output_path) - except FileNotFoundError: - pass + if keep_output: + # 签名失败不会破坏上一份同名产物或其他 HAP;显式输出还会在发布 + # 时再次执行不可覆盖门禁,避免并发任务越过签名前的快速检查。 + self._publish_staged_output( + output_path, + final_path, + overwrite=not explicit_output or self.overwrite_output, + ) + if not explicit_output: + self._cleanup_previous_signed_haps(final_path) + self._save_signed_hap_manifest(final_path) + else: + self._signed_hap_path = output_path + except BaseException: + if keep_output: + self._cleanup_staged_output(output_path) raise - if self.keep_signed_hap: - final_path = os.path.join( - self.signed_output_dir, - f"{hap_basename}_signed.hap", + logger.info("签名后 hap: %s", self._signed_hap_path) + + def _publish_existing_signed_hap(self) -> None: + """跳过重签名时仍按显式输出和覆盖策略原子发布 HAP。""" + final_path = os.path.abspath(self.signed_output_path) + temporary_path = self._prepare_staged_output( + final_path, + operation="copying", + refuse_existing=not self.overwrite_output, + ) + try: + shutil.copy2(self.hap_path, temporary_path) + self._publish_staged_output( + temporary_path, + final_path, + overwrite=self.overwrite_output, + ) + except BaseException: + self._cleanup_staged_output(temporary_path) + raise + + @staticmethod + def _prepare_staged_output( + final_path: str, + *, + operation: str, + refuse_existing: bool, + ) -> str: + """为同目录原子发布准备唯一暂存路径,并执行早期不覆盖检查。""" + output_directory = os.path.dirname(final_path) + os.makedirs(output_directory, exist_ok=True) + if refuse_existing and os.path.exists(final_path): + raise FileExistsError( + f"签名输出已存在:{final_path};如需覆盖请显式启用覆盖" ) - # 原子发布新文件;签名失败不会破坏上一份同名产物或其他 HAP。 - os.replace(output_path, final_path) - self._cleanup_previous_signed_haps(final_path) - self._save_signed_hap_manifest(final_path) - self._signed_hap_path = final_path + return os.path.join( + output_directory, + f".hapsign-{uuid.uuid4().hex}.{operation}.tmp.hap", + ) + + def _publish_staged_output( + self, + staged_path: str, + final_path: str, + *, + overwrite: bool, + ) -> None: + """按覆盖策略发布完整暂存文件,并记录最终产物路径。""" + if overwrite: + os.replace(staged_path, final_path) else: - self._signed_hap_path = output_path - logger.info("签名后 hap: %s", self._signed_hap_path) + self._publish_without_overwrite(staged_path, final_path) + self._signed_hap_path = final_path + + @staticmethod + def _cleanup_staged_output(staged_path: str) -> None: + try: + os.remove(staged_path) + except FileNotFoundError: + return + except OSError as exc: + logger.warning( + "无法清理签名临时文件 %s:%s", + staged_path, + redact_sensitive_text(exc), + ) + + @staticmethod + def _publish_without_overwrite(source_path: str, final_path: str) -> None: + """发布完整产物且绝不替换并发创建的目标文件。""" + + def already_exists() -> FileExistsError: + return FileExistsError( + f"签名输出已存在:{final_path};如需覆盖请显式启用覆盖" + ) + + try: + # 源与目标位于同一目录。硬链接让完整文件原子可见,并由文件系统保证 + # 目标必须不存在;NTFS、Linux 和 macOS 常见文件系统均支持。 + os.link(source_path, final_path) + except FileExistsError as exc: + raise already_exists() from exc + except OSError: + # FAT/exFAT 等文件系统不支持硬链接。用 O_EXCL 保留“不覆盖”保证; + # 复制失败时删除不完整目标,调用方仍会清理签名临时文件。 + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0) + try: + descriptor = os.open(final_path, flags, 0o600) + except FileExistsError as exc: + raise already_exists() from exc + try: + with ( + os.fdopen(descriptor, "wb") as target, + open(source_path, "rb") as source, + ): + shutil.copyfileobj(source, target) + target.flush() + os.fsync(target.fileno()) + except BaseException: + try: + os.remove(final_path) + except OSError: + pass + raise + try: + os.remove(source_path) + except OSError as exc: + # 产物已完整发布,残留的唯一 UUID 隐藏临时名不应让签名结果失败。 + logger.warning( + "签名产物已发布,但无法清理临时文件 %s:%s", + source_path, + redact_sensitive_text(exc), + ) def _cleanup_previous_signed_haps(self, final_path: str) -> None: """只清理 manifest 记录的旧产物,绝不按扩展名删除用户文件。""" diff --git a/hapsign/runtime.py b/hapsign/runtime.py index bed98e6..2865c18 100644 --- a/hapsign/runtime.py +++ b/hapsign/runtime.py @@ -8,6 +8,7 @@ import os import platform +import shutil import sys from dataclasses import dataclass from pathlib import Path @@ -58,9 +59,18 @@ class ToolchainPaths: hdc: Path source: str - def missing(self, *, require_signing: bool = True) -> list[str]: - """返回缺少的工具;已签名 HAP 只要求 HDC。""" - required = [("HDC", self.hdc)] + def missing( + self, + *, + require_signing: bool = True, + require_hdc: bool = True, + ) -> list[str]: + """返回缺少或不可执行的工具。 + + ``require_hdc=False`` 用于仅签名且调用方已经提供设备 UDID,或复用已有 + Profile 的场景。默认值保持旧版“签名并安装”的检查语义。 + """ + required = [("HDC", self.hdc)] if require_hdc else [] if require_signing: required.extend( [ @@ -69,7 +79,17 @@ def missing(self, *, require_signing: bool = True) -> list[str]: ("hap-sign-tool.jar", self.hap_sign_tool), ] ) - return [f"{name}: {path}" for name, path in required if not path.is_file()] + problems: list[str] = [] + for name, path in required: + if not path.is_file(): + problems.append(f"{name}: {path}") + elif ( + name != "hap-sign-tool.jar" + and os.name != "nt" + and not os.access(path, os.X_OK) + ): + problems.append(f"{name}: {path}(文件不可执行)") + return problems def _executable_name(name: str) -> str: @@ -142,32 +162,74 @@ def _deveco_home_candidates() -> list[tuple[Path, str]]: candidates.extend( [ (Path("/opt/DevEco-Studio"), "DevEco Studio"), + (Path("/opt/Huawei/DevEco-Studio"), "DevEco Studio"), (Path.home() / "DevEco-Studio", "DevEco Studio"), + (Path.home() / "Huawei" / "DevEco-Studio", "DevEco Studio"), ] ) return candidates +def _from_path_environment() -> ToolchainPaths: + """从 JAVA_HOME、PATH 和签名器环境变量组合源码运行工具链。""" + + runtime_bin = None + java_home = os.environ.get("JAVA_HOME") + if java_home: + runtime_bin = Path(java_home).expanduser() / "bin" + + def command(name: str) -> Path: + executable = _executable_name(name) + if runtime_bin is not None and name in {"java", "keytool"}: + candidate = runtime_bin / executable + if candidate.is_file(): + return candidate + discovered = shutil.which(executable) + return Path(discovered) if discovered else Path(executable) + + signer = os.environ.get("HAPSIGN_HAP_SIGN_TOOL") + return ToolchainPaths( + java=command("java"), + keytool=command("keytool"), + hap_sign_tool=( + Path(signer).expanduser() if signer else Path("hap-sign-tool.jar") + ), + hdc=command("hdc"), + source="JAVA_HOME/PATH", + ) + + def _with_direct_overrides(paths: ToolchainPaths) -> ToolchainPaths: def override(name: str, current: Path) -> Path: value = os.environ.get(name) return Path(value).expanduser() if value else current + overrides = { + name + for name in ( + "HAPSIGN_JAVA", + "HAPSIGN_KEYTOOL", + "HAPSIGN_HAP_SIGN_TOOL", + "HAPSIGN_HDC", + ) + if os.environ.get(name) + } return ToolchainPaths( java=override("HAPSIGN_JAVA", paths.java), keytool=override("HAPSIGN_KEYTOOL", paths.keytool), hap_sign_tool=override("HAPSIGN_HAP_SIGN_TOOL", paths.hap_sign_tool), hdc=override("HAPSIGN_HDC", paths.hdc), - source=paths.source, + source="environment overrides" if overrides else paths.source, ) def discover_toolchain() -> ToolchainPaths: - """优先发现便携资源,其次查找本机 DevEco Studio。""" + """发现便携资源、DevEco Studio,最后组合 JAVA_HOME/PATH。""" candidates = [_from_portable_resources()] candidates.extend( _from_deveco_home(home, source) for home, source in _deveco_home_candidates() ) + candidates.append(_from_path_environment()) overridden = [_with_direct_overrides(item) for item in candidates] complete = next((item for item in overridden if not item.missing()), None) diff --git a/hapsign/settings.py b/hapsign/settings.py index e8b9b11..f1b58ef 100644 --- a/hapsign/settings.py +++ b/hapsign/settings.py @@ -48,7 +48,10 @@ def user_local_data_dir() -> Path: def signing_files_dir(settings: AppSettings) -> Path: - """按设置返回签名材料、缓存和签名后 HAP 的目录。""" + """按专用环境变量、数据根目录和桌面设置返回签名状态目录。""" + signing_override = os.environ.get("HAPSIGN_SIGNING_DIR") + if signing_override: + return Path(signing_override).expanduser().resolve() override = os.environ.get("HAPSIGN_DATA_DIR") if override: return Path(override).expanduser().resolve() / "signing_files" @@ -65,7 +68,10 @@ def log_directory() -> Path: def signed_haps_dir() -> Path: - """最终签名 HAP 始终位于程序目录,不随签名材料设置变化。""" + """返回默认签名产物目录,允许用专用环境变量覆盖。""" + override = os.environ.get("HAPSIGN_SIGNED_HAPS_DIR") + if override: + return Path(override).expanduser().resolve() return application_dir() / "signed_haps" diff --git a/hapsign/signing/installer.py b/hapsign/signing/installer.py index de5479f..b9679be 100644 --- a/hapsign/signing/installer.py +++ b/hapsign/signing/installer.py @@ -5,10 +5,12 @@ import datetime import logging import os +import platform import re import subprocess import threading import time +from pathlib import Path from hapsign import config from hapsign.subprocess_utils import no_window_kwargs, run_process @@ -66,7 +68,7 @@ def _listener_pid_windows() -> int | None: def _listener_pid_posix() -> int | None: - """POSIX 平台回退使用 lsof 定位监听 8710 的 PID。""" + """POSIX 平台优先使用 lsof;Linux 精简环境回退读取 /proc。""" try: result = subprocess.run( ["lsof", "-nP", f"-iTCP:{_HDC_SERVER_PORT}", "-sTCP:LISTEN"], @@ -75,14 +77,58 @@ def _listener_pid_posix() -> int | None: timeout=5, ) except (OSError, subprocess.TimeoutExpired): + result = None + if result is not None: + for line in result.stdout.splitlines()[1:]: # 跳过表头 + parts = line.split() + if len(parts) >= 2: + try: + return int(parts[1]) + except ValueError: + continue + if platform.system() == "Linux": + return _listener_pid_linux_proc() + return None + + +def _listener_pid_linux_proc(proc_root: Path = Path("/proc")) -> int | None: + """不依赖 lsof/ss,从 Linux procfs 查找监听 HDC 端口的当前用户进程。""" + socket_inodes: set[str] = set() + expected_port = f"{_HDC_SERVER_PORT:04X}" + for relative in (Path("net/tcp"), Path("net/tcp6")): + try: + lines = (proc_root / relative).read_text(encoding="ascii").splitlines() + except OSError: + continue + for line in lines[1:]: + fields = line.split() + if len(fields) < 10 or fields[3] != "0A": # TCP_LISTEN + continue + local = fields[1].rsplit(":", 1) + if len(local) == 2 and local[1].upper() == expected_port: + socket_inodes.add(fields[9]) + + if not socket_inodes: + return None + try: + processes = list(proc_root.iterdir()) + except OSError: return None - for line in result.stdout.splitlines()[1:]: # 跳过表头 - parts = line.split() - if len(parts) >= 2: + for process in processes: + if not process.name.isdigit(): + continue + try: + descriptors = list((process / "fd").iterdir()) + except OSError: + continue + for descriptor in descriptors: try: - return int(parts[1]) - except ValueError: + target = os.readlink(descriptor) + except OSError: continue + match = re.fullmatch(r"socket:\[(\d+)]", target) + if match and match.group(1) in socket_inodes: + return int(process.name) return None @@ -138,6 +184,8 @@ def _process_start_time_windows(pid: int) -> float | None: def _process_start_time_posix(pid: int) -> float | None: + if platform.system() == "Linux": + return _process_start_time_linux_proc(pid) try: result = subprocess.run( ["ps", "-o", "lstart=", "-p", str(pid)], @@ -157,6 +205,28 @@ def _process_start_time_posix(pid: int) -> float | None: return time.mktime(parsed.timetuple()) +def _process_start_time_linux_proc( + pid: int, + proc_root: Path = Path("/proc"), +) -> float | None: + """用 Linux 时钟 tick 计算进程 epoch 启动时间,不受 ``ps`` 本地化影响。""" + try: + stat_text = (proc_root / str(pid) / "stat").read_text(encoding="ascii") + # comm 字段允许空格和括号;最后一个 ``) `` 之后从字段 3 开始。 + fields_after_comm = stat_text.rsplit(") ", 1)[1].split() + start_ticks = int(fields_after_comm[19]) # /proc//stat field 22 + boot_line = next( + line + for line in (proc_root / "stat").read_text(encoding="ascii").splitlines() + if line.startswith("btime ") + ) + boot_time = int(boot_line.split()[1]) + ticks_per_second = int(os.sysconf("SC_CLK_TCK")) + except (IndexError, OSError, StopIteration, ValueError): + return None + return boot_time + start_ticks / ticks_per_second + + class Installer: """使用 hdc 获取设备 UDID 并安装 hap 包。""" diff --git a/pyproject.toml b/pyproject.toml index 88f09b1..72dcd52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -23,6 +24,7 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] dependencies = [ + "playwright>=1.40,<2", "requests>=2.31,<3", ] @@ -73,7 +75,6 @@ source = ["hapsign"] omit = ["hapsign/gui.py"] [tool.coverage.report] -fail_under = 58 show_missing = true skip_covered = true diff --git a/scripts/build_portable.py b/scripts/build_portable.py index ac01dd7..361f600 100644 --- a/scripts/build_portable.py +++ b/scripts/build_portable.py @@ -1,4 +1,4 @@ -"""构建当前平台的 HapSign 便携版目录和 ZIP。 +"""构建当前平台的 HapSign GUI、agent CLI 便携目录和发布归档。 构建机需要 Python、项目 bundle 依赖以及 prepare_toolchain 生成的公开工具链。 DevEco 仅作为显式启用的本机兼容回退。 @@ -9,6 +9,7 @@ import argparse import hashlib import importlib.util +import json import os import re import shutil @@ -29,6 +30,7 @@ "playwright", "greenlet", # playwright 的运行时传递依赖 "pyee", # playwright 的运行时传递依赖 + "typing-extensions", # pyee 的运行时传递依赖 "requests", "urllib3", "certifi", @@ -123,6 +125,53 @@ def _run(command: list[str], *, env: dict[str, str]) -> None: subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=True) +def _cli_executable_name() -> str: + return "hapsign-cli.exe" if sys.platform == "win32" else "hapsign-cli" + + +def _portable_archive_settings(platform_name: str) -> tuple[str, str]: + """返回发布包扩展名和 shutil 格式;Linux 用 tar 保留可执行位。""" + if platform_name == "linux": + return ".tar.gz", "gztar" + return ".zip", "zip" + + +def _build_cli_executable(portable_root: Path, *, env: dict[str, str]) -> Path: + """构建 agent 可直接调用的独立控制台程序,并放入 GUI 便携目录。""" + cli_dist = PROJECT_ROOT / "build" / "pyinstaller-cli-dist" + cli_spec = PROJECT_ROOT / "build" / "pyinstaller-cli-spec" + cli_spec.mkdir(parents=True, exist_ok=True) + _run( + [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--onefile", + "--console", + "--name", + "hapsign-cli", + "--additional-hooks-dir", + str(PROJECT_ROOT / "bundle" / "hooks"), + "--distpath", + str(cli_dist), + "--workpath", + str(PROJECT_ROOT / "build" / "pyinstaller-cli"), + "--specpath", + str(cli_spec), + str(PROJECT_ROOT / "main.py"), + ], + env=env, + ) + source = cli_dist / _cli_executable_name() + if not source.is_file(): + raise RuntimeError(f"PyInstaller 未生成 CLI 程序:{source}") + target = portable_root / source.name + shutil.copy2(source, target) + return target + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: @@ -286,6 +335,8 @@ def _copy_release_documents(portable_root: Path) -> None: "THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "docs/PACKAGING.md": "BUILDING.md", "docs/OPEN_SOURCE_RELEASE.md": "OPEN_SOURCE_RELEASE.md", + "docs/AGENT_SIGNING.md": "AGENT_SIGNING.md", + "docs/MIGRATIONS.md": "MIGRATIONS.md", } for source_name, target_name in documents.items(): shutil.copy2(PROJECT_ROOT / source_name, portable_root / target_name) @@ -395,10 +446,11 @@ def _copy_local_toolchain(portable_root: Path, *, keep_full_jbr: bool) -> None: shutil.copy2(toolchain.hap_sign_tool, lib_dir / "hap-sign-tool.jar") shutil.copy2(toolchain.hdc, bin_dir / toolchain.hdc.name) - # Windows HDC 与同目录的 libusb_shared.dll 配套分发。 - libusb = toolchain.hdc.parent / "libusb_shared.dll" - if libusb.is_file(): - shutil.copy2(libusb, bin_dir / libusb.name) + # 各平台 HDC 与同目录的 libusb 动态库配套分发。 + for name in ("libusb_shared.dll", "libusb_shared.so", "libusb_shared.dylib"): + libusb = toolchain.hdc.parent / name + if libusb.is_file(): + shutil.copy2(libusb, bin_dir / libusb.name) notice = toolchain.hdc.parent / "NOTICE.txt" if notice.is_file(): shutil.copy2(notice, target / "NOTICE.txt") @@ -511,6 +563,44 @@ def _smoke_test_frozen_app( ) +def _smoke_test_frozen_cli( + portable_root: Path, + *, + require_toolchain: bool, +) -> None: + executable = portable_root / _cli_executable_name() + version = subprocess.run( + [str(executable), "--version"], + cwd=portable_root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if version.returncode != 0 or "hapsign" not in version.stdout.lower(): + details = version.stderr.strip() or version.stdout.strip() + raise RuntimeError(f"冻结 CLI 版本自检失败:{details}") + if not require_toolchain: + return + doctor = subprocess.run( + [str(executable), "doctor", "--json"], + cwd=portable_root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + try: + payload = json.loads(doctor.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"冻结 CLI doctor 未返回有效 JSON:{doctor.stdout.strip()}" + ) from exc + if doctor.returncode != 0 or payload.get("ok") is not True: + details = payload.get("error") or doctor.stderr.strip() or payload + raise RuntimeError(f"冻结 CLI 工具链自检失败:{details}") + + def _remove_smoke_artifacts(portable_root: Path) -> None: """避免把构建自检生成的本地日志或运行数据收入发布包。""" config = portable_root / "hapsign-config.json" @@ -554,6 +644,7 @@ def build( ) portable_root = DIST_DIR / "HapSign" + _build_cli_executable(portable_root, env=env) _prune_playwright_extras( portable_root, keep_bundled_browser=keep_bundled_browser, @@ -569,22 +660,32 @@ def build( portable_root, keep_bundled_browser=keep_bundled_browser, ) + _smoke_test_frozen_cli( + portable_root, + require_toolchain=not skip_toolchain, + ) _remove_smoke_artifacts(portable_root) sys.path.insert(0, str(PROJECT_ROOT)) from hapsign.runtime import platform_tag + platform_name = platform_tag() archive_variant = "-compat" if keep_bundled_browser else "" - archive_base = DIST_DIR / (f"HapSign-portable-{platform_tag()}{archive_variant}") - archive_path = archive_base.with_suffix(".zip") + archive_base = DIST_DIR / (f"HapSign-portable-{platform_name}{archive_variant}") + archive_extension, archive_format = _portable_archive_settings(platform_name) + archive_path = Path(f"{archive_base}{archive_extension}") if archive_path.is_file(): archive_path.unlink() - shutil.make_archive( - str(archive_base), - "zip", - root_dir=portable_root.parent, - base_dir=portable_root.name, + generated_archive = Path( + shutil.make_archive( + str(archive_base), + archive_format, + root_dir=portable_root.parent, + base_dir=portable_root.name, + ) ) + if generated_archive != archive_path: + raise RuntimeError(f"归档路径与预期不符:{generated_archive} != {archive_path}") _write_sha256_file(archive_path) return archive_path @@ -594,7 +695,7 @@ def main() -> int: parser.add_argument( "--skip-toolchain", action="store_true", - help="只构建 GUI,不复制签名与设备工具链", + help="构建 GUI/CLI,但不复制签名与设备工具链", ) parser.add_argument( "--keep-full-jbr", diff --git a/scripts/prepare_toolchain.py b/scripts/prepare_toolchain.py index a3c6d27..73a63b0 100644 --- a/scripts/prepare_toolchain.py +++ b/scripts/prepare_toolchain.py @@ -1,6 +1,6 @@ """从可审计的公开上游准备 HapSign 便携工具链。 -下载内容、大小和 SHA-256 固定在 ``toolchain.lock.json``。Windows 当前使用: +下载内容、大小和 SHA-256 固定在 ``toolchain.lock.json``。Windows/Linux 使用: * OpenHarmony 6.1 公共 SDK 中的 HDC、libusb 和 hap-sign-tool; * Eclipse Temurin 21 JDK,经 jlink 缩成仅供 HapSign 使用的运行时。 @@ -23,6 +23,7 @@ import time import urllib.parse import urllib.request +import warnings import zipfile from pathlib import Path, PurePosixPath from typing import Any @@ -43,6 +44,15 @@ def _platform_tag() -> str: return "linux" +def _architecture_tag() -> str: + machine = platform.machine().lower() + if machine in {"amd64", "x86_64"}: + return "x64" + if machine in {"aarch64", "arm64"}: + return "arm64" + return machine + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: @@ -197,6 +207,8 @@ def _extract_openharmony_files( target.parent.mkdir(parents=True, exist_ok=True) with archive.open(member) as source, target.open("wb") as destination: shutil.copyfileobj(source, destination, length=DOWNLOAD_CHUNK_SIZE) + if metadata.get("executable"): + target.chmod(target.stat().st_mode | 0o111) _verify_file(target, metadata, label=target_name) @@ -213,12 +225,63 @@ def _safe_extract_zip(archive_path: Path, destination: Path) -> None: archive.extractall(destination) +def _safe_extract_tar(archive_path: Path, destination: Path) -> None: + """安全提取 tar 归档,并保留 Linux JDK 的可执行位和内部符号链接。""" + destination_resolved = destination.resolve() + with tarfile.open(archive_path, mode="r:*") as archive: + for member in archive.getmembers(): + if not ( + member.isfile() or member.isdir() or member.issym() or member.islnk() + ): + raise RuntimeError(f"TAR 包含不支持的特殊文件:{member.name}") + target = (destination / member.name).resolve() + if ( + target != destination_resolved + and destination_resolved not in target.parents + ): + raise RuntimeError(f"TAR 包含越界路径:{member.name}") + if member.issym(): + link_target = (target.parent / member.linkname).resolve() + elif member.islnk(): + link_target = (destination / member.linkname).resolve() + else: + continue + if ( + link_target != destination_resolved + and destination_resolved not in link_target.parents + ): + raise RuntimeError( + f"TAR 包含越界链接:{member.name} -> {member.linkname}" + ) + # ``filter`` 在 Python 3.11.4 引入;项目仍兼容 3.11.0。旧补丁版本使用 + # 上面的显式路径/链接门禁,锁定归档也已在调用前校验 SHA-256。 + data_filter = getattr(tarfile, "data_filter", None) + if data_filter is None: + with warnings.catch_warnings(): + # 新版 Python 对无 filter 提取发出迁移警告;旧版没有 filter 参数, + # 路径、链接和特殊文件已在上方逐项校验。 + warnings.simplefilter("ignore", DeprecationWarning) + archive.extractall(destination) + else: + archive.extractall(destination, filter=data_filter) + + +def _extract_jdk_archive(archive_path: Path, destination: Path) -> None: + if zipfile.is_zipfile(archive_path): + _safe_extract_zip(archive_path, destination) + return + if tarfile.is_tarfile(archive_path): + _safe_extract_tar(archive_path, destination) + return + raise RuntimeError(f"不支持的 JDK 归档格式:{archive_path}") + + def _find_jdk_root(extracted: Path) -> Path: executable = "jlink.exe" if os.name == "nt" else "jlink" matches = list(extracted.glob(f"*/bin/{executable}")) if len(matches) != 1: names = ", ".join(str(item) for item in matches) or "(未找到)" - raise RuntimeError(f"Temurin ZIP 中无法唯一定位 {executable}:{names}") + raise RuntimeError(f"Temurin 归档中无法唯一定位 {executable}:{names}") return matches[0].parent.parent @@ -230,7 +293,7 @@ def _create_java_runtime( ) -> None: with tempfile.TemporaryDirectory(prefix="hapsign-temurin-") as temporary: extracted = Path(temporary) - _safe_extract_zip(jdk_archive, extracted) + _extract_jdk_archive(jdk_archive, extracted) jdk_root = _find_jdk_root(extracted) executable = "jlink.exe" if os.name == "nt" else "jlink" command = [ @@ -393,6 +456,12 @@ def prepare( f"jlink 不能跨平台生成运行时:目标 {target_platform}," f"当前主机 {_platform_tag()}" ) + target_architecture = platform_config.get("architecture") + if target_architecture and target_architecture != _architecture_tag(): + raise RuntimeError( + f"工具链架构不匹配:目标 {target_architecture}," + f"当前主机 {_architecture_tag()}" + ) if output.exists() and not force: raise RuntimeError(f"输出目录已存在;确认后使用 --force 重建:{output}") @@ -434,7 +503,7 @@ def prepare( _verify_file( toolchains_zip, {"sha256": openharmony["toolchains_sha256"]}, - label="OpenHarmony Windows toolchains", + label=f"OpenHarmony {target_platform} toolchains", ) staging = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=str(output.parent))) @@ -489,7 +558,7 @@ def main() -> int: parser.add_argument( "--jdk-archive", type=Path, - help="使用已下载的 Temurin JDK ZIP,同时仍执行锁定校验", + help="使用已下载的 Temurin JDK 归档,同时仍执行锁定校验", ) parser.add_argument( "--force", diff --git a/tests/test_build_portable.py b/tests/test_build_portable.py index 67c2f7a..c80d11f 100644 --- a/tests/test_build_portable.py +++ b/tests/test_build_portable.py @@ -1,9 +1,12 @@ """便携版保守精简规则测试。""" import hashlib +from types import SimpleNamespace +from unittest.mock import Mock import pytest +from hapsign.runtime import ToolchainPaths from scripts import build_portable @@ -18,6 +21,63 @@ def test_write_sha256_file_uses_standard_sidecar_format(tmp_path) -> None: assert checksum.read_text(encoding="ascii") == f"{expected} {archive.name}\n" +def test_linux_portable_archive_uses_tar_to_preserve_executable_bits() -> None: + assert build_portable._portable_archive_settings("linux") == ( + ".tar.gz", + "gztar", + ) + assert build_portable._portable_archive_settings("windows") == (".zip", "zip") + assert build_portable._portable_archive_settings("macos") == (".zip", "zip") + + +def test_build_cli_executable_adds_console_program_to_portable( + tmp_path, + monkeypatch, +) -> None: + project = tmp_path / "project" + portable = tmp_path / "HapSign" + portable.mkdir() + + def fake_run(command, *, env) -> None: + assert env["TEST_ENV"] == "1" + output = command[command.index("--distpath") + 1] + name = command[command.index("--name") + 1] + destination = build_portable.Path(output) + destination.mkdir(parents=True) + suffix = ".exe" if build_portable.sys.platform == "win32" else "" + (destination / f"{name}{suffix}").write_bytes(b"cli") + + monkeypatch.setattr(build_portable, "PROJECT_ROOT", project) + monkeypatch.setattr(build_portable, "_run", fake_run) + + result = build_portable._build_cli_executable( + portable, + env={"TEST_ENV": "1"}, + ) + + assert result == portable / build_portable._cli_executable_name() + assert result.read_bytes() == b"cli" + + +def test_frozen_cli_smoke_checks_version_and_doctor(tmp_path, monkeypatch) -> None: + executable = tmp_path / build_portable._cli_executable_name() + executable.write_bytes(b"cli") + run = Mock( + side_effect=[ + SimpleNamespace(returncode=0, stdout="hapsign 0.1.0\n", stderr=""), + SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr=""), + ] + ) + monkeypatch.setattr(build_portable.subprocess, "run", run) + + build_portable._smoke_test_frozen_cli(tmp_path, require_toolchain=True) + + assert [call.args[0][1:] for call in run.call_args_list] == [ + ["--version"], + ["doctor", "--json"], + ] + + def test_jbr_pruning_only_targets_known_jcef_resources(tmp_path) -> None: jbr = tmp_path / "jbr" bin_dir = jbr / "bin" @@ -291,6 +351,8 @@ def test_release_documents_include_open_source_notices( "THIRD_PARTY_NOTICES.md": "notices", "docs/PACKAGING.md": "building", "docs/OPEN_SOURCE_RELEASE.md": "release", + "docs/AGENT_SIGNING.md": "agent", + "docs/MIGRATIONS.md": "migrations", } for name, content in sources.items(): path = project / name @@ -313,6 +375,8 @@ def test_release_documents_include_open_source_notices( "THIRD_PARTY_NOTICES.md", "BUILDING.md", "OPEN_SOURCE_RELEASE.md", + "AGENT_SIGNING.md", + "MIGRATIONS.md", ): assert (portable / name).is_file() @@ -370,3 +434,37 @@ def test_copy_toolchain_requires_explicit_deveco_fallback( keep_full_jbr=False, allow_local_toolchain=False, ) + + +def test_local_linux_toolchain_copies_libusb_shared_object( + tmp_path, + monkeypatch, +) -> None: + source = tmp_path / "deveco" + runtime = source / "jbr" + toolchains = source / "toolchains" + java = runtime / "bin" / "java" + keytool = runtime / "bin" / "keytool" + signer = toolchains / "lib" / "hap-sign-tool.jar" + hdc = toolchains / "hdc" + libusb = toolchains / "libusb_shared.so" + for path in (java, keytool, signer, hdc, libusb): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(path.name.encode()) + path.chmod(path.stat().st_mode | 0o111) + selected = ToolchainPaths( + java=java, + keytool=keytool, + hap_sign_tool=signer, + hdc=hdc, + source="test DevEco", + ) + portable = tmp_path / "HapSign" + monkeypatch.setattr("hapsign.runtime.discover_toolchain", lambda: selected) + monkeypatch.setattr("hapsign.runtime.platform_tag", lambda: "linux") + monkeypatch.setattr(build_portable, "_smoke_test_toolchain", lambda _root: None) + + build_portable._copy_local_toolchain(portable, keep_full_jbr=False) + + target = portable / "resources" / "toolchain" / "linux" + assert (target / "bin" / "libusb_shared.so").read_bytes() == (libusb.read_bytes()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 612a52d..9d89391 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,22 +2,49 @@ import json import zipfile +from datetime import date +from pathlib import Path import pytest from hapsign import cli +from hapsign.runtime import ToolchainPaths -def _write_hap(path, module_data: dict | None = None) -> None: +def _write_hap(path: Path, module_data: dict | None = None) -> None: data = module_data or {"app": {"bundleName": "com.example.app"}} with zipfile.ZipFile(path, "w") as archive: archive.writestr("module.json", json.dumps(data)) +def _write_legacy_metadata( + path: Path, + bundle_name: str, + *, + enable_capability: bool | None = None, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + metadata = { + "creation_date": date.today().isoformat(), + "bundle_name": bundle_name, + "udid": "A" * 64, + "p12_path": "key.p12", + "cer_path": "cert.cer", + "p7b_path": "profile.p7b", + } + if enable_capability is not None: + metadata["enable_capability"] = enable_capability + path.write_text( + json.dumps(metadata), + encoding="utf-8", + ) + for name in ("key.p12", "cert.cer", "profile.p7b"): + (path.parent / name).write_bytes(b"material") + + def test_detect_bundle_name(tmp_path) -> None: hap_path = tmp_path / "app.hap" _write_hap(hap_path) - assert cli.detect_bundle_name(str(hap_path)) == "com.example.app" @@ -25,7 +52,6 @@ def test_detect_bundle_name_requires_module_json(tmp_path) -> None: hap_path = tmp_path / "app.hap" with zipfile.ZipFile(hap_path, "w") as archive: archive.writestr("other.json", "{}") - with pytest.raises(ValueError, match="module.json"): cli.detect_bundle_name(str(hap_path)) @@ -33,28 +59,28 @@ def test_detect_bundle_name_requires_module_json(tmp_path) -> None: def test_detect_bundle_name_requires_bundle_name(tmp_path) -> None: hap_path = tmp_path / "app.hap" _write_hap(hap_path, {"app": {}}) - with pytest.raises(ValueError, match="app.bundleName"): cli.detect_bundle_name(str(hap_path)) -def test_parser_uses_home_state_dir_by_default(monkeypatch, tmp_path) -> None: - expected = str(tmp_path / ".hapsign") - monkeypatch.setattr(cli, "default_state_dir", lambda: expected) - +def test_parser_uses_configured_application_state_dir_by_default( + monkeypatch, tmp_path +) -> None: + settings = object() + expected = tmp_path / "signing_files" + monkeypatch.setattr(cli, "load_settings", lambda: settings) + monkeypatch.setattr(cli, "signing_files_dir", lambda value: expected) args = cli.build_parser().parse_args(["auth", "status"]) - - assert args.state_dir == expected + assert Path(args.state_dir) == expected -def test_auth_help_renders_windows_home_example(capsys) -> None: +def test_auth_help_describes_configurable_application_state(capsys) -> None: parser = cli.build_parser() - with pytest.raises(SystemExit, match="0"): parser.parse_args(["auth", "--help"]) - help_text = capsys.readouterr().out - assert "%USERPROFILE%\\.hapsign" in help_text + assert "HAPSIGN_SIGNING_DIR" in help_text + assert "应用配置" in help_text @pytest.mark.parametrize(("pipeline_result", "exit_code"), [(True, 0), (False, 1)]) @@ -62,6 +88,8 @@ def test_deploy_json_returns_pipeline_result( monkeypatch, tmp_path, capsys, pipeline_result, exit_code ) -> None: hap_path = tmp_path / "example.hap" + state_dir = tmp_path / "signing-state" + output_dir = tmp_path / "signed-outputs" _write_hap(hap_path) captured = {} @@ -70,11 +98,15 @@ def __init__(self, **kwargs): captured.update(kwargs) self.signed_hap_path = str(hap_path) self.last_error = "检测设备连接: device unavailable" + self.enable_capability = kwargs["enable_capability"] def run(self): return pipeline_result monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + monkeypatch.setattr(cli, "load_settings", lambda: object()) + monkeypatch.setattr(cli, "signing_files_dir", lambda _settings: state_dir) + monkeypatch.setattr(cli, "signed_haps_dir", lambda: output_dir) monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) monkeypatch.setattr( cli, @@ -85,18 +117,9 @@ def run(self): "version_name": "1.0.0", }, ) - result = cli.main( - [ - "deploy", - "--hap", - str(hap_path), - "--serial", - "device-serial", - "--json", - ] + ["deploy", "--hap", str(hap_path), "--serial", "device-serial", "--json"] ) - assert result == exit_code payload = json.loads(capsys.readouterr().out) assert payload["ok"] is pipeline_result @@ -105,11 +128,17 @@ def run(self): assert captured["bundle_name"] == "com.example.app" assert captured["serial"] == "device-serial" assert captured["install_after_sign"] is True + assert captured["browser_mode"] == "system_controlled" + assert Path(captured["state_dir"]) == state_dir + assert Path(captured["signed_output_dir"]) == output_dir if pipeline_result: assert payload["installed"] is True assert payload["signed_hap"] == str(hap_path) + assert payload["requested_capability_mode"] == "normal" + assert payload["capability_mode"] == "normal" + assert payload["capability_fallback"] is False else: - assert payload["error"]["message"] == ("检测设备连接: device unavailable") + assert payload["error"]["message"] == "检测设备连接: device unavailable" def test_failure_json_redacts_pipeline_device_udid( @@ -128,25 +157,16 @@ def run(self): monkeypatch.setattr(cli, "SignPipeline", FakePipeline) monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) - assert ( cli.main( - [ - "sign", - "--hap", - str(hap_path), - "--serial", - "device-serial", - "--json", - ] + ["sign", "--hap", str(hap_path), "--serial", "device-serial", "--json"] ) == 1 ) - stdout = capsys.readouterr().out assert device_udid not in stdout payload = json.loads(stdout) - assert payload["error"]["message"] == ("Device not found in list: ") + assert payload["error"]["message"] == "Device not found in list: " def test_sign_json_does_not_install(monkeypatch, tmp_path, capsys) -> None: @@ -159,6 +179,7 @@ class FakePipeline: def __init__(self, **kwargs): captured.update(kwargs) self.signed_hap_path = str(signed_path) + self.enable_capability = kwargs["enable_capability"] def run(self): return True @@ -170,26 +191,434 @@ def run(self): "_inspect_installed_bundle", lambda *_args: pytest.fail("sign 不应检查已安装 bundle"), ) + assert ( + cli.main( + ["sign", "--hap", str(hap_path), "--serial", "device-serial", "--json"] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["installed"] is False + assert payload["signed_hap"] == str(signed_path) + assert payload["requested_capability_mode"] == "normal" + assert payload["capability_mode"] == "normal" + assert payload["capability_fallback"] is False + assert captured["install_after_sign"] is False + + +def test_sign_json_reports_real_profile_fallback(monkeypatch, tmp_path, capsys) -> None: + hap_path = tmp_path / "unsigned.hap" + signed_path = tmp_path / "unsigned_signed.hap" + _write_hap(hap_path) + + class FakePipeline: + signed_hap_path = str(signed_path) + last_error = "" + enable_capability = False + + def __init__(self, **_kwargs): + pass + + def run(self): + return True + + monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + + result = cli.main(["sign", "--hap", str(hap_path), "--enable-capability", "--json"]) + payload = json.loads(capsys.readouterr().out) + + assert result == 0 + assert payload["requested_capability_mode"] == "system-basic" + assert payload["capability_mode"] == "normal" + assert payload["capability_fallback"] is True + + +def test_signed_input_rejects_existing_output_as_invalid_input( + monkeypatch, tmp_path, capsys +) -> None: + hap_path = tmp_path / "signed.hap" + output_path = tmp_path / "existing.hap" + _write_hap(hap_path) + output_path.write_bytes(b"existing") + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: True) + result = cli.main( + [ + "sign", + "--hap", + str(hap_path), + "--output", + str(output_path), + "--json", + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert result == 2 + assert payload["error"]["type"] == "invalid_input" + assert "输出已存在" in payload["error"]["message"] + assert output_path.read_bytes() == b"existing" + + +def test_cli_directory_flags_override_configured_defaults( + tmp_path, monkeypatch +) -> None: + hap_path = tmp_path / "example.hap" + state = tmp_path / "state" + output = tmp_path / "output" + _write_hap(hap_path) + captured = {} + + class FakePipeline: + signed_hap_path = str(tmp_path / "signed.hap") + last_error = "" + + def __init__(self, **kwargs): + captured.update(kwargs) + self.enable_capability = kwargs["enable_capability"] + + def run(self): + return True + + monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + result = cli.main( + [ + "sign", + "--hap", + str(hap_path), + "--state-dir", + str(state), + "--output-dir", + str(output), + "--json", + ] + ) + assert result == 0 + assert Path(captured["state_dir"]) == state + assert Path(captured["work_dir"]) == state / "com.example.app" + assert Path(captured["signed_output_dir"]) == output + + +def test_exact_output_takes_priority_over_output_directory( + tmp_path, monkeypatch +) -> None: + hap_path = tmp_path / "example.hap" + exact_output = tmp_path / "exact" / "signed.hap" + output_dir = tmp_path / "fallback" + _write_hap(hap_path) + captured = {} + + class FakePipeline: + signed_hap_path = str(exact_output) + last_error = "" + + def __init__(self, **kwargs): + captured.update(kwargs) + self.enable_capability = kwargs["enable_capability"] + + def run(self): + return True + + monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) assert ( cli.main( [ "sign", "--hap", str(hap_path), - "--serial", - "device-serial", + "--output-dir", + str(output_dir), + "--output", + str(exact_output), "--json", ] ) == 0 ) + assert captured["signed_output_path"] == str(exact_output) + assert Path(captured["signed_output_dir"]) == output_dir + + +def test_existing_exact_output_fails_before_pipeline_runs( + tmp_path, monkeypatch, capsys +) -> None: + hap_path = tmp_path / "example.hap" + output = tmp_path / "signed.hap" + _write_hap(hap_path) + output.write_bytes(b"existing") + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + monkeypatch.setattr( + cli, + "SignPipeline", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("pipeline ran")), + ) + + result = cli.main( + [ + "sign", + "--hap", + str(hap_path), + "--output", + str(output), + "--json", + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert result == 2 + assert payload["error"]["type"] == "invalid_input" + assert output.read_bytes() == b"existing" + + +def test_cli_browser_default_allows_valid_environment_override(monkeypatch) -> None: + monkeypatch.setenv("HAPSIGN_BROWSER", "system") + assert cli.build_parser().parse_args(["auth"]).browser == "system" + monkeypatch.setenv("HAPSIGN_BROWSER", "invalid") + assert cli.build_parser().parse_args(["auth"]).browser == "system_controlled" + + +def test_sign_rejects_serial_and_device_udid_together(capsys) -> None: + result = cli.main( + [ + "sign", + "--hap", + "app.hap", + "--serial", + "device-serial", + "--device-udid", + "A" * 64, + "--json", + ] + ) payload = json.loads(capsys.readouterr().out) + assert result == 2 + assert payload["error"]["type"] == "invalid_arguments" + assert "not allowed" in payload["error"]["message"] + + +def test_sign_accepts_explicit_udid_and_prints_json( + tmp_path, monkeypatch, capsys +) -> None: + hap_path = tmp_path / "example.hap" + exact_output = tmp_path / "artifacts" / "signed.hap" + _write_hap(hap_path) + captured = {} + + class FakePipeline: + signed_hap_path = str(exact_output) + last_error = "" + + def __init__(self, **kwargs): + captured.update(kwargs) + self.enable_capability = kwargs["enable_capability"] + + def run(self): + return True + + monkeypatch.setattr(cli, "SignPipeline", FakePipeline) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + result = cli.main( + [ + "sign", + "--hap", + str(hap_path), + "--device-udid", + "A" * 64, + "--output", + str(exact_output), + "--json", + ] + ) + payload = json.loads(capsys.readouterr().out) + assert result == 0 assert payload["ok"] is True + assert payload["command"] == "sign" assert payload["installed"] is False - assert payload["signed_hap"] == str(signed_path) + assert payload["browser_mode"] == "system_controlled" + assert payload["migration_warnings"] == [] + assert Path(payload["signed_hap"]).is_absolute() assert captured["install_after_sign"] is False + assert captured["device_udid"] == "A" * 64 + assert captured["signed_output_path"] == str(exact_output) + + +def test_inspect_json_does_not_run_pipeline(tmp_path, monkeypatch, capsys) -> None: + hap_path = tmp_path / "app.hap" + _write_hap(hap_path, {"app": {"bundleName": "com.example.inspect"}}) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: True) + monkeypatch.setattr( + cli, + "SignPipeline", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("pipeline ran")), + ) + result = cli.main(["inspect", "--hap", str(hap_path), "--json"]) + payload = json.loads(capsys.readouterr().out) + assert result == 0 + assert payload["command"] == "inspect" + assert payload["bundle_name"] == "com.example.inspect" + assert payload["signed"] is True + assert payload["migration_warnings"] == [] + + +def test_inspect_reports_applicable_destructive_cache_migration( + tmp_path, monkeypatch, capsys +) -> None: + hap_path = tmp_path / "app.hap" + state = tmp_path / "state" + metadata = state / "com.example.inspect" / "metadata.json" + _write_hap(hap_path, {"app": {"bundleName": "com.example.inspect"}}) + _write_legacy_metadata(metadata, "com.example.inspect") + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + result = cli.main( + [ + "inspect", + "--hap", + str(hap_path), + "--state-dir", + str(state), + "--json", + ] + ) + payload = json.loads(capsys.readouterr().out) + assert result == 0 + assert payload["migration_warnings"][0]["id"] == "HAPSIGN-BREAKING-001" + assert payload["migration_warnings"][0]["destructive"] is True + + +def test_inspect_evaluates_the_requested_capability_mode( + tmp_path, monkeypatch, capsys +) -> None: + hap_path = tmp_path / "app.hap" + state = tmp_path / "state" + metadata = state / "com.example.inspect" / "metadata.json" + _write_hap(hap_path, {"app": {"bundleName": "com.example.inspect"}}) + _write_legacy_metadata( + metadata, + "com.example.inspect", + enable_capability=True, + ) + monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) + + result = cli.main( + ["inspect", "--hap", str(hap_path), "--state-dir", str(state), "--json"] + ) + normal_payload = json.loads(capsys.readouterr().out) + assert result == 0 + assert normal_payload["migration_warnings"][0]["reasons"] == [ + "capability_mode_mismatch" + ] + + result = cli.main( + [ + "inspect", + "--hap", + str(hap_path), + "--state-dir", + str(state), + "--enable-capability", + "--json", + ] + ) + capability_payload = json.loads(capsys.readouterr().out) + assert result == 0 + assert capability_payload["migration_warnings"] == [] + + +def test_migrate_legacy_cache_command_does_not_run_pipeline( + tmp_path, monkeypatch, capsys +) -> None: + hap_path = tmp_path / "app.hap" + state = tmp_path / "state" + metadata = state / "com.example.app" / "metadata.json" + _write_hap(hap_path) + _write_legacy_metadata(metadata, "com.example.app") + monkeypatch.setattr( + cli, + "SignPipeline", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("pipeline ran")), + ) + result = cli.main( + [ + "migrate-cache", + "--hap", + str(hap_path), + "--state-dir", + str(state), + "--profile-type", + "normal", + "--json", + ] + ) + payload = json.loads(capsys.readouterr().out) + assert result == 0 + assert payload["command"] == "migrate-cache" + assert payload["changed"] is True + assert payload["enable_capability"] is False + + +def test_doctor_report_separates_signing_and_device_capabilities(tmp_path) -> None: + java = tmp_path / "java" + keytool = tmp_path / "keytool" + signer = tmp_path / "hap-sign-tool.jar" + for path in (java, keytool): + path.write_bytes(b"tool") + path.chmod(path.stat().st_mode | 0o111) + signer.write_bytes(b"signer") + toolchain = ToolchainPaths( + java=java, + keytool=keytool, + hap_sign_tool=signer, + hdc=tmp_path / "missing-hdc", + source="test", + ) + report = cli.doctor_report(toolchain) + assert report["ok"] is False + assert report["capabilities"]["signing"]["ok"] is True + assert report["capabilities"]["device"]["ok"] is False + assert report["tools"]["hap_sign_tool"]["exists"] is True + assert report["paths"]["state_dir"] + assert report["breaking_changes"][0]["id"].startswith("HAPSIGN-BREAKING-") + + +def test_doctor_json_uses_exit_code_for_incomplete_toolchain( + monkeypatch, capsys +) -> None: + report = { + "ok": False, + "command": "doctor", + "platform": "linux", + "architecture": "x86_64", + "python": "3.13", + "toolchain_source": "test", + "capabilities": {}, + "tools": {}, + } + monkeypatch.setattr(cli, "doctor_report", lambda **_kwargs: report) + result = cli.main(["doctor", "--json"]) + report["message"] = "环境诊断完成" + assert result == 1 + assert json.loads(capsys.readouterr().out) == report + + +def test_invalid_udid_returns_machine_readable_error(tmp_path, capsys) -> None: + hap_path = tmp_path / "app.hap" + _write_hap(hap_path) + result = cli.main( + ["sign", "--hap", str(hap_path), "--device-udid", "invalid", "--json"] + ) + output = capsys.readouterr().out + assert output.isascii() + payload = json.loads(output) + assert result == 2 + assert payload["ok"] is False + assert payload["command"] == "sign" + assert payload["error"]["type"] == "invalid_input" + assert "64 位" in payload["error"]["message"] def test_auth_status_json_never_emits_token(monkeypatch, tmp_path, capsys) -> None: @@ -210,9 +639,7 @@ def auth_status(self): } monkeypatch.setattr(cli, "SignPipeline", FakePipeline) - assert cli.main(["auth", "status", "--json"]) == 0 - output = capsys.readouterr().out payload = json.loads(output) assert payload["authenticated"] is True @@ -239,15 +666,10 @@ def authenticate(self, force_refresh=False): } def auth_status(self): - return { - "authenticated": True, - "cache_path": str(cache_path), - } + return {"authenticated": True, "cache_path": str(cache_path)} monkeypatch.setattr(cli, "SignPipeline", FakePipeline) - assert cli.main(["auth", "--json"]) == 0 - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is True assert payload["from_cache"] is True @@ -270,15 +692,10 @@ def authenticate(self, force_refresh=False): } def auth_status(self): - return { - "authenticated": False, - "cache_path": str(cache_path), - } + return {"authenticated": False, "cache_path": str(cache_path)} monkeypatch.setattr(cli, "SignPipeline", FakePipeline) - assert cli.main(["auth", "--json"]) == 1 - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["error"]["type"] == "operation_failed" @@ -312,9 +729,7 @@ def list_targets(self, connected_only=False): return targets monkeypatch.setattr(cli, "Installer", FakeInstaller) - assert cli.main(["devices", "list", "--connected-only", "--json"]) == 0 - payload = json.loads(capsys.readouterr().out) assert payload["count"] == 1 assert payload["connected_count"] == 1 @@ -333,9 +748,7 @@ def list_targets(self, connected_only=False): raise FileNotFoundError(2, "No such file or directory", "hdc") monkeypatch.setattr(cli, "Installer", MissingHdcInstaller) - assert cli.main(["devices", "list", "--json"]) == 1 - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["command"] == "devices" @@ -344,18 +757,16 @@ def list_targets(self, connected_only=False): def test_json_argument_error_has_stable_exit_code(capsys) -> None: - assert cli.main(["sign", "--hap", "app.hap", "--json"]) == 2 - + assert cli.main(["deploy", "--hap", "app.hap", "--json"]) == 2 payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False - assert payload["command"] == "sign" + assert payload["command"] == "deploy" assert payload["error"]["type"] == "invalid_arguments" assert "--serial" in payload["error"]["message"] def test_legacy_hap_invocation_is_rejected(capsys) -> None: assert cli.main(["--hap", "app.hap", "--serial", "device-serial", "--json"]) == 2 - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["command"] == "unknown" @@ -365,7 +776,6 @@ def test_legacy_hap_invocation_is_rejected(capsys) -> None: @pytest.mark.parametrize("command", ["sign", "install", "deploy"]) def test_empty_serial_is_rejected_as_invalid_arguments(command, capsys) -> None: assert cli.main([command, "--hap", "app.hap", "--serial", " ", "--json"]) == 2 - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["command"] == command @@ -375,21 +785,7 @@ def test_empty_serial_is_rejected_as_invalid_arguments(command, capsys) -> None: def test_missing_hap_json_is_invalid_input(capsys, tmp_path) -> None: missing = tmp_path / "missing.hap" - - assert ( - cli.main( - [ - "sign", - "--hap", - str(missing), - "--serial", - "device-serial", - "--json", - ] - ) - == 2 - ) - + assert cli.main(["sign", "--hap", str(missing), "--json"]) == 2 payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["error"]["type"] == "invalid_input" @@ -423,7 +819,6 @@ def inspect_bundle(self, bundle_name): monkeypatch.setattr(cli, "Installer", FakeInstaller) monkeypatch.setattr(cli, "is_hap_signed", lambda _path: True) - assert ( cli.main( [ @@ -437,7 +832,6 @@ def inspect_bundle(self, bundle_name): ) == 0 ) - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is True assert payload["installed"] is True @@ -469,7 +863,6 @@ def install(self, path): monkeypatch.setattr(cli, "Installer", MissingHdcInstaller) monkeypatch.setattr(cli, "is_hap_signed", lambda _path: True) - assert ( cli.main( [ @@ -483,7 +876,6 @@ def install(self, path): ) == 1 ) - payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert payload["command"] == "install" @@ -495,7 +887,6 @@ def test_install_rejects_unsigned_hap(monkeypatch, tmp_path, capsys) -> None: hap_path = tmp_path / "unsigned.hap" _write_hap(hap_path) monkeypatch.setattr(cli, "is_hap_signed", lambda _path: False) - assert ( cli.main( [ @@ -509,7 +900,6 @@ def test_install_rejects_unsigned_hap(monkeypatch, tmp_path, capsys) -> None: ) == 2 ) - payload = json.loads(capsys.readouterr().out) assert payload["error"]["type"] == "invalid_input" assert "deploy" in payload["error"]["message"] diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 62e31f5..7e64799 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -78,3 +78,11 @@ def test_redact_sensitive_text_hides_device_udid() -> None: assert "Device not found in list: " in text # 不应截断更长的十六进制诊断标识。 assert f"request_id={udid}0" in text + + +def test_device_udid_validation_uses_the_same_boundary_as_redaction() -> None: + assert diagnostics.is_valid_device_udid(" " + "A" * 64 + "\n") + assert diagnostics.is_valid_device_udid("a" * 64) + assert not diagnostics.is_valid_device_udid("A" * 63) + assert not diagnostics.is_valid_device_udid("G" * 64) + assert not diagnostics.is_valid_device_udid(None) diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..938974a --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,229 @@ +"""兼容性变更目录和旧缓存迁移测试。""" + +import json +from datetime import date + +import pytest + +from hapsign.migrations import ( + LEGACY_CACHE_CHANGE_ID, + LEGACY_STATE_CHANGE_ID, + breaking_changes, + cache_compatibility_warning, + legacy_state_warning, + migrate_legacy_cache, +) + + +def _write_metadata(path, **overrides) -> None: + metadata = { + "creation_date": date.today().isoformat(), + "bundle_name": "com.example.app", + "udid": "A" * 64, + "p12_path": "key.p12", + "cer_path": "cert.cer", + "p7b_path": "profile.p7b", + **overrides, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(metadata), encoding="utf-8") + for name in ("key.p12", "cert.cer", "profile.p7b"): + (path.parent / name).write_bytes(b"material") + + +def test_breaking_change_catalog_has_stable_unique_ids() -> None: + changes = breaking_changes() + + ids = [change["id"] for change in changes] + assert len(ids) == len(set(ids)) + assert LEGACY_CACHE_CHANGE_ID in ids + state_change = next( + change for change in changes if change["id"] == LEGACY_STATE_CHANGE_ID + ) + assert state_change["destructive"] is True + assert all( + change["decision"] == "accepted" + and change["compatibility_strategy"] + in { + "configuration", + "configuration-and-explicit-migration", + "migration-only", + } + and change["compatibility_options"] + and "remediation" in change + and "docs" in change + for change in changes + ) + + +def test_cache_warning_detects_missing_capability_mode(tmp_path) -> None: + metadata = tmp_path / "metadata.json" + _write_metadata(metadata) + + warning = cache_compatibility_warning(metadata) + + assert warning is not None + assert warning["id"] == LEGACY_CACHE_CHANGE_ID + assert warning["destructive"] is True + assert warning["requires_user_decision"] is True + assert warning["migratable"] is True + assert warning["reasons"] == ["missing_capability_mode"] + + _write_metadata(metadata, enable_capability=False) + assert cache_compatibility_warning(metadata) is None + + +def test_cache_warning_detects_capability_mode_mismatch(tmp_path) -> None: + metadata = tmp_path / "metadata.json" + _write_metadata(metadata, enable_capability=True) + + warning = cache_compatibility_warning(metadata, enable_capability=False) + + assert warning is not None + assert warning["migratable"] is False + assert warning["reasons"] == ["capability_mode_mismatch"] + assert warning["cached_capability_mode"] == "system-basic" + assert warning["expected_capability_mode"] == "normal" + assert cache_compatibility_warning(metadata, enable_capability=True) is None + + _write_metadata( + metadata, + enable_capability=False, + requested_enable_capability=True, + ) + assert cache_compatibility_warning(metadata, enable_capability=True) is None + + _write_metadata( + metadata, + enable_capability=True, + requested_enable_capability=False, + ) + warning = cache_compatibility_warning(metadata, enable_capability=False) + assert warning is not None + assert warning["reasons"] == ["invalid_effective_capability_mode"] + + +def test_legacy_state_warning_reports_home_cache_not_selected(tmp_path) -> None: + state = tmp_path / "application-state" + work = state / "com.example.app" + legacy = tmp_path / ".hapsign" + legacy.mkdir() + (legacy / ".token_cache.json").write_bytes(b"token") + _write_metadata(legacy / "com.example.app" / "metadata.json") + + warning = legacy_state_warning( + state, + work, + bundle_name="com.example.app", + legacy_state_dir=legacy, + ) + + assert warning is not None + assert warning["id"] == LEGACY_STATE_CHANGE_ID + assert warning["destructive"] is True + assert warning["requires_user_decision"] is True + assert warning["found"] == ["token_cache", "signing_materials"] + assert warning["legacy_state_dir"] == str(legacy.resolve()) + + assert ( + legacy_state_warning( + legacy, + legacy / "com.example.app", + bundle_name="com.example.app", + legacy_state_dir=legacy, + ) + is None + ) + + +def test_legacy_state_warning_does_not_block_for_token_only(tmp_path) -> None: + state = tmp_path / "application-state" + legacy = tmp_path / ".hapsign" + legacy.mkdir() + (legacy / ".token_cache.json").write_bytes(b"token") + + warning = legacy_state_warning( + state, + state / "com.example.app", + bundle_name="com.example.app", + legacy_state_dir=legacy, + ) + + assert warning is not None + assert warning["destructive"] is False + assert warning["requires_user_decision"] is False + assert warning["found"] == ["token_cache"] + + +def test_migrate_legacy_cache_is_atomic_backed_up_and_idempotent(tmp_path) -> None: + metadata = tmp_path / "metadata.json" + _write_metadata(metadata) + original = metadata.read_bytes() + + result = migrate_legacy_cache( + metadata, + bundle_name="com.example.app", + enable_capability=False, + ) + + backup = tmp_path / "metadata.json.pre-capability-migration.bak" + assert result["changed"] is True + assert result["backup"] == str(backup) + assert backup.read_bytes() == original + assert ( + json.loads(metadata.read_text(encoding="utf-8"))["enable_capability"] is False + ) + migrated = json.loads(metadata.read_text(encoding="utf-8")) + assert migrated["requested_enable_capability"] is False + assert migrated["p12_path"] == str((tmp_path / "key.p12").resolve()) + assert not (tmp_path / "metadata.json.tmp").exists() + + repeated = migrate_legacy_cache( + metadata, + bundle_name="com.example.app", + enable_capability=False, + ) + assert repeated["changed"] is False + + +def test_migrate_legacy_cache_rejects_bundle_or_mode_mismatch(tmp_path) -> None: + metadata = tmp_path / "metadata.json" + _write_metadata(metadata) + + with pytest.raises(ValueError, match="bundle_name"): + migrate_legacy_cache( + metadata, + bundle_name="com.other.app", + enable_capability=False, + ) + + _write_metadata(metadata, enable_capability=True) + with pytest.raises(ValueError, match="其他能力模式"): + migrate_legacy_cache( + metadata, + bundle_name="com.example.app", + enable_capability=False, + ) + + +def test_invalid_legacy_udid_is_reported_but_not_migratable(tmp_path) -> None: + metadata = tmp_path / "metadata.json" + _write_metadata(metadata, udid="invalid") + + warning = cache_compatibility_warning( + metadata, + bundle_name="com.example.app", + ) + + assert warning is not None + assert warning["migratable"] is False + assert warning["reasons"] == [ + "missing_capability_mode", + "invalid_device_udid", + ] + with pytest.raises(ValueError, match="设备 UDID"): + migrate_legacy_cache( + metadata, + bundle_name="com.example.app", + enable_capability=False, + ) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index d88ad2a..405b7df 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -7,6 +7,7 @@ import threading import zipfile from datetime import date, timedelta +from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -444,6 +445,185 @@ def test_metadata_does_not_store_keystore_password(tmp_path, monkeypatch) -> Non metadata = json.loads((tmp_path / "signing" / "metadata.json").read_text("utf-8")) assert "keystore_password" not in metadata + assert metadata["enable_capability"] is False + assert metadata["requested_enable_capability"] is False + + +def test_real_profile_fallback_persists_requested_and_effective_modes( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + enable_capability=True, + ) + pipeline._cert_api = Mock() + pipeline._cert_api.get_team_id.return_value = "team" + pipeline._capability_api = Mock() + pipeline._capability_api.get_app_brief_info.return_value = None + + pipeline._step_get_app_info() + pipeline._save_metadata( + "key.p12", + "cert.cer", + "profile.p7b", + "team", + "oid", + "A" * 64, + ) + + metadata = json.loads(Path(pipeline._metadata_path).read_text(encoding="utf-8")) + assert pipeline.requested_enable_capability is True + assert pipeline.enable_capability is False + assert metadata["requested_enable_capability"] is True + assert metadata["enable_capability"] is False + + +def _write_cached_metadata( + pipeline: SignPipeline, + tmp_path, + *, + bundle_name: str = "com.example.app", + udid: str = "A" * 64, + enable_capability: bool = False, + requested_enable_capability: bool | None = None, +) -> None: + materials = [] + for name in ("key.p12", "cert.cer", "profile.p7b"): + path = tmp_path / "materials" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"material") + materials.append(str(path)) + metadata = { + "creation_date": date.today().isoformat(), + "bundle_name": bundle_name, + "p12_path": materials[0], + "cer_path": materials[1], + "p7b_path": materials[2], + "udid": udid, + "enable_capability": enable_capability, + } + if requested_enable_capability is not None: + metadata["requested_enable_capability"] = requested_enable_capability + Path(pipeline._metadata_path).write_text( + json.dumps(metadata), + encoding="utf-8", + ) + + +def test_cached_metadata_must_match_bundle(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + _write_cached_metadata(pipeline, tmp_path, bundle_name="com.example.other") + + assert pipeline._load_cached_metadata() is None + + +def test_cached_metadata_must_match_requested_capability( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + enable_capability=True, + ) + _write_cached_metadata(pipeline, tmp_path, enable_capability=False) + + assert pipeline._load_cached_metadata() is None + + +def test_cached_fallback_profile_matches_the_original_real_profile_request( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.chdir(tmp_path) + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + enable_capability=True, + ) + _write_cached_metadata( + pipeline, + tmp_path, + enable_capability=False, + requested_enable_capability=True, + ) + + assert pipeline._load_cached_metadata() is not None + assert pipeline.enable_capability is False + + _write_cached_metadata( + pipeline, + tmp_path, + enable_capability=True, + requested_enable_capability=False, + ) + assert pipeline._load_cached_metadata() is None + + +def test_legacy_cached_metadata_without_capability_mode_is_not_reused( + tmp_path, + monkeypatch, +) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + _write_cached_metadata(pipeline, tmp_path) + metadata_path = Path(pipeline._metadata_path) + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metadata.pop("enable_capability") + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + + assert pipeline._load_cached_metadata() is None + + +def test_cached_metadata_must_match_known_device(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + device_udid="B" * 64, + ) + _write_cached_metadata(pipeline, tmp_path, udid="A" * 64) + + assert pipeline._load_cached_metadata() is None + + +def test_cached_metadata_requires_valid_device_udid(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + _write_cached_metadata(pipeline, tmp_path, udid="unknown") + + assert pipeline._load_cached_metadata() is None + + +def test_cached_metadata_requires_regular_material_files( + tmp_path, + monkeypatch, +) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + material_directory = tmp_path / "material-directory" + material_directory.mkdir() + Path(pipeline._metadata_path).write_text( + json.dumps( + { + "creation_date": date.today().isoformat(), + "bundle_name": "com.example.app", + "p12_path": str(material_directory), + "cer_path": str(material_directory), + "p7b_path": str(material_directory), + "udid": "A" * 64, + "enable_capability": False, + } + ), + encoding="utf-8", + ) + + assert pipeline._load_cached_metadata() is None def test_metadata_for_another_device_is_ignored(tmp_path, monkeypatch) -> None: @@ -578,7 +758,10 @@ def test_run_installs_already_signed_hap_directly(tmp_path, monkeypatch) -> None pipeline._step_sign_hap.assert_not_called() -def test_sign_only_keeps_signed_hap_without_installing(tmp_path, monkeypatch) -> None: +def test_sign_only_returns_already_signed_hap_without_hdc( + tmp_path, + monkeypatch, +) -> None: from hapsign import pipeline as pipeline_module hap = tmp_path / "signed.hap" @@ -592,24 +775,61 @@ def test_sign_only_keeps_signed_hap_without_installing(tmp_path, monkeypatch) -> progress_callback=lambda value, label: values.append((value, label)), ) monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: True) - install = Mock(side_effect=AssertionError("sign must not install")) monkeypatch.setattr( pipeline_module, "Installer", - lambda **_kwargs: SimpleNamespace( - get_udid=lambda: "A" * 64, - install=install, - close=Mock(), - ), + Mock(side_effect=AssertionError("HDC should not be used")), ) assert pipeline.run() is True assert pipeline.signed_hap_path == str(hap) assert values[-1] == (100, "签名完成") - install.assert_not_called() -def test_unsigned_sign_only_omits_install_step(tmp_path, monkeypatch) -> None: +def test_sign_only_publishes_already_signed_hap_to_explicit_output( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + hap = tmp_path / "signed.hap" + hap.write_bytes(b"signed") + output = tmp_path / "artifacts" / "published.hap" + pipeline = SignPipeline( + hap_path=str(hap), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + signed_output_path=str(output), + ) + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: True) + monkeypatch.setattr( + pipeline_module, + "Installer", + Mock(side_effect=AssertionError("HDC should not be used")), + ) + + assert pipeline.run() is True + assert output.read_bytes() == b"signed" + assert pipeline.signed_hap_path == str(output.absolute()) + assert not list(output.parent.glob("*.tmp.hap")) + + refusing = SignPipeline( + hap_path=str(hap), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing-2"), + install_after_sign=False, + signed_output_path=str(output), + ) + with pytest.raises(FileExistsError, match="输出已存在"): + refusing.run() + assert output.read_bytes() == b"signed" + + +def test_sign_only_reuses_cached_materials_without_device( + tmp_path, + monkeypatch, +) -> None: from hapsign import pipeline as pipeline_module pipeline = SignPipeline( @@ -619,29 +839,94 @@ def test_unsigned_sign_only_omits_install_step(tmp_path, monkeypatch) -> None: install_after_sign=False, ) monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: False) - monkeypatch.setattr(pipeline, "_step_check_device", Mock()) monkeypatch.setattr( pipeline, "_load_cached_metadata", - Mock( - return_value={ - "p12_path": "key.p12", - "cer_path": "cert.cer", - "p7b_path": "profile.p7b", - } - ), + lambda: { + "p12_path": "key.p12", + "cer_path": "certificate.cer", + "p7b_path": "profile.p7b", + }, ) - labels = [] + check_device = Mock(side_effect=AssertionError("device should not be queried")) + sign = Mock() + install = Mock(side_effect=AssertionError("install should not run")) + monkeypatch.setattr(pipeline, "_step_check_device", check_device) + monkeypatch.setattr(pipeline, "_step_sign_hap", sign) + monkeypatch.setattr(pipeline, "_step_install", install) - def _capture_steps(steps): - labels.extend(label for label, _step in steps) - return True + assert pipeline.run() is True + check_device.assert_not_called() + sign.assert_called_once() + install.assert_not_called() - monkeypatch.setattr(pipeline, "_run_steps", _capture_steps) - assert pipeline._run_pipeline() is True - assert labels == ["检测设备连接", "签名 hap"] - assert "安装 hap 到设备" not in labels +def test_sign_only_with_serial_checks_device_before_reusing_cache( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + pipeline = SignPipeline( + hap_path=str(tmp_path / "unsigned.hap"), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + serial="device-b", + install_after_sign=False, + ) + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: False) + check_device = Mock(side_effect=lambda: setattr(pipeline, "_udid", "B" * 64)) + load_metadata = Mock( + return_value={ + "p12_path": "key.p12", + "cer_path": "certificate.cer", + "p7b_path": "profile.p7b", + } + ) + monkeypatch.setattr(pipeline, "_step_check_device", check_device) + monkeypatch.setattr(pipeline, "_load_cached_metadata", load_metadata) + monkeypatch.setattr(pipeline, "_step_sign_hap", Mock()) + + assert pipeline.run() is True + check_device.assert_called_once() + load_metadata.assert_called_once() + assert pipeline._udid == "B" * 64 + + +def test_sign_only_accepts_explicit_udid_for_new_profile( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + pipeline = SignPipeline( + hap_path=str(tmp_path / "unsigned.hap"), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + device_udid="B" * 64, + ) + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: False) + monkeypatch.setattr(pipeline, "_load_cached_metadata", lambda: None) + monkeypatch.setattr( + pipeline, + "_load_token_cache", + lambda: {"access_token": "access", "user_id": "user", "jwt_token": "jwt"}, + ) + check_device = Mock(side_effect=AssertionError("device should not be queried")) + monkeypatch.setattr(pipeline, "_step_check_device", check_device) + for method in ( + "_step_generate_keypair", + "_step_add_certificate", + "_step_register_device", + "_step_create_provision", + "_step_sign_hap", + ): + monkeypatch.setattr(pipeline, method, Mock()) + + assert pipeline.run() is True + check_device.assert_not_called() + assert pipeline._udid == "B" * 64 def test_run_unsigned_hap_does_not_take_signed_shortcut(tmp_path, monkeypatch) -> None: @@ -811,6 +1096,21 @@ def test_pipeline_reports_stage_progress(tmp_path, monkeypatch) -> None: assert values[-1] == (100, "安装完成") +def test_sign_only_reports_signing_completion(tmp_path, monkeypatch) -> None: + values = [] + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + progress_callback=lambda value, label: values.append((value, label)), + ) + monkeypatch.setattr(pipeline, "_run_pipeline", Mock(return_value=True)) + + assert pipeline.run() is True + assert values[-1] == (100, "签名完成") + + def _prepare_sign_step(pipeline: SignPipeline) -> None: pipeline._cer_path = "certificate.cer" pipeline._p7b_path = "profile.p7b" @@ -903,6 +1203,157 @@ def sign_hap(self, *_args) -> bool: assert old.read_bytes() == b"old" +def test_explicit_output_is_atomic_and_requires_overwrite_opt_in( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + source = tmp_path / "unsigned.hap" + source.write_bytes(b"source") + output = tmp_path / "artifacts" / "agent-signed.hap" + + class FakeSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + with open(_args[-1], "wb") as stream: + stream.write(b"signed") + return True + + monkeypatch.setattr(pipeline_module, "HapSigner", FakeSigner) + pipeline = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + signed_output_path=str(output), + ) + _prepare_sign_step(pipeline) + + pipeline._step_sign_hap() + + assert output.read_bytes() == b"signed" + assert pipeline.signed_hap_path == str(output.absolute()) + assert not (output.parent / ".hapsign-signed-haps.json").exists() + assert not list(output.parent.glob("*.tmp.hap")) + + refusing = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing-2"), + install_after_sign=False, + signed_output_path=str(output), + ) + _prepare_sign_step(refusing) + with pytest.raises(FileExistsError, match="输出已存在"): + refusing._step_sign_hap() + + replacing = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing-3"), + install_after_sign=False, + signed_output_path=str(output), + overwrite_output=True, + ) + _prepare_sign_step(replacing) + replacing._step_sign_hap() + assert output.read_bytes() == b"signed" + + +def test_explicit_output_cannot_replace_input(tmp_path) -> None: + source = tmp_path / "app.hap" + + with pytest.raises(ValueError, match="不能覆盖输入"): + SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + signed_output_path=str(source), + ) + + +def test_explicit_output_race_never_overwrites_competing_file( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + source = tmp_path / "unsigned.hap" + source.write_bytes(b"source") + output = tmp_path / "artifacts" / "agent-signed.hap" + + class FakeSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + Path(_args[-1]).write_bytes(b"signed") + return True + + def competing_publish(_source, destination) -> None: + Path(destination).write_bytes(b"competitor") + raise FileExistsError(destination) + + monkeypatch.setattr(pipeline_module, "HapSigner", FakeSigner) + monkeypatch.setattr(pipeline_module.os, "link", competing_publish) + pipeline = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + signed_output_path=str(output), + ) + _prepare_sign_step(pipeline) + + with pytest.raises(FileExistsError, match="输出已存在"): + pipeline._step_sign_hap() + + assert output.read_bytes() == b"competitor" + assert not list(output.parent.glob("*.tmp.hap")) + + +def test_explicit_output_falls_back_when_hard_links_are_unavailable( + tmp_path, + monkeypatch, +) -> None: + from hapsign import pipeline as pipeline_module + + source = tmp_path / "unsigned.hap" + source.write_bytes(b"source") + output = tmp_path / "artifacts" / "agent-signed.hap" + + class FakeSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + Path(_args[-1]).write_bytes(b"signed") + return True + + monkeypatch.setattr(pipeline_module, "HapSigner", FakeSigner) + monkeypatch.setattr( + pipeline_module.os, + "link", + Mock(side_effect=OSError("hard links unsupported")), + ) + pipeline = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + install_after_sign=False, + signed_output_path=str(output), + ) + _prepare_sign_step(pipeline) + + pipeline._step_sign_hap() + + assert output.read_bytes() == b"signed" + assert not list(output.parent.glob("*.tmp.hap")) + + def test_disabled_signed_hap_retention_uses_temporary_file( tmp_path, monkeypatch ) -> None: diff --git a/tests/test_prepare_toolchain.py b/tests/test_prepare_toolchain.py index f7c87fc..00f1782 100644 --- a/tests/test_prepare_toolchain.py +++ b/tests/test_prepare_toolchain.py @@ -4,6 +4,9 @@ import hashlib import io +import json +import os +import stat import tarfile import zipfile @@ -88,3 +91,89 @@ def test_safe_extract_zip_rejects_parent_traversal(tmp_path) -> None: prepare_toolchain._safe_extract_zip(archive_path, tmp_path / "target") assert not (tmp_path / "escaped.txt").exists() + + +def test_extract_jdk_tar_preserves_executable(tmp_path) -> None: + archive_path = tmp_path / "jdk.tar.gz" + payload = b"jlink" + with tarfile.open(archive_path, mode="w:gz") as archive: + member = tarfile.TarInfo("jdk-21/bin/jlink") + member.mode = 0o755 + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + output = tmp_path / "jdk" + prepare_toolchain._extract_jdk_archive(archive_path, output) + + jlink = output / "jdk-21" / "bin" / "jlink" + assert jlink.read_bytes() == payload + if os.name != "nt": + assert jlink.stat().st_mode & stat.S_IXUSR + + +def test_safe_extract_tar_supports_early_python_311( + tmp_path, + monkeypatch, +) -> None: + archive_path = tmp_path / "jdk.tar.gz" + with tarfile.open(archive_path, mode="w:gz") as archive: + member = tarfile.TarInfo("jdk-21/release") + member.size = 6 + archive.addfile(member, io.BytesIO(b"locked")) + monkeypatch.delattr(prepare_toolchain.tarfile, "data_filter", raising=False) + + output = tmp_path / "output" + prepare_toolchain._safe_extract_tar(archive_path, output) + + assert (output / "jdk-21" / "release").read_bytes() == b"locked" + + +def test_safe_extract_tar_rejects_parent_traversal(tmp_path) -> None: + archive_path = tmp_path / "unsafe.tar.gz" + with tarfile.open(archive_path, mode="w:gz") as archive: + member = tarfile.TarInfo("../escaped.txt") + member.size = 6 + archive.addfile(member, io.BytesIO(b"unsafe")) + + with pytest.raises(RuntimeError, match="越界路径"): + prepare_toolchain._safe_extract_tar(archive_path, tmp_path / "target") + + assert not (tmp_path / "escaped.txt").exists() + + +def test_safe_extract_tar_rejects_special_files(tmp_path) -> None: + archive_path = tmp_path / "unsafe-special.tar.gz" + with tarfile.open(archive_path, mode="w:gz") as archive: + member = tarfile.TarInfo("jdk-21/pipe") + member.type = tarfile.FIFOTYPE + archive.addfile(member) + + with pytest.raises(RuntimeError, match="特殊文件"): + prepare_toolchain._safe_extract_tar(archive_path, tmp_path / "target") + + +def test_lock_includes_verified_linux_x64_toolchain() -> None: + lock = json.loads(prepare_toolchain.LOCK_PATH.read_text(encoding="utf-8")) + + linux = lock["platforms"]["linux"] + assert linux["architecture"] == "x64" + assert linux["openharmony"]["toolchains_member"].startswith("toolchains-linux-x64-") + assert len(linux["openharmony"]["toolchains_sha256"]) == 64 + assert linux["openharmony"]["files"]["bin/hdc"]["executable"] is True + assert linux["java"]["archive"]["url"].endswith(".tar.gz") + assert len(linux["java"]["archive"]["sha256"]) == 64 + + +def test_prepare_rejects_host_architecture_mismatch(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(prepare_toolchain, "_platform_tag", lambda: "linux") + monkeypatch.setattr(prepare_toolchain, "_architecture_tag", lambda: "arm64") + + with pytest.raises(RuntimeError, match="架构不匹配"): + prepare_toolchain.prepare( + target_platform="linux", + cache_dir=tmp_path / "cache", + output=tmp_path / "output", + sdk_archive_override=None, + jdk_archive_override=None, + force=False, + ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 727594e..2323c69 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,8 +1,18 @@ """跨平台运行目录与便携工具链发现测试。""" +import os + +import pytest + from hapsign import runtime +def _write_tool(path, content: bytes = b"tool") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + path.chmod(path.stat().st_mode | 0o111) + + def test_app_data_dir_honors_override(tmp_path, monkeypatch) -> None: target = tmp_path / "state" monkeypatch.setenv("HAPSIGN_DATA_DIR", str(target)) @@ -29,8 +39,7 @@ def test_portable_toolchain_takes_precedence(tmp_path, monkeypatch) -> None: root / "bin" / "hdc.exe", ] for path in paths: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"tool") + _write_tool(path) toolchain = runtime.discover_toolchain() @@ -50,14 +59,12 @@ def test_portable_public_runtime_takes_precedence_over_legacy_jbr( for name in ("java.exe", "keytool.exe"): for runtime_name in ("runtime", "jbr"): path = root / runtime_name / "bin" / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(runtime_name.encode()) + _write_tool(path, runtime_name.encode()) for path in ( root / "lib" / "hap-sign-tool.jar", root / "bin" / "hdc.exe", ): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"tool") + _write_tool(path) toolchain = runtime.discover_toolchain() @@ -67,7 +74,7 @@ def test_portable_public_runtime_takes_precedence_over_legacy_jbr( def test_signed_hap_only_requires_hdc(tmp_path) -> None: hdc = tmp_path / "hdc" - hdc.write_bytes(b"tool") + _write_tool(hdc) toolchain = runtime.ToolchainPaths( java=tmp_path / "missing-java", keytool=tmp_path / "missing-keytool", @@ -78,9 +85,73 @@ def test_signed_hap_only_requires_hdc(tmp_path) -> None: assert toolchain.missing(require_signing=False) == [] assert len(toolchain.missing(require_signing=True)) == 3 + assert toolchain.missing(require_signing=True, require_hdc=False) == [ + f"Java: {tmp_path / 'missing-java'}", + f"keytool: {tmp_path / 'missing-keytool'}", + f"hap-sign-tool.jar: {tmp_path / 'missing.jar'}", + ] def test_platform_tag_normalizes_darwin(monkeypatch) -> None: monkeypatch.setattr(runtime.platform, "system", lambda: "Darwin") assert runtime.platform_tag() == "macos" + + +def test_linux_discovers_java_home_path_and_explicit_signer( + tmp_path, + monkeypatch, +) -> None: + resources = tmp_path / "empty-resources" + java_home = tmp_path / "jdk" + path_bin = tmp_path / "path-bin" + java = java_home / "bin" / "java" + keytool = java_home / "bin" / "keytool" + hdc = path_bin / "hdc" + signer = tmp_path / "sdk" / "lib" / "hap-sign-tool.jar" + for path in (java, keytool, hdc): + _write_tool(path) + signer.parent.mkdir(parents=True) + signer.write_bytes(b"signer") + + monkeypatch.setattr(runtime.platform, "system", lambda: "Linux") + monkeypatch.setenv("HAPSIGN_RESOURCE_DIR", str(resources)) + monkeypatch.setenv("JAVA_HOME", str(java_home)) + monkeypatch.setenv("PATH", str(path_bin)) + monkeypatch.setenv("HAPSIGN_HAP_SIGN_TOOL", str(signer)) + monkeypatch.setattr( + runtime.shutil, + "which", + lambda name: str(hdc) if name == "hdc" else None, + ) + for name in ("HAPSIGN_JAVA", "HAPSIGN_KEYTOOL", "HAPSIGN_HDC", "DEVECO_HOME"): + monkeypatch.delenv(name, raising=False) + + toolchain = runtime.discover_toolchain() + + assert toolchain.missing() == [] + assert toolchain.java == java + assert toolchain.keytool == keytool + assert toolchain.hdc == hdc + assert toolchain.hap_sign_tool == signer + assert toolchain.source == "environment overrides" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX executable bits only") +def test_posix_non_executable_tool_is_reported(tmp_path) -> None: + java = tmp_path / "java" + java.write_bytes(b"java") + toolchain = runtime.ToolchainPaths( + java=java, + keytool=tmp_path / "unused-keytool", + hap_sign_tool=tmp_path / "unused.jar", + hdc=tmp_path / "unused-hdc", + source="test", + ) + assert ( + "文件不可执行" + in toolchain.missing( + require_signing=True, + require_hdc=False, + )[0] + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 7537dff..d62177f 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -87,6 +87,17 @@ def test_data_directory_environment_override_wins(tmp_path, monkeypatch) -> None ) == (tmp_path / "override" / "signing_files") +def test_exact_signing_directory_environment_override_wins( + tmp_path, monkeypatch +) -> None: + monkeypatch.setenv("HAPSIGN_DATA_DIR", str(tmp_path / "data-root")) + monkeypatch.setenv("HAPSIGN_SIGNING_DIR", str(tmp_path / "exact-state")) + + assert settings.signing_files_dir(settings.AppSettings()) == ( + tmp_path / "exact-state" + ) + + def test_signed_hap_directory_is_always_in_program_directory( tmp_path, monkeypatch ) -> None: @@ -94,3 +105,9 @@ def test_signed_hap_directory_is_always_in_program_directory( monkeypatch.setenv("HAPSIGN_DATA_DIR", str(tmp_path / "elsewhere")) assert settings.signed_haps_dir() == tmp_path / "app" / "signed_haps" + + +def test_signed_hap_directory_environment_override_wins(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HAPSIGN_SIGNED_HAPS_DIR", str(tmp_path / "outputs")) + + assert settings.signed_haps_dir() == tmp_path / "outputs" diff --git a/tests/test_signing.py b/tests/test_signing.py index de7f6b8..ce8fd20 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -426,6 +426,41 @@ def test_listener_pid_returns_none_when_port_free(monkeypatch) -> None: assert installer._listener_pid() is None +def test_linux_proc_fallback_finds_hdc_listener(tmp_path, monkeypatch) -> None: + proc_root = tmp_path / "proc" + net = proc_root / "net" + descriptor_dir = proc_root / "47024" / "fd" + net.mkdir(parents=True) + descriptor_dir.mkdir(parents=True) + (descriptor_dir / "7").write_text("descriptor", encoding="ascii") + (net / "tcp").write_text( + "sl local_address rem_address st tx_queue tm->when retrnsmt uid timeout inode\n" + "0: 0100007F:2206 00000000:0000 0A 00000000:00000000 " + "00:00000000 00000000 1000 0 12345\n", + encoding="ascii", + ) + (net / "tcp6").write_text("header\n", encoding="ascii") + monkeypatch.setattr(installer.os, "readlink", lambda _path: "socket:[12345]") + + assert installer._listener_pid_linux_proc(proc_root) == 47024 + + +def test_linux_proc_start_time_is_locale_independent(tmp_path, monkeypatch) -> None: + proc_root = tmp_path / "proc" + process = proc_root / "47024" + process.mkdir(parents=True) + fields_after_comm = ["S"] + ["0"] * 19 + fields_after_comm[19] = "500" + (process / "stat").write_text( + f"47024 (hdc server) {' '.join(fields_after_comm)}\n", + encoding="ascii", + ) + (proc_root / "stat").write_text("cpu 1 2 3\nbtime 1000\n", encoding="ascii") + monkeypatch.setattr(installer.os, "sysconf", lambda _name: 100, raising=False) + + assert installer._process_start_time_linux_proc(47024, proc_root) == 1005.0 + + def test_generate_keypair_builds_command(monkeypatch, tmp_path) -> None: run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) monkeypatch.setattr(keytool_util, "run_process", run) diff --git a/toolchain.lock.json b/toolchain.lock.json index 689711d..f8f19af 100644 --- a/toolchain.lock.json +++ b/toolchain.lock.json @@ -68,6 +68,75 @@ "jdk.unsupported" ] } + }, + "linux": { + "architecture": "x64", + "openharmony": { + "version": "6.1.0.31", + "api_version": "23", + "release": "6.1-Release", + "archive": { + "url": "https://repo.huaweicloud.com/openharmony/os/6.1-Release/ohos-sdk-windows_linux-public.tar.gz", + "sha256": "b833b75a64ee46bbd7880921abbb49b733ec5c8171b6684c9b524d57f624cee0", + "size": 2499936800 + }, + "toolchains_member": "toolchains-linux-x64-6.1.0.31-Release.zip", + "toolchains_sha256": "8b9d6e4b800351d190ed7372ac410e9e956ced7854eabb32e14e886d4680ec68", + "files": { + "bin/hdc": { + "archive_suffix": "hdc", + "sha256": "1cac7ca68779f92a82c06083d1a994099f67a5ee53378f68edbc72571339a9d9", + "executable": true + }, + "bin/libusb_shared.so": { + "archive_suffix": "libusb_shared.so", + "sha256": "431e69ebe2f87ac693c3dae032ec82baa3196b9d403139ae9775ccbaf9227887" + }, + "lib/hap-sign-tool.jar": { + "archive_suffix": "lib/hap-sign-tool.jar", + "sha256": "91cea67369a8389e0cec60ebac6f2382d157515b9d96b0ef36b60b86ca528e9b" + }, + "NOTICE.txt": { + "archive_suffix": "NOTICE.txt", + "sha256": "45664d1a732d46d69a576887ce2483bb8174f46959a9afd453ad3c28381cc6f4" + }, + "oh-uni-package.json": { + "archive_suffix": "oh-uni-package.json", + "sha256": "0bdb26d146442042f26390c1aa4c61a0d0658122d3c4c70c79f75d3f890aec4d" + } + }, + "libusb_source": { + "repository": "https://gitee.com/openharmony/third_party_libusb", + "commit": "b982552012b9faf15db3bd5c714594da91df33b0", + "path": "third_party/libusb/openharmony-third_party_libusb-b982552.tar.gz", + "sha256": "d6b7e36319a9aab07854e8bc89c458203697e7442ad66dd000c240df2a39a90c" + }, + "source_repositories": { + "hap-sign-tool": "https://gitee.com/openharmony/developtools_hapsigner", + "hdc": "https://gitee.com/openharmony/developtools_hdc" + } + }, + "java": { + "distribution": "Eclipse Temurin", + "version": "21.0.12+8", + "archive": { + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12%2B8/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12_8.tar.gz", + "sha256": "e4446ff06a276155697597cc0f1b15da004ff083f4964a35271ecee567177370", + "size": 207486543 + }, + "modules": [ + "java.base", + "java.compiler", + "java.desktop", + "java.management", + "java.naming", + "java.rmi", + "java.scripting", + "java.sql", + "jdk.crypto.ec", + "jdk.unsupported" + ] + } } } }