@rightkit/release 0.2.164 → 0.2.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ Signing and publication rules are in the workspace file `docs/rules/release-sign
15
15
 
16
16
  Release and update builds (`right-release build`, `publish-update`) must not contain any test-automation or debugging surface; the development lane may. Layers, in the order they run:
17
17
 
18
- 1. **Source (before anything is built; `right-release build --dry-run` only loads the config and does not reach this layer)** - `qa-build-features.mjs` refuses a build command, a package.json script it runs, or an app `Cargo.toml` that enables `devtools`, `qa-native`, `qa-control`, `dev-native` or `dev-inspect` (also `--all-features`, and `devtools` on the `tauri` dependency, `qa-control` on a non-optional `rightkit-qa-driver`, `dev-inspect` on a non-optional `rightkit-tauri-shell`, a non-optional `tauri-plugin-wdio-webdriver`, or any of them reachable from the default features).
18
+ 1. **Source (before anything is built; `right-release build --dry-run` only loads the config and does not reach this layer)** - `qa-build-features.mjs` refuses a build command, a package.json script it runs, or an app `Cargo.toml` that enables `devtools`, `qa-native`, `qa-control`, `dev-native` or `dev-inspect` (also `--all-features`, and `devtools` on the `tauri` dependency, `qa-control` on a non-optional `rightkit-qa-driver` (or `control` on a non-optional `rightkit-qa`; `rightkit-qa/control` is also refused as a feature item), `dev-inspect` on a non-optional `rightkit-tauri-shell`, a non-optional `tauri-plugin-wdio-webdriver`, or any of them reachable from the default features).
19
19
  2. **Pre-pack payload scan** - `qa-control-scan.mjs` over the raw EXE (Windows NSIS and portable), the macOS signing set and `notarize.app` (the `.app` going into the DMG; `dmg build` also scans its staging directory), the MSIX `stageDir` before packing, and `target.qaControlScan`. Mach-O, PE and ELF files are matched against five rules: `qa-control-server` (marker `RIGHTKIT-QA-CONTROL-SERVER-PRESENT`), `shell-dev-inspect` (marker `RIGHTKIT-SHELL-DEV-INSPECT-COMPILED`, written by rightkit-tauri-shell's `dev-inspect` feature; no allow-list lifts it), `wdio-webdriver` (2 of 3 of `tauri-plugin-wdio-webdriver`, `tauri_plugin_wdio_webdriver`, `wdio-webdriver:default`), `remote-debugging` (`--remote-debugging-port`, `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`) and `tauri-devtools` (`plugin:webview|internal_toggle_devtools`, `toggleDevtoolsHotkey`); each signature also as UTF-16LE. A scan that finds no native binary fails.
20
20
 
21
21
  Web-bundle mock scan: `release` also runs `assertNoWebBundleMocks` over the frontend dist (`target.webBundleScan`, else `build.frontendDist` of `src-tauri/tauri.conf.json`) before packaging when it already exists (also under `--dry-run`) and after the package command, where a missing or empty dist fails. `right-release bundle-scan` (Node/Python runtime roots) and `identity-scan` (needs the app's declared cdhash/team/identifier) are not automatic: both need app-specific inputs, so each app runs them from its own `checks`.
@@ -69,7 +69,10 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
69
69
  | Build (SwiftPM apps) | `swift-package` (`windows-stage`, `msix`, `mac-app`) (config schema 1: `windows.{exe,out,searchDirs,include,dependencies,publisher,assets}`, `mac.{executable,bundleId,out,frameworks,resources,entitlements}`) |
70
70
  | Install | `install-app` |
71
71
  | Publish | `publish`, `upload`, `publish-update`, `github`, `mirror-root-artifact` |
72
- | Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater` |
72
+ | Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater`, `sign-dev-artifact`, `sign-candidate` |
73
+ | Engine locator | `engine-locate --engine cutright\|genright [--sha256 <hex>] [--json]`: the verified route to the installed CutRight or GenRight engine, also importable as `locateEngine` from `engine-locator.mjs`. The path is fixed (`<LocalAppData or ~/Library/Application Support>/Orthic Labs/<App>/current/bin/<engine>`), must be a regular file (no symlink or junction), and must pass signature and identity checks (macOS: strict codesign, Developer ID Team ID, fixed Identifier, hardened runtime, not ad hoc; Windows: valid Authenticode with the Orthic Labs signer). An optional sha256 pins the exact build. There is no environment variable, path option or PATH search, so release builds never trust a caller-supplied engine path. Exit 1 with a typed `ENGINE_*` code on any failure |
74
+ | Candidate signing | `sign-candidate --platform win\|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]`: signs the exact unsigned candidate bytes in place instead of rebuilding from source. The candidate receipt (`{ schema: 1, files: [{ path, sha256 }] }`) is checked first and any mismatch refuses; Windows signs through Azure Artifact Signing (`--publisher` pins the MSIX signer subject), macOS through Developer ID with fixed identifiers from `--manifest`. Writes a binding receipt (`rebuilt: false`, candidate sha -> signed sha per file). Never builds, notarizes or publishes |
75
+ | Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
73
76
  | Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
74
77
  | Ownership | `ownership`, `ownership-approval`, `ownership-report` |
75
78
  | Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
@@ -93,8 +96,18 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
93
96
 
94
97
  It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, stages a `ditto` copy beside it in the destination and verifies that copy, quits the running instance with SIGTERM (15 s, then SIGKILL), swaps the staged copy in (one atomic exchange when the `rightkit-swap` helper is found, otherwise two journaled renames; the old bundle is restored on failure), re-verifies the installed copy, only then moves the old bundle to `~/.Trash/<App> <timestamp>.app`, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`, among others). `right-release install-app --recover [--dest <dir>] [--app-name <App.app>] [--json]` rolls an interrupted install forward or back from its journal; every install runs that recovery first. Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
95
98
 
