From 225f1a7844c2ed9df1ee169f1194fab18eab9932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=85=E8=A5=BF?= Date: Thu, 28 May 2026 16:13:55 +0800 Subject: [PATCH] fix(desktop): stabilize workflow trust hashes --- ui/desktop/src/main.ts | 3 ++- ui/desktop/src/utils/recipeHash.ts | 12 +++------ ui/desktop/src/utils/stableRecipeHash.test.ts | 23 ++++++++++++++++ ui/desktop/src/utils/stableRecipeHash.ts | 27 +++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 ui/desktop/src/utils/stableRecipeHash.test.ts create mode 100644 ui/desktop/src/utils/stableRecipeHash.ts diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 88ce9f499895..1f59a34bb224 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -47,6 +47,7 @@ import { } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; import './utils/recipeHash'; +import { calculateStableRecipeHash } from './utils/stableRecipeHash'; import { Client } from './api/client'; import { GooseApp } from './api'; import * as mesh from './mesh'; @@ -210,7 +211,7 @@ async function seedDefaultRecipes(): Promise { ) { bundledTitles.push({ title: parsed.title, description: parsed.description }); } - const hash = crypto.createHash('sha256').update(JSON.stringify(parsed)).digest('hex'); + const hash = calculateStableRecipeHash(parsed); const hashFile = path.join(hashesDir, `${hash}.hash`); if (!fsSync.existsSync(hashFile)) { await fs.writeFile(hashFile, new Date().toISOString()); diff --git a/ui/desktop/src/utils/recipeHash.ts b/ui/desktop/src/utils/recipeHash.ts index c79bd0b498c1..b368140893a2 100644 --- a/ui/desktop/src/utils/recipeHash.ts +++ b/ui/desktop/src/utils/recipeHash.ts @@ -2,13 +2,7 @@ import { ipcMain, app, BrowserWindow } from 'electron'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; -import crypto from 'crypto'; - -function calculateRecipeHash(recipe: unknown): string { - const hash = crypto.createHash('sha256'); - hash.update(JSON.stringify(recipe)); - return hash.digest('hex'); -} +import { calculateStableRecipeHash } from './stableRecipeHash'; async function getRecipeHashesDir(): Promise { const userDataPath = app.getPath('userData'); @@ -41,7 +35,7 @@ function isBundledRecipeByTitleAndDescription(recipe: unknown): boolean { } ipcMain.handle('has-accepted-recipe-before', async (_event, recipe) => { - const hash = calculateRecipeHash(recipe); + const hash = calculateStableRecipeHash(recipe); const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`); try { await fs.access(hashFile); @@ -55,7 +49,7 @@ ipcMain.handle('has-accepted-recipe-before', async (_event, recipe) => { }); ipcMain.handle('record-recipe-hash', async (_event, recipe) => { - const hash = calculateRecipeHash(recipe); + const hash = calculateStableRecipeHash(recipe); const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`); const timestamp = new Date().toISOString(); await fs.writeFile(filePath, timestamp); diff --git a/ui/desktop/src/utils/stableRecipeHash.test.ts b/ui/desktop/src/utils/stableRecipeHash.test.ts new file mode 100644 index 000000000000..1dee16d9ff5c --- /dev/null +++ b/ui/desktop/src/utils/stableRecipeHash.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { calculateStableRecipeHash } from './stableRecipeHash'; + +describe('calculateStableRecipeHash', () => { + it('is stable when object keys are serialized in a different order', () => { + const fromSave = { + version: '1.0.0', + title: '本地验收工作流', + description: '用于验证保存后信任', + instructions: '请按要求回答。', + parameters: [{ key: 'question', input_type: 'string', requirement: 'required' }], + }; + const fromList = { + parameters: [{ requirement: 'required', input_type: 'string', key: 'question' }], + instructions: '请按要求回答。', + description: '用于验证保存后信任', + title: '本地验收工作流', + version: '1.0.0', + }; + + expect(calculateStableRecipeHash(fromSave)).toBe(calculateStableRecipeHash(fromList)); + }); +}); diff --git a/ui/desktop/src/utils/stableRecipeHash.ts b/ui/desktop/src/utils/stableRecipeHash.ts new file mode 100644 index 000000000000..7344ebac4655 --- /dev/null +++ b/ui/desktop/src/utils/stableRecipeHash.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto'; + +function normalizeForHash(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeForHash); + } + + if (value && typeof value === 'object') { + return Object.keys(value as Record) + .sort() + .reduce>((acc, key) => { + const item = (value as Record)[key]; + if (item !== undefined) { + acc[key] = normalizeForHash(item); + } + return acc; + }, {}); + } + + return value; +} + +export function calculateStableRecipeHash(recipe: unknown): string { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(normalizeForHash(recipe))); + return hash.digest('hex'); +}