From ac8d05f1abc66b64d375beb56e3d39fa1ae96d9f Mon Sep 17 00:00:00 2001 From: Memmy AutoDev Date: Tue, 27 Oct 2026 08:38:32 +0800 Subject: [PATCH] fix(desktop): repair macOS update codesign requirement and expose update logs The macOS update helper passed a bare requirement expression to `codesign --verify -R`, so codesign treated it as a requirement file path ("No such file or directory"), and the Developer ID certificate OID lacked the `field.` prefix, which the requirement parser rejects at the first dot ("unexpected char: '.'"). Every signed in-app upgrade therefore failed at staging with "Memmy update does not have the installed Apple Developer ID identity", leaving the UI on a generic "try again later" message. - Build the requirement through `buildMacSigningRequirement()`, which emits the literal `=` prefix and `certificate leaf[field.] exists`, while keeping the bundle ID, Developer ID extension, TeamIdentifier and full signature check. Values containing quotes/backslashes are rejected instead of being spliced into the shell script. - Add real `codesign` coverage for the generated requirement on macOS: the corrected form parses and matches bundle ID, team and Developer ID extension via a throwaway self-signed identity, while the two legacy forms still fail with the reported errors and a foreign team/bundle ID is rejected. A mock codesign also asserts the helper now forwards the literal requirement. - Show a "View update logs" action next to update failures. A new `memmy:open-update-logs` IPC opens the updates directory, where `mac-update-install.log` records the concrete failure reason. Refs #753 --- .../desktop/src/app/update-coordinator.tsx | 6 +- App/frontend/desktop/src/global.d.ts | 1 + App/frontend/desktop/src/i18n/messages.ts | 2 + .../desktop/src/pages/settings-page.tsx | 27 +- .../src/pages/tests/settings-page.test.tsx | 56 +++++ .../desktop/src/main/mac-update-scripts.ts | 31 ++- App/shell/desktop/src/main/main.ts | 22 ++ App/shell/desktop/src/preload/preload.cts | 5 + .../desktop/tests/mac-update-scripts.test.ts | 234 +++++++++++++++++- .../tests/packaged-runtime-boundary.test.ts | 5 + 10 files changed, 383 insertions(+), 6 deletions(-) diff --git a/App/frontend/desktop/src/app/update-coordinator.tsx b/App/frontend/desktop/src/app/update-coordinator.tsx index 200ec0bb8..49051933a 100644 --- a/App/frontend/desktop/src/app/update-coordinator.tsx +++ b/App/frontend/desktop/src/app/update-coordinator.tsx @@ -31,6 +31,8 @@ export type UpdatePhase = export interface UpdateFeedback { key: MessageKey; values?: MessageValues; + /** A failed install records its reason only in the updates log directory. */ + showLogs?: boolean; } export interface UpdateCoordinatorValue { @@ -255,7 +257,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { preparedUpdatePath: null, downloadProgress: null, dialog: null, - feedback: { key: "settings.about.updateInstallFailed" } + feedback: { key: "settings.about.updateInstallFailed", showLogs: true } })); } finally { if (downloadInFlightRef.current === request) { @@ -390,7 +392,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { phase: "prepared", dialog: null, downloadProgress: null, - feedback: { key: "settings.about.updateInstallFailed" } + feedback: { key: "settings.about.updateInstallFailed", showLogs: true } })); } finally { installInFlightRef.current = null; diff --git a/App/frontend/desktop/src/global.d.ts b/App/frontend/desktop/src/global.d.ts index f45e97927..c5c24420a 100644 --- a/App/frontend/desktop/src/global.d.ts +++ b/App/frontend/desktop/src/global.d.ts @@ -104,6 +104,7 @@ declare global { installCliTools(): Promise; restartMemoryService(): Promise; openLogsDirectory(): Promise; + openUpdateLogs(): Promise; exportDiagnosticsReport(): Promise; getLogLevel(): Promise<"error" | "warn" | "info" | "debug">; setLogLevel(level: "error" | "warn" | "info" | "debug"): Promise; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 8d8143c4d..0c3c885b7 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -2199,6 +2199,7 @@ export const zhCNMessages = { "settings.about.updateUnavailable": "当前运行环境不支持检查更新。", "settings.about.updateCheckFailed": "检查更新失败,请稍后再试。", "settings.about.updateInstallFailed": "安装更新失败,请稍后再试。", + "settings.about.openUpdateLogs": "查看更新日志", "settings.about.terms": "服务协议", "settings.about.community": "加入社区", "settings.back": "返回设置", @@ -4428,6 +4429,7 @@ export const enUSMessages: Record = { "settings.about.updateUnavailable": "Update checking is unavailable in this runtime.", "settings.about.updateCheckFailed": "Update check failed. Please try again later.", "settings.about.updateInstallFailed": "Update installation failed. Please try again later.", + "settings.about.openUpdateLogs": "View update logs", "settings.about.terms": "Terms of Service", "settings.about.community": "Join Community", "settings.back": "Back to Settings", diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx index cd221f1c8..c97cff33c 100644 --- a/App/frontend/desktop/src/pages/settings-page.tsx +++ b/App/frontend/desktop/src/pages/settings-page.tsx @@ -1323,6 +1323,22 @@ export function SettingsPageView(props: SettingsPageViewProps) { } } + /** + * Opens the update log directory in the system file manager. + * + * Update helper scripts only record their failure reason in the updates directory, so the About + * tab exposes it directly instead of leaving the user with a generic "try again later" message. + */ + function openUpdateLogs() { + const openLogs = typeof window === "undefined" ? undefined : window.memmy?.openUpdateLogs; + if (typeof openLogs !== "function") { + return; + } + void openLogs().catch((error: unknown) => { + console.warn("open update logs failed", error); + }); + } + /** * Opens the desktop log directory in the system file manager. */ @@ -1887,8 +1903,17 @@ export function SettingsPageView(props: SettingsPageViewProps) { void openExternalUrl(getLegalLinkUrl("terms", language, bootstrap?.legal))} /> {update.feedback && ( -
+
{t(update.feedback.key, update.feedback.values)} + {update.feedback.showLogs && ( + + )}
)} {update.phase === "downloading" && ( diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index e327b087e..541b63193 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -517,6 +517,62 @@ describe("SettingsPageView", () => { expect(html).toContain("512.0 KB / 1.0 MB"); }); + it("更新失败时在关于区提供更新日志入口,而不是只显示稍后再试", () => { + const html = normalizeSsrHtml(renderSettingsPageView( + createReadyState(), + "zh-CN", + createUpdateViewModel({ + phase: "error", + feedback: { key: "settings.about.updateInstallFailed", showLogs: true } + }) + )); + + expect(html).toContain("安装更新失败,请稍后再试。"); + expect(html).toContain("查看更新日志"); + expect(html).toContain('class="ml-2 underline underline-offset-2'); + + // An install failure keeps the prepared phase, so the entry follows the feedback flag, not the phase. + const preparedFailureHtml = normalizeSsrHtml(renderSettingsPageView( + createReadyState(), + "zh-CN", + createUpdateViewModel({ + phase: "prepared", + preparedUpdatePath: "/tmp/Memmy-update.dmg", + feedback: { key: "settings.about.updateInstallFailed", showLogs: true } + }) + )); + expect(preparedFailureHtml).toContain("查看更新日志"); + + // Success states keep the plain feedback line without a log entry. + const successHtml = normalizeSsrHtml(renderSettingsPageView( + createReadyState(), + "zh-CN", + createUpdateViewModel({ + phase: "latest", + feedback: { key: "settings.about.upToDate", values: { version: "2.1.0" } } + }) + )); + expect(successHtml).not.toContain("查看更新日志"); + + const englishHtml = normalizeSsrHtml(renderSettingsPageView( + createReadyState(), + "en-US", + createUpdateViewModel({ + phase: "error", + feedback: { key: "settings.about.updateInstallFailed", showLogs: true } + }) + )); + expect(englishHtml).toContain("View update logs"); + + const source = readFileSync(settingsPageSourcePath, "utf8"); + expect(source).toContain("window.memmy?.openUpdateLogs"); + expect(source).toContain("function openUpdateLogs()"); + expect(source).toContain("onClick={openUpdateLogs}"); + + const coordinatorSource = readFileSync(updateCoordinatorSourcePath, "utf8"); + expect(coordinatorSource).toContain('feedback: { key: "settings.about.updateInstallFailed", showLogs: true }'); + }); + it("菜单栏图标开关保存到应用设置并同步桌面 bridge", () => { const source = readFileSync(settingsPageSourcePath, "utf8"); diff --git a/App/shell/desktop/src/main/mac-update-scripts.ts b/App/shell/desktop/src/main/mac-update-scripts.ts index 53d3440a5..23c592ef2 100644 --- a/App/shell/desktop/src/main/mac-update-scripts.ts +++ b/App/shell/desktop/src/main/mac-update-scripts.ts @@ -7,13 +7,40 @@ export interface MacUpdateValidationOptions { currentVersion?: string; } +const MEMMY_BUNDLE_ID = "cn.memtensor.memmy"; +/** Apple Developer ID intermediate/leaf extension marking a notarizable Developer ID certificate. */ +const APPLE_DEVELOPER_ID_CERTIFICATE_EXTENSION = "1.2.840.113635.100.6.1.13"; + +/** + * Builds the literal `codesign --requirements` expression used to pin an update to the installed + * bundle identity and signing team. + * + * Two details matter for `codesign` to treat the value as an inline requirement instead of a file: + * - The leading `=` marks a literal requirement; without it `codesign` reports + * "No such file or directory" because it looks for a requirement file. + * - Certificate extension OIDs must carry the `field.` prefix, otherwise the requirement parser + * fails at the first dot of the OID ("unexpected char: '.'"). + * + * @param teamIdentifier The TeamIdentifier of the currently installed app. + * @param bundleIdentifier The expected application bundle identifier. + * @returns A literal requirement string starting with `=`. + */ +export function buildMacSigningRequirement(teamIdentifier: string, bundleIdentifier: string = MEMMY_BUNDLE_ID): string { + if (!teamIdentifier) throw new Error("Mac signing requirement requires a team identifier"); + if (/["\\]/.test(teamIdentifier) || /["\\]/.test(bundleIdentifier)) { + throw new Error("Mac signing requirement values cannot contain quotes or backslashes"); + } + return `=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[field.${APPLE_DEVELOPER_ID_CERTIFICATE_EXTENSION}] exists and certificate leaf[subject.OU] = "${teamIdentifier}"`; +} + function quotedShellValue(value: string): string { return "'" + value.replaceAll("'", "'\\''") + "'"; } function macBundleValidationScript(options: MacUpdateValidationOptions): string { if (!options.teamIdentifier && !options.allowUnsigned) throw new Error("Signed macOS update requires a signing team"); - return `EXPECTED_BUNDLE_ID='cn.memtensor.memmy' + const signingRequirement = options.teamIdentifier ? buildMacSigningRequirement(options.teamIdentifier) : ""; + return `EXPECTED_BUNDLE_ID=${quotedShellValue(MEMMY_BUNDLE_ID)} EXPECTED_ARCH=${quotedShellValue(options.architecture === "x64" ? "x86_64" : "arm64")} EXPECTED_TEAM=${quotedShellValue(options.teamIdentifier ?? "")} EXPECTED_VERSION=${quotedShellValue(options.expectedVersion ?? "")} @@ -83,7 +110,7 @@ validate_app_bundle() { exit 1 fi if [[ -n "$EXPECTED_TEAM" ]]; then - local signing_requirement=${quotedShellValue(`anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "${options.teamIdentifier ?? ""}"`)} + local signing_requirement=${quotedShellValue(signingRequirement)} if ! /usr/bin/codesign --verify --deep --strict -R "$signing_requirement" "$app_path"; then echo "Memmy update does not have the installed Apple Developer ID identity" exit 1 diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index d1115931f..d71153044 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -1700,6 +1700,10 @@ function registerIpcHandlers(): void { await openLogsDirectory(); }); + ipcMain.handle("memmy:open-update-logs", async () => { + await openUpdateLogs(); + }); + ipcMain.handle("memmy:export-diagnostics-report", async (event) => exportDiagnosticsReport(BrowserWindow.fromWebContents(event.sender))); ipcMain.handle("memmy:get-log-level", () => getCurrentLogLevel()); @@ -6136,6 +6140,7 @@ async function cleanupBeforeQuit(): Promise { ipcMain.removeHandler("memmy:install-cli-tools"); ipcMain.removeHandler("memmy:restart-memory-service"); ipcMain.removeHandler("memmy:open-logs-directory"); + ipcMain.removeHandler("memmy:open-update-logs"); ipcMain.removeHandler("memmy:export-diagnostics-report"); ipcMain.removeHandler("memmy:get-launch-at-login"); ipcMain.removeHandler("memmy:set-launch-at-login"); @@ -6667,6 +6672,23 @@ async function openLogsDirectory(): Promise { } } +/** + * Opens the update staging/install log directory in the system file manager. + * + * Update helper scripts write `mac-update-install.log` / `win-update-install.log` here, which is + * the only place a failed in-app install records its concrete reason. + * + * @returns Resolves once opened; throws an error when the system refuses to open it. + */ +async function openUpdateLogs(): Promise { + const updatesDirectory = resolveUpdatesDirectory(); + await mkdir(updatesDirectory, { recursive: true }); + const openError = await shell.openPath(updatesDirectory); + if (openError) { + throw new Error(openError); + } +} + /** Opens a stored Computer History summary in the user's default Markdown application. */ async function openComputerHistoryMarkdown(rawPath: string): Promise { const filePath = resolveComputerHistoryMarkdownPath(rawPath); diff --git a/App/shell/desktop/src/preload/preload.cts b/App/shell/desktop/src/preload/preload.cts index 9ab23ab84..73f2c6df1 100644 --- a/App/shell/desktop/src/preload/preload.cts +++ b/App/shell/desktop/src/preload/preload.cts @@ -89,6 +89,7 @@ interface MemmyPreloadApi { installCliTools(): Promise; restartMemoryService(): Promise; openLogsDirectory(): Promise; + openUpdateLogs(): Promise; exportDiagnosticsReport(): Promise; getLogLevel(): Promise<"error" | "warn" | "info" | "debug">; setLogLevel(level: "error" | "warn" | "info" | "debug"): Promise; @@ -375,6 +376,10 @@ const memmyPreloadApi: MemmyPreloadApi = { return ipcRenderer.invoke("memmy:open-logs-directory"); }, + async openUpdateLogs(): Promise { + return ipcRenderer.invoke("memmy:open-update-logs"); + }, + async exportDiagnosticsReport(): Promise { return ipcRenderer.invoke("memmy:export-diagnostics-report"); }, diff --git a/App/shell/desktop/tests/mac-update-scripts.test.ts b/App/shell/desktop/tests/mac-update-scripts.test.ts index 4501ccf3a..345ef5cff 100644 --- a/App/shell/desktop/tests/mac-update-scripts.test.ts +++ b/App/shell/desktop/tests/mac-update-scripts.test.ts @@ -3,7 +3,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMacDmgUpdateInstallScript, createMacDmgUpdateStageScript } from "../src/main/mac-update-scripts.js"; +import { + buildMacSigningRequirement, + createMacDmgUpdateInstallScript, + createMacDmgUpdateStageScript +} from "../src/main/mac-update-scripts.js"; let root: string; let destination: string; @@ -40,6 +44,234 @@ function install(options: { expectedVersion?: string; currentVersion?: string } return spawnSync("/bin/zsh", [helper, "unused.dmg", destination, log, "99999999", "0", marker, staged, `${staged}.ready`], { encoding: "utf8", timeout: 10_000 }); } +describe("macOS update signing requirement", () => { + it("emits a literal requirement with the field-prefixed Developer ID certificate extension", () => { + const requirement = buildMacSigningRequirement("S7NLXHGBJ2"); + // Without the leading "=" codesign treats the value as a requirement file and fails with ENOENT. + expect(requirement.startsWith("=")).toBe(true); + // The raw OID is a syntax error: the requirement parser stops at the first dot. + expect(requirement).toContain('certificate leaf[field.1.2.840.113635.100.6.1.13] exists'); + expect(requirement).not.toMatch(/leaf\[1\.2\.840/); + expect(requirement).toContain('identifier "cn.memtensor.memmy"'); + expect(requirement).toContain('certificate leaf[subject.OU] = "S7NLXHGBJ2"'); + expect(requirement).toContain("anchor apple generic"); + }); + + it("rejects requirement values that could escape the requirement string", () => { + expect(() => buildMacSigningRequirement("")).toThrow(/team identifier/u); + expect(() => buildMacSigningRequirement('TEAM" or anchor apple generic')).toThrow(/quotes/u); + expect(() => buildMacSigningRequirement("TEAM1", 'cn.memtensor."')).toThrow(/quotes/u); + }); + + it("embeds the corrected literal requirement in both generated helper scripts", () => { + const options = { architecture: "arm64" as const, teamIdentifier: "S7NLXHGBJ2" }; + const requirement = buildMacSigningRequirement("S7NLXHGBJ2"); + for (const script of [createMacDmgUpdateInstallScript(options), createMacDmgUpdateStageScript(options)]) { + // The legacy forms must never come back: they made every signed update fail validation. + expect(script).toContain(`'${requirement}'`); + expect(script).not.toContain("certificate leaf[1.2.840.113635.100.6.1.13]"); + expect(script).not.toContain('local signing_requirement=\'anchor apple generic'); + expect(script).toContain('/usr/bin/codesign --verify --deep --strict -R "$signing_requirement" "$app_path"'); + expect(script).toContain('/usr/bin/codesign --verify --deep --strict "$app_path"'); + } + }); + + it("omits the team requirement only for unsigned builds", () => { + const unsigned = createMacDmgUpdateStageScript({ architecture: "arm64", teamIdentifier: null, allowUnsigned: true }); + expect(unsigned).toContain("EXPECTED_TEAM=''"); + expect(unsigned).not.toContain("certificate leaf[subject.OU]"); + expect(() => createMacDmgUpdateStageScript({ architecture: "arm64", teamIdentifier: null })).toThrow(/signing team/u); + }); +}); + +describe.runIf(process.platform === "darwin")("macOS helper script passes the literal requirement to codesign", () => { + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-requirement-")); + destination = path.join(root, "Memmy.app"); + staged = path.join(root, "Memmy.staged.app"); + marker = path.join(root, "prepared.json"); + log = path.join(root, "update.log"); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it("passes the literal requirement instead of a requirement file path", () => { + fixture(destination, "original"); + fixture(staged, "replacement"); + fs.writeFileSync(`${staged}.ready`, ""); + fs.writeFileSync(marker, "pending"); + const recorded = path.join(root, "codesign-requirement.txt"); + tools = { + "/usr/bin/pgrep": mockTool("pgrep", "exit 1"), + "/usr/bin/lipo": mockTool("lipo", "exit 0"), + "/usr/bin/sw_vers": mockTool("sw_vers", "echo 14.0"), + "/usr/bin/xattr": mockTool("xattr", "exit 0"), + "/usr/bin/codesign": mockTool("codesign", `for arg in "$@"; do app="$arg"; done +if [ "$1" = -dv ]; then echo "TeamIdentifier=$(cat "$app/team")" >&2; exit 0; fi +previous="" +for arg in "$@"; do + if [ "$previous" = -R ]; then printf '%s' "$arg" > '${recorded}'; fi + previous="$arg" +done +[ "$(cat "$app/signature")" = valid ]`), + }; + const result = install(); + expect(result.status, fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "no log").toBe(0); + expect(fs.readFileSync(recorded, "utf8")).toBe(buildMacSigningRequirement("MEMMYTEAM1")); + expect(fs.readFileSync(path.join(destination, "tag"), "utf8")).toBe("replacement"); + }); +}); + +describe.runIf(process.platform === "darwin")("real codesign verification of the generated requirement", () => { + let tempRoot: string; + + function signedFixtureApp(bundleId: string, identity?: { name: string; keychain: string }): string { + const app = path.join(tempRoot, `${bundleId}.app`); + fs.mkdirSync(path.join(app, "Contents", "MacOS"), { recursive: true }); + fs.writeFileSync(path.join(app, "Contents", "Info.plist"), `CFBundleIdentifier${bundleId}CFBundleExecutableMemmy`); + // A real Mach-O executable lets codesign seal and verify the bundle without launching it. + fs.copyFileSync("/usr/bin/true", path.join(app, "Contents", "MacOS", "Memmy")); + const args = identity + ? ["--force", "--sign", identity.name, "--keychain", identity.keychain, app] + : ["--force", "--sign", "-", app]; + const signed = spawnSync("/usr/bin/codesign", args, { encoding: "utf8" }); + expect(signed.status, signed.stderr).toBe(0); + return app; + } + + /** + * Creates a throwaway self-signed code-signing identity in a temporary keychain. + * + * The certificate carries the TeamIdentifier in `subject.OU` and the Developer ID extension OID, + * mirroring the fields the update requirement matches on. It is deliberately not anchored to + * Apple, so `anchor apple generic` still fails while every other clause can be exercised. + * + * @returns The identity handle, or null when this machine cannot create a temporary identity. + */ + function tryCreateTestIdentity(): { name: string; keychain: string } | null { + const keychain = path.join(tempRoot, "memmy-test.keychain-db"); + const password = "memmy-test"; + const config = path.join(tempRoot, "openssl.cnf"); + const key = path.join(tempRoot, "identity.key"); + const certificate = path.join(tempRoot, "identity.pem"); + const identityBundle = path.join(tempRoot, "identity.p12"); + fs.writeFileSync(config, `[req] +distinguished_name = dn +x509_extensions = ext +prompt = no +[dn] +CN = Memmy Update Test +O = MemTensor +OU = S7NLXHGBJ2 +[ext] +keyUsage = critical,digitalSignature +extendedKeyUsage = codeSigning +basicConstraints = critical,CA:false +1.2.840.113635.100.6.1.13 = DER:0500 +`); + keychains.add(keychain); + const steps: Array<[string, string[]]> = [ + ["/usr/bin/openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-config", config, "-keyout", key, "-out", certificate]], + ["/usr/bin/openssl", ["pkcs12", "-export", "-inkey", key, "-in", certificate, "-out", identityBundle, "-passout", `pass:${password}`, "-name", "Memmy Update Test"]], + ["/usr/bin/security", ["create-keychain", "-p", password, keychain]], + ["/usr/bin/security", ["unlock-keychain", "-p", password, keychain]], + ["/usr/bin/security", ["import", identityBundle, "-k", keychain, "-P", password, "-A", "-T", "/usr/bin/codesign"]], + ["/usr/bin/security", ["set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain]], + ]; + for (const [command, args] of steps) { + if (spawnSync(command, args, { encoding: "utf8" }).status !== 0) { + return null; + } + } + return { name: "Memmy Update Test", keychain }; + } + + function verifyWithRequirement(app: string, requirement: string) { + return spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", "-R", requirement, app], { encoding: "utf8" }); + } + + /** + * Reports whether codesign accepts the requirement as a well-formed expression. + * + * A syntax error surfaces as "unexpected char" / "invalid requirement specification"; an + * identity mismatch is a different, expected failure for synthetic fixtures. + * + * @param requirement The literal requirement, including its leading "=". + * @returns True when the requirement is syntactically valid. + */ + function requirementSyntaxIsValid(app: string, requirement: string): boolean { + const result = verifyWithRequirement(app, requirement); + return !/unexpected char|invalid requirement specification|invalid or corrupted code requirement/u.test(result.stderr); + } + + const keychains = new Set(); + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-codesign-")); + }); + afterEach(() => { + for (const keychain of keychains) { + spawnSync("/usr/bin/security", ["delete-keychain", keychain], { encoding: "utf8" }); + } + keychains.clear(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("treats the generated value as an inline requirement, not a file path", () => { + const app = signedFixtureApp("cn.memtensor.memmy"); + const requirement = buildMacSigningRequirement("S7NLXHGBJ2"); + const result = verifyWithRequirement(app, requirement); + expect(result.stderr).not.toContain("No such file or directory"); + expect(result.stderr).not.toContain("invalid requirement specification"); + expect(result.stderr).not.toMatch(/unexpected char/u); + expect(requirementSyntaxIsValid(app, requirement)).toBe(true); + }); + + it("reproduces both legacy syntax failures the shipped v1.2.4 update hit", () => { + const app = signedFixtureApp("cn.memtensor.memmy"); + // 1. Without the literal "=" prefix codesign looks for a file and fails with ENOENT. + const missingLiteralPrefix = verifyWithRequirement(app, buildMacSigningRequirement("S7NLXHGBJ2").replace(/^=/u, "")); + expect(missingLiteralPrefix.stderr).toContain("No such file or directory"); + // 2. A bare OID is rejected by the requirement parser at the first dot. + const bareOid = verifyWithRequirement(app, '=anchor apple generic and identifier "cn.memtensor.memmy" and certificate leaf[1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "S7NLXHGBJ2"'); + expect(bareOid.status).not.toBe(0); + expect(bareOid.stderr).toMatch(/unexpected char|invalid requirement specification|invalid or corrupted code requirement/u); + expect(requirementSyntaxIsValid(app, '=certificate leaf[1.2.840.113635.100.6.1.13] exists')).toBe(false); + }); + + it("matches the bundle identifier and rejects a different one", () => { + const app = signedFixtureApp("cn.memtensor.memmy"); + expect(verifyWithRequirement(app, '=identifier "cn.memtensor.memmy"').status).toBe(0); + expect(verifyWithRequirement(app, '=identifier "cn.memtensor.other"').status).not.toBe(0); + }); + + it("accepts the installed team and the Developer ID extension, and rejects mismatches", () => { + const identity = tryCreateTestIdentity(); + if (!identity) { + // Keychain access can be unavailable in sandboxed runners; the syntax coverage above still applies. + return; + } + const app = signedFixtureApp("cn.memtensor.memmy", identity); + // A same-team package satisfies the team and certificate-extension clauses of the requirement. + expect(verifyWithRequirement(app, '=certificate leaf[subject.OU] = "S7NLXHGBJ2"').status).toBe(0); + expect(verifyWithRequirement(app, '=certificate leaf[field.1.2.840.113635.100.6.1.13] exists').status).toBe(0); + // A package from another team, another bundle ID, or without the Developer ID extension is rejected. + expect(verifyWithRequirement(app, '=certificate leaf[subject.OU] = "OTHERTEAM1"').status).not.toBe(0); + expect(verifyWithRequirement(app, '=identifier "cn.memtensor.other"').status).not.toBe(0); + expect(verifyWithRequirement(app, '=certificate leaf[field.1.2.840.113635.100.6.1.14] exists').status).not.toBe(0); + // The shipped requirement parses, and only the Apple anchor clause keeps this fixture out. + const requirement = buildMacSigningRequirement("S7NLXHGBJ2"); + expect(requirementSyntaxIsValid(app, requirement)).toBe(true); + expect(verifyWithRequirement(app, requirement).status).not.toBe(0); + expect(verifyWithRequirement(app, requirement.replace("anchor apple generic and ", "")).status).toBe(0); + }); + + it("rejects a bundle signed under another TeamIdentifier", () => { + const app = signedFixtureApp("cn.memtensor.memmy"); + // Ad-hoc signing carries no TeamIdentifier, so a team-pinned requirement must fail closed. + expect(verifyWithRequirement(app, '=certificate leaf[subject.OU] = "S7NLXHGBJ2"').status).not.toBe(0); + }); +}); + describe.runIf(process.platform === "darwin")("macOS automatic update validation and rollback", () => { beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-update-兼容-")); diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index 883b41108..a9bccff52 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -1173,6 +1173,11 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("buildDiagnosticsReport()"); expect(mainSource).toContain("await writeFile(selected.filePath, report, \"utf8\")"); expect(mainSource).toContain('ipcMain.removeHandler("memmy:open-logs-directory")'); + expect(preloadSource).toContain("openUpdateLogs(): Promise;"); + expect(preloadSource).toContain('ipcRenderer.invoke("memmy:open-update-logs")'); + expect(mainSource).toContain('ipcMain.handle("memmy:open-update-logs"'); + expect(mainSource).toContain("async function openUpdateLogs()"); + expect(mainSource).toContain('ipcMain.removeHandler("memmy:open-update-logs")'); expect(mainSource).toContain('ipcMain.removeHandler("memmy:export-diagnostics-report")'); });