diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example index 57c91b2fd2..18360eadef 100644 --- a/apps/desktop/config/macos-packaging/production.env.example +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -5,3 +5,5 @@ DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= +# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example index 649860d2b7..aa40a119ce 100644 --- a/apps/desktop/config/macos-packaging/test.env.example +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -10,3 +10,5 @@ DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= # Temporarily changes the active network service's system HTTP/HTTPS proxies. DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= +# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index e34c8e7cfe..00a03f782a 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: e3f51e7130a0fe05 zh: 70fd6a40d5899871 /desktop-packaging/macos-self-hosted-packaging: - en: 524d726b1fdc87b3 - zh: d1031ef3266afae3 + en: 1929f652d7654d7e + zh: bab90dfb9523ef6a /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e @@ -36,8 +36,8 @@ en: 2feaa0412247d81f zh: 461dde8e13fd4734 /desktop-packaging/macos-release-signing-and-diagnostics: - en: 84dcc94fce25eb1a - zh: c4f70d76174b1383 + en: 62c125856aa03c26 + zh: 9fc00a43c8b1e8e2 /desktop-packaging/directory-builds-and-preparation: en: 30d58517c9c2044f zh: 16d26bdc923fc4ba diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 21b81b18ed..37f3b0151d 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -111,9 +111,9 @@ Open the completed run in Actions and download its Artifacts. `windows-x64-signe The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. -Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` to prepare private `runner/` and `packaging/{presets,secrets,certificates}` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. +Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. -Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C; service installation is separate. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. Signature caches remain under the checkout's build directory and may be removed by clean checkout. +Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C; service installation is separate. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). @@ -153,7 +153,7 @@ The signed macOS configuration uses the required release environment instead of macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. -macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. +macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 614c9ddb61..8f3aafd37c 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -111,9 +111,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS `Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 -运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` 创建私有 `runner/` 和 `packaging/{presets,secrets,certificates}` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 +运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 -运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。Ctrl-C 停止,服务安装单独进行。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。签名缓存仍在 checkout 的构建目录中,干净检出可能移除它。 +运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。Ctrl-C 停止,服务安装单独进行。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 @@ -153,7 +153,7 @@ macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 -macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 +macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index ef19317608..ce8357a9e6 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -8,6 +8,7 @@ import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSS import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' +import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' @@ -15,7 +16,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u -const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u +const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -52,7 +53,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING const publicPlatformSetting = platform === 'win32' ? /^DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY)$/u - : /^DSH_DESKTOP_MACOS_(?:PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u + : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u const settings = readSettings(path, name => preset === undefined ? SHARED_SETTING.test(name) || platformSetting.test(name) : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name)) @@ -141,6 +142,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = }) if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment) } else { + if (environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR !== undefined) validateMacOSSignatureCacheDirectory(environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR) resolveMacOSSigningEnvironment(environment) const strategies = [ ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], diff --git a/apps/desktop/scripts/macos-signature-cache-directory.d.mts b/apps/desktop/scripts/macos-signature-cache-directory.d.mts new file mode 100644 index 0000000000..163905a886 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.d.mts @@ -0,0 +1,15 @@ +/** Optional machine-owned macOS signature storage. */ +/** + * Require existing private storage with no linked ancestors. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; throws on invalid paths, ownership, permissions or access. + */ +export function validateMacOSSignatureCacheDirectory(path: string): string + +/** + * Select machine storage or retain the target-local default when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Target build location. + * @returns Validated external directory, or the default directory created later by signing. + */ +export function resolveMacOSSignatureCacheDirectory(environment: NodeJS.ProcessEnv, targetBuildRoot: string): string diff --git a/apps/desktop/scripts/macos-signature-cache-directory.mjs b/apps/desktop/scripts/macos-signature-cache-directory.mjs new file mode 100644 index 0000000000..6a78af8e13 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.mjs @@ -0,0 +1,38 @@ +/** Resolve optional machine-owned macOS signature storage before signing begins. */ +import { accessSync, constants, lstatSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +/** + * Require existing private storage with no linked ancestors; never create or repair it during packaging. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; invalid paths, ownership, permissions and access fail before signing. + */ +export function validateMacOSSignatureCacheDirectory(path) { + if (!path || path.includes('\0') || !isAbsolute(path)) { + throw new Error('DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR must be a nonempty absolute directory') + } + const root = resolve(path) + for (let ancestor = root; ; ancestor = dirname(ancestor)) { + const info = lstatSync(ancestor) + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error(`macOS signature cache: expected an unlinked directory: ${ancestor}`) + } + if (ancestor === root && (info.uid !== process.getuid() || (info.mode & 0o077) !== 0 || (info.mode & 0o700) !== 0o700)) { + throw new Error(`macOS signature cache: directory must be private, accessible and owned by the current account: ${root}`) + } + if (ancestor === dirname(ancestor)) break + } + accessSync(root, constants.R_OK | constants.W_OK | constants.X_OK) + return root +} + +/** + * Select validated machine storage or retain the target-local cache when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Existing target build location used by local packaging. + * @returns Selected cache directory; only the default may be created later by the signer. + */ +export function resolveMacOSSignatureCacheDirectory(environment, targetBuildRoot) { + const selected = environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR + return selected === undefined ? join(targetBuildRoot, 'signature-cache') : validateMacOSSignatureCacheDirectory(selected) +} diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 808b8ae8b5..9de39d3ff2 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,5 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ +import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { packagingStep } from './packaging-step.mjs' import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' @@ -157,8 +158,9 @@ async function main(): Promise { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } if (process.platform === 'darwin' && process.env.DSH_DESKTOP_UNSIGNED !== '1') { - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'), 'primary')) + const cacheDirectory = resolveMacOSSignatureCacheDirectory(process.env, BUILD_PATHS.root) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory)) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory, 'primary')) } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) diff --git a/apps/desktop/scripts/setup-macos-packaging.mjs b/apps/desktop/scripts/setup-macos-packaging.mjs index 2077d4c013..d04200f058 100644 --- a/apps/desktop/scripts/setup-macos-packaging.mjs +++ b/apps/desktop/scripts/setup-macos-packaging.mjs @@ -1,5 +1,5 @@ /** Prepare private machine directories and templates without registering a runner or overwriting configuration. */ -import { constants, copyFileSync, existsSync, lstatSync, mkdirSync } from 'node:fs' +import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' const [root, mode] = process.argv.slice(2) @@ -7,11 +7,12 @@ if (process.platform !== 'darwin') throw new Error('Run macOS packaging setup on if (!root || !isAbsolute(root) || (mode !== undefined && mode !== '--check') || process.argv.length > 4) { throw new Error('Usage: node setup-macos-packaging.mjs ABSOLUTE_ROOT [--check]') } +if (/[\r\n']/u.test(root)) throw new Error('Packaging root cannot contain single quotes or newlines') for (let path = resolve(root); ; path = dirname(path)) { if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Packaging root ancestors cannot be links') if (path === dirname(path)) break } -for (const relative of ['', 'packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'runner']) { +for (const relative of ['', 'packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'runner', 'cache', 'cache/macos-signatures']) { const path = join(root, relative) if (!existsSync(path) && mode !== '--check') mkdirSync(path, { recursive: true, mode: 0o700 }) const info = lstatSync(path) @@ -22,9 +23,12 @@ for (const relative of ['', 'packaging', 'packaging/presets', 'packaging/secrets if (mode !== '--check') { for (const [name, directory] of [['test', 'presets'], ['production', 'presets'], ['macos-signing', 'secrets']]) { const destination = join(root, 'packaging', directory, `${name}.env.example`) - if (!existsSync(destination)) copyFileSync( - resolve(import.meta.dirname, '../config/macos-packaging', `${name}.env.example`), destination, constants.COPYFILE_EXCL, - ) + if (!existsSync(destination)) { + const template = readFileSync(resolve(import.meta.dirname, '../config/macos-packaging', `${name}.env.example`), 'utf8') + const cache = join(root, 'cache/macos-signatures') + writeFileSync(destination, template.replace('# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=', + `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR='${cache}'`), { flag: 'wx', mode: 0o600 }) + } } } process.stdout.write(`Private packaging directories verified: ${resolve(root)}\n`) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index a892a8548a..450258d4f7 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -229,3 +229,11 @@ it('owns macOS tuning in the local file and validates it before signing credenti expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'socks5://localhost:8080' }, MACOS) }).toThrow('NOTARIZATION_PROXY') }) }) + +it('rejects an explicit invalid macOS cache before signing while leaving unsigned builds independent', () => { + const environment = { ...RELEASE, DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: '' } + for (const options of [{}, { prepareOnly: true }]) { + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, options) }).toThrow('SIGNATURE_CACHE_DIR') + } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { unsigned: true }) }).not.toThrow() +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts index 9938ac3efb..78eecf40d8 100644 --- a/apps/desktop/tests/macos-package-preset.spec.ts +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process' import { mkdir, mkdtemp, realpath, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { parseEnv } from 'node:util' import { expect, it } from 'vitest' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' @@ -12,7 +13,7 @@ it('loads machine signing credentials independently of the checkout and rejects await mkdir(join(root, 'secrets')) const preset = join(root, 'presets/test.env') const secrets = join(root, 'secrets/macos-signing.env') - await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\n') + await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\nDSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=/machine/cache\n') await writeFile(secrets, 'CSC_LINK=../certificates/app.p12\nCSC_KEY_PASSWORD=machine-secret\nAPPLE_API_KEY=../certificates/key.p8\n') const env = { DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: root, CSC_KEY_PASSWORD: 'ambient-secret' } for (const options of [{}, { prepareOnly: true }]) { @@ -21,6 +22,7 @@ it('loads machine signing credentials independently of the checkout and rejects expect(selected.APPLE_API_KEY).toBe(join(root, 'certificates/key.p8')) expect(selected.CSC_KEY_PASSWORD).toBe('machine-secret') expect(selected.DSH_DESKTOP_MACOS_PACK_CONCURRENCY).toBe('2') + expect(selected.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe('/machine/cache') } await rm(secrets) expect(loadDesktopPackageEnvironment('darwin', env, root, { unsigned: true }).CSC_KEY_PASSWORD).toBeUndefined() @@ -43,6 +45,7 @@ it.skipIf(process.platform !== 'darwin')('prepares private machine directories w [join(import.meta.dirname, '../scripts/setup-macos-packaging.mjs'), machine, ...args], { encoding: 'utf8' }) expect(run().status).toBe(0) const config = join(machine, 'packaging/presets/test.env.example') + expect(parseEnv(await readFile(config, 'utf8')).DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe(join(machine, 'cache/macos-signatures')) await writeFile(config, 'keep existing configuration\n') expect(run().status).toBe(0) expect(await readFile(config, 'utf8')).toBe('keep existing configuration\n') diff --git a/apps/desktop/tests/macos-signature-cache-directory.spec.ts b/apps/desktop/tests/macos-signature-cache-directory.spec.ts new file mode 100644 index 0000000000..adefc7a060 --- /dev/null +++ b/apps/desktop/tests/macos-signature-cache-directory.spec.ts @@ -0,0 +1,63 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveMacOSSignatureCacheDirectory, validateMacOSSignatureCacheDirectory } from '../scripts/macos-signature-cache-directory.mjs' +import { cachedMacOSSignature } from '../scripts/macos-signature-cache.ts' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function fixture(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-cache-directory-'))) + roots.push(root) + return root +} + +it('retains target-local storage only when the setting is absent', () => { + expect(resolveMacOSSignatureCacheDirectory({}, '/build/mac-arm64')).toBe(join('/build/mac-arm64', 'signature-cache')) + for (const path of ['', 'relative/cache', '~/ci/cache', '/cache\0invalid']) { + expect(() => resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: path }, '/build')).toThrow(/absolute directory/u) + } +}) + +// Machine storage requires POSIX ownership and permissions, which Windows cannot represent. +describe.skipIf(process.platform === 'win32')('private external macOS storage', () => { + it('rejects missing, non-directory, linked and non-private storage without repairing it', () => { + const root = fixture() + expect(() => validateMacOSSignatureCacheDirectory(join(root, 'absent'))).toThrow(/ENOENT/u) + const file = join(root, 'file') + writeFileSync(file, '') + expect(() => validateMacOSSignatureCacheDirectory(file)).toThrow(/unlinked directory/u) + const cache = join(root, 'cache') + mkdirSync(cache, { mode: 0o700 }) + const link = join(root, 'link') + symlinkSync(root, link) + expect(() => validateMacOSSignatureCacheDirectory(join(link, 'cache'))).toThrow(/unlinked directory/u) + for (const mode of [0o755, 0o500]) { + chmodSync(cache, mode) + expect(() => validateMacOSSignatureCacheDirectory(cache)).toThrow(/private, accessible/u) + } + chmodSync(cache, 0o700) + expect(validateMacOSSignatureCacheDirectory(cache)).toBe(cache) + }) + + it('restores a verified signature after removing and recreating the checkout', async () => { + const root = fixture() + const cache = join(root, 'machine-cache') + mkdirSync(cache, { mode: 0o700 }) + const checkout = join(root, 'checkout') + const sign = vi.fn(async (path: string) => { writeFileSync(path, 'signed bytes') }) + const verify = vi.fn((path: string) => { expect(readFileSync(path, 'utf8')).toBe('signed bytes') }) + for (const hit of [false, true]) { + mkdirSync(checkout) + const file = join(checkout, 'runtime') + writeFileSync(file, 'unsigned bytes', { mode: 0o755 }) + const selected = resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: cache }, checkout) + expect(await cachedMacOSSignature(file, selected, { policy: 'same certificate and tools', sign, verify })).toBe(hit) + expect(readFileSync(file, 'utf8')).toBe('signed bytes') + rmSync(checkout, { recursive: true }) + } + expect(sign).toHaveBeenCalledTimes(1) + expect(verify).toHaveBeenCalledTimes(2) + }) +})