99
+ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. It is added by `right-release publish npm --package @rightkit/release --local` on the Mac (managed `rightkit cargo build --release -p rightkit-fs --bin rightkit-swap`, `codesign --options runtime --timestamp`, strict verify; CI has no signing secrets, so this package is published locally). `install-app` refuses a helper that exists but is not validly signed (`SWAP_HELPER_UNSIGNED`, naming the fix); with no helper at all it uses the journaled two-rename fallback and logs it (`swap-fallback`, `replaced {swap}`).
100
+
96
101
  Development builds (from `@rightkit/ci` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
97
102
 
103
+ ## One fixed path, one identity, one `.previous` (every app)
104
+
105
+ macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. Rollback is a persistent `<name>.previous` beside the fixed path (rename it back); nothing ever runs from `.previous` or from a staging copy.
106
+
107
+ - `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash); `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe>` does the same for a Windows folder install. `right-release dev` (mac) installs to `install.destination` (required) with `<dest>.previous` and fails if its designated requirement differs from the installed copy; a portable dev payload installs as `<installRoot>/current` with `current.previous`; the Windows direct installer installs to `current` with `current.previous`.
108
+ - `right-release dev` (mac) signs with the same Developer ID Application identity as release (an app that needs a provisioning profile uses a Developer ID profile); Apple Development certificates are refused with `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`.
109
+ - **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (keeping `<name>.previous`); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
110
+
98
111
  ## Runtime budget gate (crates)
99
112
 
100
113
  `publish cargo` runs the `runtime budget` step (`runtime-budgets.mjs`) right after the secret scan. Full contract, schema and refusal table: `docs/RUNTIME-BUDGETS.md`.
@@ -109,6 +122,14 @@ What it does not do: run the bench, prove the bench ran, detect `[profile]`, `[p
109
122
 
110
123
  A real `publish cargo --crate <name>` refuses with `RUNTIME_BUDGET_MISSING` listing every problem; `--dry-run` prints `[FAIL] runtime-budget: ...` and continues.
111
124
 
125
+ ## QA adoption (mandatory)
126
+
127
+ RightKit QA is a mandatory component: every Orthic Labs app adopts it. `assertQaAdoption` (`qa-contract.mjs`, next to the native-QA backdoor contract) runs in `release.mjs` beside the package and Cargo contracts on every release, `--dry-run` and `doctor` included, SwiftPM apps without a `package.json` too.
128
+
129
+ - **Refused:** the app has no QA lane. A lane is the native wdio lane (`right-qa.config.mjs`, validated by `validateQaBackdoorContract`), `@rightkit/qa` in any non-`node_modules` `package.json` (dependencies or devDependencies), or `rightkit-qa` in any `Cargo.toml` (dependencies, dev-dependencies, build-dependencies, `[workspace.dependencies]`; path and workspace declarations count). Message: `RightKit QA is mandatory for every Orthic Labs app, and <app> has no QA lane ... Adopt it: ...`.
130
+ - **Warned:** a `@rightkit/qa`, `@rightkit/qa-chrome` or `rightkit-qa` pin behind the latest published version in `rightkit-versions.json` (`[right-release] QA warning: <app> runs RightKit QA behind the latest published version: <pin> -> <latest> (<file>)`). **Refused** when the lag is a breaking bump (`breakingBump` of `rightkit-drift`: a minor bump below 1.0, a major from 1.0). `@rightkit/qa-chrome` is lag-checked but its presence is not enforced.
131
+ - **Exempt:** only a repository that is not an app. `qa-adoption-exemptions.json` (`nonAppRepositories`: `{ repositoryIdentity, kind: "library" | "tooling", reason }`, matched against the git `origin`) is RightKit-owned, so a change needs a RightKit release; the RightKit source tree is recognised by `packages/release/package.json` named `@rightkit/release`. There is no per-app opt-out, config key or environment switch.
132
+
112
133
  ## Entry points
113
134
 
114
135
  Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
@@ -156,8 +177,8 @@ For `projectType: "swiftpm"` on mac with `appBeforePackage: true` and no `target
156
177
 
157
178
  By default every code item is signed with the hardened runtime and no entitlements, so a bundle that needs entitlements on some of its code (an app, a sandboxed extension) declares them per file. Both forms below are optional and backwards compatible; a config of plain path strings signs exactly as before.
158
179
 
159
- - `sign.prePackageFiles[]` entries are a path string or `{ file, entitlements?, hardenedRuntime?, options? }`. `entitlements` is a plist path relative to the release config directory. For a `.app` entry the settings apply to the bundle signature.
160
- - `sign.nestedCode` is a map from a path inside the bundle (`Contents/...`, forward slashes) to `{ entitlements?, hardenedRuntime?, options? }`. It needs exactly one `.app` entry in `sign.prePackageFiles` to resolve against. Nested code the map does not name keeps the defaults.
180
+ - `sign.prePackageFiles[]` entries are a path string or `{ file, entitlements?, hardenedRuntime?, options?, identifier? }`. `identifier` is the codesign `--identifier` (letters, digits, `.`, `-`): set it on loose tools (for example `legion`, `legion-hook`, `legion-mcp`) so each keeps one stable code identity across builds; it is carried through the plan and signing manifest and checked against the receipt. `entitlements` is a plist path relative to the release config directory. For a `.app` entry the settings apply to the bundle signature.
181
+ - `sign.nestedCode` is a map from a path inside the bundle (`Contents/...`, forward slashes) to `{ entitlements?, hardenedRuntime?, options?, identifier? }`. It needs exactly one `.app` entry in `sign.prePackageFiles` to resolve against. Nested code the map does not name keeps the defaults.
161
182
  - `options` are extra codesign `--options` names (`expires`, `hard`, `host`, `kill`, `library`; `runtime` is always on). `hardenedRuntime` may be `true` or omitted: `false` is a config error because a notarized target cannot ship code without it.
162
183
 
163
184
  Signing order stays inside-out: all nested code, deepest path first (a nested bundle after everything inside it), then the `sign.prePackageFiles` entries in declared order. A `nestedCode` path is signed once, with its own settings, in that sequence; it must exist, must not be a symlink or leave the bundle, and must not be the bundle's main executable (that is signed with the bundle). Every configuration problem found is reported at once, before anything is signed.
@@ -198,6 +219,102 @@ Supported: `title`, `icon` (`.icns` volume icon), `background` (PNG or TIFF), `i
198
219
 
199
220
  `right-release upload` and `right-release github --release` share one gate (`github-upload-gate.mjs`): both require `--tier patch|update`, the primary checkout with a clean tree, a sealed release built from the current HEAD with matching ownership evidence, and a release config that declares `distribution.provider: "github-releases"` and `distribution.repository` (`--repo` may only repeat it; the `origin` remote is never consulted). The gate makes no network call and also runs on `--dry-run`. Private products publish with `publish-update` and `distribution.provider: "r2-rightapps"`. `github --addon` publishes headless add-ons and takes no tier. Not gated the same way: `github --addon` (no tier), `addon upload` (no provider requirement), and the direct R2 uploaders `upload-large.mjs` and `pub-upload.mjs` (no tier, provider or clean-tree check).
200
221
 
222
+ ## GitHub Releases updater lane (`publish-update`, `github-releases`)
223
+
224
+ A public app with an in-app Tauri updater publishes with `right-release publish-update --release <sealed-id> --platform win|mac --tier update` and `distribution: { provider: "github-releases", repository: "<owner>/<repo>", updaterKeyId?: "29395F9FF466261D" }`. It is the r2-rightapps lane with a different destination, not a second signer: the updater bundles are signed by `sign-updater.mjs` with the Right Suite updater key (the one every app uses; no per-app key or trust root), sealed with their `.sig` files, and re-verified by `verifyPublicationRelease` before anything is uploaded. The lane then:
225
+
226
+ - builds the same `platforms` map the R2 lane builds (`{ <target>: { signature, url } }`), with each `url` the GitHub asset URL `https://github.com/<owner>/<repo>/releases/download/v<version>/<asset>`;
227
+ - uploads each sealed bundle and `.sig` to release `v<version>` (created when missing, never a draft), byte-verifying every asset by download, and refusing, not overwriting, a remote asset whose bytes differ;
228
+ - writes a Tauri v2 static `latest.json` (`version`, `notes`, `pub_date`, `platforms`) and uploads it last, merged with the other platform's entries already on that release (the win and mac runs share one `latest.json`; a different version on the release is refused), so `https://github.com/<owner>/<repo>/releases/latest/download/latest.json` resolves.
229
+
230
+ `updaterKeyId` is optional; when set, a sealed signature made by any other key refuses the publish (`PUBLICATION_SIGNATURE_KEY_MISMATCH`). Notes come from `RIGHT_RELEASE_NOTES`. `--dry-run` prints the upload plan and the manifest with no network call. Uploads use `gh` (already authenticated); no RightApps token is read.
231
+
232
+ Updater artifacts are declared as for R2 but need no `key`; `platform` must be a Tauri updater target (`windows-x86_64`, `windows-aarch64`, `darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`, optionally with an installer suffix such as `-nsis`, `-msi`, `-app`, `-appimage`) and file names must match `[A-Za-z0-9._+-]` so GitHub does not rename them:
233
+
234
+ ```js
235
+ targets: {
236
+ win: {
237
+ updater: { keyFile: "%APPDATA%/RightKit/updater.key", artifacts: [
238
+ { file: "src-tauri/target/release/bundle/nsis/Ember_1.2.3_x64-setup.exe", signature: "src-tauri/target/release/bundle/nsis/Ember_1.2.3_x64-setup.exe.sig", platform: "windows-x86_64" },
239
+ ] },
240
+ artifacts: ["dist/Ember-1.2.3-portable-win.zip"], // portable zips seal alongside; they are not in latest.json
241
+ },
242
+ mac: { updater: { artifacts: [
243
+ { file: "src-tauri/target/release/bundle/macos/Ember.app.tar.gz", signature: "src-tauri/target/release/bundle/macos/Ember.app.tar.gz.sig", platform: "darwin-aarch64" },
244
+ ] } },
245
+ }
246
+ ```
247
+
248
+ NSIS, MSI, `.app.tar.gz` and AppImage bundles all work the same way (any file signed by `sign-updater`). With no sealed installer the installer platform-trust check is skipped (reported in the output); installer-bearing seals still run it. The app side: `plugins.updater.endpoints` is the `latest.json` URL above and `plugins.updater.pubkey` is the suite updater public key (see `@rightkit/app-update` README).
249
+
250
+ ## Android (AAB build, Play internal track, dev install)
251
+
252
+ The Android lane is native Kotlin with Gradle plus Rust `.so` libraries per ABI (`docs/design/android-minimum.md` section 4). It follows the same split as the desktop lanes: `build` never uploads, `upload` never builds.
253
+
254
+ | Step | Command | Config |
255
+ |---|---|---|
256
+ | Build | `right-release build --platform android` | `targets.android`: `gradle` (`bundle<Variant>Release` only), optional `ndk`, `aab`, `artifacts`, `signing` (see `android-release.mjs`) |
257
+ | Upload | `right-release upload --platform android --release <sealed-id> [--apply]` | top-level `distribution: { provider: "google-play", packageName, track: "internal", releaseStatus?, releaseName?, serviceAccount }` |
258
+ | Dev | `right-release dev --platform android --serial <adb serial>` | `development.targets.android`: `ndk?`, `build` (debug tasks), `install: { kind: "adb", apk, package, activity? }` |
259
+
260
+ - Build: `targets.android.ndk = { abis?: ["arm64-v8a"], jniLibs: "android/app/src/main/jniLibs", manifestPath?, package?, features?, apiLevel? }` runs `cargo ndk -t <abi> ... -o <jniLibs> build --release` before Gradle. Release defaults to `arm64-v8a`; add `x86_64` only for an emulator-installable bundle. `jniLibs` is generated and emptied before each real build. Gradle then runs `bundleRelease` with the upload keystore passwords handed over only as the path of a transient 0600 properties file. The AAB is scanned and must be signed by the pinned upload certificate.
261
+ - Upload: dry run is the default; `--apply` performs `edits.insert`, `bundles.upload`, `tracks.update` (internal) and `edits.commit`. A debug-signed, unsigned or unpinned bundle is refused. Only the internal track and a non-test package can be named. There is no `--tier`.
262
+ - Secrets: the keystore passwords and the Play service-account JSON live in the OS credential store (macOS Keychain `rightkit-android-<name>`; a protected token file on Windows and Linux, so store the JSON as one line) or in a 0600 file outside the repository, selected by `{ credential: "<name>" }` or `{ file: "<path>" }`. They never appear in the environment, on argv, in config, or in a log; key material found in the environment refuses the command.
263
+ - Dev: a debug build installed by serial (never "the only device") with a package ending in `.dev`, `.qa`, `.test` or `.debug`; release tasks and signing properties are refused.
264
+
265
+ ## Bundled FFmpeg decode sidecar (Ember and other media apps)
266
+
267
+ An app that decodes video through `rightkit-ffmpeg` ships the verified LGPL `ffmpeg` and `ffprobe` programs as a Tauri `externalBin` sidecar. right-release adds no new bundling mechanism: Tauri bundles the files (mac `Contents/MacOS`, Windows next to the app EXE), and right-release plans them from the catalog the app compiled against, ships the LGPL text and source offer, signs the binaries with the app, and seals their hashes. Set `targets.mac.mediaSidecar` and `targets.win.mediaSidecar`:
268
+
269
+ ```js
270
+ mediaSidecar: {
271
+ arch: "aarch64", // catalog arch, one per build (mac aarch64 | x86_64; win x86_64). Default: the build host.
272
+ variant: "decode", // catalog variant (default "decode")
273
+ tools: ["ffmpeg", "ffprobe"], // default both
274
+ stageDir: "src-tauri/binaries", // where Tauri finds binaries/<tool>-<triple>[.exe] (default)
275
+ bundleDir: "Contents/MacOS", // mac only; "Contents/Resources" if the app looks there instead
276
+ notices: { dir: "src-tauri/resources/third-party/ffmpeg", sourceOfferContact: "https://example.com/ffmpeg-source" },
277
+ stage: { cmd: "<runner>", args: ["run", "--bin", "stage-ffmpeg"] }, // optional: your call to rightkit_ffmpeg::stage_external_bin
278
+ catalog: "path/to/artifacts.json", // optional; default: the rightkit-ffmpeg crate the app's Cargo graph resolves (metadata, offline)
279
+ }
280
+ ```
281
+
282
+ The app still declares `"externalBin": ["binaries/ffmpeg", "binaries/ffprobe"]` and lists `notices.dir` under Tauri `bundle.resources`; `LIGHTCRAFT_FFMPEG_DIR` and the other lookup places are the app's own. Order of a build:
283
+
284
+ 1. Plan: one approved catalog row per tool for the variant, OS and arch. A missing, pending or stale row, a row without a content digest, or an ambiguous match refuses the release (`MEDIA_SIDECAR_NO_ROW`, `MEDIA_SIDECAR_NOT_APPROVED`, `MEDIA_SIDECAR_AMBIGUOUS_ROW`).
285
+ 2. Before the app is built: the row's notice files (the LGPL text and build notes) are copied into `notices.dir` together with a generated `FFMPEG-SOURCE-OFFER.txt` (licence, exact FFmpeg source URL and archive SHA-256, build recipes, component notices, `sourceOfferContact`); then `stage` runs if configured. Staging must use `stage_external_bin`, which checks the catalog content digest (signature-independent) as it copies. right-release does not recompute that digest; it checks that each `<tool>-<triple>[.exe]` exists, is a non-empty file, and records its hash.
286
+ 3. Signing: macOS adds `Contents/MacOS/ffmpeg` and `ffprobe` to `sign.nestedCode` (hardened runtime, no entitlements, signed inside-out with the bundle and notarized with the app; the nested signing receipt must show each strict-valid and the app must contain `FFMPEG-SOURCE-OFFER.txt`). Windows signs the staged `.exe` files through `sign-windows` in the same raw-EXE step as the app EXE, before Tauri packs them into the installer, so they land in the `raw-exe` receipt.
287
+ 4. Receipt and seal: `media-sidecar.json` (state root) records the catalog row ids and content digests, the pre-sign and signed SHA-256 and size of each binary, the notice digests and the signing mode. `right-release build` refuses to seal a release that configures `mediaSidecar` without it, and on Windows checks each signed digest against the sealed raw-EXE receipt. The sealed `release-manifest.json` carries it as `mediaSidecars`, and publication re-validates its shape.
288
+
289
+ Linux has catalog and planner support only: the planner maps `linux` to the `*-unknown-linux-gnu` triples and works as soon as the crate catalog carries approved Linux rows, but there is no Linux release target in right-release yet, and the catalog has no Linux rows (they need a Linux build and the crate's identity check; see the rightkit-ffmpeg README). Until then an app ships without video on Linux. Tests (fakes only, no catalog download, binary, signer or notary): `media-sidecar.test.mjs`.
290
+
291
+ ## Generic Tauri externalBin sidecars (any app)
292
+
293
+ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`, `mailright-core`) sets `targets.mac.externalBin` / `targets.win.externalBin`: `{ names: [...], arch?, stageDir? ("src-tauri/binaries"), bundleDir? (mac: "Contents/MacOS" | "Contents/Resources"), stage?: { cmd, args? } }`. It is the generic form of the ffmpeg staging above and reuses its target-triple naming (`<name>-<triple>[.exe]`), staged-file checks and signing evidence checks (`external-bin.mjs` over `media-sidecar.mjs`); it has no catalog, notices or manifest seal. `stage` is the app's own build of the files. Windows signs the staged files in the raw-EXE step; macOS signs `<bundleDir>/<name>` as nested code (hardened runtime) and checks the nested receipt. `external-bin.json` (state root) records pre-sign and signed digests. Names that `mediaSidecar` stages (`ffmpeg`, `ffprobe`) are refused here. Tests: `external-bin.test.mjs`.
294
+
295
+ ## Keeping agent writes off the internal drive (`disk agent-env`, `disk probe`)
296
+
297
+ No agent may write to the internal drive (C: on Windows, the internal disk on macOS); only installed apps go there.
298
+
299
+ `right-release disk agent-env [--root <dir>] [--format sh|ps1|cmd|json] [--check] [--include-agent-homes]` prints the environment that sends agent and child-tool writes to the external root (default `D:\` on Windows, `/Volumes/D` on macOS; default format `ps1` on Windows, `sh` on macOS). It fails closed: if the root is missing, not writable, on the system drive, or (macOS) not under `/Volumes`, it prints nothing on stdout and exits 1. It never falls back to the internal drive. It creates the target folders under the root. `CARGO_TARGET_DIR` is deliberately not set because RustRight owns cargo targets. `CODEX_HOME` and `CLAUDE_CONFIG_DIR` appear only with `--include-agent-homes`, because moving them requires restarting every chat. `--check` compares the current process environment with the map and lists each variable that is on the internal drive or unset; exit 1 when any is. The paths are the ones RustRight applied to the user environment, in one exported table (`AGENT_ENV_TABLE`, imported by the RustRight broker), relative to the root and the same on both platforms:
300
+
301
+ | Variables | Path under the root |
302
+ | --- | --- |
303
+ | `TEMP`, `TMP`, `TMPDIR`, `CLAUDE_CODE_TMPDIR` | `agent-cache/tmp` |
304
+ | `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME` | `agent-cache/xdg`, `xdg-data`, `xdg-state`, `xdg-config` |
305
+ | `PNPM_STORE_DIR`, `NUGET_PACKAGES` | `agent-cache/pnpm-store`, `agent-cache/nuget` |
306
+ | `npm_config_cache`, `PIP_CACHE_DIR`, `UV_CACHE_DIR`, `HF_HOME` | `.dev-cache/npm-cache`, `pip`, `uv`, `huggingface` |
307
+ | `YARN_CACHE_FOLDER`, `TORCH_HOME`, `TRANSFORMERS_CACHE`, `PLAYWRIGHT_BROWSERS_PATH`, `PUPPETEER_CACHE_DIR`, `GOCACHE`, `GOMODCACHE`, `DOTNET_CLI_HOME`, `ELECTRON_CACHE`, `DENO_DIR` | `agent-cache/yarn`, `torch`, `transformers`, `playwright`, `puppeteer`, `go-build`, `go-mod`, `dotnet`, `electron`, `deno` |
308
+ | `CODEX_HOME`, `CLAUDE_CONFIG_DIR` (`--include-agent-homes`) | `agent-home/codex`, `agent-home/claude` |
309
+
310
+ `right-release disk probe [--json]` makes a real attempt to create, then immediately remove, one uniquely named 0-byte file (`.rk-disk-probe-<pid>-<nonce>`) in the user profile root, the local temp (`AppData\Local\Temp`, or `TMPDIR` on macOS), the OS temp and the external root. It writes nothing else. Run it from Claude, Codex and a child process as proof. It exits 1 when `internalWritable` is true (or a probe file could not be removed). `--json` prints exactly:
311
+
312
+ ```json
313
+ {"schema":"rightkit.disk.probe.v1","host":"<hostname>","root":"<external root>","results":[{"location":"profile|local-temp|os-temp|external-root","path":"<directory probed>","outcome":"blocked|allowed|error","code":"<error code or null>"}],"internalWritable":false}
314
+ ```
315
+
316
+ `outcome` is `allowed` (file created and removed), `blocked` (`EACCES`, `EPERM` or `EROFS`) or `error` (directory missing or another failure; `code` says which). `code` is `null` when allowed, except `UNLINK_<code>` when the probe file could not be removed. `internalWritable` is true when profile, local-temp or os-temp is `allowed`.
317
+
201
318
  ## Known issues
202
319
 
203
320
  - `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
package/addon-command.mjs CHANGED
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameS
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { spawnSync } from "./background-process.mjs";
7
+ import { swapInPlace } from "./in-place-install.mjs";
7
8
  import { fileURLToPath, pathToFileURL } from "node:url";
8
9
  import { addonManifestSha256, addonRoutes, canonicalAddonManifest, createAddonManifest, downloadImmutableAddOn, immutableAddonManifestUrl, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
9
10
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
@@ -147,7 +148,7 @@ async function adopt(options) {
147
148
  if (!immutableAddonManifestUrl(manifest, entry.manifestUrl, entry.manifestSha256)) fail("add-on lock manifestUrl is not immutable");
148
149
  const output = path.resolve(options.output);
149
150
  const destination = path.join(output, manifest.addon);
150
- const stage = `${destination}.tmp-${process.pid}`;
151
+ const stage = `${destination}.staging`;
151
152
  const adoptedFiles = [];
152
153
  rmSync(stage, { recursive: true, force: true }); mkdirSync(stage, { recursive: true });
153
154
  for (const file of manifest.files) {
@@ -171,19 +172,11 @@ async function adopt(options) {
171
172
  }
172
173
  }
173
174
  copyFileSync(path.join(source, "addon-manifest.json"), path.join(stage, "addon-manifest.json"));
174
- // Directory rename is atomic on one filesystem. Move any previous add-on
175
- // aside first, restore it if replacement fails, then clean it only after the
176
- // new verified tree is in place.
177
- const previous = `${destination}.previous-${process.pid}`;
175
+ // Directory rename is atomic on one filesystem. The previous add-on stays beside the fixed path as
176
+ // `<destination>.previous` (one copy, rename it back to roll back; never run from it) and is restored if
177
+ // replacement fails.
178
178
  mkdirSync(output, { recursive: true });
179
- if (existsSync(destination)) renameSync(destination, previous);
180
- try {
181
- renameSync(stage, destination);
182
- } catch (error) {
183
- if (existsSync(previous)) renameSync(previous, destination);
184
- throw error;
185
- }
186
- rmSync(previous, { recursive: true, force: true });
179
+ swapInPlace({ staging: stage, destination });
187
180
  if (temporary) rmSync(temporary, { recursive: true, force: true });
188
181
  const repoRoot = git(path.dirname(lockPath), ["rev-parse", "--show-toplevel"]);
189
182
  const receipt = path.join(repoRoot, ".right-release", "addons", "adoptions", `${manifest.addon}-${manifest.platform}.json`);
@@ -0,0 +1,265 @@
1
+ // Release-tier scan of an Android App Bundle (.aab), read in-process: an AAB is a ZIP, so no unzip, bundletool or
2
+ // jarsigner binary is run. It applies the same refusals the desktop lane applies to its binaries:
3
+ // - every native library (`<module>/lib/<abi>/*.so`) is an ELF file and goes through the shared QA/debug rules of
4
+ // qa-control-scan.mjs (QA control marker, wdio WebDriver, remote debugging, devtools, dev-inspect);
5
+ // - every frontend text file inside the bundle goes through the web-bundle mock markers of qa-control-scan.mjs;
6
+ // - the bundle must be a well-formed AAB (BundleConfig.pb and a base manifest) with at least one native library, so an
7
+ // empty or wrong file never reads as "clean";
8
+ // - the bundle must carry exactly one jarsigner signature block, whose first certificate is returned as a SHA-256
9
+ // fingerprint (the caller pins it to the expected upload key) and which must not be the Android debug certificate.
10
+ // Fails closed: anything that cannot be read is a finding or a problem, never a pass.
11
+ import { createHash, X509Certificate } from "node:crypto";
12
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import zlib from "node:zlib";
15
+ import { bufferQaFindings, webBundleMockFindings } from "./qa-control-scan.mjs";
16
+
17
+ const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
18
+
19
+ const DEFAULT_LIMITS = Object.freeze({
20
+ maxEntries: 50_000,
21
+ maxFileBytes: 2 * 1024 * 1024 * 1024 - 1, // readFileSync ceiling
22
+ maxEntryBytes: 1024 * 1024 * 1024,
23
+ totalInflated: 6 * 1024 * 1024 * 1024,
24
+ });
25
+ const limitsOf = (limits) => ({ ...DEFAULT_LIMITS, ...(limits ?? {}) });
26
+
27
+ export const AAB_REQUIRED_ENTRIES = Object.freeze(["BundleConfig.pb", "base/manifest/AndroidManifest.xml"]);
28
+ const NATIVE_LIB = /^([^/]+)\/lib\/([^/]+)\/([^/]+\.so)$/i;
29
+ const SIGNATURE_BLOCK = /^META-INF\/([^/]+)\.(RSA|DSA|EC)$/i;
30
+ const WEB_FILE = /\.(?:m?js|cjs|html?|css|map)$/i;
31
+ const DEBUG_CERT_MARKER = Buffer.from("Android Debug", "latin1");
32
+
33
+ // ---- CRC-32 (verifies that an entry was read whole and unmodified) -----------------------------------------------
34
+ const CRC_TABLE = (() => {
35
+ const table = new Uint32Array(256);
36
+ for (let n = 0; n < 256; n += 1) {
37
+ let c = n;
38
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
39
+ table[n] = c >>> 0;
40
+ }
41
+ return table;
42
+ })();
43
+ export function crc32(buffer) {
44
+ if (typeof zlib.crc32 === "function") return zlib.crc32(buffer) >>> 0;
45
+ let c = 0xffffffff;
46
+ for (let i = 0; i < buffer.length; i += 1) c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
47
+ return (c ^ 0xffffffff) >>> 0;
48
+ }
49
+
50
+ // ---- ZIP reader (stored and deflate; ZIP64 sizes and offsets) ----------------------------------------------------
51
+ function u64(buffer, offset) {
52
+ const value = buffer.readBigUInt64LE(offset);
53
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("zip64 value out of range");
54
+ return Number(value);
55
+ }
56
+
57
+ function findEndOfCentralDirectory(buffer) {
58
+ const floor = Math.max(0, buffer.length - (22 + 65535));
59
+ for (let i = buffer.length - 22; i >= floor; i -= 1) {
60
+ if (buffer.readUInt32LE(i) !== 0x06054b50) continue;
61
+ if (i + 22 + buffer.readUInt16LE(i + 20) > buffer.length) continue;
62
+ let entries = buffer.readUInt16LE(i + 10); let size = buffer.readUInt32LE(i + 12); let offset = buffer.readUInt32LE(i + 16);
63
+ if (entries === 0xffff || size === 0xffffffff || offset === 0xffffffff) {
64
+ if (i < 20 || buffer.readUInt32LE(i - 20) !== 0x07064b50) throw new Error("zip64 locator missing");
65
+ const record = u64(buffer, i - 12);
66
+ if (record + 56 > buffer.length || buffer.readUInt32LE(record) !== 0x06064b50) throw new Error("zip64 end record missing");
67
+ entries = u64(buffer, record + 32); size = u64(buffer, record + 40); offset = u64(buffer, record + 48);
68
+ }
69
+ return { entries, size, offset };
70
+ }
71
+ throw new Error("end of central directory not found");
72
+ }
73
+
74
+ export function listZipEntries(buffer, limits = DEFAULT_LIMITS) {
75
+ const eocd = findEndOfCentralDirectory(buffer);
76
+ if (eocd.entries > limits.maxEntries) throw new Error(`more than ${limits.maxEntries} entries`);
77
+ if (eocd.offset + eocd.size > buffer.length) throw new Error("central directory out of bounds");
78
+ const out = [];
79
+ let at = eocd.offset;
80
+ const end = eocd.offset + eocd.size;
81
+ for (let n = 0; n < eocd.entries; n += 1) {
82
+ if (at + 46 > end || buffer.readUInt32LE(at) !== 0x02014b50) throw new Error("bad central directory entry");
83
+ const flags = buffer.readUInt16LE(at + 8); const method = buffer.readUInt16LE(at + 10);
84
+ const crc = buffer.readUInt32LE(at + 16);
85
+ let compressed = buffer.readUInt32LE(at + 20); let uncompressed = buffer.readUInt32LE(at + 24);
86
+ const nameLength = buffer.readUInt16LE(at + 28); const extraLength = buffer.readUInt16LE(at + 30);
87
+ const commentLength = buffer.readUInt16LE(at + 32); let localOffset = buffer.readUInt32LE(at + 42);
88
+ const next = at + 46 + nameLength + extraLength + commentLength;
89
+ if (next > end) throw new Error("central directory entry out of bounds");
90
+ const name = buffer.subarray(at + 46, at + 46 + nameLength).toString("utf8");
91
+ const extra = buffer.subarray(at + 46 + nameLength, at + 46 + nameLength + extraLength);
92
+ for (let e = 0; e + 4 <= extra.length;) {
93
+ const id = extra.readUInt16LE(e); const size = extra.readUInt16LE(e + 2);
94
+ if (id === 0x0001) {
95
+ let p = e + 4;
96
+ if (uncompressed === 0xffffffff) { uncompressed = u64(extra, p); p += 8; }
97
+ if (compressed === 0xffffffff) { compressed = u64(extra, p); p += 8; }
98
+ if (localOffset === 0xffffffff) localOffset = u64(extra, p);
99
+ }
100
+ e += 4 + size;
101
+ }
102
+ out.push({ name, flags, method, crc, compressed, uncompressed, localOffset });
103
+ at = next;
104
+ }
105
+ return out;
106
+ }
107
+
108
+ export function readZipEntry(buffer, entry, limits = DEFAULT_LIMITS) {
109
+ if (entry.flags & 1) throw new Error("encrypted entry");
110
+ const at = entry.localOffset;
111
+ if (at + 30 > buffer.length || buffer.readUInt32LE(at) !== 0x04034b50) throw new Error("bad local header");
112
+ const start = at + 30 + buffer.readUInt16LE(at + 26) + buffer.readUInt16LE(at + 28);
113
+ if (start + entry.compressed > buffer.length) throw new Error("entry data out of bounds");
114
+ if (entry.uncompressed > limits.maxEntryBytes || entry.compressed > limits.maxEntryBytes) throw new Error(`entry larger than ${limits.maxEntryBytes} bytes`);
115
+ const raw = buffer.subarray(start, start + entry.compressed);
116
+ let data;
117
+ if (entry.method === 0) data = raw;
118
+ else if (entry.method === 8) data = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, entry.uncompressed + 1) });
119
+ else throw new Error(`unsupported compression method ${entry.method}`);
120
+ if (data.length !== entry.uncompressed) throw new Error("entry size does not match its directory record");
121
+ if (crc32(data) !== entry.crc) throw new Error("entry CRC-32 mismatch");
122
+ return data;
123
+ }
124
+
125
+ // ---- PKCS#7 signature block --------------------------------------------------------------------------------------
126
+ function tlv(buffer, at, limit = buffer.length) {
127
+ if (at + 2 > limit) throw new Error("DER truncated");
128
+ const tag = buffer[at]; let length = buffer[at + 1]; let head = 2;
129
+ if (length & 0x80) {
130
+ const count = length & 0x7f;
131
+ if (count === 0 || count > 4 || at + 2 + count > limit) throw new Error("DER length unsupported");
132
+ length = 0;
133
+ for (let i = 0; i < count; i += 1) length = length * 256 + buffer[at + 2 + i];
134
+ head = 2 + count;
135
+ }
136
+ const end = at + head + length;
137
+ if (end > limit) throw new Error("DER truncated");
138
+ return { tag, start: at, body: at + head, end };
139
+ }
140
+ function children(buffer, node) {
141
+ const out = [];
142
+ for (let at = node.body; at < node.end;) {
143
+ const child = tlv(buffer, at, node.end);
144
+ out.push(child);
145
+ at = child.end;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // DER certificates carried by a PKCS#7 SignedData block, in file order (jarsigner writes the signer first).
151
+ export function pkcs7Certificates(block) {
152
+ const root = tlv(block, 0);
153
+ if (root.tag !== 0x30) throw new Error("signature block is not a DER sequence");
154
+ const [oid, wrap] = children(block, root);
155
+ if (!oid || oid.tag !== 0x06 || !wrap || wrap.tag !== 0xa0) throw new Error("signature block is not PKCS#7 SignedData");
156
+ const signed = children(block, wrap)[0];
157
+ if (!signed || signed.tag !== 0x30) throw new Error("signature block has no SignedData");
158
+ // SignedData: version, digestAlgorithms, contentInfo, [0] certificates, [1] crls, signerInfos.
159
+ const certificates = children(block, signed).find((part, index) => index >= 3 && part.tag === 0xa0);
160
+ if (!certificates) throw new Error("signature block carries no certificate");
161
+ return children(block, certificates).filter((cert) => cert.tag === 0x30).map((cert) => block.subarray(cert.start, cert.end));
162
+ }
163
+
164
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
165
+
166
+ function signerOf(blockName, block, problems) {
167
+ try {
168
+ const certificates = pkcs7Certificates(block);
169
+ if (certificates.length === 0) throw new Error("signature block carries no certificate");
170
+ const der = certificates[0];
171
+ let subject = null; let notAfter = null;
172
+ try { const x509 = new X509Certificate(der); subject = x509.subject.replace(/\n/g, ", "); notAfter = Date.parse(x509.validTo); } catch { /* the pinned fingerprint is the authority */ }
173
+ return { block: blockName, certSha256: sha256Hex(der), certificates: certificates.length, subject, notAfter: Number.isFinite(notAfter) ? notAfter : null, debugCertificate: der.includes(DEBUG_CERT_MARKER) };
174
+ } catch (error) {
175
+ problems.push(`${blockName}: ${error.message}`);
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ---- the AAB scan ------------------------------------------------------------------------------------------------
181
+ // Returns { entries, problems[], findings[], nativeLibs[], webFiles, signature }. `problems` are structural/signature
182
+ // defects, `findings` are test-automation or debugging surfaces; either one refuses the release.
183
+ export function scanAabBuffer(buffer, { allowContentOnlyDevtools = false, limits, label = "<aab>" } = {}) {
184
+ const lim = limitsOf(limits);
185
+ const result = { entries: 0, problems: [], findings: [], nativeLibs: [], webFiles: 0, signature: null };
186
+ let entries;
187
+ try { entries = listZipEntries(buffer, lim); } catch (error) { result.problems.push(`not a readable ZIP/AAB (${error.message})`); return result; }
188
+ result.entries = entries.length;
189
+ const names = new Map();
190
+ for (const entry of entries) {
191
+ if (names.has(entry.name)) result.problems.push(`duplicate entry name ${entry.name}`);
192
+ names.set(entry.name, entry);
193
+ }
194
+ for (const required of AAB_REQUIRED_ENTRIES) if (!names.has(required)) result.problems.push(`missing required AAB entry ${required}`);
195
+ let budget = lim.totalInflated;
196
+ const read = (entry) => {
197
+ if ((budget -= entry.uncompressed) < 0) throw new Error("bundle exceeds its inflate budget");
198
+ return readZipEntry(buffer, entry, lim);
199
+ };
200
+ const unscannable = (entry, why) => result.findings.push({ file: `${label}!/${entry.name}`, rule: "unscannable", reason: `could not scan: ${why}` });
201
+
202
+ for (const entry of entries) {
203
+ if (entry.name.endsWith("/")) continue;
204
+ const lib = NATIVE_LIB.exec(entry.name);
205
+ if (lib) {
206
+ try {
207
+ const bytes = read(entry);
208
+ if (!bytes.subarray(0, 4).equals(ELF_MAGIC)) { unscannable(entry, "native library is not an ELF file"); continue; }
209
+ result.nativeLibs.push({ name: entry.name, module: lib[1], abi: lib[2], size: bytes.length, sha256: sha256Hex(bytes) });
210
+ for (const finding of bufferQaFindings(bytes, { allowContentOnlyDevtools })) result.findings.push({ file: `${label}!/${entry.name}`, ...finding });
211
+ } catch (error) { unscannable(entry, error.message); }
212
+ } else if (WEB_FILE.test(entry.name)) {
213
+ try {
214
+ result.webFiles += 1;
215
+ for (const finding of webBundleMockFindings(read(entry).toString("utf8"))) result.findings.push({ file: `${label}!/${entry.name}`, ...finding });
216
+ } catch (error) { unscannable(entry, error.message); }
217
+ }
218
+ }
219
+ if (result.nativeLibs.length === 0 && !result.findings.some((f) => f.rule === "unscannable")) {
220
+ result.problems.push("no native library (<module>/lib/<abi>/*.so) found to scan; cannot verify");
221
+ }
222
+
223
+ const blocks = entries.filter((entry) => SIGNATURE_BLOCK.test(entry.name));
224
+ if (blocks.length === 0) result.problems.push("bundle is not signed: no META-INF/*.RSA, *.DSA or *.EC signature block");
225
+ else if (blocks.length > 1) result.problems.push(`bundle carries ${blocks.length} signature blocks; exactly one upload-key signature is expected`);
226
+ else {
227
+ const [block] = blocks;
228
+ const stem = SIGNATURE_BLOCK.exec(block.name)[1];
229
+ if (!names.has(`META-INF/${stem}.SF`)) result.problems.push(`signature block ${block.name} has no matching META-INF/${stem}.SF`);
230
+ if (!names.has("META-INF/MANIFEST.MF")) result.problems.push("signed bundle has no META-INF/MANIFEST.MF");
231
+ try { result.signature = signerOf(block.name, read(block), result.problems); } catch (error) { result.problems.push(`${block.name}: ${error.message}`); }
232
+ }
233
+ return result;
234
+ }
235
+
236
+ export function scanAab(file, options = {}) {
237
+ const size = statSync(file).size;
238
+ const lim = limitsOf(options.limits);
239
+ if (size > lim.maxFileBytes) return { entries: 0, problems: [`bundle is larger than ${lim.maxFileBytes} bytes`], findings: [], nativeLibs: [], webFiles: 0, signature: null };
240
+ return scanAabBuffer(readFileSync(file), { ...options, label: options.label ?? file });
241
+ }
242
+
243
+ // Writes every native library of the bundle to `dir` (flat, index-named, so an entry name can never escape it) and returns
244
+ // the written paths. Lets the caller run path-based layers (release-switch scan) over the same bytes.
245
+ export function extractNativeLibs(file, dir, { limits } = {}) {
246
+ const lim = limitsOf(limits);
247
+ const buffer = readFileSync(file);
248
+ mkdirSync(dir, { recursive: true });
249
+ const written = [];
250
+ let index = 0;
251
+ for (const entry of listZipEntries(buffer, lim)) {
252
+ if (!NATIVE_LIB.test(entry.name)) continue;
253
+ const target = path.join(dir, `${String(index += 1).padStart(3, "0")}-${path.basename(entry.name).replace(/[^A-Za-z0-9._-]/g, "_")}`);
254
+ writeFileSync(target, readZipEntry(buffer, entry, lim));
255
+ written.push(target);
256
+ }
257
+ return written;
258
+ }
259
+
260
+ export function describeAabProblems(scan) {
261
+ return [
262
+ ...scan.problems.map((problem) => `- ${problem}`),
263
+ ...scan.findings.map((finding) => `- ${finding.file}: ${finding.reason}`),
264
+ ];
265
+ }
@@ -0,0 +1,111 @@
1
+ // right-release dev --platform android: a DEBUG build installed on an emulator or a device through adb. Never a release lane:
2
+ // no keystore, no password, no upload key and no AAB. The release lane (android-release.mjs) and the upload (android-play-upload.mjs)
3
+ // refuse everything this file accepts.
4
+ //
5
+ // Config (development.targets.android):
6
+ // {
7
+ // ndk?: { abis?: ["arm64-v8a", "x86_64"], jniLibs: "android/app/src/main/jniLibs", manifestPath?, package?, features?, apiLevel? },
8
+ // build: [{ cmd: "./gradlew", args: [":app:assembleDebug"], cwd: "android" }], // debug tasks only (no bundle/assemble Release)
9
+ // install: { kind: "adb", apk: "android/app/build/outputs/apk/debug/app-debug.apk", package: "com.orthiclabs.heardright.dev",
10
+ // activity?: ".MainActivity", serial?: "emulator-5554" },
11
+ // }
12
+ // The device is chosen by serial only, never "the only device": `serial` here, `--serial` on the command line, or
13
+ // RIGHTKIT_ANDROID_SERIAL. The package's last segment must be dev, qa, test or debug, so a dev install can coexist with the
14
+ // production app and can never replace it (rightkit-qa requirement, docs/design/android-minimum.md section 5).
15
+ import { existsSync, statSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { spawnSync } from "./background-process.mjs";
18
+ import { androidNdkCommand, validateAndroidNdk } from "./android-ndk.mjs";
19
+
20
+ export class AndroidDevelopmentError extends Error {
21
+ constructor(code, message) { super(message); this.name = "AndroidDevelopmentError"; this.code = code; }
22
+ }
23
+ const refuse = (code, message) => { throw new AndroidDevelopmentError(code, message); };
24
+
25
+ const SERIAL = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
26
+ const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/;
27
+ const ACTIVITY = /^\.?[A-Za-z][A-Za-z0-9_.$]*$/;
28
+ const TEST_SEGMENT = /^(?:dev|qa|test|debug)$/i;
29
+ const RELEASE_TASK = /(?:bundle|assemble|install)[A-Za-z0-9]*Release|publish|upload/i;
30
+ const inside = (root, child) => { const rel = path.relative(path.resolve(root), path.resolve(child)); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); };
31
+
32
+ export function androidDevelopmentSerial({ descriptor, serial, env = process.env }) {
33
+ const chosen = serial ?? descriptor?.serial ?? env.RIGHTKIT_ANDROID_SERIAL;
34
+ if (!chosen) refuse("DEV_ANDROID_SERIAL_REQUIRED", "name the device by serial (install.serial, --serial or RIGHTKIT_ANDROID_SERIAL); the only attached device is never assumed");
35
+ if (!SERIAL.test(chosen)) refuse("DEV_ANDROID_SERIAL_INVALID", "the adb serial must be a plain device or emulator serial");
36
+ return chosen;
37
+ }
38
+
39
+ // Validates development.targets.android in place of the generic build/install rules. Returns { ndk } (normalised or null).
40
+ export function validateAndroidDevelopment(target, { root }) {
41
+ for (const [index, command] of target.build.entries()) {
42
+ const tokens = [command.cmd, ...(command.args ?? [])].map(String);
43
+ const bad = tokens.find((token) => !token.startsWith("-") && RELEASE_TASK.test(token));
44
+ if (bad !== undefined) refuse("DEV_ANDROID_RELEASE_TASK", `development.targets.android.build[${index}] runs ${bad}; a dev build is a debug build (assembleDebug), never a release or upload task`);
45
+ if (tokens.some((token) => /pass(?:word|phrase)?\s*=|storepass|keypass|signing\.(?:store|key)|RIGHTKIT_ANDROID_SIGNING/i.test(token))) {
46
+ refuse("DEV_ANDROID_SIGNING_ARGUMENT", `development.targets.android.build[${index}] carries a signing property; a dev build uses the debug keystore only`);
47
+ }
48
+ }
49
+ if (target.install !== undefined && target.install.kind !== "adb") refuse("DEV_ANDROID_INSTALL_KIND", "development.targets.android.install.kind must be adb");
50
+ const ndk = target.ndk === undefined ? null : validateAndroidNdk(target.ndk, { root, kind: "dev" });
51
+ if (target.install) {
52
+ const { apk, package: pkg, activity } = target.install;
53
+ if (typeof apk !== "string" || !/\.apk$/i.test(apk) || path.isAbsolute(apk) || path.win32.isAbsolute(apk) || !inside(root, path.resolve(root, apk))) refuse("DEV_ANDROID_APK_INVALID", "install.apk must be a relative .apk path inside the app");
54
+ if (/(^|[\\/])release([\\/]|$)|[-_.]release\.apk$/i.test(apk)) refuse("DEV_ANDROID_APK_INVALID", "install.apk names a release output; a dev install is a debug APK");
55
+ if (typeof pkg !== "string" || !PACKAGE_NAME.test(pkg)) refuse("DEV_ANDROID_PACKAGE_INVALID", "install.package must be the Android applicationId");
56
+ if (!TEST_SEGMENT.test(pkg.split(".").at(-1))) refuse("DEV_ANDROID_PACKAGE_NOT_TEST", `install.package ${pkg} must end in .dev, .qa, .test or .debug so it never replaces the production app`);
57
+ if (activity !== undefined && (typeof activity !== "string" || !ACTIVITY.test(activity))) refuse("DEV_ANDROID_ACTIVITY_INVALID", "install.activity must be a class name such as .MainActivity");
58
+ if (target.install.serial !== undefined && !SERIAL.test(String(target.install.serial))) refuse("DEV_ANDROID_SERIAL_INVALID", "install.serial must be a plain device or emulator serial");
59
+ }
60
+ return { ndk };
61
+ }
62
+
63
+ export function androidDevelopmentNdkCommand(ndk) {
64
+ return androidNdkCommand(ndk, { profile: "dev" });
65
+ }
66
+
67
+ export function resolveAdb(env = process.env, { exists = existsSync, platform = process.platform } = {}) {
68
+ if (env.RIGHTKIT_ADB) return env.RIGHTKIT_ADB;
69
+ for (const home of [env.ANDROID_HOME, env.ANDROID_SDK_ROOT]) {
70
+ if (!home) continue;
71
+ const candidate = path.join(home, "platform-tools", platform === "win32" ? "adb.exe" : "adb");
72
+ if (exists(candidate)) return candidate;
73
+ }
74
+ return "adb";
75
+ }
76
+
77
+ // Pure: the adb argument lists, in order. No secret is ever part of them.
78
+ export function androidInstallPlan(descriptor, { root, serial }) {
79
+ const apk = path.resolve(root, descriptor.apk);
80
+ const base = ["-s", serial];
81
+ return {
82
+ apk,
83
+ serial,
84
+ steps: [
85
+ { id: "state", args: [...base, "get-state"] },
86
+ { id: "install", args: [...base, "install", "-r", "-d", apk] },
87
+ { id: "verify", args: [...base, "shell", "pm", "path", descriptor.package] },
88
+ ...(descriptor.activity ? [{ id: "launch", args: [...base, "shell", "am", "start", "-n", `${descriptor.package}/${descriptor.activity}`] }] : []),
89
+ ],
90
+ };
91
+ }
92
+
93
+ // `run` is spawnSync-shaped (tests inject a fake). Every step is bounded; the first failure stops and throws.
94
+ export function installAndroidDevelopment(descriptor, { root, env = process.env, run = spawnSync, serial, adb = resolveAdb(env), log = console.log, stat = statSync } = {}) {
95
+ const chosen = androidDevelopmentSerial({ descriptor, serial, env });
96
+ const plan = androidInstallPlan(descriptor, { root, serial: chosen });
97
+ let size = 0;
98
+ try { size = stat(plan.apk).size; } catch { /* reported below */ }
99
+ if (!size) refuse("DEV_ANDROID_APK_MISSING", `the debug APK was not produced: ${descriptor.apk}`);
100
+ const timeouts = { state: 30_000, install: 5 * 60_000, verify: 30_000, launch: 30_000 };
101
+ for (const step of plan.steps) {
102
+ const result = run(adb, step.args, { cwd: root, env, encoding: "utf8", windowsHide: true, shell: false, timeout: timeouts[step.id] });
103
+ const out = `${result.stdout ?? ""}`.trim();
104
+ if (result.error || result.status !== 0) refuse(`DEV_ANDROID_${step.id.toUpperCase()}_FAILED`, `adb ${step.id} failed (${result.status ?? "unavailable"}) for ${chosen}${result.error ? `: ${result.error.message}` : ""}`);
105
+ if (step.id === "state" && out !== "device") refuse("DEV_ANDROID_DEVICE_NOT_READY", `${chosen} is not ready (adb state: ${out || "unknown"})`);
106
+ if (step.id === "install" && !/Success/i.test(out)) refuse("DEV_ANDROID_INSTALL_FAILED", `adb install did not report Success for ${chosen}`);
107
+ if (step.id === "verify" && !out.includes("package:")) refuse("DEV_ANDROID_VERIFY_FAILED", `${descriptor.package} is not installed on ${chosen} after the install`);
108
+ }
109
+ log(`right-release dev: installed ${descriptor.package} on ${chosen} (debug build; not a release artifact)`);
110
+ return { serial: chosen, package: descriptor.package, apk: plan.apk };
111
+ }