فهرست منبع

fix(desktop): verify installed update ASAR runtime layout

winewill 3 روز پیش
والد
کامیت
e8a3bc701a

+ 2 - 2
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md
-2026-09-14-desktop-installed-update-materials.md: c402d83608e32c3c40569edb67569efa30f603f8
-2026-09-14-desktop-installed-update-materials.zh.md: 75123c62c99684de75005b014cf821c8c427a3d0
+2026-09-14-desktop-installed-update-materials.md: 1813c6d9bbdf12697e32f7440e4b654a33bb2384
+2026-09-14-desktop-installed-update-materials.zh.md: b92bb70b32e6549b8f2aed48f6aa9b2b8364fe54

+ 1 - 1
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md

@@ -42,7 +42,7 @@ The read-only [distribution planner](../../../../apps/desktop/scripts/installed-
 
 The separate [signature reader](../../../../apps/desktop/scripts/installed-update-signature.mjs) requires the real updater verifier, valid Authenticode and timestamp attributes, and unchanged executable bytes. It runs verification children with scrubbed credentials and without inherited PowerShell module paths; incompatible module overrides cannot be treated as successful verification. The publisher comes from the trusted public certificate rather than remote YAML. Read-only verification never calls SignTool, accesses the signing token, or executes the inspected file. Signature success cannot establish package contents or a completed installation.
 
-The [package verifier](../../../../apps/desktop/scripts/verify-installed-update-package.ts) extracts the signed installer itself rather than trusting a neighboring unpacked directory. Its archived identity, update configuration, frozen application files, and Harness runtime must match the retained run; unsigned runtime executable changes cannot pass through a regenerated inventory alone. Independent immutable records preserve each check's stages and failures. Final input hashes prevent an installer/feed change from inheriting an earlier result. Real ASAR and runtime fixtures cover mismatches while substituting only external verification and archive processes; actual signed packages still require their own receipts. Installer registration and installed-app behavior are outside this file inspection and remain operator checks.
+The [package verifier](../../../../apps/desktop/scripts/verify-installed-update-package.ts) extracts the signed installer itself rather than trusting a neighboring unpacked directory. Its archived identity, update configuration, frozen application files, and Harness runtime must match the retained run, and its mandatory test policy must be valid. It reads runtime files from ASAR, accepts only electron-builder's dependency-manifest transformation, and checks executable signatures in `app.asar.unpacked`. Unsigned runtime executable changes cannot pass through a regenerated inventory alone. Independent immutable records preserve each check's stages and failures. Final input hashes prevent an installer/feed change from inheriting an earlier result. Real ASAR and runtime fixtures cover mismatches while substituting only external verification and archive processes; actual signed packages still require their own receipts. Installer registration and installed-app behavior are outside this file inspection and remain operator checks.
 
 The [network fault procedure](../../../../apps/desktop/tests/installed-update/network/README.md) binds a program-specific outbound rule to the verified original executable hash and random run identity. It rejects changed bytes before creation and after confirmation, and removes only a matching owned rule. Recovery remains available if the executable disappears. The operator controls both mutations; stages persist before and after each operation, and failures stop without retry. Firewall-cmdlet substitutes exercise the real script, but a rule's recorded presence or absence never proves traffic interruption or successful download recovery. Adapter-wide or VPN changes are excluded because they can disconnect the operator's remote control.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md

@@ -42,7 +42,7 @@ feed 发布复用已完成的二进制上传结果,其保留计划必须与当
 
 独立的[签名读取器](../../../../apps/desktop/scripts/installed-update-signature.mjs)要求真实 updater 验签、有效 Authenticode 与时间戳属性,以及未变的可执行文件字节。验签子进程移除凭据和继承的 PowerShell 模块路径;不兼容模块覆盖不能被当作验签成功。发布者来自可信公钥证书,而非远端 YAML。只读检查绝不调用 SignTool、访问签名令牌或执行被检查文件。签名通过不能证明包内容或安装完成。
 
-[安装包检查器](../../../../apps/desktop/scripts/verify-installed-update-package.ts)解开签名安装包本身,而非信任相邻的解包目录。归档内身份、更新配置、冻结应用文件和 Harness 运行时必须与保留批次一致未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/feed 变化继承旧结果。真实 ASAR 和运行时夹具覆盖不符情况,仅替换外部验签和归档进程;实际签名包仍需各自回执。安装器注册和已安装应用行为不属于文件检查,保留为人工检查项。
+[安装包检查器](../../../../apps/desktop/scripts/verify-installed-update-package.ts)解开签名安装包本身,而非信任相邻的解包目录。归档内身份、更新配置、冻结应用文件和 Harness 运行时必须与保留批次一致,测试强更策略也必须有效。检查器从 ASAR 读取运行时文件,只接受 electron-builder 对依赖 manifest 的转换,并在 `app.asar.unpacked` 中检查可执行文件签名。未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/feed 变化继承旧结果。真实 ASAR 和运行时夹具覆盖不符情况,仅替换外部验签和归档进程;实际签名包仍需各自回执。安装器注册和已安装应用行为不属于文件检查,保留为人工检查项。
 
 [网络故障步骤](../../../../apps/desktop/tests/installed-update/network/README.zh.md)将指定程序的出站规则绑定到已验证原版本可执行文件哈希及随机批次身份。它在创建前和确认后拒绝变化的字节,仅移除匹配的自有规则。可执行文件消失后仍可恢复。两次改动均由操作者控制;每次操作前后持久记录阶段,失败即停、不重试。防火墙命令替身执行真实脚本,但记录规则存在或不存在绝不证明流量中断或下载恢复成功。不改网卡或 VPN,因为这可能断开操作者的远控。
 

+ 49 - 11
apps/desktop/scripts/installed-update-package-content.ts

@@ -1,15 +1,22 @@
 /** Verify identity and update configuration from an extracted installer payload, not a neighboring unpacked build. */
 import { createHash } from 'node:crypto'
-import { readFile } from 'node:fs/promises'
+import { mkdtemp, readFile } from 'node:fs/promises'
 import { createRequire } from 'node:module'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { load } from 'js-yaml'
 import { readAsar, type Node as AsarNode } from 'app-builder-lib/out/asar/asar.js'
 import { readInstalledUpdateRun } from './installed-update-qualification.ts'
 import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts'
-import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { inventoryDesktopRuntime, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts'
 
 const require = createRequire(import.meta.url)
+const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
+const { extractAll } = builderRequire('@electron/asar') as { extractAll: (archive: string, destination: string) => void }
+const { createTransformer } = builderRequire('app-builder-lib/out/fileTransformer.js') as {
+  createTransformer: (source: string, configuration: object, metadata: null) =>
+  (file: string) => string | null | Promise<string | null>
+}
 
 function object(value: unknown): Record<string, unknown> {
   if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid package metadata')
@@ -30,9 +37,10 @@ export async function verifyInstalledUpdatePackageContent(manifest: string, vers
   await verifyInstalledUpdateApplication(run.root)
   const archive = await readAsar(join(payload, 'resources/app.asar'))
   const metadata = object(await archive.readJson('package.json'))
+  const policy = resolveDesktopPolicyConfig(metadata.dshMandatoryUpdatePolicy)
   if (metadata.name !== `dsh-update-test-${run.id}` || metadata.version !== version
     || metadata.dshDesktopAppId !== run.appId || metadata.main !== 'qualification-bootstrap.mjs'
-    || metadata.type !== 'module' || metadata.dshMandatoryUpdatePolicy !== undefined) {
+    || metadata.type !== 'module' || policy?.authentication !== 'feishu-test') {
     throw new Error('installed update: packaged application identity, version, entry, or policy differs')
   }
   const inventory = JSON.parse(await readFile(join(run.root, 'application/result.json'), 'utf8')) as {
@@ -41,12 +49,22 @@ export async function verifyInstalledUpdatePackageContent(manifest: string, vers
   const expectedPaths = new Set(inventory.files.map(file => file.path))
   const inspect = (node: AsarNode, path = ''): void => {
     if (path === 'node_modules' || path === 'package.json') return
-    if (node.link !== undefined || node.unpacked === true) throw new Error('installed update: application archive contains external entries')
+    if (node.link !== undefined || (node.unpacked === true && !path.startsWith('dsh/'))) {
+      throw new Error('installed update: application archive contains external entries')
+    }
     if (node.files !== undefined) {
-      for (const [name, child] of Object.entries(node.files)) inspect(child, path === '' ? name : `${path}/${name}`)
-    } else if (!expectedPaths.has(path)) throw new Error('installed update: application archive contains additional files')
+      for (const [name, child] of Object.entries(node.files)) {
+        if (name === '.' || name === '..' || /[\\/:*?"<>|\x00-\x1f]/u.test(name)) {
+          throw new Error('installed update: application archive contains an unsafe path')
+        }
+        inspect(child, path === '' ? name : `${path}/${name}`)
+      }
+    } else if (!path.startsWith('dsh/') && !expectedPaths.has(path)) {
+      throw new Error('installed update: application archive contains additional files')
+    }
   }
   inspect(archive.header)
+  if (archive.header.files?.dsh?.files === undefined) throw new Error('installed update: application archive lacks the dsh runtime')
   for (const file of inventory.files) {
     const path = file.path.split('/').join(process.platform === 'win32' ? '\\' : '/')
     const node = archive.getFile(path, false)
@@ -69,19 +87,39 @@ export async function verifyInstalledUpdatePackageContent(manifest: string, vers
     || !Array.isArray(update.publisherName) || update.publisherName.length !== 1 || update.publisherName[0] !== publisher) {
     throw new Error('installed update: packaged feed, cache identity, channel, or publisher differs')
   }
-  const runtime = await verifyDesktopRuntime(join(payload, 'resources/dsh'), version, { platform: 'win32', arch: 'x64' })
+  const extracted = await mkdtemp(join(dirname(payload), 'asar-'))
+  extractAll(join(payload, 'resources/app.asar'), extracted)
   const prepared = await verifyDesktopRuntime(join(run.root, version, 'dsh'), version, { platform: 'win32', arch: 'x64' })
+  const runtime = readDesktopRuntime(join(extracted, 'dsh'))
   if (JSON.stringify(runtime.release) !== JSON.stringify(prepared.release)
     || JSON.stringify(runtime.sharedPackages) !== JSON.stringify(prepared.sharedPackages)
     || JSON.stringify(runtime.files.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) {
     throw new Error('installed update: packaged runtime does not describe the prepared release')
   }
-  for (const [index, file] of runtime.files.entries()) {
-    if (!file.path.endsWith('.exe') && (file.sha256 !== prepared.files[index]!.sha256 || file.bytes !== prepared.files[index]!.bytes)) {
+  const actualFiles = inventoryDesktopRuntime(join(extracted, 'dsh'))
+  if (JSON.stringify(actualFiles.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) {
+    throw new Error('installed update: packaged runtime file list differs from prepared inputs')
+  }
+  const transform = createTransformer('', {}, null)
+  for (const [index, file] of prepared.files.entries()) {
+    if (file.path.endsWith('.exe')) continue
+    if (runtime.files[index]!.sha256 !== file.sha256 || runtime.files[index]!.bytes !== file.bytes) {
+      throw new Error('installed update: non-executable runtime descriptor differs from prepared inputs')
+    }
+    const transformed = file.path.startsWith('node_modules/') && file.path.endsWith('/package.json')
+      ? await transform(runtimePath(join(run.root, version, 'dsh'), file.path)) : null
+    const bytes = transformed === null ? file.bytes : Buffer.byteLength(transformed)
+    const hash = transformed === null ? file.sha256 : createHash('sha256').update(transformed).digest('hex')
+    if (actualFiles[index]!.sha256 !== hash || actualFiles[index]!.bytes !== bytes) {
       throw new Error('installed update: non-executable runtime bytes differ from prepared inputs')
     }
   }
   return { version, appId: run.appId, applicationFiles: inventory.files.length, dependencies, dependenciesFrozen: false,
     runtimeFiles: runtime.files.length, feedUrl: `${run.origin}/${run.feedKey}`, installed: false,
-    resignedExecutables: runtime.files.filter(file => file.path.endsWith('.exe')).map(file => join(payload, 'resources/dsh', file.path)) }
+    resignedExecutables: runtime.files.filter(file => file.path.endsWith('.exe')).map((file) => {
+      if (archive.getFile(join('dsh', file.path), false).unpacked !== true) {
+        throw new Error('installed update: executable runtime file must be outside ASAR')
+      }
+      return join(payload, 'resources/app.asar.unpacked/dsh', file.path)
+    }) }
 }

+ 55 - 17
apps/desktop/tests/installed-update-package-content.spec.ts

@@ -2,13 +2,13 @@ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { createHash } from 'node:crypto'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts'
 import { prepareInstalledUpdateBootstrap } from '../scripts/prepare-installed-update-bootstrap.ts'
 import { prepareInstalledUpdateApplication } from '../scripts/prepare-installed-update-application.ts'
 import { verifyInstalledUpdatePackageContent } from '../scripts/installed-update-package-content.ts'
-import { writeDesktopRuntime } from '../src/runtime-tree.ts'
+import { readDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
 import { runtimeFixture } from './runtime-fixture.ts'
 import { validateInstalledUpdateArchivePaths, verifyInstalledUpdatePackage } from '../scripts/verify-installed-update-package.ts'
 
@@ -30,7 +30,9 @@ afterEach(() => { external.archive.mockReset(); external.signature.mockReset();
 
 const require = createRequire(import.meta.url)
 const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
-const { createPackage } = builderRequire('@electron/asar') as { createPackage: (source: string, destination: string) => Promise<void> }
+const { createPackageWithOptions } = builderRequire('@electron/asar') as {
+  createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise<void>
+}
 const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const
 const publisher = 'CN=Fixture,O=Fixture,C=CN'
 
@@ -40,7 +42,7 @@ async function fixture(body: (context: {
   source: string
   version: string
   seal: () => Promise<void>
-  resealRuntime: () => void
+  resealRuntime: () => Promise<void>
 }) => Promise<void>, version: string = versions[0]): Promise<void> {
   const root = await mkdtemp(join(tmpdir(), 'dsh-package-content-'))
   try {
@@ -57,7 +59,9 @@ async function fixture(body: (context: {
     await prepareInstalledUpdateApplication(manifest, source)
     await cp(join(run.root, 'application/files'), source, { recursive: true })
     await writeFile(join(source, 'package.json'), JSON.stringify({ name: `dsh-update-test-${run.id}`, version,
-      dshDesktopAppId: run.appId, main: 'qualification-bootstrap.mjs', type: 'module' }))
+      dshDesktopAppId: run.appId, main: 'qualification-bootstrap.mjs', type: 'module',
+      dshMandatoryUpdatePolicy: { origin: 'https://policy.example.com', allowedPageOrigins: ['https://policy.example.com'],
+        authentication: 'feishu-test' } }))
     for (const name of ['electron-updater', 'semver']) {
       await mkdir(join(source, 'node_modules', name), { recursive: true })
       await cp(require.resolve(`${name}/package.json`), join(source, 'node_modules', name, 'package.json'))
@@ -69,15 +73,20 @@ async function fixture(body: (context: {
       writeDesktopRuntime(directory, descriptor.release, descriptor.sharedPackages.map(entry => entry.name), { platform: 'win32', arch: 'x64' })
     }
     reseal(dsh)
+    await cp(dsh, join(source, 'dsh'), { recursive: true })
     const payload = join(root, 'payload')
     await mkdir(join(payload, 'resources'), { recursive: true })
-    await cp(dsh, join(payload, 'resources/dsh'), { recursive: true })
     await writeFile(join(payload, 'resources/app-update.yml'), JSON.stringify({ provider: 'generic', channel: 'nightly',
       url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, publisherName: [publisher],
       updaterCacheDirName: `dsh-update-test-${run.id}-updater` }))
-    const seal = () => createPackage(source, join(payload, 'resources/app.asar'))
+    const seal = async () => {
+      await createPackageWithOptions(source, join(payload, 'resources/app.asar'), { unpack: '**/*.exe' })
+    }
     await seal()
-    await body({ manifest, source, payload, version, seal, resealRuntime: () => { reseal(join(payload, 'resources/dsh')) } })
+    await body({ manifest, source, payload, version, seal, resealRuntime: async () => {
+      reseal(join(source, 'dsh'))
+      await seal()
+    } })
   } finally { await rm(root, { recursive: true, force: true }) }
 }
 
@@ -86,7 +95,7 @@ describe('installed update archive contents', () => {
     await fixture(async ({ manifest, payload }) => {
       expect(await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).toMatchObject({
         version, applicationFiles: 8, dependenciesFrozen: false, installed: false,
-        resignedExecutables: [join(payload, 'resources/dsh/tool.exe')],
+        resignedExecutables: [join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')],
       })
     }, version)
   })
@@ -99,7 +108,8 @@ describe('installed update archive contents', () => {
         data[field] = 'wrong'
         await writeFile(path, JSON.stringify(data))
         await seal()
-        await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow('identity')
+        await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher))
+          .rejects.toThrow(field === 'dshMandatoryUpdatePolicy' ? 'desktop policy' : 'identity')
       })
     })
 
@@ -133,20 +143,48 @@ describe('installed update archive contents', () => {
     })
   })
 
+  it('accepts only the builder transformation of runtime dependency manifests', async () => {
+    await fixture(async ({ manifest, source, payload, version, seal }) => {
+      const preparedRoot = join(dirname(manifest), version, 'dsh')
+      const packagePath = 'node_modules/@deepseek-ai/dsh/package.json'
+      const preparedPath = join(preparedRoot, packagePath)
+      const data = JSON.parse(await readFile(preparedPath, 'utf8')) as Record<string, unknown>
+      data.scripts = { test: 'inert' }
+      data.bugs = { url: 'https://example.com/issues' }
+      await writeFile(preparedPath, JSON.stringify(data))
+      const descriptor = readDesktopRuntime(preparedRoot)
+      writeDesktopRuntime(preparedRoot, descriptor.release, descriptor.sharedPackages.map(entry => entry.name),
+        { platform: 'win32', arch: 'x64' })
+      await cp(join(preparedRoot, 'desktop-runtime.json'), join(source, 'dsh/desktop-runtime.json'))
+      const packagedPath = join(source, 'dsh', packagePath)
+      const packaged = { ...data }
+      delete packaged.scripts
+      delete packaged.bugs
+      await writeFile(packagedPath, JSON.stringify(packaged, null, 2))
+      await seal()
+      await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).resolves.toMatchObject({ version })
+      packaged.version = '0.0.0'
+      await writeFile(packagedPath, JSON.stringify(packaged, null, 2))
+      await seal()
+      await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow('runtime bytes differ')
+    })
+  })
+
   it.each(['unsealed', 'resealed-change', 'resealed-addition'])(
     'rejects %s runtime content even when metadata is regenerated', async (failure) => {
-      await fixture(async ({ manifest, payload, version, resealRuntime }) => {
-        await writeFile(join(payload, 'resources/dsh', failure === 'resealed-addition' ? 'extra.js' : 'package.json'), '{}')
-        if (failure !== 'unsealed') resealRuntime()
+      await fixture(async ({ manifest, source, payload, version, seal, resealRuntime }) => {
+        await writeFile(join(source, 'dsh', failure === 'resealed-addition' ? 'extra.js' : 'package.json'), '{}')
+        if (failure !== 'unsealed') await resealRuntime()
+        else await seal()
         await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow()
       })
     })
 
   it('identifies changed executable resources for separate signature verification', async () => {
-    await fixture(async ({ manifest, payload, version, resealRuntime }) => {
-      const executable = join(payload, 'resources/dsh/tool.exe')
-      await writeFile(executable, 'inert changed executable, not a signature')
-      resealRuntime()
+    await fixture(async ({ manifest, source, payload, version, resealRuntime }) => {
+      const executable = join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')
+      await writeFile(join(source, 'dsh/tool.exe'), 'inert changed executable, not a signature')
+      await resealRuntime()
       expect((await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).resignedExecutables).toEqual([executable])
     })
   })

+ 2 - 2
apps/desktop/tests/installed-update/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/tests/installed-update/README.md
-README.md: 574d462b18030afaac83152c7a4e3ea1cd9682da
-README.zh.md: 65bfa67fa6171da15493d7106b1a1096b89f334a
+README.md: 139a80246d6539898cc870129fe55ad726b2673d
+README.zh.md: 2b140cceb37ac104c1984c35322da0814ac365b7

+ 1 - 1
apps/desktop/tests/installed-update/README.md

@@ -62,7 +62,7 @@ The [package verifier](../../scripts/verify-installed-update-package.ts) checks
 node --import tsx apps/desktop/scripts/verify-installed-update-package.ts "<run.json>" 0.1.6-alpha.1.20260916.1 "<public.cer>" "<reviewed-7za.exe>"
 ```
 
-The verifier checks actual archived application identity, entry, frozen application bytes, updater dependency versions, feed/cache/publisher configuration, and the bundled Harness runtime against prepared inputs. Changed runtime executables require separate signature checks; other runtime bytes must match. It rejects unsafe archive paths before extraction and records installer, application, and runtime-executable signatures. Before success, it rechecks installer, feed/blockmap, manifest, certificate, and tool hashes. `passed` covers these checks only: dependency bytes are not frozen, and installer registration, startup, upgrade, and data retention remain explicit manual checks. Real archive reading has been observed on a retained older package; complete signed-package verification for the prepared versions remains pending.
+The verifier checks actual archived application identity, entry, frozen application bytes, updater dependency versions, feed/cache/publisher configuration, and the bundled Harness runtime against prepared inputs; the mandatory test policy must be valid. It extracts the runtime from `app.asar` and checks executable signatures at their `app.asar.unpacked` paths. Changed runtime executables require separate signature checks; other runtime bytes must match the prepared bytes after electron-builder's dependency-manifest transformation. It rejects unsafe archive paths before extraction and records installer, application, and runtime-executable signatures. Before success, it rechecks installer, feed/blockmap, manifest, certificate, and tool hashes. `passed` covers these checks only: dependency bytes are not frozen, and installer registration, startup, upgrade, and data retention remain explicit manual checks. Real archive reading has been observed on a retained older package; complete signed-package verification for the prepared versions remains pending.
 
 Follow the separate [upload and publication procedure](publication/README.md). Leave the fixed feed absent for the 404 case, then publish version 1 for the same-version case. Keep version 2 metadata local until both cases finish in the installed version 1 application. Retain publication records and remote objects for diagnosis; do not delete a published feed to recreate an earlier case.
 

+ 1 - 1
apps/desktop/tests/installed-update/README.zh.md

@@ -62,7 +62,7 @@ Windows [只读签名检查器](../../scripts/installed-update-signature.mjs)使
 node --import tsx apps/desktop/scripts/verify-installed-update-package.ts "<run.json>" 0.1.6-alpha.1.20260916.1 "<public.cer>" "<reviewed-7za.exe>"
 ```
 
-检查器核对实际归档内的应用身份、入口、冻结应用字节、updater 依赖版本、feed/缓存/发布者配置,并将内置 Harness 运行时与准备输入比较。发生变化的运行时可执行文件须单独验签;其他运行时字节必须一致。它在解包前拒绝不安全归档路径,记录安装器、应用及运行时可执行文件的签名。成功前再次核对安装包、feed/blockmap、清单、证书和工具哈希。`passed` 仅覆盖这些检查:依赖字节未冻结,安装器注册、启动、升级和数据保留仍是明确的人工检查项。保留的旧包已完成真实归档读取;本批次准备版本的完整签名包检查仍待执行。
+检查器核对实际归档内的应用身份、入口、冻结应用字节、updater 依赖版本、feed/缓存/发布者配置,并将内置 Harness 运行时与准备输入比较;测试强更策略也必须有效。它从 `app.asar` 提取运行时,并在 `app.asar.unpacked` 路径核对可执行文件签名。发生变化的运行时可执行文件须单独验签;其他运行时字节在经过 electron-builder 的依赖 manifest 转换后必须与准备输入一致。它在解包前拒绝不安全归档路径,记录安装器、应用及运行时可执行文件的签名。成功前再次核对安装包、feed/blockmap、清单、证书和工具哈希。`passed` 仅覆盖这些检查:依赖字节未冻结,安装器注册、启动、升级和数据保留仍是明确的人工检查项。保留的旧包已完成真实归档读取;本批次准备版本的完整签名包检查仍待执行。
 
 遵循独立的[上传与发布步骤](publication/README.zh.md)。先保持固定 feed 不存在以测试 404,再发布版本 1 以测试同版本。两个场景在已安装的版本 1 应用内完成之前,版本 2 元数据保留在本地。保留发布记录及远端对象用于诊断,不删除已发布的 feed 来重建较早场景。