From 054d295b533b59d0b432ad72c1c55e8bc971ed5e Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 02:16:16 +0800 Subject: [PATCH 01/11] build: attest packaged resource versions --- package.json | 2 +- scripts/backend/build-backend.mjs | 33 +- scripts/backend/runtime-manifest.mjs | 50 +++ scripts/backend/runtime-manifest.test.mjs | 75 ++++ scripts/prepare-resources.mjs | 47 ++- scripts/prepare-resources/mode-dispatch.mjs | 25 +- .../prepare-resources/mode-dispatch.test.mjs | 26 +- scripts/prepare-resources/mode-tasks.mjs | 42 ++ .../prepare-resources/resource-identity.mjs | 377 ++++++++++++++++++ .../resource-identity.test.mjs | 265 ++++++++++++ scripts/prepare-resources/source-repo.mjs | 17 + .../prepare-resources/source-repo.test.mjs | 35 +- scripts/prepare-resources/version-sync.mjs | 5 +- .../prepare-resources/version-sync.test.mjs | 13 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 34 +- 17 files changed, 1012 insertions(+), 36 deletions(-) create mode 100644 scripts/backend/runtime-manifest.mjs create mode 100644 scripts/backend/runtime-manifest.test.mjs create mode 100644 scripts/prepare-resources/resource-identity.mjs create mode 100644 scripts/prepare-resources/resource-identity.test.mjs diff --git a/package.json b/package.json index e03e45fe..b88c552f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:prepare-resources": "node --test \"scripts/**/*.test.mjs\"", "prepare:webui": "node scripts/prepare-resources.mjs webui", "prepare:backend": "node scripts/prepare-resources.mjs backend", - "prepare:resources": "pnpm run prepare:webui && pnpm run prepare:backend", + "prepare:resources": "node scripts/prepare-resources.mjs all", "dev": "tauri dev", "build": "tauri build" }, diff --git a/scripts/backend/build-backend.mjs b/scripts/backend/build-backend.mjs index 9a500b76..1aeb99dd 100644 --- a/scripts/backend/build-backend.mjs +++ b/scripts/backend/build-backend.mjs @@ -20,6 +20,7 @@ import { } from './runtime-linux-compat-utils.mjs'; import { isWindowsArm64BundledRuntime } from './runtime-arch-utils.mjs'; import { generateRuntimeCoreLock } from './runtime-core-lock.mjs'; +import { createRuntimeManifest } from './runtime-manifest.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..', '..'); @@ -40,6 +41,9 @@ const runtimeSource = process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME || process.env.ASTRBOT_DESKTOP_CPYTHON_HOME; const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1'; +const desktopVersionOverride = process.env.ASTRBOT_DESKTOP_VERSION || ''; +const sourceRef = process.env.ASTRBOT_SOURCE_GIT_REF || ''; +const sourceCommit = process.env.ASTRBOT_SOURCE_GIT_COMMIT || ''; const requiredSourceEntries = ['astrbot', 'main.py', 'requirements.txt']; const optionalSourceEntries = ['changelogs']; @@ -449,13 +453,32 @@ const writeLauncherScript = () => { fs.writeFileSync(launcherPath, content, 'utf8'); }; -const writeRuntimeManifest = (runtimePython) => { - const manifest = { - mode: 'cpython-runtime', +const readCoreVersion = (resolvedSourceDir) => { + const explicitVersion = String(process.env.ASTRBOT_CORE_VERSION || '').trim(); + if (explicitVersion) { + return explicitVersion; + } + + const pyprojectPath = path.join(resolvedSourceDir, 'pyproject.toml'); + const content = fs.readFileSync(pyprojectPath, 'utf8'); + const match = /^version\s*=\s*["']([^"']+)["']/m.exec(content); + if (!match) { + throw new Error(`Cannot resolve AstrBot Core version from ${pyprojectPath}.`); + } + return match[1]; +}; + +const writeRuntimeManifest = (runtimePython, resolvedSourceDir) => { + const coreVersion = readCoreVersion(resolvedSourceDir); + const manifest = createRuntimeManifest({ python: runtimePython.relative, entrypoint: path.basename(launcherPath), app: path.relative(outputDir, appDir), - }; + desktopVersion: desktopVersionOverride || coreVersion, + coreVersion, + sourceRef, + sourceCommit, + }); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); }; @@ -705,7 +728,7 @@ const main = () => { pruneLinuxTkinterRuntime(runtimeDir); patchLinuxRuntimeRpaths(runtimeDir); writeLauncherScript(); - writeRuntimeManifest(runtimePython); + writeRuntimeManifest(runtimePython, resolvedSourceDir); console.log(`Prepared CPython backend runtime in ${outputDir}`); console.log(`Runtime source: ${runtimeSourceReal}`); diff --git a/scripts/backend/runtime-manifest.mjs b/scripts/backend/runtime-manifest.mjs new file mode 100644 index 00000000..56d4fc54 --- /dev/null +++ b/scripts/backend/runtime-manifest.mjs @@ -0,0 +1,50 @@ +import path from 'node:path'; + +const requiredString = (value, field) => { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized) { + throw new Error(`Backend runtime manifest field ${field} must not be empty.`); + } + return normalized; +}; + +export const requiredRuntimeRelativePath = (value, field) => { + const normalized = requiredString(value, field); + const portablePath = normalized.replaceAll('\\', '/'); + const segments = portablePath.split('/'); + if ( + normalized.includes('\0') || + path.posix.isAbsolute(portablePath) || + path.win32.parse(normalized).root || + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error( + `Backend runtime manifest field ${field} must be a canonical relative path inside the backend directory.`, + ); + } + return normalized; +}; + +const optionalString = (value) => { + const normalized = typeof value === 'string' ? value.trim() : ''; + return normalized || null; +}; + +export const createRuntimeManifest = ({ + python, + entrypoint, + app, + desktopVersion, + coreVersion, + sourceRef, + sourceCommit, +}) => ({ + mode: 'cpython-runtime', + python: requiredRuntimeRelativePath(python, 'python'), + entrypoint: requiredRuntimeRelativePath(entrypoint, 'entrypoint'), + app: requiredString(app, 'app'), + desktopVersion: requiredString(desktopVersion, 'desktopVersion').replace(/^v/i, ''), + coreVersion: requiredString(coreVersion, 'coreVersion').replace(/^v/i, ''), + sourceRef: optionalString(sourceRef), + sourceCommit: optionalString(sourceCommit), +}); diff --git a/scripts/backend/runtime-manifest.test.mjs b/scripts/backend/runtime-manifest.test.mjs new file mode 100644 index 00000000..7231f762 --- /dev/null +++ b/scripts/backend/runtime-manifest.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createRuntimeManifest } from './runtime-manifest.mjs'; + +test('createRuntimeManifest records Core source identity', () => { + const manifest = createRuntimeManifest({ + python: 'python/bin/python3', + entrypoint: 'launch_backend.py', + app: 'app', + desktopVersion: 'v4.27.4', + coreVersion: 'v4.27.4', + sourceRef: 'v4.27.4', + sourceCommit: 'a'.repeat(40), + }); + + assert.deepEqual(manifest, { + mode: 'cpython-runtime', + python: 'python/bin/python3', + entrypoint: 'launch_backend.py', + app: 'app', + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRef: 'v4.27.4', + sourceCommit: 'a'.repeat(40), + }); +}); + +test('createRuntimeManifest keeps optional source identity explicit', () => { + const manifest = createRuntimeManifest({ + python: 'python/bin/python3', + entrypoint: 'launch_backend.py', + app: 'app', + desktopVersion: '4.27.4-nightly.20260901.abcdef12', + coreVersion: '4.27.4', + }); + + assert.equal(manifest.sourceRef, null); + assert.equal(manifest.sourceCommit, null); +}); + +test('createRuntimeManifest requires a Core version', () => { + assert.throws( + () => + createRuntimeManifest({ + python: 'python/bin/python3', + entrypoint: 'launch_backend.py', + app: 'app', + desktopVersion: '4.27.4', + coreVersion: '', + }), + /coreVersion must not be empty/, + ); +}); + +test('createRuntimeManifest rejects backend paths that escape the bundle', () => { + for (const [field, value] of [ + ['python', '../python.exe'], + ['python', 'C:\\outside\\python.exe'], + ['entrypoint', '/tmp/launch_backend.py'], + ['entrypoint', 'scripts/../launch_backend.py'], + ]) { + assert.throws( + () => + createRuntimeManifest({ + python: field === 'python' ? value : 'python/bin/python3', + entrypoint: field === 'entrypoint' ? value : 'launch_backend.py', + app: 'app', + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + }), + new RegExp(`${field} must be a canonical relative path`), + ); + } +}); diff --git a/scripts/prepare-resources.mjs b/scripts/prepare-resources.mjs index f50fa2c6..030d9962 100644 --- a/scripts/prepare-resources.mjs +++ b/scripts/prepare-resources.mjs @@ -8,12 +8,14 @@ import { } from './prepare-resources/version-sync.mjs'; import { ensureSourceRepo, + resolveSourceRepoCommit, } from './prepare-resources/source-repo.mjs'; import { ensureStartupShellAssets, } from './prepare-resources/mode-tasks.mjs'; import { runModeTasks } from './prepare-resources/mode-dispatch.mjs'; import { createPrepareResourcesContext } from './prepare-resources/context.mjs'; +import { requiresDesktopCoreMatch } from './prepare-resources/resource-identity.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); @@ -38,7 +40,11 @@ const prepareAstrbotVersionSync = async ({ context }) => { console.log( '[prepare-resources] Skip source repo sync in version-only mode because ASTRBOT_DESKTOP_VERSION is set.', ); - return desktopVersionOverride; + return { + desktopVersion: desktopVersionOverride, + coreVersion: '', + sourceRepoCommit: '', + }; } ensureSourceRepo({ @@ -49,23 +55,32 @@ const prepareAstrbotVersionSync = async ({ context }) => { sourceDirOverrideRaw: sourceDirOverrideInput, }); - const astrbotVersion = - desktopVersionOverride || (await readAstrbotVersionFromPyproject({ sourceDir })); + const coreVersion = await readAstrbotVersionFromPyproject({ sourceDir }); + const desktopVersion = desktopVersionOverride || coreVersion; await validateAstrbotRuntimeVersion({ sourceDir, - expectedVersion: desktopVersionOverride ? undefined : astrbotVersion, + expectedVersion: coreVersion, }); + if (requiresDesktopCoreMatch(desktopVersion) && desktopVersion !== coreVersion) { + throw new Error( + `Stable bundle version mismatch: Desktop is ${desktopVersion}, but Core is ${coreVersion}.`, + ); + } + if (desktopVersionOverride) { - const sourceVersion = await readAstrbotVersionFromPyproject({ sourceDir }); - if (sourceVersion !== desktopVersionOverride) { + if (coreVersion !== desktopVersionOverride) { console.warn( - `[prepare-resources] Version override drift detected: ASTRBOT_DESKTOP_VERSION=${desktopVersionInput} (normalized=${desktopVersionOverride}), source pyproject version=${sourceVersion} (${sourceDir})`, + `[prepare-resources] Version override drift detected: ASTRBOT_DESKTOP_VERSION=${desktopVersionInput} (normalized=${desktopVersionOverride}), source pyproject version=${coreVersion} (${sourceDir})`, ); } } - return astrbotVersion; + return { + desktopVersion, + coreVersion, + sourceRepoCommit: resolveSourceRepoCommit(sourceDir), + }; }; const main = async () => { @@ -87,18 +102,24 @@ const main = async () => { ); } - const astrbotVersion = await prepareAstrbotVersionSync({ context }); + const { desktopVersion, coreVersion, sourceRepoCommit } = + await prepareAstrbotVersionSync({ context }); - await syncDesktopVersionFiles({ projectRoot, version: astrbotVersion }); + await syncDesktopVersionFiles({ projectRoot, version: desktopVersion }); if (desktopVersionOverride) { console.log( - `[prepare-resources] Synced desktop version to override ${astrbotVersion} (ASTRBOT_DESKTOP_VERSION)`, + `[prepare-resources] Synced desktop version to override ${desktopVersion} (ASTRBOT_DESKTOP_VERSION)`, ); } else { - console.log(`[prepare-resources] Synced desktop version to AstrBot ${astrbotVersion}`); + console.log(`[prepare-resources] Synced desktop version to AstrBot ${desktopVersion}`); } - await runModeTasks(mode, context); + await runModeTasks(mode, { + ...context, + desktopVersion, + coreVersion, + sourceRepoCommit, + }); }; main().catch((error) => { diff --git a/scripts/prepare-resources/mode-dispatch.mjs b/scripts/prepare-resources/mode-dispatch.mjs index 8de4b2c7..a10aa5f9 100644 --- a/scripts/prepare-resources/mode-dispatch.mjs +++ b/scripts/prepare-resources/mode-dispatch.mjs @@ -1,10 +1,15 @@ -import { prepareBackend, prepareWebui } from './mode-tasks.mjs'; +import { + prepareBackend, + prepareWebui, + validatePreparedResources, +} from './mode-tasks.mjs'; const VALID_MODES = new Set(['version', 'webui', 'backend', 'all']); const defaultTaskRunner = { prepareWebui, prepareBackend, + validatePreparedResources, }; export const runModeTasks = async ( @@ -15,6 +20,9 @@ export const runModeTasks = async ( const { sourceDir, projectRoot, + desktopVersion, + coreVersion, + sourceRepoCommit, sourceRepoRef, isSourceRepoRefVersionTag, isDesktopBridgeExpectationStrict, @@ -34,6 +42,7 @@ export const runModeTasks = async ( await taskRunner.prepareWebui({ sourceDir, projectRoot, + coreVersion, sourceRepoRef, isSourceRepoRefVersionTag, isDesktopBridgeExpectationStrict, @@ -44,8 +53,22 @@ export const runModeTasks = async ( await taskRunner.prepareBackend({ sourceDir, projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef, + sourceRepoCommit, pythonBuildStandaloneRelease, pythonBuildStandaloneVersion, }); } + + if (mode === 'all') { + await taskRunner.validatePreparedResources({ + projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef, + sourceRepoCommit, + }); + } }; diff --git a/scripts/prepare-resources/mode-dispatch.test.mjs b/scripts/prepare-resources/mode-dispatch.test.mjs index 931b8cf4..73e2230e 100644 --- a/scripts/prepare-resources/mode-dispatch.test.mjs +++ b/scripts/prepare-resources/mode-dispatch.test.mjs @@ -1,11 +1,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import { runModeTasks } from './mode-dispatch.mjs'; const createContext = (calls) => ({ sourceDir: '/tmp/source', projectRoot: '/tmp/project', + desktopVersion: '4.19.2', + coreVersion: '4.19.2', + sourceRepoCommit: 'a'.repeat(40), sourceRepoRef: 'v4.19.2', isSourceRepoRefVersionTag: true, isDesktopBridgeExpectationStrict: false, @@ -16,6 +20,7 @@ const createContext = (calls) => ({ const createTaskRunner = (calls) => ({ prepareWebui: async () => calls.push('webui'), prepareBackend: async () => calls.push('backend'), + validatePreparedResources: async () => calls.push('validate'), }); test('runModeTasks skips handlers in version mode', async () => { @@ -47,7 +52,7 @@ test('runModeTasks runs webui then backend handlers in all mode', async () => { await runModeTasks('all', createContext(calls), createTaskRunner(calls)); - assert.deepEqual(calls, ['webui', 'backend']); + assert.deepEqual(calls, ['webui', 'backend', 'validate']); }); test('runModeTasks throws for unsupported mode', async () => { @@ -57,3 +62,22 @@ test('runModeTasks throws for unsupported mode', async () => { /Unsupported mode: desktop\. Expected version\/webui\/backend\/all\./, ); }); + +test('prepare:resources uses the single all-mode validation path', async () => { + const packageJson = JSON.parse(await readFile('package.json', 'utf8')); + + assert.equal(packageJson.scripts['prepare:resources'], 'node scripts/prepare-resources.mjs all'); +}); + +test('prepare:resources always validates the runtime version against Core', async () => { + const source = await readFile('scripts/prepare-resources.mjs', 'utf8'); + + assert.match( + source, + /validateAstrbotRuntimeVersion\(\{\s*sourceDir,\s*expectedVersion: coreVersion,\s*\}\)/, + ); + assert.doesNotMatch( + source, + /expectedVersion:\s*desktopVersionOverride\s*&&\s*!isSourceRepoRefVersionTag/, + ); +}); diff --git a/scripts/prepare-resources/mode-tasks.mjs b/scripts/prepare-resources/mode-tasks.mjs index db9a3331..41cf6c19 100644 --- a/scripts/prepare-resources/mode-tasks.mjs +++ b/scripts/prepare-resources/mode-tasks.mjs @@ -8,6 +8,12 @@ import { verifyDesktopBridgeArtifacts, } from './desktop-bridge-checks.mjs'; import { ensureBundledRuntime } from './backend-runtime.mjs'; +import { + attestPreparedResourceBundle, + validateBackendRuntimeIdentity, + validateWebuiResources, + writeWebuiVersionMarker, +} from './resource-identity.mjs'; const runChecked = (cmd, args, cwd, envExtra = {}, spawnExtra = {}) => { const result = spawnSync(cmd, args, { @@ -60,6 +66,7 @@ const resolveDesktopReleaseBaseUrl = () => { export const prepareWebui = async ({ sourceDir, projectRoot, + coreVersion, sourceRepoRef, isSourceRepoRefVersionTag, isDesktopBridgeExpectationStrict, @@ -86,11 +93,20 @@ export const prepareWebui = async ({ const targetWebuiDir = path.join(projectRoot, 'resources', 'webui'); await syncResourceDir(sourceWebuiDir, targetWebuiDir); + await writeWebuiVersionMarker({ webuiDir: targetWebuiDir, coreVersion }); + await validateWebuiResources({ + webuiDir: targetWebuiDir, + expectedCoreVersion: coreVersion, + }); }; export const prepareBackend = async ({ sourceDir, projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef, + sourceRepoCommit, pythonBuildStandaloneRelease, pythonBuildStandaloneVersion, }) => { @@ -106,6 +122,10 @@ export const prepareBackend = async ({ { ASTRBOT_SOURCE_DIR: sourceDir, ASTRBOT_DESKTOP_CPYTHON_HOME: runtimeRoot, + ASTRBOT_DESKTOP_VERSION: desktopVersion, + ASTRBOT_CORE_VERSION: coreVersion, + ASTRBOT_SOURCE_GIT_REF: sourceRepoRef, + ASTRBOT_SOURCE_GIT_COMMIT: sourceRepoCommit, }, ); @@ -113,8 +133,30 @@ export const prepareBackend = async ({ if (!existsSync(path.join(sourceBackendDir, 'runtime-manifest.json'))) { throw new Error(`Backend runtime output missing: ${sourceBackendDir}`); } + await validateBackendRuntimeIdentity({ + backendDir: sourceBackendDir, + expectedDesktopVersion: desktopVersion, + expectedCoreVersion: coreVersion, + expectedSourceRef: sourceRepoRef, + expectedSourceCommit: sourceRepoCommit, + }); }; +export const validatePreparedResources = async ({ + projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef, + sourceRepoCommit, +}) => + attestPreparedResourceBundle({ + projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef, + sourceRepoCommit, + }); + export const ensureStartupShellAssets = (projectRoot) => { const startupUiDir = path.join(projectRoot, 'ui'); const requiredFiles = ['index.html', 'astrbot-logo.png']; diff --git a/scripts/prepare-resources/resource-identity.mjs b/scripts/prepare-resources/resource-identity.mjs new file mode 100644 index 00000000..4b651e7d --- /dev/null +++ b/scripts/prepare-resources/resource-identity.mjs @@ -0,0 +1,377 @@ +import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { requiredRuntimeRelativePath } from '../backend/runtime-manifest.mjs'; + +const VERSION_PREFIX_PATTERN = /^v/i; +const LOCAL_ENTRY_PATTERN = /\.(?:css|js)$/i; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const SEMVER_CORE_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +const SEMVER_IDENTIFIER_PATTERN = /^[0-9A-Za-z-]+$/; + +const sha256 = (content) => createHash('sha256').update(content).digest('hex'); + +export const normalizeResourceVersion = (version) => { + const normalized = typeof version === 'string' ? version.trim().replace(VERSION_PREFIX_PATTERN, '') : ''; + if (!normalized) { + throw new Error('Resource version must not be empty.'); + } + return normalized; +}; + +export const formatWebuiVersion = (coreVersion) => + `v${normalizeResourceVersion(coreVersion)}`; + +export const requiresDesktopCoreMatch = (desktopVersion) => { + const normalized = normalizeResourceVersion(desktopVersion); + const buildParts = normalized.split('+'); + if ( + buildParts.length > 2 || + (buildParts.length === 2 && + (!buildParts[1] || + !buildParts[1].split('.').every((part) => SEMVER_IDENTIFIER_PATTERN.test(part)))) + ) { + return true; + } + const versionWithoutBuild = buildParts[0]; + const prereleaseSeparator = versionWithoutBuild.indexOf('-'); + const core = prereleaseSeparator < 0 + ? versionWithoutBuild + : versionWithoutBuild.slice(0, prereleaseSeparator); + if (!SEMVER_CORE_PATTERN.test(core)) { + return true; + } + if (prereleaseSeparator < 0) { + return true; + } + const prerelease = versionWithoutBuild.slice(prereleaseSeparator + 1); + const identifiers = prerelease.split('.'); + const validPrerelease = identifiers.every( + (identifier) => + SEMVER_IDENTIFIER_PATTERN.test(identifier) && + (!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')), + ); + // Match semver::Version on the Rust side: invalid versions fail closed as + // stable, and build metadata alone does not make a release a prerelease. + return !validPrerelease; +}; + +const normalizeLocalAssetReference = (reference) => { + const trimmed = reference.trim(); + if ( + !trimmed || + trimmed.startsWith('#') || + trimmed.startsWith('//') || + /^[a-z][a-z\d+.-]*:/i.test(trimmed) + ) { + return null; + } + + const withoutQuery = trimmed.split(/[?#]/, 1)[0]; + let decoded; + try { + decoded = decodeURIComponent(withoutQuery); + } catch { + throw new Error(`WebUI index contains an invalid asset URL: ${reference}`); + } + + const relative = decoded.replace(/^\/+/, '').replace(/^\.\//, ''); + if (!relative || !LOCAL_ENTRY_PATTERN.test(relative)) { + return null; + } + + const normalized = path.normalize(relative); + if (normalized === '..' || normalized.startsWith(`..${path.sep}`) || path.isAbsolute(normalized)) { + throw new Error(`WebUI index asset escapes the WebUI directory: ${reference}`); + } + return normalized; +}; + +export const extractWebuiEntryAssets = (indexHtml) => { + const entries = new Set(); + const attributePattern = /\b(?:src|href)\s*=\s*["']([^"']+)["']/gi; + for (const match of indexHtml.matchAll(attributePattern)) { + const entry = normalizeLocalAssetReference(match[1]); + if (entry) { + entries.add(entry); + } + } + return [...entries].sort(); +}; + +export const writeWebuiVersionMarker = async ({ webuiDir, coreVersion }) => { + const assetsDir = path.join(webuiDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile( + path.join(assetsDir, 'version'), + `${formatWebuiVersion(coreVersion)}\n`, + 'utf8', + ); +}; + +export const validateWebuiResources = async ({ webuiDir, expectedCoreVersion }) => { + const indexPath = path.join(webuiDir, 'index.html'); + if (!existsSync(indexPath)) { + throw new Error(`WebUI index is missing: ${indexPath}`); + } + + const markerPath = path.join(webuiDir, 'assets', 'version'); + if (!existsSync(markerPath)) { + throw new Error(`WebUI version marker is missing: ${markerPath}`); + } + + const [indexContent, marker] = await Promise.all([ + readFile(indexPath), + readFile(markerPath, 'utf8'), + ]); + const indexHtml = indexContent.toString('utf8'); + const webuiVersion = normalizeResourceVersion(marker); + const coreVersion = normalizeResourceVersion(expectedCoreVersion); + if (webuiVersion !== coreVersion) { + throw new Error( + `WebUI version mismatch: assets/version has ${marker.trim()}, expected v${coreVersion}.`, + ); + } + + const entryAssets = extractWebuiEntryAssets(indexHtml); + if (!entryAssets.some((entry) => entry.toLowerCase().endsWith('.js'))) { + throw new Error(`WebUI index does not reference a JavaScript entry: ${indexPath}`); + } + const entryDigests = []; + for (const entry of entryAssets) { + const entryPath = path.join(webuiDir, entry); + if (!existsSync(entryPath)) { + throw new Error(`WebUI index references a missing entry asset: ${entryPath}`); + } + entryDigests.push({ + path: entry.split(path.sep).join('/'), + sha256: sha256(await readFile(entryPath)), + }); + } + + return { + webuiVersion, + indexSha256: sha256(indexContent), + entryAssets, + entryDigests, + }; +}; + +const expectedWebuiAttestation = (webui) => ({ + version: webui.webuiVersion, + indexSha256: webui.indexSha256, + entryAssets: webui.entryDigests, +}); + +const normalizeWebuiAttestation = (attestation) => { + if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) { + throw new Error('Backend runtime manifest is missing the WebUI bundle attestation.'); + } + const version = normalizeResourceVersion(attestation.version); + const indexSha256 = typeof attestation.indexSha256 === 'string' + ? attestation.indexSha256.trim().toLowerCase() + : ''; + if (!SHA256_PATTERN.test(indexSha256)) { + throw new Error('Backend runtime manifest WebUI indexSha256 must be a SHA-256 digest.'); + } + if (!Array.isArray(attestation.entryAssets)) { + throw new Error('Backend runtime manifest WebUI entryAssets must be an array.'); + } + const entryAssets = attestation.entryAssets.map((entry) => { + const entryPath = typeof entry?.path === 'string' ? entry.path.trim() : ''; + const entrySha256 = typeof entry?.sha256 === 'string' + ? entry.sha256.trim().toLowerCase() + : ''; + if (!entryPath || !SHA256_PATTERN.test(entrySha256)) { + throw new Error('Backend runtime manifest contains an invalid WebUI entry digest.'); + } + return { path: entryPath, sha256: entrySha256 }; + }); + return { version, indexSha256, entryAssets }; +}; + +const validateWebuiAttestation = ({ manifest, webui, required }) => { + if (manifest.webui === undefined && !required) { + return; + } + const actual = normalizeWebuiAttestation(manifest.webui); + const expected = expectedWebuiAttestation(webui); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Backend runtime manifest WebUI bundle attestation does not match the prepared WebUI.'); + } +}; + +const validateRuntimeFileContainment = async ({ backendDir, relativePath, field }) => { + let backendRoot; + let resolvedFile; + try { + [backendRoot, resolvedFile] = await Promise.all([ + realpath(backendDir), + realpath(path.resolve(backendDir, relativePath)), + ]); + } catch (error) { + throw new Error( + `Backend runtime manifest ${field} file is missing or unreadable: ${relativePath} (${error instanceof Error ? error.message : String(error)})`, + ); + } + const relativeToBackend = path.relative(backendRoot, resolvedFile); + if ( + !relativeToBackend || + path.isAbsolute(relativeToBackend) || + relativeToBackend === '..' || + relativeToBackend.startsWith(`..${path.sep}`) + ) { + throw new Error( + `Backend runtime manifest ${field} resolves outside the backend directory: ${relativePath}`, + ); + } + if (!(await stat(resolvedFile)).isFile()) { + throw new Error(`Backend runtime manifest ${field} is not a file: ${relativePath}`); + } +}; + +export const validateBackendRuntimeIdentity = async ({ + backendDir, + expectedDesktopVersion = '', + expectedCoreVersion, + expectedSourceRef = '', + expectedSourceCommit = '', +}) => { + const manifestPath = path.join(backendDir, 'runtime-manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error(`Backend runtime manifest is missing: ${manifestPath}`); + } + + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + } catch (error) { + throw new Error( + `Backend runtime manifest is invalid: ${manifestPath} (${error instanceof Error ? error.message : String(error)})`, + ); + } + + const manifestCoreVersion = normalizeResourceVersion(manifest.coreVersion); + const runtimePython = requiredRuntimeRelativePath(manifest.python, 'python'); + const runtimeEntrypoint = requiredRuntimeRelativePath(manifest.entrypoint, 'entrypoint'); + await Promise.all([ + validateRuntimeFileContainment({ + backendDir, + relativePath: runtimePython, + field: 'python', + }), + validateRuntimeFileContainment({ + backendDir, + relativePath: runtimeEntrypoint, + field: 'entrypoint', + }), + ]); + const coreVersion = normalizeResourceVersion(expectedCoreVersion); + if (manifestCoreVersion !== coreVersion) { + throw new Error( + `Backend core version mismatch: runtime-manifest.json has ${manifest.coreVersion}, expected ${coreVersion}.`, + ); + } + if (expectedDesktopVersion) { + const manifestDesktopVersion = normalizeResourceVersion(manifest.desktopVersion); + const desktopVersion = normalizeResourceVersion(expectedDesktopVersion); + if (manifestDesktopVersion !== desktopVersion) { + throw new Error( + `Backend Desktop version mismatch: runtime-manifest.json has ${manifest.desktopVersion}, expected ${desktopVersion}.`, + ); + } + } + for (const field of ['sourceRef', 'sourceCommit']) { + if ( + !(field in manifest) || + (manifest[field] !== null && + (typeof manifest[field] !== 'string' || !manifest[field].trim())) + ) { + throw new Error(`Backend runtime manifest field ${field} must be a string or null.`); + } + } + if (expectedSourceRef && manifest.sourceRef !== expectedSourceRef) { + throw new Error( + `Backend source ref mismatch: runtime-manifest.json has ${manifest.sourceRef}, expected ${expectedSourceRef}.`, + ); + } + if (expectedSourceCommit && manifest.sourceCommit !== expectedSourceCommit) { + throw new Error( + `Backend source commit mismatch: runtime-manifest.json has ${manifest.sourceCommit}, expected ${expectedSourceCommit}.`, + ); + } + if (manifest.sourceCommit && !/^[0-9a-f]{40,64}$/i.test(manifest.sourceCommit)) { + throw new Error('Backend runtime manifest sourceCommit must be a full Git commit hash.'); + } + + return manifest; +}; + +export const validatePreparedResourceBundle = async ({ + projectRoot, + desktopVersion, + coreVersion, + sourceRepoRef = '', + sourceRepoCommit = '', + requireWebuiAttestation = false, +}) => { + const normalizedDesktopVersion = normalizeResourceVersion(desktopVersion); + const normalizedCoreVersion = normalizeResourceVersion(coreVersion); + const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')); + const packageVersion = normalizeResourceVersion(packageJson.version); + + if (packageVersion !== normalizedDesktopVersion) { + throw new Error( + `Desktop version mismatch: package.json has ${packageJson.version}, expected ${normalizedDesktopVersion}.`, + ); + } + if ( + requiresDesktopCoreMatch(normalizedDesktopVersion) && + normalizedDesktopVersion !== normalizedCoreVersion + ) { + throw new Error( + `Stable bundle version mismatch: Desktop is ${normalizedDesktopVersion}, but Core is ${normalizedCoreVersion}.`, + ); + } + + const webui = await validateWebuiResources({ + webuiDir: path.join(projectRoot, 'resources', 'webui'), + expectedCoreVersion: normalizedCoreVersion, + }); + const backend = await validateBackendRuntimeIdentity({ + backendDir: path.join(projectRoot, 'resources', 'backend'), + expectedDesktopVersion: normalizedDesktopVersion, + expectedCoreVersion: normalizedCoreVersion, + expectedSourceRef: sourceRepoRef, + expectedSourceCommit: sourceRepoCommit, + }); + validateWebuiAttestation({ + manifest: backend, + webui, + required: requireWebuiAttestation, + }); + + console.log( + `[prepare-resources] Verified resource identity: Desktop ${normalizedDesktopVersion}, Core ${normalizedCoreVersion}, WebUI v${webui.webuiVersion}.`, + ); + return { desktopVersion: normalizedDesktopVersion, coreVersion: normalizedCoreVersion, webui, backend }; +}; + +export const attestPreparedResourceBundle = async (options) => { + const identity = await validatePreparedResourceBundle({ + ...options, + requireWebuiAttestation: false, + }); + const manifestPath = path.join(options.projectRoot, 'resources', 'backend', 'runtime-manifest.json'); + const manifest = { + ...identity.backend, + webui: expectedWebuiAttestation(identity.webui), + }; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + return validatePreparedResourceBundle({ + ...options, + requireWebuiAttestation: true, + }); +}; diff --git a/scripts/prepare-resources/resource-identity.test.mjs b/scripts/prepare-resources/resource-identity.test.mjs new file mode 100644 index 00000000..4981e098 --- /dev/null +++ b/scripts/prepare-resources/resource-identity.test.mjs @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + attestPreparedResourceBundle, + extractWebuiEntryAssets, + formatWebuiVersion, + requiresDesktopCoreMatch, + validatePreparedResourceBundle, + validateWebuiResources, + writeWebuiVersionMarker, +} from './resource-identity.mjs'; + +const createBundleFixture = async ({ + desktopVersion = '4.27.4', + coreVersion = '4.27.4', +} = {}) => { + const projectRoot = await mkdtemp(path.join(os.tmpdir(), 'astrbot-resource-identity-')); + const webuiDir = path.join(projectRoot, 'resources', 'webui'); + const backendDir = path.join(projectRoot, 'resources', 'backend'); + const assetsDir = path.join(webuiDir, 'assets'); + const pythonDir = path.join(backendDir, 'python', 'bin'); + await mkdir(assetsDir, { recursive: true }); + await mkdir(pythonDir, { recursive: true }); + await writeFile( + path.join(projectRoot, 'package.json'), + `${JSON.stringify({ version: desktopVersion })}\n`, + 'utf8', + ); + await writeFile( + path.join(webuiDir, 'index.html'), + '' + + '', + 'utf8', + ); + await writeFile(path.join(assetsDir, 'index-a1.js'), 'export {};\n', 'utf8'); + await writeFile(path.join(assetsDir, 'index-b2.css'), 'body {}\n', 'utf8'); + await writeFile(path.join(pythonDir, 'python3'), '', 'utf8'); + await writeFile(path.join(backendDir, 'launch_backend.py'), '', 'utf8'); + await writeWebuiVersionMarker({ webuiDir, coreVersion }); + await writeFile( + path.join(backendDir, 'runtime-manifest.json'), + `${JSON.stringify({ + mode: 'cpython-runtime', + python: 'python/bin/python3', + entrypoint: 'launch_backend.py', + app: 'app', + desktopVersion, + coreVersion, + sourceRef: `v${coreVersion}`, + sourceCommit: 'a'.repeat(40), + })}\n`, + 'utf8', + ); + return { projectRoot, webuiDir, assetsDir, backendDir }; +}; + +test('formatWebuiVersion produces the marker expected by AstrBot Core', () => { + assert.equal(formatWebuiVersion('4.27.4'), 'v4.27.4'); + assert.equal(formatWebuiVersion('v4.27.4'), 'v4.27.4'); +}); + +test('requiresDesktopCoreMatch mirrors the runtime stable-version rule', () => { + assert.equal(requiresDesktopCoreMatch('4.27.5'), true); + assert.equal(requiresDesktopCoreMatch('4.27.5+rebuilt.1'), true); + assert.equal(requiresDesktopCoreMatch('4.27.5-nightly.20260901.abcdef12'), false); + assert.equal(requiresDesktopCoreMatch('not-semver'), true); + assert.equal(requiresDesktopCoreMatch('4.27.5-alpha..1'), true); + assert.equal(requiresDesktopCoreMatch('4.27.5-01'), true); +}); + +test('extractWebuiEntryAssets finds local JavaScript and CSS entries', () => { + const entries = extractWebuiEntryAssets( + '' + + '' + + '', + ); + + assert.deepEqual(entries, [path.join('assets', 'app.css'), path.join('assets', 'app.js')]); +}); + +test('validatePreparedResourceBundle accepts a matching stable bundle', async () => { + const fixture = await createBundleFixture(); + try { + const identity = await validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }); + + assert.equal(identity.webui.webuiVersion, '4.27.4'); + assert.equal(identity.backend.coreVersion, '4.27.4'); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('attestPreparedResourceBundle binds the runtime manifest to WebUI content', async () => { + const fixture = await createBundleFixture(); + try { + const options = { + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }; + const identity = await attestPreparedResourceBundle(options); + assert.equal(identity.backend.webui.version, '4.27.4'); + assert.match(identity.backend.webui.indexSha256, /^[0-9a-f]{64}$/); + assert.equal(identity.backend.webui.entryAssets.length, 2); + + await writeFile(path.join(fixture.assetsDir, 'index-a1.js'), 'export const stale = true;\n', 'utf8'); + await assert.rejects( + validatePreparedResourceBundle({ ...options, requireWebuiAttestation: true }), + /WebUI bundle attestation does not match/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validatePreparedResourceBundle rejects stable Desktop/Core drift without relying on a source tag', async () => { + const fixture = await createBundleFixture({ desktopVersion: '4.27.5' }); + try { + await assert.rejects( + validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.5', + coreVersion: '4.27.4', + sourceRepoRef: 'abcdef0123456789abcdef0123456789abcdef01', + sourceRepoCommit: 'a'.repeat(40), + }), + /Stable bundle version mismatch/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validatePreparedResourceBundle allows a derived nightly Desktop version', async () => { + const fixture = await createBundleFixture({ desktopVersion: '4.27.5-nightly.20260901.abcdef12' }); + try { + await validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.5-nightly.20260901.abcdef12', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validateWebuiResources rejects a missing index entry asset', async () => { + const fixture = await createBundleFixture(); + try { + await rm(path.join(fixture.assetsDir, 'index-a1.js')); + await assert.rejects( + validateWebuiResources({ + webuiDir: fixture.webuiDir, + expectedCoreVersion: '4.27.4', + }), + /missing entry asset/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validateWebuiResources rejects a stale version marker', async () => { + const fixture = await createBundleFixture(); + try { + await writeFile(path.join(fixture.assetsDir, 'version'), 'v4.27.0\n', 'utf8'); + await assert.rejects( + validateWebuiResources({ + webuiDir: fixture.webuiDir, + expectedCoreVersion: '4.27.4', + }), + /WebUI version mismatch/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validatePreparedResourceBundle rejects an escaping backend manifest path', async () => { + const fixture = await createBundleFixture(); + try { + const manifestPath = path.join(fixture.backendDir, 'runtime-manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.entrypoint = '../launch_backend.py'; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, 'utf8'); + + await assert.rejects( + validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }), + /entrypoint must be a canonical relative path/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test('validatePreparedResourceBundle rejects a missing backend runtime file', async () => { + const fixture = await createBundleFixture(); + try { + await rm(path.join(fixture.backendDir, 'launch_backend.py')); + await assert.rejects( + validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }), + /entrypoint file is missing or unreadable/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test( + 'validatePreparedResourceBundle rejects a backend runtime symlink escape', + { skip: process.platform === 'win32' }, + async () => { + const fixture = await createBundleFixture(); + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-runtime-outside-')); + try { + const manifestPath = path.join(fixture.backendDir, 'runtime-manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const outsideEntrypoint = path.join(outsideDir, 'outside.py'); + await writeFile(outsideEntrypoint, '', 'utf8'); + await rm(path.join(fixture.backendDir, 'launch_backend.py')); + await symlink(outsideEntrypoint, path.join(fixture.backendDir, 'launch_backend.py')); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, 'utf8'); + + await assert.rejects( + validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.27.4', + coreVersion: '4.27.4', + sourceRepoRef: 'v4.27.4', + sourceRepoCommit: 'a'.repeat(40), + }), + /entrypoint resolves outside the backend directory/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + } + }, +); diff --git a/scripts/prepare-resources/source-repo.mjs b/scripts/prepare-resources/source-repo.mjs index fca4d9fd..ea1a0511 100644 --- a/scripts/prepare-resources/source-repo.mjs +++ b/scripts/prepare-resources/source-repo.mjs @@ -65,6 +65,23 @@ export const resolveSourceDir = (projectRoot, sourceDirOverrideRaw, cwd = proces return path.join(projectRoot, 'vendor', 'AstrBot'); }; +export const resolveSourceRepoCommit = (sourceDir, spawn = spawnSync) => { + if (!existsSync(path.join(sourceDir, '.git'))) { + return ''; + } + + const result = spawn('git', ['-C', sourceDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + windowsHide: true, + }); + if (result.error || result.status !== 0) { + return ''; + } + + const commit = String(result.stdout || '').trim(); + return /^[0-9a-f]{40,64}$/i.test(commit) ? commit : ''; +}; + export const ensureSourceRepo = ({ sourceDir, sourceRepoUrl, diff --git a/scripts/prepare-resources/source-repo.test.mjs b/scripts/prepare-resources/source-repo.test.mjs index 3df4b942..1f1a60ef 100644 --- a/scripts/prepare-resources/source-repo.test.mjs +++ b/scripts/prepare-resources/source-repo.test.mjs @@ -1,10 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { getSourceRefInfo, normalizeSourceRepoConfig, resolveSourceDir, + resolveSourceRepoCommit, } from './source-repo.mjs'; test('normalizeSourceRepoConfig normalizes GitHub tree URL and infers ref', () => { @@ -45,8 +49,35 @@ test('getSourceRefInfo respects explicit commit hint env flag', () => { test('resolveSourceDir honors override and default project layout', () => { const resolvedOverride = resolveSourceDir('/project/root', './vendor/custom', '/work'); - assert.equal(resolvedOverride, '/work/vendor/custom'); + assert.equal(resolvedOverride, path.resolve('/work', 'vendor/custom')); const resolvedDefault = resolveSourceDir('/project/root', '', '/work'); - assert.equal(resolvedDefault, '/project/root/vendor/AstrBot'); + assert.equal(resolvedDefault, path.join('/project/root', 'vendor', 'AstrBot')); +}); + +test('resolveSourceRepoCommit returns the checked out commit', async () => { + const sourceDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-source-ref-')); + try { + await mkdir(path.join(sourceDir, '.git')); + const commit = 'a'.repeat(40); + const calls = []; + const spawn = (...args) => { + calls.push(args); + return { status: 0, stdout: `${commit}\n` }; + }; + + assert.equal(resolveSourceRepoCommit(sourceDir, spawn), commit); + assert.deepEqual(calls[0][1], ['-C', sourceDir, 'rev-parse', 'HEAD']); + } finally { + await rm(sourceDir, { recursive: true, force: true }); + } +}); + +test('resolveSourceRepoCommit tolerates sources without Git metadata', async () => { + const sourceDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-source-ref-')); + try { + assert.equal(resolveSourceRepoCommit(sourceDir), ''); + } finally { + await rm(sourceDir, { recursive: true, force: true }); + } }); diff --git a/scripts/prepare-resources/version-sync.mjs b/scripts/prepare-resources/version-sync.mjs index 57a2e082..ac3c9d6e 100644 --- a/scripts/prepare-resources/version-sync.mjs +++ b/scripts/prepare-resources/version-sync.mjs @@ -87,13 +87,16 @@ export const readAstrbotRuntimeVersion = async ({ sourceDir }) => { }; export const validateAstrbotRuntimeVersion = async ({ sourceDir, expectedVersion }) => { + if (!expectedVersion) { + throw new Error('Expected AstrBot Core version is required for runtime validation.'); + } const runtimeVersion = await readAstrbotRuntimeVersion({ sourceDir }); if (runtimeVersion === '0.0.0') { throw new Error( `AstrBot runtime VERSION resolved to 0.0.0 in ${sourceDir}. Use an AstrBot source ref that contains the static runtime VERSION fix.`, ); } - if (expectedVersion && runtimeVersion !== expectedVersion) { + if (runtimeVersion !== expectedVersion) { throw new Error( `AstrBot version mismatch in ${sourceDir}: pyproject.toml has ${expectedVersion}, but runtime VERSION is ${runtimeVersion}.`, ); diff --git a/scripts/prepare-resources/version-sync.test.mjs b/scripts/prepare-resources/version-sync.test.mjs index 21db59a5..2ef231d9 100644 --- a/scripts/prepare-resources/version-sync.test.mjs +++ b/scripts/prepare-resources/version-sync.test.mjs @@ -142,24 +142,15 @@ test('validateAstrbotRuntimeVersion rejects runtime version drift', async () => } }); -test('validateAstrbotRuntimeVersion allows drift when no expected version is supplied', async () => { +test('validateAstrbotRuntimeVersion requires the expected Core version', async () => { const tempDir = await createTempAstrBotSource({ pyprojectVersion: '4.26.0-beta.10', runtimeVersion: '4.26.0-beta.9', }); - try { - await validateAstrbotRuntimeVersion({ sourceDir: tempDir }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -}); - -test('validateAstrbotRuntimeVersion still rejects 0.0.0 when no expected version is supplied', async () => { - const tempDir = await createTempAstrBotSource({ runtimeVersion: '0.0.0' }); try { await assert.rejects( validateAstrbotRuntimeVersion({ sourceDir: tempDir }), - /runtime VERSION resolved to 0\.0\.0/, + /Expected AstrBot Core version is required/, ); } finally { await rm(tempDir, { recursive: true, force: true }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 11bb7490..6b597596 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -66,6 +66,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "shlex", "tauri", "tauri-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1dc6dd11..34edbdcf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ build = "build.rs" [build-dependencies] serde_json = "1.0" +sha2 = "0.10" tauri-build = { version = "2.0", features = [] } [dependencies] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ecc07d82..873652c1 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,12 +1,15 @@ use serde_json::Value; +use sha2::{Digest, Sha256}; use std::{ - fs, + env, fs, path::{Component, Path}, }; const TAURI_CONFIG_PATH: &str = "tauri.conf.json"; const BACKEND_RESOURCE_SOURCE: &str = "../resources/backend"; const WEBUI_RESOURCE_SOURCE: &str = "../resources/webui"; +const RUNTIME_MANIFEST_RELATIVE_PATH: &str = "../resources/backend/runtime-manifest.json"; +const DEVELOPMENT_UNBOUND_MANIFEST: &str = "development-unbound"; fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) -> String { // Keep validation rules aligned with @@ -60,6 +63,31 @@ fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) alias.to_string() } +fn runtime_manifest_sha256() -> String { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .expect("Cargo did not provide CARGO_MANIFEST_DIR to build.rs"); + let manifest_path = Path::new(&manifest_dir).join(RUNTIME_MANIFEST_RELATIVE_PATH); + println!("cargo:rerun-if-changed={}", manifest_path.display()); + match fs::read(&manifest_path) { + Ok(bytes) if !bytes.is_empty() => format!("{:x}", Sha256::digest(bytes)), + Ok(_) => panic!( + "packaged runtime manifest is empty: {}", + manifest_path.display() + ), + Err(error) if env::var("PROFILE").as_deref() != Ok("release") => { + println!( + "cargo:warning=packaged runtime manifest is unavailable in a development build: {} ({error})", + manifest_path.display() + ); + DEVELOPMENT_UNBOUND_MANIFEST.to_string() + } + Err(error) => panic!( + "failed to read packaged runtime manifest {} before release compilation: {error}", + manifest_path.display() + ), + } +} + fn main() { let marker_path = Path::new("windows").join("portable-runtime-marker.txt"); let tauri_config_path = Path::new(TAURI_CONFIG_PATH); @@ -85,6 +113,10 @@ fn main() { let webui_resource_alias = load_bundle_resource_alias(&tauri_config, WEBUI_RESOURCE_SOURCE); println!("cargo:rustc-env=ASTRBOT_BACKEND_RESOURCE_ALIAS={backend_resource_alias}"); println!("cargo:rustc-env=ASTRBOT_WEBUI_RESOURCE_ALIAS={webui_resource_alias}"); + println!( + "cargo:rustc-env=ASTRBOT_RUNTIME_MANIFEST_SHA256={}", + runtime_manifest_sha256() + ); tauri_build::build() } From b95affaf323606fca5199557769facca5c3e9063 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 04:17:16 +0800 Subject: [PATCH 02/11] fix(runtime): keep packaged Core and WebUI coherent --- docs/environment-variables.md | 2 +- .../startup-shell-copy.test.mjs | 15 + src-tauri/Cargo.toml | 1 + src-tauri/src/app_helpers.rs | 14 +- src-tauri/src/app_types.rs | 21 + src-tauri/src/backend/http.rs | 2 + src-tauri/src/backend/http_response.rs | 66 +- src-tauri/src/backend/readiness.rs | 372 ++++++- src-tauri/src/backend/restart.rs | 7 + src-tauri/src/bridge/commands.rs | 15 +- src-tauri/src/launch_plan.rs | 906 ++++++++++++++++-- src-tauri/src/main.rs | 2 +- src-tauri/src/runtime_paths.rs | 75 +- src-tauri/src/startup_task.rs | 9 +- src-tauri/src/ui_dispatch.rs | 52 +- src-tauri/src/window/main_window.rs | 67 +- ui/index.html | 43 +- 17 files changed, 1525 insertions(+), 144 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 69a85a0b..61027b47 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -18,7 +18,7 @@ | `ASTRBOT_BRIDGE_BACKEND_PING_TIMEOUT_MS` | 桥接层 ping 超时 | 默认回退到 `ASTRBOT_BACKEND_PING_TIMEOUT_MS` | | `ASTRBOT_BACKEND_CMD` | 后端启动命令覆盖 | 未设置则按 launch plan 推导 | | `ASTRBOT_BACKEND_CWD` | 后端工作目录覆盖 | 未设置则按 launch plan 推导 | -| `ASTRBOT_WEBUI_DIR` | WebUI 目录覆盖 | 未设置则按资源目录推导 | +| `ASTRBOT_WEBUI_DIR` | 自定义/开发启动时的 WebUI 目录覆盖 | 打包版忽略该变量,以保证 Core 与 WebUI 来自同一已校验资源包 | | `ASTRBOT_ROOT` | AstrBot 根目录 | 未设置则按打包/临时目录回退 | | `ASTRBOT_DASHBOARD_HOST` | 后端读取的 dashboard host 变量 | 若 `DASHBOARD_HOST` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_HOST=127.0.0.1` | | `ASTRBOT_DASHBOARD_PORT` | 后端读取的 dashboard port 变量 | 若 `DASHBOARD_PORT` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_PORT=6185` | diff --git a/scripts/prepare-resources/startup-shell-copy.test.mjs b/scripts/prepare-resources/startup-shell-copy.test.mjs index 1939aeee..e0dbd20d 100644 --- a/scripts/prepare-resources/startup-shell-copy.test.mjs +++ b/scripts/prepare-resources/startup-shell-copy.test.mjs @@ -72,6 +72,21 @@ test('startup shell loads shared copy config, reuses applyStartupMode, and expos /if\s*\(status\.textContent\s*===\s*next\.status\)\s*return;/, 'expected startup shell to skip duplicate status announcements', ); + assert.match( + source, + /window\.__astrbotShowStartupError\s*=\s*\(message\)\s*=>/, + 'expected startup failures to be rendered in the visible startup shell', + ); + assert.match( + source, + /typeof\s+window\.__astrbotPendingStartupError\s*===\s*["']string["']/, + 'expected failures dispatched before page load to be rendered after initialization', + ); + assert.match( + source, + /panel\.classList\.add\(["']error["']\)/, + 'expected startup failures to switch the shell into its error state', + ); assert.match( configSource, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 34edbdcf..f87a3e24 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ home = "0.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" semver = "1.0" +sha2 = "0.10" shlex = "1.3" tauri = { version = "2.0", features = ["tray-icon"] } tauri-plugin-autostart = "2.0" diff --git a/src-tauri/src/app_helpers.rs b/src-tauri/src/app_helpers.rs index 99ea83c2..2a83b2bd 100644 --- a/src-tauri/src/app_helpers.rs +++ b/src-tauri/src/app_helpers.rs @@ -13,9 +13,16 @@ use crate::{ static DESKTOP_LOG_WRITE_LOCK: OnceLock> = OnceLock::new(); static BACKEND_PATH_OVERRIDE: OnceLock> = OnceLock::new(); -pub(crate) fn navigate_main_window_to_backend(app_handle: &AppHandle) -> Result<(), String> { +pub(crate) fn navigate_main_window_to_backend( + app_handle: &AppHandle, + cache_version: Option<&str>, +) -> Result<(), String> { let state = app_handle.state::(); - window::main_window::navigate_main_window_to_backend(app_handle, &state.backend_url) + window::main_window::navigate_main_window_to_backend( + app_handle, + &state.backend_url, + cache_version, + ) } pub(crate) fn inject_desktop_bridge(webview: &tauri::Webview) { @@ -79,6 +86,9 @@ mod tests { cwd: PathBuf::from("."), root_dir: None, webui_dir: None, + webui_cache_version: None, + packaged_core_version: None, + packaged_webui_index_sha256: None, startup_heartbeat_path: None, packaged_mode: false, }; diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs index 6f187ea5..832797f1 100644 --- a/src-tauri/src/app_types.rs +++ b/src-tauri/src/app_types.rs @@ -24,9 +24,27 @@ pub(crate) struct TrayMenuState { } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub(crate) struct RuntimeManifest { pub(crate) python: Option, pub(crate) entrypoint: Option, + pub(crate) desktop_version: Option, + pub(crate) core_version: Option, + pub(crate) webui: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeWebuiAttestation { + pub(crate) version: String, + pub(crate) index_sha256: String, + pub(crate) entry_assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RuntimeWebuiEntryDigest { + pub(crate) path: String, + pub(crate) sha256: String, } #[derive(Debug)] @@ -36,6 +54,9 @@ pub(crate) struct LaunchPlan { pub(crate) cwd: PathBuf, pub(crate) root_dir: Option, pub(crate) webui_dir: Option, + pub(crate) webui_cache_version: Option, + pub(crate) packaged_core_version: Option, + pub(crate) packaged_webui_index_sha256: Option, pub(crate) startup_heartbeat_path: Option, pub(crate) packaged_mode: bool, } diff --git a/src-tauri/src/backend/http.rs b/src-tauri/src/backend/http.rs index 7669a1d0..69c68a48 100644 --- a/src-tauri/src/backend/http.rs +++ b/src-tauri/src/backend/http.rs @@ -113,6 +113,8 @@ impl BackendState { Host: {host}\r\n\ Accept: application/json\r\n\ Accept-Encoding: identity\r\n\ +Cache-Control: no-cache\r\n\ +Pragma: no-cache\r\n\ Connection: close\r\n\ {authorization_header}\ {desktop_session_header}\ diff --git a/src-tauri/src/backend/http_response.rs b/src-tauri/src/backend/http_response.rs index 59cd8ad3..9d641895 100644 --- a/src-tauri/src/backend/http_response.rs +++ b/src-tauri/src/backend/http_response.rs @@ -1,23 +1,54 @@ use std::borrow::Cow; pub fn parse_http_json_response(raw: &[u8]) -> Option { + let payload = parse_http_success_body(raw)?; + serde_json::from_slice(&payload).ok() +} + +pub fn parse_http_success_body(raw: &[u8]) -> Option> { let (header_text, body_bytes) = parse_http_response_parts(raw)?; let status_code = parse_http_status_code_from_headers(&header_text)?; if !(200..300).contains(&status_code) { return None; } - let is_chunked = header_text.lines().any(|line| { - let line = line.trim().to_ascii_lowercase(); - line.starts_with("transfer-encoding:") && line.contains("chunked") - }); - let payload = if is_chunked { - decode_chunked_body(body_bytes)? - } else { - body_bytes.to_vec() - }; + let mut is_chunked = false; + let mut content_length = None; + for line in header_text.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim(); + let value = value.trim(); + if name.eq_ignore_ascii_case("transfer-encoding") + && value + .split(',') + .any(|encoding| encoding.trim().eq_ignore_ascii_case("chunked")) + { + is_chunked = true; + } + if name.eq_ignore_ascii_case("content-encoding") + && !value.is_empty() + && !value.eq_ignore_ascii_case("identity") + { + // The caller requests identity encoding so a content digest can be + // compared with the exact packaged file bytes. Fail closed if an + // intermediary ignores that request. + return None; + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some(value.parse::().ok()?); + } + } - serde_json::from_slice(&payload).ok() + if is_chunked { + return decode_chunked_body(body_bytes); + } + match content_length { + Some(length) if body_bytes.len() >= length => Some(body_bytes[..length].to_vec()), + Some(_) => None, + None => Some(body_bytes.to_vec()), + } } pub fn parse_http_status_code(raw: &[u8]) -> Option { @@ -103,6 +134,21 @@ mod tests { assert_eq!(parsed["ok"], json!(true)); } + #[test] + fn parse_http_success_body_returns_exact_identity_encoded_payload() { + let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Encoding: identity\r\n\r\nindexignored"; + assert_eq!(parse_http_success_body(raw), Some(b"index".to_vec())); + } + + #[test] + fn parse_http_success_body_rejects_encoded_or_incomplete_payloads() { + let encoded = b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\ncompressed"; + assert_eq!(parse_http_success_body(encoded), None); + + let incomplete = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nabc"; + assert_eq!(parse_http_success_body(incomplete), None); + } + #[test] fn parse_http_json_response_rejects_non_success_status() { let raw = b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}"; diff --git a/src-tauri/src/backend/readiness.rs b/src-tauri/src/backend/readiness.rs index dc49725f..444d54da 100644 --- a/src-tauri/src/backend/readiness.rs +++ b/src-tauri/src/backend/readiness.rs @@ -5,6 +5,7 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +use sha2::{Digest, Sha256}; use tauri::AppHandle; use crate::{ @@ -12,16 +13,108 @@ use crate::{ PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS, }; +const BACKEND_RESOURCE_VERSIONS_PATH: &str = "/api/v1/stats/versions"; +const BACKEND_WEBUI_INDEX_PATH: &str = "/index.html"; + +#[derive(Debug, PartialEq, Eq)] +struct RunningResourceVersions { + core: String, + code: String, + webui: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum RunningResourceIdentityError { + Unavailable(String), + Mismatch(String), +} + +impl RunningResourceIdentityError { + fn into_message(self) -> String { + match self { + Self::Unavailable(message) | Self::Mismatch(message) => message, + } + } +} + +fn normalized_running_version(value: &serde_json::Value, field: &str) -> Result { + let raw = value + .as_str() + .ok_or_else(|| format!("running backend version field {field} is missing"))?; + let trimmed = raw.trim(); + let normalized = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed); + if normalized.is_empty() { + return Err(format!("running backend version field {field} is empty")); + } + Ok(normalized.to_string()) +} + +fn parse_running_resource_versions( + payload: &serde_json::Value, +) -> Result { + if payload.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return Err("running backend versions endpoint did not return status=ok".to_string()); + } + let data = payload + .get("data") + .ok_or_else(|| "running backend versions response is missing data".to_string())?; + Ok(RunningResourceVersions { + core: normalized_running_version(&data["astrbot_version"], "astrbot_version")?, + code: normalized_running_version(&data["astrbot_code_version"], "astrbot_code_version")?, + webui: normalized_running_version(&data["webui_version"], "webui_version")?, + }) +} + +fn validate_running_resource_versions( + expected_core_version: &str, + versions: &RunningResourceVersions, +) -> Result<(), String> { + if versions.core == expected_core_version + && versions.code == expected_core_version + && versions.webui == expected_core_version + { + return Ok(()); + } + Err(format!( + "A different or stale AstrBot backend is already serving the Desktop port: expected Core/WebUI {}, got running Core {}, code {}, WebUI {}. Close the stale backend process, then restart AstrBot Desktop.", + expected_core_version, versions.core, versions.code, versions.webui + )) +} + +fn validate_running_webui_index( + expected_index_sha256: &str, + running_index_sha256: &str, +) -> Result<(), String> { + if running_index_sha256 == expected_index_sha256 { + return Ok(()); + } + Err(format!( + "A different or stale AstrBot WebUI is already serving the Desktop port: expected index SHA-256 {expected_index_sha256}, got {running_index_sha256}. Close the stale backend process, then restart AstrBot Desktop." + )) +} + impl BackendState { - pub(crate) fn ensure_backend_ready(&self, app: &AppHandle) -> Result<(), String> { - if self.ping_backend(backend::runtime::backend_ping_timeout_ms( - append_desktop_log, - )) { + pub(crate) fn ensure_backend_ready(&self, app: &AppHandle) -> Result, String> { + let auto_start_enabled = + env::var("ASTRBOT_BACKEND_AUTO_START").unwrap_or_else(|_| "1".to_string()) != "0"; + let ping_timeout_ms = backend::runtime::backend_ping_timeout_ms(append_desktop_log); + if self.ping_backend(ping_timeout_ms) { + if !auto_start_enabled { + append_desktop_log( + "backend already reachable with auto-start disabled; using external backend without packaged resource identity enforcement", + ); + return Ok(None); + } append_desktop_log("backend already reachable, skip spawn"); - return Ok(()); + let plan = self.resolve_launch_plan(app)?; + self.verify_running_resource_identity(&plan, ping_timeout_ms.max(1_000))?; + return Ok(plan.webui_cache_version); } - if env::var("ASTRBOT_BACKEND_AUTO_START").unwrap_or_else(|_| "1".to_string()) == "0" { + if !auto_start_enabled { append_desktop_log("backend auto-start disabled by ASTRBOT_BACKEND_AUTO_START=0"); return Err( "Backend auto-start is disabled (ASTRBOT_BACKEND_AUTO_START=0).".to_string(), @@ -32,7 +125,8 @@ impl BackendState { .ok_or_else(|| "Backend action already in progress.".to_string())?; let plan = self.resolve_launch_plan(app)?; self.start_backend_process(app, &plan)?; - self.wait_for_backend(&plan) + self.wait_for_backend(&plan)?; + Ok(plan.webui_cache_version) } pub(crate) fn wait_for_backend(&self, plan: &crate::LaunchPlan) -> Result<(), String> { @@ -47,13 +141,27 @@ impl BackendState { let start_time = Instant::now(); let mut tcp_ready_logged = false; let mut ever_tcp_reachable = false; + let mut last_identity_unavailable = None; let mut startup_heartbeat_state = StartupHeartbeatTracker::new(); loop { let (http_status, tcp_reachable) = self.probe_backend_readiness(&readiness.path, readiness.probe_timeout_ms); if matches!(http_status, Some(status_code) if (200..400).contains(&status_code)) { - return Ok(()); + match self + .check_running_resource_identity(plan, readiness.probe_timeout_ms.max(1_000)) + { + Ok(()) => return Ok(()), + Err(RunningResourceIdentityError::Mismatch(message)) => return Err(message), + Err(RunningResourceIdentityError::Unavailable(message)) => { + if last_identity_unavailable.as_deref() != Some(message.as_str()) { + append_desktop_log(&format!( + "backend HTTP dashboard is ready but packaged resource identity is not readable yet; waiting: {message}" + )); + } + last_identity_unavailable = Some(message); + } + } } let wall_now = SystemTime::now(); let monotonic_now = Instant::now(); @@ -91,9 +199,14 @@ impl BackendState { ever_tcp_reachable, startup_heartbeat_state.last_seen_at, ); + let identity_detail = last_identity_unavailable + .as_deref() + .map(|message| format!(" Last identity check error: {message}")) + .unwrap_or_default(); return Err(format!( - "Timed out after {}ms waiting for backend startup.", - limit.as_millis() + "Timed out after {}ms waiting for backend startup.{}", + limit.as_millis(), + identity_detail )); } } @@ -102,6 +215,69 @@ impl BackendState { } } + pub(crate) fn verify_running_resource_identity( + &self, + plan: &crate::LaunchPlan, + timeout_ms: u64, + ) -> Result<(), String> { + self.check_running_resource_identity(plan, timeout_ms) + .map_err(RunningResourceIdentityError::into_message) + } + + fn check_running_resource_identity( + &self, + plan: &crate::LaunchPlan, + timeout_ms: u64, + ) -> Result<(), RunningResourceIdentityError> { + let Some(expected_core_version) = plan.packaged_core_version.as_deref() else { + return Ok(()); + }; + let expected_index_sha256 = plan + .packaged_webui_index_sha256 + .as_deref() + .ok_or_else(|| { + RunningResourceIdentityError::Mismatch( + "Packaged launch plan is missing the expected WebUI index digest. Run the Desktop update again or reinstall AstrBot." + .to_string(), + ) + })?; + let payload = self + .request_backend_json( + "GET", + BACKEND_RESOURCE_VERSIONS_PATH, + timeout_ms, + None, + None, + ) + .ok_or_else(|| { + RunningResourceIdentityError::Unavailable(format!( + "Cannot verify the running AstrBot Core/WebUI identity at {BACKEND_RESOURCE_VERSIONS_PATH}. Close any stale backend process, then restart AstrBot Desktop." + )) + })?; + let versions = parse_running_resource_versions(&payload) + .map_err(RunningResourceIdentityError::Mismatch)?; + validate_running_resource_versions(expected_core_version, &versions) + .map_err(RunningResourceIdentityError::Mismatch)?; + + let index_body = self + .request_backend_with( + "GET", + BACKEND_WEBUI_INDEX_PATH, + timeout_ms, + None, + None, + backend::http_response::parse_http_success_body, + ) + .ok_or_else(|| { + RunningResourceIdentityError::Unavailable(format!( + "Cannot read the running AstrBot WebUI entry document at {BACKEND_WEBUI_INDEX_PATH}." + )) + })?; + let running_index_sha256 = format!("{:x}", Sha256::digest(&index_body)); + validate_running_webui_index(expected_index_sha256, &running_index_sha256) + .map_err(RunningResourceIdentityError::Mismatch) + } + fn probe_backend_readiness( &self, ready_http_path: &str, @@ -308,12 +484,186 @@ fn step_startup_heartbeat( #[cfg(test)] mod tests { - use std::time::{Duration, Instant, UNIX_EPOCH}; + use std::{ + io::{Read, Write}, + net::TcpListener, + path::PathBuf, + thread, + time::{Duration, Instant, UNIX_EPOCH}, + }; use tempfile::TempDir; use super::*; + fn sha256_hex(payload: &[u8]) -> String { + format!("{:x}", Sha256::digest(payload)) + } + + fn packaged_plan(core_version: &str, index_sha256: &str) -> crate::LaunchPlan { + crate::LaunchPlan { + cmd: "python".to_string(), + args: Vec::new(), + cwd: PathBuf::from("."), + root_dir: None, + webui_dir: None, + webui_cache_version: None, + packaged_core_version: Some(core_version.to_string()), + packaged_webui_index_sha256: Some(index_sha256.to_string()), + startup_heartbeat_path: None, + packaged_mode: true, + } + } + + fn spawn_identity_server(index_body: Vec) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind identity server"); + let address = listener.local_addr().expect("read identity server address"); + let versions_body = br#"{"status":"ok","data":{"astrbot_version":"4.27.5","astrbot_code_version":"4.27.5","webui_version":"4.27.5"}}"#.to_vec(); + let responses = [ + ( + BACKEND_RESOURCE_VERSIONS_PATH, + "application/json", + versions_body, + ), + (BACKEND_WEBUI_INDEX_PATH, "text/html", index_body), + ]; + let handle = thread::spawn(move || { + for (expected_path, content_type, body) in responses { + let (mut stream, _) = listener.accept().expect("accept identity request"); + let mut request_bytes = [0_u8; 4096]; + let read = stream + .read(&mut request_bytes) + .expect("read identity request"); + let request = String::from_utf8_lossy(&request_bytes[..read]); + assert!( + request.starts_with(&format!("GET {expected_path} HTTP/1.1\r\n")), + "unexpected request: {request}" + ); + assert!(request.contains("Accept-Encoding: identity\r\n")); + assert!(request.contains("Cache-Control: no-cache\r\n")); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(headers.as_bytes()) + .expect("write identity headers"); + stream.write_all(&body).expect("write identity body"); + } + }); + (format!("http://{address}/"), handle) + } + + #[test] + fn running_resource_versions_accept_matching_core_code_and_webui() { + let payload = serde_json::json!({ + "status": "ok", + "data": { + "astrbot_version": "4.27.5", + "astrbot_code_version": "4.27.5", + "webui_version": "v4.27.5", + }, + }); + + let versions = + parse_running_resource_versions(&payload).expect("parse matching running versions"); + + assert_eq!( + versions, + RunningResourceVersions { + core: "4.27.5".to_string(), + code: "4.27.5".to_string(), + webui: "4.27.5".to_string(), + } + ); + assert_eq!( + validate_running_resource_versions("4.27.5", &versions), + Ok(()) + ); + } + + #[test] + fn running_resource_versions_reject_a_stale_backend_or_webui() { + let versions = RunningResourceVersions { + core: "4.27.0".to_string(), + code: "4.27.5".to_string(), + webui: "4.27.0".to_string(), + }; + + let error = validate_running_resource_versions("4.27.5", &versions) + .expect_err("stale live resources must fail"); + + assert!(error.contains("expected Core/WebUI 4.27.5")); + assert!(error.contains("running Core 4.27.0, code 4.27.5, WebUI 4.27.0")); + assert!(error.contains("Close the stale backend process")); + } + + #[test] + fn running_resource_versions_require_all_public_version_fields() { + let payload = serde_json::json!({ + "status": "ok", + "data": { + "astrbot_version": "4.27.5", + "webui_version": "v4.27.5", + }, + }); + + let error = parse_running_resource_versions(&payload) + .expect_err("missing code version must fail closed"); + + assert!(error.contains("astrbot_code_version is missing")); + } + + #[test] + fn running_webui_index_requires_an_exact_content_digest() { + let expected = sha256_hex(b"new index"); + let stale = sha256_hex(b"old index"); + + assert_eq!(validate_running_webui_index(&expected, &expected), Ok(())); + let error = validate_running_webui_index(&expected, &stale) + .expect_err("same-version stale WebUI content must fail"); + assert!(error.contains(&expected)); + assert!(error.contains(&stale)); + assert!(error.contains("Close the stale backend process")); + } + + #[test] + fn live_identity_check_accepts_the_exact_served_index_document() { + let index_body = b"current".to_vec(); + let expected_digest = sha256_hex(&index_body); + let (backend_url, server) = spawn_identity_server(index_body); + let state = BackendState { + backend_url, + ..BackendState::default() + }; + + assert_eq!( + state.verify_running_resource_identity( + &packaged_plan("4.27.5", &expected_digest), + 1_000, + ), + Ok(()) + ); + server.join().expect("identity server should finish"); + } + + #[test] + fn live_identity_check_rejects_same_version_stale_index_content() { + let stale_index = b"stale".to_vec(); + let expected_digest = sha256_hex(b"current"); + let (backend_url, server) = spawn_identity_server(stale_index); + let state = BackendState { + backend_url, + ..BackendState::default() + }; + + let error = state + .verify_running_resource_identity(&packaged_plan("4.27.5", &expected_digest), 1_000) + .expect_err("same-version stale index must be rejected"); + assert!(error.contains("stale AstrBot WebUI")); + server.join().expect("identity server should finish"); + } + #[test] fn startup_heartbeat_progress_is_fresh_for_recent_instant() { assert!(startup_heartbeat_progress_is_fresh( diff --git a/src-tauri/src/backend/restart.rs b/src-tauri/src/backend/restart.rs index 7372d6f9..30c741e0 100644 --- a/src-tauri/src/backend/restart.rs +++ b/src-tauri/src/backend/restart.rs @@ -279,6 +279,10 @@ impl BackendState { Ok(()) if strategy != backend::restart_strategy::RestartStrategy::ManagedSkipGraceful => { + self.verify_running_resource_identity( + &plan, + backend::runtime::backend_ping_timeout_ms(append_desktop_log).max(1_000), + )?; return Ok(()); } Ok(()) => {} @@ -340,6 +344,9 @@ mod tests { cwd: std::path::PathBuf::from("."), root_dir: None, webui_dir: None, + webui_cache_version: None, + packaged_core_version: None, + packaged_webui_index_sha256: None, startup_heartbeat_path: None, packaged_mode: true, }; diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index 64d5bb21..8b6d343b 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -136,11 +136,13 @@ where fn build_restart_backend_after_failed_install( app_handle: AppHandle, - restart_plan: crate::LaunchPlan, ) -> impl FnOnce() -> Result<(), String> { move || { let state = app_handle.state::(); - append_desktop_log("update install failed before exit, restarting managed backend"); + append_desktop_log( + "update install failed before exit, revalidating resources before restarting managed backend", + ); + let restart_plan = state.resolve_launch_plan(&app_handle)?; state.start_backend_process(&app_handle, &restart_plan)?; state.wait_for_backend(&restart_plan) } @@ -376,7 +378,7 @@ pub(crate) fn desktop_bridge_set_app_update_channel( let packaged_root_dir = runtime_paths::default_packaged_root_dir(); match update_channel::write_cached_update_channel(Some(channel), packaged_root_dir.as_deref()) { Ok(()) => { - append_desktop_log(&format!("update channel set to {:?}", channel)); + append_desktop_log(&format!("update channel set to {channel:?}")); let _ = app_handle; map_update_channel_ok(channel) } @@ -459,18 +461,17 @@ pub(crate) async fn desktop_bridge_install_app_update( let state = app_handle.state::(); let stop_managed_backend = cfg!(target_os = "windows") && has_managed_backend_child(&state); let restart_backend_after_failed_install = if stop_managed_backend { - let restart_plan = match state.resolve_launch_plan(&app_handle) { - Ok(plan) => plan, + match state.resolve_launch_plan(&app_handle) { + Ok(_) => {} Err(error) => { append_desktop_log(&format!( "failed to resolve managed backend relaunch plan for update install recovery: {error}" )); return map_update_install_error(error); } - }; + } Some(build_restart_backend_after_failed_install( app_handle.clone(), - restart_plan, )) } else { None diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index 8069ce98..e93667e4 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -1,17 +1,373 @@ use std::{ + collections::HashSet, env, fs, - path::{Path, PathBuf}, + io::Read, + path::{Component, Path, PathBuf}, }; +use sha2::{Digest, Sha256}; use tauri::AppHandle; -use crate::{backend, packaged_webui, runtime_paths, LaunchPlan, RuntimeManifest}; +use crate::{ + backend, packaged_webui, + runtime_paths::{self, PackagedResourceLocation}, + LaunchPlan, RuntimeManifest, RuntimeWebuiAttestation, +}; const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); -fn build_packaged_resource_relative_path(resource_alias: &str, leaf_name: &str) -> PathBuf { - PathBuf::from(resource_alias).join(leaf_name) +#[derive(Debug)] +struct PackagedResourceCandidate { + label: &'static str, + backend_dir: PathBuf, + webui_dir: PathBuf, +} + +#[derive(Debug)] +struct PackagedResourceFailure { + label: &'static str, + reason: String, +} + +#[derive(Debug)] +struct ResolvedPackagedResources { + label: &'static str, + core_version: String, + python_path: PathBuf, + launch_script_path: PathBuf, + webui_dir: PathBuf, + webui_index_sha256: String, + rejected: Vec, +} + +fn resolve_packaged_resource_candidate( + app: &AppHandle, + location: PackagedResourceLocation, +) -> Result { + let label = location.label(); + let backend_dir = + runtime_paths::resolve_packaged_resource_path(app, location, BACKEND_RESOURCE_ALIAS) + .map_err(|reason| PackagedResourceFailure { label, reason })?; + let webui_dir = + runtime_paths::resolve_packaged_resource_path(app, location, WEBUI_RESOURCE_ALIAS) + .map_err(|reason| PackagedResourceFailure { label, reason })?; + + Ok(PackagedResourceCandidate { + label, + backend_dir, + webui_dir, + }) +} + +fn normalize_resource_version(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + let normalized = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed); + if normalized.is_empty() { + return Err(format!("{field} is empty")); + } + Ok(normalized.to_string()) +} + +fn required_manifest_version(value: Option<&str>, field: &str) -> Result { + let value = value.ok_or_else(|| format!("runtime-manifest.json is missing {field}"))?; + normalize_resource_version(value, &format!("runtime-manifest.json {field}")) +} + +fn packaged_webui_cache_version( + desktop_version: &str, + core_version: &str, + index_sha256: &str, +) -> String { + let digest_prefix = &index_sha256[..16]; + format!("desktop-{desktop_version}-core-{core_version}-webui-{digest_prefix}") +} + +fn normalize_sha256(value: &str, field: &str) -> Result { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.len() != 64 || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("{field} must be a SHA-256 digest")); + } + Ok(normalized) +} + +fn resolve_manifest_file( + root: &Path, + relative_path: &Path, + field: &str, + description: &str, +) -> Result { + if relative_path.as_os_str().is_empty() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!( + "runtime-manifest.json {field} must be a canonical relative path inside {}", + root.display() + )); + } + let candidate = root.join(relative_path); + if !candidate.is_file() { + return Err(format!("{description} is missing: {}", candidate.display())); + } + let canonical_root = fs::canonicalize(root).map_err(|error| { + format!( + "cannot resolve packaged resource directory {}: {}", + root.display(), + error + ) + })?; + let canonical_candidate = fs::canonicalize(&candidate).map_err(|error| { + format!( + "cannot resolve packaged resource file {}: {}", + candidate.display(), + error + ) + })?; + if !canonical_candidate.starts_with(&canonical_root) { + return Err(format!( + "runtime-manifest.json {field} escapes packaged resource directory {}", + root.display() + )); + } + Ok(canonical_candidate) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path) + .map_err(|error| format!("cannot open {} for hashing: {}", path.display(), error))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(|error| format!("cannot hash {}: {}", path.display(), error))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn validate_webui_attestation( + webui_dir: &Path, + core_version: &str, + attestation: &RuntimeWebuiAttestation, +) -> Result { + let attested_version = + normalize_resource_version(&attestation.version, "WebUI bundle attestation version")?; + if attested_version != core_version { + return Err(format!( + "Core/WebUI attestation version mismatch: Core is {core_version}, attestation is {attested_version}" + )); + } + + let webui_index = resolve_manifest_file( + webui_dir, + Path::new("index.html"), + "webui.index", + "WebUI index", + )?; + let expected_index_sha256 = normalize_sha256( + &attestation.index_sha256, + "runtime-manifest.json webui.indexSha256", + )?; + let actual_index_sha256 = sha256_file(&webui_index)?; + if actual_index_sha256 != expected_index_sha256 { + return Err(format!( + "WebUI index digest mismatch: expected {expected_index_sha256}, got {actual_index_sha256}" + )); + } + + let mut seen_entries = HashSet::new(); + let mut has_javascript_entry = false; + for entry in &attestation.entry_assets { + let relative = PathBuf::from(entry.path.trim()); + if !seen_entries.insert(relative.clone()) { + return Err(format!( + "runtime-manifest.json contains duplicate WebUI entry asset: {}", + entry.path + )); + } + let extension = relative + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if extension.eq_ignore_ascii_case("js") { + has_javascript_entry = true; + } else if !extension.eq_ignore_ascii_case("css") { + return Err(format!( + "runtime-manifest.json contains unsupported WebUI entry asset: {}", + entry.path + )); + } + let entry_path = resolve_manifest_file( + webui_dir, + &relative, + "webui.entryAssets[].path", + "WebUI entry asset", + )?; + let expected_sha256 = normalize_sha256( + &entry.sha256, + "runtime-manifest.json webui.entryAssets[].sha256", + )?; + let actual_sha256 = sha256_file(&entry_path)?; + if actual_sha256 != expected_sha256 { + return Err(format!( + "WebUI entry asset digest mismatch for {}: expected {}, got {}", + entry.path, expected_sha256, actual_sha256 + )); + } + } + if !has_javascript_entry { + return Err("WebUI bundle attestation has no JavaScript entry asset".to_string()); + } + Ok(actual_index_sha256) +} + +fn validate_packaged_resource_candidate( + candidate: PackagedResourceCandidate, + expected_desktop_version: &str, +) -> Result { + let manifest_path = candidate.backend_dir.join("runtime-manifest.json"); + let manifest_text = fs::read_to_string(&manifest_path).map_err(|error| { + format!( + "cannot read backend manifest {}: {}", + manifest_path.display(), + error + ) + })?; + let manifest: RuntimeManifest = serde_json::from_str(&manifest_text).map_err(|error| { + format!( + "cannot parse backend manifest {}: {}", + manifest_path.display(), + error + ) + })?; + + let expected_desktop_version = + normalize_resource_version(expected_desktop_version, "running Desktop version")?; + let desktop_version = + required_manifest_version(manifest.desktop_version.as_deref(), "desktopVersion")?; + if desktop_version != expected_desktop_version { + return Err(format!( + "Desktop version mismatch: manifest has {desktop_version}, running executable is {expected_desktop_version}" + )); + } + let core_version = required_manifest_version(manifest.core_version.as_deref(), "coreVersion")?; + let desktop_semver = semver::Version::parse(&expected_desktop_version).map_err(|error| { + format!( + "running Desktop version {expected_desktop_version} is not valid semantic version: {error}" + ) + })?; + if desktop_semver.pre.is_empty() && core_version != expected_desktop_version { + return Err(format!( + "Stable Desktop/Core version mismatch: Desktop is {expected_desktop_version}, Core is {core_version}" + )); + } + + let default_python_relative = if cfg!(target_os = "windows") { + PathBuf::from("python").join("Scripts").join("python.exe") + } else { + PathBuf::from("python").join("bin").join("python3") + }; + let python_relative = manifest + .python + .as_deref() + .map(PathBuf::from) + .unwrap_or(default_python_relative); + let python_path = resolve_manifest_file( + &candidate.backend_dir, + &python_relative, + "python", + "backend Python executable", + )?; + + let entrypoint_relative = manifest + .entrypoint + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("launch_backend.py")); + let launch_script_path = resolve_manifest_file( + &candidate.backend_dir, + &entrypoint_relative, + "entrypoint", + "backend entrypoint", + )?; + + let attestation = manifest.webui.as_ref().ok_or_else(|| { + "runtime-manifest.json is missing the WebUI bundle attestation".to_string() + })?; + let webui_index_sha256 = + validate_webui_attestation(&candidate.webui_dir, &core_version, attestation)?; + let webui_version_path = candidate.webui_dir.join("assets").join("version"); + let webui_version_raw = fs::read_to_string(&webui_version_path).map_err(|error| { + format!( + "cannot read WebUI version marker {}: {}", + webui_version_path.display(), + error + ) + })?; + let webui_version = normalize_resource_version(&webui_version_raw, "WebUI version marker")?; + if webui_version != core_version { + return Err(format!( + "Core/WebUI version mismatch: Core is {core_version}, WebUI is {webui_version}" + )); + } + + Ok(ResolvedPackagedResources { + label: candidate.label, + core_version, + python_path, + launch_script_path, + webui_dir: candidate.webui_dir, + webui_index_sha256, + rejected: Vec::new(), + }) +} + +fn select_packaged_resources( + expected_desktop_version: &str, + candidates: Vec>, +) -> Result> { + let mut failures = Vec::new(); + for candidate in candidates { + let candidate = match candidate { + Ok(candidate) => candidate, + Err(failure) => { + failures.push(failure); + continue; + } + }; + let label = candidate.label; + match validate_packaged_resource_candidate(candidate, expected_desktop_version) { + Ok(mut resolved) => { + resolved.rejected = failures; + return Ok(resolved); + } + Err(reason) => failures.push(PackagedResourceFailure { label, reason }), + } + } + Err(failures) +} + +fn packaged_resources_unavailable_error( + expected_desktop_version: &str, + failures: &[PackagedResourceFailure], +) -> String { + let details = failures + .iter() + .map(|failure| format!("{}: {}", failure.label, failure.reason)) + .collect::>() + .join("; "); + format!( + "Packaged resources are unavailable for AstrBot Desktop {expected_desktop_version}. {details}. Please run the Desktop update again or reinstall AstrBot with the matching full installer." + ) } fn resolve_launch_startup_heartbeat_path( @@ -50,6 +406,9 @@ pub fn resolve_custom_launch(custom_cmd: String) -> Result { cwd, root_dir, webui_dir, + webui_cache_version: None, + packaged_core_version: None, + packaged_webui_index_sha256: None, startup_heartbeat_path, packaged_mode: false, }) @@ -63,63 +422,54 @@ pub fn resolve_packaged_launch( where F: Fn(&str) + Copy, { - let manifest_relative_path = - build_packaged_resource_relative_path(BACKEND_RESOURCE_ALIAS, "runtime-manifest.json"); - let manifest_relative_path_string = manifest_relative_path.to_string_lossy().to_string(); - let manifest_path = - match runtime_paths::resolve_resource_path(app, &manifest_relative_path_string, log) { - Some(path) if path.is_file() => path, - _ => return Ok(None), - }; - let backend_dir = manifest_path - .parent() - .ok_or_else(|| format!("Invalid backend manifest path: {}", manifest_path.display()))?; - - let manifest_text = fs::read_to_string(&manifest_path).map_err(|error| { - format!( - "Failed to read packaged backend manifest {}: {}", - manifest_path.display(), - error - ) - })?; - let manifest: RuntimeManifest = serde_json::from_str(&manifest_text).map_err(|error| { - format!( - "Failed to parse packaged backend manifest {}: {}", - manifest_path.display(), - error - ) - })?; - - let default_python_relative = if cfg!(target_os = "windows") { - PathBuf::from("python").join("Scripts").join("python.exe") - } else { - PathBuf::from("python").join("bin").join("python3") + let expected_desktop_version = app.package_info().version.to_string(); + let candidates = [ + PackagedResourceLocation::Direct, + PackagedResourceLocation::UpdaterStaging, + ] + .into_iter() + .map(|location| resolve_packaged_resource_candidate(app, location)) + .collect::>(); + let has_packaged_manifest = candidates.iter().any(|candidate| { + candidate.as_ref().is_ok_and(|candidate| { + candidate + .backend_dir + .join("runtime-manifest.json") + .is_file() + }) + }); + let selected = match select_packaged_resources(&expected_desktop_version, candidates) { + Ok(selected) => selected, + Err(failures) if cfg!(debug_assertions) && !has_packaged_manifest => { + for failure in failures { + log(&format!( + "packaged resource candidate {} unavailable in development: {}", + failure.label, failure.reason + )); + } + return Ok(None); + } + Err(failures) => { + return Err(packaged_resources_unavailable_error( + &expected_desktop_version, + &failures, + )); + } }; - let python_path = backend_dir.join( - manifest - .python - .as_deref() - .map(PathBuf::from) - .unwrap_or(default_python_relative), - ); - if !python_path.is_file() { - return Err(format!( - "Packaged runtime python executable is missing: {}", - python_path.display() + for failure in &selected.rejected { + log(&format!( + "rejected packaged resource candidate {}: {}", + failure.label, failure.reason )); } - - let entrypoint_relative = manifest - .entrypoint - .as_deref() - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("launch_backend.py")); - let launch_script_path = backend_dir.join(entrypoint_relative); - if !launch_script_path.is_file() { - return Err(format!( - "Packaged backend launch script is missing: {}", - launch_script_path.display() - )); + log(&format!( + "using {} packaged resource bundle for Desktop {}, Core {}", + selected.label, expected_desktop_version, selected.core_version + )); + if env::var_os("ASTRBOT_WEBUI_DIR").is_some() { + log( + "ignoring ASTRBOT_WEBUI_DIR in packaged mode to preserve Backend/WebUI bundle identity", + ); } let root_dir = env::var(crate::ASTRBOT_ROOT_ENV) @@ -129,41 +479,50 @@ where let cwd = env::var("ASTRBOT_BACKEND_CWD") .map(PathBuf::from) .unwrap_or_else(|_| { - root_dir - .clone() - .unwrap_or_else(|| backend_dir.to_path_buf()) - }); - let embedded_webui_dir = env::var("ASTRBOT_WEBUI_DIR") - .ok() - .map(PathBuf::from) - .or_else(|| { - let webui_index_relative_path = - build_packaged_resource_relative_path(WEBUI_RESOURCE_ALIAS, "index.html"); - let webui_index_relative_path_string = - webui_index_relative_path.to_string_lossy().to_string(); - runtime_paths::resolve_resource_path(app, &webui_index_relative_path_string, log) - .and_then(|index_path| index_path.parent().map(Path::to_path_buf)) + root_dir.clone().unwrap_or_else(|| { + selected + .launch_script_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(runtime_paths::workspace_root_dir) + }) }); + let selected_webui_dir = selected.webui_dir; let webui_dir = packaged_webui::resolve_packaged_webui_dir( - embedded_webui_dir, + Some(selected_webui_dir.clone()), root_dir.as_deref(), default_shell_locale, log, )?; + if webui_dir != selected_webui_dir { + return Err( + "Selected packaged WebUI became unavailable; refusing to use data/dist outside the selected resource bundle. Please run the Desktop update again or reinstall AstrBot." + .to_string(), + ); + } + let webui_index_sha256 = selected.webui_index_sha256; + let webui_cache_version = packaged_webui_cache_version( + &expected_desktop_version, + &selected.core_version, + &webui_index_sha256, + ); let args = vec![ - launch_script_path.to_string_lossy().to_string(), + selected.launch_script_path.to_string_lossy().to_string(), "--webui-dir".to_string(), webui_dir.to_string_lossy().to_string(), ]; let startup_heartbeat_path = resolve_launch_startup_heartbeat_path(root_dir.as_deref(), true); let plan = LaunchPlan { - cmd: python_path.to_string_lossy().to_string(), + cmd: selected.python_path.to_string_lossy().to_string(), args, cwd, root_dir, webui_dir: Some(webui_dir), + webui_cache_version: Some(webui_cache_version), + packaged_core_version: Some(selected.core_version), + packaged_webui_index_sha256: Some(webui_index_sha256), startup_heartbeat_path, packaged_mode: true, }; @@ -202,6 +561,9 @@ pub fn resolve_dev_launch() -> Result { .unwrap_or(source_root), root_dir, webui_dir, + webui_cache_version: None, + packaged_core_version: None, + packaged_webui_index_sha256: None, startup_heartbeat_path, packaged_mode: false, }) @@ -210,6 +572,85 @@ pub fn resolve_dev_launch() -> Result { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + const DESKTOP_VERSION: &str = "4.27.4"; + const CORE_VERSION: &str = "4.27.4"; + + fn create_bundle_candidate( + temp_dir: &TempDir, + directory_name: &str, + label: &'static str, + desktop_version: &str, + core_version: &str, + webui_version: &str, + ) -> PackagedResourceCandidate { + let resource_root = temp_dir.path().join(directory_name); + let backend_dir = resource_root.join("backend"); + let webui_dir = resource_root.join("webui"); + fs::create_dir_all(&backend_dir).expect("create backend fixture"); + fs::create_dir_all(webui_dir.join("assets")).expect("create WebUI fixture"); + + fs::write(backend_dir.join("python-test"), b"python fixture") + .expect("write Python fixture"); + fs::write(backend_dir.join("launch_backend.py"), b"print('fixture')\n") + .expect("write entrypoint fixture"); + fs::write( + webui_dir.join("index.html"), + b"", + ) + .expect("write WebUI index fixture"); + fs::write( + webui_dir.join("assets").join("index-test.js"), + b"export {};\n", + ) + .expect("write WebUI entry fixture"); + fs::write( + webui_dir.join("assets").join("version"), + format!("{webui_version}\n"), + ) + .expect("write WebUI version fixture"); + let manifest = serde_json::json!({ + "python": "python-test", + "entrypoint": "launch_backend.py", + "desktopVersion": desktop_version, + "coreVersion": core_version, + "webui": { + "version": webui_version, + "indexSha256": sha256_file(&webui_dir.join("index.html")) + .expect("hash WebUI index fixture"), + "entryAssets": [{ + "path": "assets/index-test.js", + "sha256": sha256_file(&webui_dir.join("assets").join("index-test.js")) + .expect("hash WebUI entry fixture"), + }], + }, + }); + fs::write( + backend_dir.join("runtime-manifest.json"), + serde_json::to_vec(&manifest).expect("serialize manifest fixture"), + ) + .expect("write manifest fixture"); + + PackagedResourceCandidate { + label, + backend_dir, + webui_dir, + } + } + + fn set_manifest_field(candidate: &PackagedResourceCandidate, field: &str, value: &str) { + let manifest_path = candidate.backend_dir.join("runtime-manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read manifest fixture")) + .expect("parse manifest fixture"); + manifest[field] = serde_json::Value::String(value.to_string()); + fs::write( + manifest_path, + serde_json::to_vec(&manifest).expect("serialize modified manifest fixture"), + ) + .expect("write modified manifest fixture"); + } struct EnvVarGuard { key: &'static str, @@ -234,15 +675,316 @@ mod tests { } #[test] - fn build_packaged_resource_relative_path_joins_alias_and_leaf_name() { - assert_eq!( - build_packaged_resource_relative_path("runtime/backend", "runtime-manifest.json"), - PathBuf::from("runtime/backend").join("runtime-manifest.json") + fn valid_direct_bundle_is_preferred_over_valid_updater_bundle() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let direct = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", ); - assert_eq!( - build_packaged_resource_relative_path("runtime/webui", "index.html"), - PathBuf::from("runtime/webui").join("index.html") + let expected_webui_dir = direct.webui_dir.clone(); + let updater = create_bundle_candidate( + &temp_dir, + "updater", + "_up_/resources", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + + let selected = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) + .expect("select direct bundle"); + + assert_eq!(selected.label, "direct"); + assert_eq!(selected.webui_dir, expected_webui_dir); + assert!(selected.rejected.is_empty()); + } + + #[test] + fn stale_direct_bundle_falls_back_to_valid_updater_bundle() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let direct = + create_bundle_candidate(&temp_dir, "direct", "direct", "4.27.3", "4.27.3", "v4.27.3"); + let updater = create_bundle_candidate( + &temp_dir, + "updater", + "_up_/resources", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + + let selected = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) + .expect("fall back to updater bundle"); + + assert_eq!(selected.label, "_up_/resources"); + assert_eq!(selected.rejected.len(), 1); + assert!(selected.rejected[0] + .reason + .contains("Desktop version mismatch")); + } + + #[test] + fn backend_and_webui_are_never_mixed_across_resource_roots() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let direct = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + fs::remove_file(direct.webui_dir.join("index.html")).expect("remove direct WebUI index"); + let updater = create_bundle_candidate( + &temp_dir, + "updater", + "_up_/resources", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + fs::remove_file(updater.backend_dir.join("runtime-manifest.json")) + .expect("remove updater backend manifest"); + + let failures = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) + .expect_err("partial roots must not be combined"); + let error = packaged_resources_unavailable_error(DESKTOP_VERSION, &failures); + + assert_eq!(failures.len(), 2); + assert!(error.contains("direct: WebUI index is missing")); + assert!(error.contains("_up_/resources: cannot read backend manifest")); + assert!(error.contains("run the Desktop update again or reinstall")); + } + + #[test] + fn incomplete_backend_or_webui_files_are_rejected() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let cases = [ + ( + "missing-python", + false, + "python-test", + "backend Python executable is missing", + ), + ( + "missing-entrypoint", + false, + "launch_backend.py", + "backend entrypoint is missing", + ), + ( + "missing-index", + true, + "index.html", + "WebUI index is missing", + ), + ( + "missing-entry-asset", + true, + "assets/index-test.js", + "WebUI entry asset is missing", + ), + ( + "missing-version-marker", + true, + "assets/version", + "cannot read WebUI version marker", + ), + ]; + + for (directory_name, remove_from_webui, missing_relative_path, expected_error) in cases { + let candidate = create_bundle_candidate( + &temp_dir, + directory_name, + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + let missing_path = if remove_from_webui { + candidate.webui_dir.join(missing_relative_path) + } else { + candidate.backend_dir.join(missing_relative_path) + }; + fs::remove_file(missing_path).expect("remove required fixture file"); + + let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + .expect_err("incomplete candidate must fail"); + assert!(error.contains(expected_error), "unexpected error: {error}"); + } + } + + #[test] + fn core_and_webui_versions_must_match() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let candidate = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.3", + ); + + let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + .expect_err("version mismatch must fail"); + + assert!(error.contains("Core/WebUI attestation version mismatch")); + assert!(error.contains("Core is 4.27.4, attestation is 4.27.3")); + } + + #[test] + fn stable_desktop_rejects_a_different_matching_core_and_webui_version() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let candidate = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + "4.27.3", + "v4.27.3", + ); + + let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + .expect_err("stable Desktop must match Core exactly"); + + assert!(error.contains("Stable Desktop/Core version mismatch")); + assert!(error.contains("Desktop is 4.27.4, Core is 4.27.3")); + } + + #[test] + fn backend_manifest_paths_cannot_escape_the_selected_bundle() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + + let parent_candidate = create_bundle_candidate( + &temp_dir, + "parent-path", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + fs::write( + parent_candidate + .backend_dir + .parent() + .expect("resource root") + .join("outside-python"), + b"outside", + ) + .expect("write outside fixture"); + set_manifest_field(&parent_candidate, "python", "../outside-python"); + let parent_error = validate_packaged_resource_candidate(parent_candidate, DESKTOP_VERSION) + .expect_err("parent traversal must fail"); + assert!(parent_error.contains("python must be a canonical relative path")); + + let absolute_candidate = create_bundle_candidate( + &temp_dir, + "absolute-path", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + let absolute_entrypoint = temp_dir.path().join("outside-launch.py"); + fs::write(&absolute_entrypoint, b"print('outside')\n") + .expect("write absolute outside fixture"); + set_manifest_field( + &absolute_candidate, + "entrypoint", + &absolute_entrypoint.to_string_lossy(), + ); + let absolute_error = + validate_packaged_resource_candidate(absolute_candidate, DESKTOP_VERSION) + .expect_err("absolute path must fail"); + assert!(absolute_error.contains("entrypoint must be a canonical relative path")); + } + + #[cfg(unix)] + #[test] + fn backend_manifest_symlink_cannot_escape_the_selected_bundle() { + use std::os::unix::fs::symlink; + + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let candidate = create_bundle_candidate( + &temp_dir, + "symlink-path", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", ); + let outside_entrypoint = temp_dir.path().join("outside-launch.py"); + fs::write(&outside_entrypoint, b"print('outside')\n").expect("write outside fixture"); + let symlink_path = candidate.backend_dir.join("linked-launch.py"); + symlink(&outside_entrypoint, &symlink_path).expect("create escape symlink"); + set_manifest_field(&candidate, "entrypoint", "linked-launch.py"); + + let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + .expect_err("symlink escape must fail"); + + assert!(error.contains("entrypoint escapes packaged resource directory")); + } + + #[test] + fn webui_content_must_match_the_attested_digests() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let candidate = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + fs::write( + candidate.webui_dir.join("assets").join("index-test.js"), + b"export const stale = true;\n", + ) + .expect("tamper WebUI entry fixture"); + + let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + .expect_err("tampered WebUI entry must fail"); + + assert!(error.contains("WebUI entry asset digest mismatch")); + } + + #[test] + fn nightly_and_custom_desktops_accept_matching_older_core_and_webui_versions() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + for (directory_name, desktop_version) in [ + ("nightly", "4.27.5-nightly.20260901.abcdef12"), + ("custom", "4.27.5-custom.abcdef12"), + ] { + let candidate = create_bundle_candidate( + &temp_dir, + directory_name, + "direct", + desktop_version, + CORE_VERSION, + "v4.27.4", + ); + + let selected = validate_packaged_resource_candidate(candidate, desktop_version) + .expect("derived Desktop may package a different Core version"); + + assert_eq!(selected.label, "direct"); + assert_eq!( + packaged_webui_cache_version( + desktop_version, + &selected.core_version, + &selected.webui_index_sha256, + ), + format!( + "desktop-{desktop_version}-core-4.27.4-webui-{}", + &selected.webui_index_sha256[..16] + ) + ); + } } #[test] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 15cc5b4b..e61f9964 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -40,7 +40,7 @@ pub(crate) use app_helpers::{ }; pub(crate) use app_types::{ AtomicFlagGuard, BackendBridgeResult, BackendBridgeState, BackendState, - DesktopAuthBridgeResult, LaunchPlan, RuntimeManifest, TrayMenuState, + DesktopAuthBridgeResult, LaunchPlan, RuntimeManifest, RuntimeWebuiAttestation, TrayMenuState, }; pub(crate) use desktop_settings::DesktopSettingsCache; diff --git a/src-tauri/src/runtime_paths.rs b/src-tauri/src/runtime_paths.rs index 0a64a02b..5d037c4e 100644 --- a/src-tauri/src/runtime_paths.rs +++ b/src-tauri/src/runtime_paths.rs @@ -4,6 +4,22 @@ use std::{ }; use tauri::{path::BaseDirectory, AppHandle, Manager}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PackagedResourceLocation { + Direct, + // Tauri's NSIS updater stages the incoming bundle below this install-root subtree. + UpdaterStaging, +} + +impl PackagedResourceLocation { + pub fn label(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::UpdaterStaging => "_up_/resources", + } + } +} + pub fn detect_astrbot_source_root() -> Option { let explicit_source_dir = env::var("ASTRBOT_SOURCE_DIR") .ok() @@ -15,31 +31,34 @@ pub fn default_packaged_root_dir() -> Option { home::home_dir().map(|home| home.join(".astrbot")) } -pub fn resolve_resource_path(app: &AppHandle, relative_path: &str, log: F) -> Option -where - F: Fn(&str), -{ - if let Ok(path) = app.path().resolve(relative_path, BaseDirectory::Resource) { - if path.exists() { - return Some(path); - } - } - - let updater_resource = Path::new("_up_").join("resources").join(relative_path); - if let Ok(path) = app - .path() - .resolve(&updater_resource, BaseDirectory::Resource) - { - if path.exists() { - return Some(path); +fn packaged_resource_relative_path( + location: PackagedResourceLocation, + relative_path: &str, +) -> PathBuf { + match location { + PackagedResourceLocation::Direct => PathBuf::from(relative_path), + PackagedResourceLocation::UpdaterStaging => { + Path::new("_up_").join("resources").join(relative_path) } } +} - log(&format!( - "resource not found: {} (checked direct and _up_/resources)", - relative_path - )); - None +pub fn resolve_packaged_resource_path( + app: &AppHandle, + location: PackagedResourceLocation, + relative_path: &str, +) -> Result { + let packaged_path = packaged_resource_relative_path(location, relative_path); + app.path() + .resolve(&packaged_path, BaseDirectory::Resource) + .map_err(|error| { + format!( + "failed to resolve {} resource {}: {}", + location.label(), + relative_path, + error + ) + }) } pub fn workspace_root_dir() -> PathBuf { @@ -139,4 +158,16 @@ mod tests { fs::remove_dir_all(&workspace).expect("cleanup workspace dir"); fs::remove_dir_all(&explicit).expect("cleanup explicit dir"); } + + #[test] + fn packaged_resource_paths_keep_direct_and_updater_roots_separate() { + assert_eq!( + packaged_resource_relative_path(PackagedResourceLocation::Direct, "backend"), + PathBuf::from("backend") + ); + assert_eq!( + packaged_resource_relative_path(PackagedResourceLocation::UpdaterStaging, "webui"), + PathBuf::from("_up_").join("resources").join("webui") + ); + } } diff --git a/src-tauri/src/startup_task.rs b/src-tauri/src/startup_task.rs index 1b13626a..7827722b 100644 --- a/src-tauri/src/startup_task.rs +++ b/src-tauri/src/startup_task.rs @@ -18,13 +18,14 @@ where .and_then(|result| result); match startup_result { - Ok(()) => { + Ok(cache_version) => { if let Err(error) = ui_dispatch::run_on_main_thread_dispatch( &startup_app_handle, "navigate backend", - move |main_app| match navigate_main_window_to_backend(main_app) { - Ok(()) => {} - Err(navigate_error) => { + move |main_app| { + if let Err(navigate_error) = + navigate_main_window_to_backend(main_app, cache_version.as_deref()) + { ui_dispatch::show_startup_error(main_app, &navigate_error, log); } }, diff --git a/src-tauri/src/ui_dispatch.rs b/src-tauri/src/ui_dispatch.rs index 80cff8c6..8b141156 100644 --- a/src-tauri/src/ui_dispatch.rs +++ b/src-tauri/src/ui_dispatch.rs @@ -1,4 +1,12 @@ -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; + +fn startup_error_script(message: &str) -> String { + let message_json = serde_json::to_string(message) + .unwrap_or_else(|_| "\"AstrBot startup failed.\"".to_string()); + format!( + "(() => {{ const message = {message_json}; window.__astrbotPendingStartupError = message; if (typeof window.__astrbotShowStartupError === 'function') {{ window.__astrbotShowStartupError(message); }} }})();" + ) +} pub fn run_on_main_thread_dispatch( app_handle: &AppHandle, @@ -22,7 +30,32 @@ where { log(&format!("startup error: {message}")); eprintln!("AstrBot startup failed: {message}"); - app_handle.exit(1); + let Some(window) = app_handle.get_webview_window("main") else { + log("failed to display startup error: main window not found"); + app_handle.exit(1); + return; + }; + if let Err(error) = window.set_title("AstrBot - 启动失败 / Startup failed") { + log(&format!( + "failed to set startup error window title: {error}" + )); + } + if let Err(error) = window.eval(startup_error_script(message)) { + log(&format!( + "failed to render startup error in startup shell: {error}" + )); + } + if let Err(error) = window.unminimize() { + log(&format!( + "failed to unminimize startup error window: {error}" + )); + } + if let Err(error) = window.show() { + log(&format!("failed to show startup error window: {error}")); + } + if let Err(error) = window.set_focus() { + log(&format!("failed to focus startup error window: {error}")); + } } pub fn show_startup_error_on_main_thread(app_handle: &AppHandle, message: &str, log: F) @@ -40,3 +73,18 @@ where )); } } + +#[cfg(test)] +mod tests { + use super::startup_error_script; + + #[test] + fn startup_error_script_serializes_untrusted_messages_as_data() { + let script = startup_error_script("stale \"WebUI\"\n"); + + assert!(script.contains("stale \\\"WebUI\\\"\\n")); + assert!(!script.contains("const message = stale")); + assert!(script.contains("__astrbotPendingStartupError")); + assert!(script.contains("__astrbotShowStartupError")); + } +} diff --git a/src-tauri/src/window/main_window.rs b/src-tauri/src/window/main_window.rs index 07e0b048..acdbc125 100644 --- a/src-tauri/src/window/main_window.rs +++ b/src-tauri/src/window/main_window.rs @@ -1,4 +1,32 @@ use tauri::{AppHandle, Manager}; +use url::Url; + +const DASHBOARD_CACHE_QUERY_KEY: &str = "astrbot_bundle"; + +fn backend_dashboard_url(backend_url: &str, cache_version: Option<&str>) -> Result { + let Some(cache_version) = cache_version + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(backend_url.to_string()); + }; + let mut url = Url::parse(backend_url) + .map_err(|error| format!("Invalid backend dashboard URL {backend_url:?}: {error}"))?; + let existing_pairs = url + .query_pairs() + .filter(|(key, _)| key.as_ref() != DASHBOARD_CACHE_QUERY_KEY) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + url.set_query(None); + { + let mut query = url.query_pairs_mut(); + for (key, value) in existing_pairs { + query.append_pair(&key, &value); + } + query.append_pair(DASHBOARD_CACHE_QUERY_KEY, cache_version); + } + Ok(url.to_string()) +} pub fn show_main_window(app_handle: &AppHandle, log: F) where @@ -49,9 +77,11 @@ where pub fn navigate_main_window_to_backend( app_handle: &AppHandle, backend_url: &str, + cache_version: Option<&str>, ) -> Result<(), String> { + let backend_url = backend_dashboard_url(backend_url, cache_version)?; let backend_url_json = - serde_json::to_string(backend_url).unwrap_or_else(|_| "\"/\"".to_string()); + serde_json::to_string(&backend_url).unwrap_or_else(|_| "\"/\"".to_string()); let Some(window) = app_handle.get_webview_window("main") else { return Err("Main window is unavailable after backend startup.".to_string()); }; @@ -61,3 +91,38 @@ pub fn navigate_main_window_to_backend( .eval(&js) .map_err(|error| format!("Failed to navigate to backend dashboard: {error}")) } + +#[cfg(test)] +mod tests { + use super::backend_dashboard_url; + + #[test] + fn dashboard_cache_version_is_stable_and_preserves_query_and_fragment() { + let input = "http://127.0.0.1:6185/dashboard?locale=zh-CN&astrbot_bundle=stale#settings"; + let version = "desktop-4.27.4-core-4.27.4"; + + let first = backend_dashboard_url(input, Some(version)).expect("build dashboard URL"); + let second = backend_dashboard_url(input, Some(version)).expect("build dashboard URL"); + + assert_eq!(first, second); + assert_eq!( + first, + "http://127.0.0.1:6185/dashboard?locale=zh-CN&astrbot_bundle=desktop-4.27.4-core-4.27.4#settings" + ); + assert_eq!( + backend_dashboard_url("http://127.0.0.1:6185/", Some(version)) + .expect("build default dashboard URL"), + "http://127.0.0.1:6185/?astrbot_bundle=desktop-4.27.4-core-4.27.4" + ); + } + + #[test] + fn dashboard_url_is_unchanged_without_a_packaged_cache_version() { + let input = "http://127.0.0.1:6185/?locale=en-US#chat"; + + assert_eq!( + backend_dashboard_url(input, None).expect("keep dashboard URL"), + input + ); + } +} diff --git a/ui/index.html b/ui/index.html index 773c8ec2..8d0c349a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -70,6 +70,8 @@ color: var(--muted); line-height: 1.6; font-size: 14px; + white-space: pre-wrap; + overflow-wrap: anywhere; } .bar-wrap { @@ -107,6 +109,27 @@ animation: pulse 1.6s infinite ease-out; } + .panel.error { + border-color: #f3b6b2; + } + + .panel.error .bar-wrap { + display: none; + } + + .panel.error .status { + color: #b42318; + align-items: flex-start; + } + + .panel.error .dot { + flex: 0 0 auto; + margin-top: 6px; + background: #d92d20; + box-shadow: none; + animation: none; + } + @keyframes slide { 0% { transform: translateX(-65%); @@ -150,7 +173,8 @@

const title = document.getElementById("startup-title"); const desc = document.getElementById("startup-desc"); const status = document.getElementById("startup-status"); - if (!title || !desc || !status) return; + const panel = document.querySelector(".panel"); + if (!title || !desc || !status || !panel) return; if (!window.astrbot || !window.astrbot.startupShell) return; const startupShell = window.astrbot.startupShell; @@ -181,7 +205,24 @@

window.__astrbotSetStartupMode = (mode) => { applyStartupMode(typeof mode === "string" ? mode : STARTUP_MODES.LOADING); }; + window.__astrbotShowStartupError = (message) => { + const isEnglish = localeKey === "en"; + panel.classList.add("error"); + title.textContent = isEnglish ? "AstrBot failed to start" : "AstrBot 启动失败"; + desc.textContent = + typeof message === "string" && message.trim() + ? message + : isEnglish + ? "An unknown startup error occurred." + : "启动时发生未知错误。"; + status.textContent = isEnglish + ? "Resolve the issue above, then restart AstrBot Desktop." + : "请按上方提示处理后,重新启动 AstrBot 桌面端。"; + }; applyStartupMode(STARTUP_MODES.LOADING); + if (typeof window.__astrbotPendingStartupError === "string") { + window.__astrbotShowStartupError(window.__astrbotPendingStartupError); + } })(); From a626bbe9214cce0c5aa34e2b402449c23ac7ef3f Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 05:55:19 +0800 Subject: [PATCH 03/11] fix(runtime): verify served webui entry assets --- src-tauri/src/app_helpers.rs | 1 + src-tauri/src/app_types.rs | 3 +- src-tauri/src/backend/readiness.rs | 153 ++++++++++++++++++++++++++--- src-tauri/src/backend/restart.rs | 1 + src-tauri/src/launch_plan.rs | 21 +++- 5 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/app_helpers.rs b/src-tauri/src/app_helpers.rs index 2a83b2bd..a6197237 100644 --- a/src-tauri/src/app_helpers.rs +++ b/src-tauri/src/app_helpers.rs @@ -89,6 +89,7 @@ mod tests { webui_cache_version: None, packaged_core_version: None, packaged_webui_index_sha256: None, + packaged_webui_entry_digests: None, startup_heartbeat_path: None, packaged_mode: false, }; diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs index 832797f1..980f4fc5 100644 --- a/src-tauri/src/app_types.rs +++ b/src-tauri/src/app_types.rs @@ -41,7 +41,7 @@ pub(crate) struct RuntimeWebuiAttestation { pub(crate) entry_assets: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] pub(crate) struct RuntimeWebuiEntryDigest { pub(crate) path: String, pub(crate) sha256: String, @@ -57,6 +57,7 @@ pub(crate) struct LaunchPlan { pub(crate) webui_cache_version: Option, pub(crate) packaged_core_version: Option, pub(crate) packaged_webui_index_sha256: Option, + pub(crate) packaged_webui_entry_digests: Option>, pub(crate) startup_heartbeat_path: Option, pub(crate) packaged_mode: bool, } diff --git a/src-tauri/src/backend/readiness.rs b/src-tauri/src/backend/readiness.rs index 444d54da..6f7f33cf 100644 --- a/src-tauri/src/backend/readiness.rs +++ b/src-tauri/src/backend/readiness.rs @@ -96,6 +96,19 @@ fn validate_running_webui_index( )) } +fn validate_running_webui_entry( + entry_path: &str, + expected_sha256: &str, + running_sha256: &str, +) -> Result<(), String> { + if running_sha256 == expected_sha256 { + return Ok(()); + } + Err(format!( + "A different, stale, or incomplete AstrBot WebUI is serving the Desktop port: entry {entry_path} expected SHA-256 {expected_sha256}, got {running_sha256}. Close the stale backend process, then restart AstrBot Desktop." + )) +} + impl BackendState { pub(crate) fn ensure_backend_ready(&self, app: &AppHandle) -> Result, String> { let auto_start_enabled = @@ -241,6 +254,16 @@ impl BackendState { .to_string(), ) })?; + let expected_entry_digests = plan + .packaged_webui_entry_digests + .as_deref() + .filter(|entries| !entries.is_empty()) + .ok_or_else(|| { + RunningResourceIdentityError::Mismatch( + "Packaged launch plan is missing the expected WebUI entry digests. Run the Desktop update again or reinstall AstrBot." + .to_string(), + ) + })?; let payload = self .request_backend_json( "GET", @@ -275,7 +298,39 @@ impl BackendState { })?; let running_index_sha256 = format!("{:x}", Sha256::digest(&index_body)); validate_running_webui_index(expected_index_sha256, &running_index_sha256) - .map_err(RunningResourceIdentityError::Mismatch) + .map_err(RunningResourceIdentityError::Mismatch)?; + + for entry in expected_entry_digests { + let request_path = format!("/{}", entry.path.trim_start_matches('/')); + let response = self + .request_backend_response_bytes("GET", &request_path, timeout_ms, None, None) + .ok_or_else(|| { + RunningResourceIdentityError::Unavailable(format!( + "Cannot read the running AstrBot WebUI entry asset at {request_path}." + )) + })?; + let status_code = backend::http_response::parse_http_status_code(&response) + .ok_or_else(|| { + RunningResourceIdentityError::Unavailable(format!( + "Cannot parse the running AstrBot WebUI entry response at {request_path}." + )) + })?; + if status_code == 404 { + return Err(RunningResourceIdentityError::Mismatch(format!( + "The running AstrBot WebUI is incomplete: attested entry asset {request_path} is missing. Close the stale backend process, then restart AstrBot Desktop." + ))); + } + let entry_body = backend::http_response::parse_http_success_body(&response) + .ok_or_else(|| { + RunningResourceIdentityError::Unavailable(format!( + "Cannot read a complete identity-encoded AstrBot WebUI entry response at {request_path} (HTTP {status_code})." + )) + })?; + let running_sha256 = format!("{:x}", Sha256::digest(&entry_body)); + validate_running_webui_entry(&request_path, &entry.sha256, &running_sha256) + .map_err(RunningResourceIdentityError::Mismatch)?; + } + Ok(()) } fn probe_backend_readiness( @@ -500,7 +555,13 @@ mod tests { format!("{:x}", Sha256::digest(payload)) } - fn packaged_plan(core_version: &str, index_sha256: &str) -> crate::LaunchPlan { + const WEBUI_ENTRY_PATH: &str = "/assets/index-test.js"; + + fn packaged_plan( + core_version: &str, + index_sha256: &str, + entry_sha256: &str, + ) -> crate::LaunchPlan { crate::LaunchPlan { cmd: "python".to_string(), args: Vec::new(), @@ -510,25 +571,36 @@ mod tests { webui_cache_version: None, packaged_core_version: Some(core_version.to_string()), packaged_webui_index_sha256: Some(index_sha256.to_string()), + packaged_webui_entry_digests: Some(vec![crate::app_types::RuntimeWebuiEntryDigest { + path: WEBUI_ENTRY_PATH.trim_start_matches('/').to_string(), + sha256: entry_sha256.to_string(), + }]), startup_heartbeat_path: None, packaged_mode: true, } } - fn spawn_identity_server(index_body: Vec) -> (String, thread::JoinHandle<()>) { + fn spawn_identity_server( + index_body: Vec, + entry_response: Option<(u16, Vec)>, + ) -> (String, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind identity server"); let address = listener.local_addr().expect("read identity server address"); let versions_body = br#"{"status":"ok","data":{"astrbot_version":"4.27.5","astrbot_code_version":"4.27.5","webui_version":"4.27.5"}}"#.to_vec(); - let responses = [ + let mut responses = vec![ ( BACKEND_RESOURCE_VERSIONS_PATH, "application/json", + 200, versions_body, ), - (BACKEND_WEBUI_INDEX_PATH, "text/html", index_body), + (BACKEND_WEBUI_INDEX_PATH, "text/html", 200, index_body), ]; + if let Some((status_code, body)) = entry_response { + responses.push((WEBUI_ENTRY_PATH, "text/javascript", status_code, body)); + } let handle = thread::spawn(move || { - for (expected_path, content_type, body) in responses { + for (expected_path, content_type, status_code, body) in responses { let (mut stream, _) = listener.accept().expect("accept identity request"); let mut request_bytes = [0_u8; 4096]; let read = stream @@ -541,8 +613,13 @@ mod tests { ); assert!(request.contains("Accept-Encoding: identity\r\n")); assert!(request.contains("Cache-Control: no-cache\r\n")); + let reason = if status_code == 200 { + "OK" + } else { + "Not Found" + }; let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status_code} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream @@ -630,8 +707,10 @@ mod tests { #[test] fn live_identity_check_accepts_the_exact_served_index_document() { let index_body = b"current".to_vec(); - let expected_digest = sha256_hex(&index_body); - let (backend_url, server) = spawn_identity_server(index_body); + let entry_body = b"console.log('current');".to_vec(); + let expected_index_digest = sha256_hex(&index_body); + let expected_entry_digest = sha256_hex(&entry_body); + let (backend_url, server) = spawn_identity_server(index_body, Some((200, entry_body))); let state = BackendState { backend_url, ..BackendState::default() @@ -639,7 +718,7 @@ mod tests { assert_eq!( state.verify_running_resource_identity( - &packaged_plan("4.27.5", &expected_digest), + &packaged_plan("4.27.5", &expected_index_digest, &expected_entry_digest,), 1_000, ), Ok(()) @@ -651,19 +730,69 @@ mod tests { fn live_identity_check_rejects_same_version_stale_index_content() { let stale_index = b"stale".to_vec(); let expected_digest = sha256_hex(b"current"); - let (backend_url, server) = spawn_identity_server(stale_index); + let expected_entry_digest = sha256_hex(b"console.log('current');"); + let (backend_url, server) = spawn_identity_server(stale_index, None); let state = BackendState { backend_url, ..BackendState::default() }; let error = state - .verify_running_resource_identity(&packaged_plan("4.27.5", &expected_digest), 1_000) + .verify_running_resource_identity( + &packaged_plan("4.27.5", &expected_digest, &expected_entry_digest), + 1_000, + ) .expect_err("same-version stale index must be rejected"); assert!(error.contains("stale AstrBot WebUI")); server.join().expect("identity server should finish"); } + #[test] + fn live_identity_check_rejects_same_version_stale_entry_content() { + let index_body = b"".to_vec(); + let expected_index_digest = sha256_hex(&index_body); + let expected_entry_digest = sha256_hex(b"console.log('current');"); + let (backend_url, server) = + spawn_identity_server(index_body, Some((200, b"console.log('stale');".to_vec()))); + let state = BackendState { + backend_url, + ..BackendState::default() + }; + + let error = state + .verify_running_resource_identity( + &packaged_plan("4.27.5", &expected_index_digest, &expected_entry_digest), + 1_000, + ) + .expect_err("same-version stale entry must be rejected"); + assert!(error.contains(WEBUI_ENTRY_PATH)); + assert!(error.contains("expected SHA-256")); + server.join().expect("identity server should finish"); + } + + #[test] + fn live_identity_check_rejects_missing_attested_entry() { + let index_body = b"".to_vec(); + let expected_index_digest = sha256_hex(&index_body); + let expected_entry_digest = sha256_hex(b"console.log('current');"); + let (backend_url, server) = + spawn_identity_server(index_body, Some((404, b"missing".to_vec()))); + let state = BackendState { + backend_url, + ..BackendState::default() + }; + + let error = state + .verify_running_resource_identity( + &packaged_plan("4.27.5", &expected_index_digest, &expected_entry_digest), + 1_000, + ) + .expect_err("missing attested entry must be rejected"); + assert!(error.contains("is incomplete")); + assert!(error.contains(WEBUI_ENTRY_PATH)); + server.join().expect("identity server should finish"); + } + #[test] fn startup_heartbeat_progress_is_fresh_for_recent_instant() { assert!(startup_heartbeat_progress_is_fresh( diff --git a/src-tauri/src/backend/restart.rs b/src-tauri/src/backend/restart.rs index 30c741e0..ad06dd3f 100644 --- a/src-tauri/src/backend/restart.rs +++ b/src-tauri/src/backend/restart.rs @@ -347,6 +347,7 @@ mod tests { webui_cache_version: None, packaged_core_version: None, packaged_webui_index_sha256: None, + packaged_webui_entry_digests: None, startup_heartbeat_path: None, packaged_mode: true, }; diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index e93667e4..670e6c90 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -9,6 +9,7 @@ use sha2::{Digest, Sha256}; use tauri::AppHandle; use crate::{ + app_types::RuntimeWebuiEntryDigest, backend, packaged_webui, runtime_paths::{self, PackagedResourceLocation}, LaunchPlan, RuntimeManifest, RuntimeWebuiAttestation, @@ -38,6 +39,7 @@ struct ResolvedPackagedResources { launch_script_path: PathBuf, webui_dir: PathBuf, webui_index_sha256: String, + webui_entry_digests: Vec, rejected: Vec, } @@ -158,7 +160,7 @@ fn validate_webui_attestation( webui_dir: &Path, core_version: &str, attestation: &RuntimeWebuiAttestation, -) -> Result { +) -> Result<(String, Vec), String> { let attested_version = normalize_resource_version(&attestation.version, "WebUI bundle attestation version")?; if attested_version != core_version { @@ -186,8 +188,10 @@ fn validate_webui_attestation( let mut seen_entries = HashSet::new(); let mut has_javascript_entry = false; + let mut validated_entries = Vec::with_capacity(attestation.entry_assets.len()); for entry in &attestation.entry_assets { - let relative = PathBuf::from(entry.path.trim()); + let normalized_entry_path = entry.path.trim().replace('\\', "/"); + let relative = PathBuf::from(&normalized_entry_path); if !seen_entries.insert(relative.clone()) { return Err(format!( "runtime-manifest.json contains duplicate WebUI entry asset: {}", @@ -223,11 +227,15 @@ fn validate_webui_attestation( entry.path, expected_sha256, actual_sha256 )); } + validated_entries.push(RuntimeWebuiEntryDigest { + path: normalized_entry_path, + sha256: expected_sha256, + }); } if !has_javascript_entry { return Err("WebUI bundle attestation has no JavaScript entry asset".to_string()); } - Ok(actual_index_sha256) + Ok((actual_index_sha256, validated_entries)) } fn validate_packaged_resource_candidate( @@ -303,7 +311,7 @@ fn validate_packaged_resource_candidate( let attestation = manifest.webui.as_ref().ok_or_else(|| { "runtime-manifest.json is missing the WebUI bundle attestation".to_string() })?; - let webui_index_sha256 = + let (webui_index_sha256, webui_entry_digests) = validate_webui_attestation(&candidate.webui_dir, &core_version, attestation)?; let webui_version_path = candidate.webui_dir.join("assets").join("version"); let webui_version_raw = fs::read_to_string(&webui_version_path).map_err(|error| { @@ -327,6 +335,7 @@ fn validate_packaged_resource_candidate( launch_script_path, webui_dir: candidate.webui_dir, webui_index_sha256, + webui_entry_digests, rejected: Vec::new(), }) } @@ -409,6 +418,7 @@ pub fn resolve_custom_launch(custom_cmd: String) -> Result { webui_cache_version: None, packaged_core_version: None, packaged_webui_index_sha256: None, + packaged_webui_entry_digests: None, startup_heartbeat_path, packaged_mode: false, }) @@ -501,6 +511,7 @@ where ); } let webui_index_sha256 = selected.webui_index_sha256; + let webui_entry_digests = selected.webui_entry_digests; let webui_cache_version = packaged_webui_cache_version( &expected_desktop_version, &selected.core_version, @@ -523,6 +534,7 @@ where webui_cache_version: Some(webui_cache_version), packaged_core_version: Some(selected.core_version), packaged_webui_index_sha256: Some(webui_index_sha256), + packaged_webui_entry_digests: Some(webui_entry_digests), startup_heartbeat_path, packaged_mode: true, }; @@ -564,6 +576,7 @@ pub fn resolve_dev_launch() -> Result { webui_cache_version: None, packaged_core_version: None, packaged_webui_index_sha256: None, + packaged_webui_entry_digests: None, startup_heartbeat_path, packaged_mode: false, }) From ad374f4449651eb4dccf2c6c215d4fc1520c62b7 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 06:04:00 +0800 Subject: [PATCH 04/11] fix(runtime): bind packaged resources to executable --- src-tauri/src/launch_plan.rs | 210 +++++++++++++++++++++++++++-------- 1 file changed, 165 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index 670e6c90..54edffaa 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -17,6 +17,7 @@ use crate::{ const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); +const PACKAGED_RUNTIME_MANIFEST_SHA256: &str = env!("ASTRBOT_RUNTIME_MANIFEST_SHA256"); #[derive(Debug)] struct PackagedResourceCandidate { @@ -38,6 +39,7 @@ struct ResolvedPackagedResources { python_path: PathBuf, launch_script_path: PathBuf, webui_dir: PathBuf, + runtime_manifest_sha256: String, webui_index_sha256: String, webui_entry_digests: Vec, rejected: Vec, @@ -79,13 +81,8 @@ fn required_manifest_version(value: Option<&str>, field: &str) -> Result String { - let digest_prefix = &index_sha256[..16]; - format!("desktop-{desktop_version}-core-{core_version}-webui-{digest_prefix}") +fn packaged_webui_cache_version(runtime_manifest_sha256: &str) -> String { + format!("v1-{runtime_manifest_sha256}") } fn normalize_sha256(value: &str, field: &str) -> Result { @@ -156,6 +153,10 @@ fn sha256_file(path: &Path) -> Result { Ok(format!("{:x}", hasher.finalize())) } +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + fn validate_webui_attestation( webui_dir: &Path, core_version: &str, @@ -241,16 +242,27 @@ fn validate_webui_attestation( fn validate_packaged_resource_candidate( candidate: PackagedResourceCandidate, expected_desktop_version: &str, + expected_runtime_manifest_sha256: &str, ) -> Result { let manifest_path = candidate.backend_dir.join("runtime-manifest.json"); - let manifest_text = fs::read_to_string(&manifest_path).map_err(|error| { + let manifest_bytes = fs::read(&manifest_path).map_err(|error| { format!( "cannot read backend manifest {}: {}", manifest_path.display(), error ) })?; - let manifest: RuntimeManifest = serde_json::from_str(&manifest_text).map_err(|error| { + let expected_runtime_manifest_sha256 = normalize_sha256( + expected_runtime_manifest_sha256, + "executable packaged runtime manifest identity", + )?; + let runtime_manifest_sha256 = sha256_bytes(&manifest_bytes); + if runtime_manifest_sha256 != expected_runtime_manifest_sha256 { + return Err(format!( + "Packaged runtime manifest identity mismatch: executable expects {expected_runtime_manifest_sha256}, candidate has {runtime_manifest_sha256}" + )); + } + let manifest: RuntimeManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { format!( "cannot parse backend manifest {}: {}", manifest_path.display(), @@ -334,6 +346,7 @@ fn validate_packaged_resource_candidate( python_path, launch_script_path, webui_dir: candidate.webui_dir, + runtime_manifest_sha256, webui_index_sha256, webui_entry_digests, rejected: Vec::new(), @@ -342,6 +355,7 @@ fn validate_packaged_resource_candidate( fn select_packaged_resources( expected_desktop_version: &str, + expected_runtime_manifest_sha256: &str, candidates: Vec>, ) -> Result> { let mut failures = Vec::new(); @@ -354,7 +368,11 @@ fn select_packaged_resources( } }; let label = candidate.label; - match validate_packaged_resource_candidate(candidate, expected_desktop_version) { + match validate_packaged_resource_candidate( + candidate, + expected_desktop_version, + expected_runtime_manifest_sha256, + ) { Ok(mut resolved) => { resolved.rejected = failures; return Ok(resolved); @@ -448,7 +466,11 @@ where .is_file() }) }); - let selected = match select_packaged_resources(&expected_desktop_version, candidates) { + let selected = match select_packaged_resources( + &expected_desktop_version, + PACKAGED_RUNTIME_MANIFEST_SHA256, + candidates, + ) { Ok(selected) => selected, Err(failures) if cfg!(debug_assertions) && !has_packaged_manifest => { for failure in failures { @@ -512,11 +534,7 @@ where } let webui_index_sha256 = selected.webui_index_sha256; let webui_entry_digests = selected.webui_entry_digests; - let webui_cache_version = packaged_webui_cache_version( - &expected_desktop_version, - &selected.core_version, - &webui_index_sha256, - ); + let webui_cache_version = packaged_webui_cache_version(&selected.runtime_manifest_sha256); let args = vec![ selected.launch_script_path.to_string_lossy().to_string(), @@ -665,6 +683,35 @@ mod tests { .expect("write modified manifest fixture"); } + fn replace_attested_entry(candidate: &PackagedResourceCandidate, contents: &[u8]) { + let entry_path = candidate.webui_dir.join("assets").join("index-test.js"); + fs::write(&entry_path, contents).expect("replace WebUI entry fixture"); + let manifest_path = candidate.backend_dir.join("runtime-manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read manifest fixture")) + .expect("parse manifest fixture"); + manifest["webui"]["entryAssets"][0]["sha256"] = + serde_json::Value::String(sha256_file(&entry_path).expect("hash replacement entry")); + fs::write( + manifest_path, + serde_json::to_vec(&manifest).expect("serialize modified manifest fixture"), + ) + .expect("write modified manifest fixture"); + } + + fn candidate_manifest_sha256(candidate: &PackagedResourceCandidate) -> String { + sha256_file(&candidate.backend_dir.join("runtime-manifest.json")) + .expect("hash runtime manifest fixture") + } + + fn validate_fixture_candidate( + candidate: PackagedResourceCandidate, + desktop_version: &str, + ) -> Result { + let expected_manifest_sha256 = candidate_manifest_sha256(&candidate); + validate_packaged_resource_candidate(candidate, desktop_version, &expected_manifest_sha256) + } + struct EnvVarGuard { key: &'static str, previous: Option, @@ -707,9 +754,14 @@ mod tests { CORE_VERSION, "v4.27.4", ); + let expected_manifest_sha256 = candidate_manifest_sha256(&direct); - let selected = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) - .expect("select direct bundle"); + let selected = select_packaged_resources( + DESKTOP_VERSION, + &expected_manifest_sha256, + vec![Ok(direct), Ok(updater)], + ) + .expect("select direct bundle"); assert_eq!(selected.label, "direct"); assert_eq!(selected.webui_dir, expected_webui_dir); @@ -717,10 +769,17 @@ mod tests { } #[test] - fn stale_direct_bundle_falls_back_to_valid_updater_bundle() { + fn same_version_stale_direct_bundle_falls_back_to_executable_bound_updater_bundle() { let temp_dir = tempfile::tempdir().expect("create temp dir"); - let direct = - create_bundle_candidate(&temp_dir, "direct", "direct", "4.27.3", "4.27.3", "v4.27.3"); + let direct = create_bundle_candidate( + &temp_dir, + "direct", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + set_manifest_field(&direct, "sourceCommit", &"a".repeat(40)); let updater = create_bundle_candidate( &temp_dir, "updater", @@ -729,15 +788,79 @@ mod tests { CORE_VERSION, "v4.27.4", ); + let expected_manifest_sha256 = candidate_manifest_sha256(&updater); - let selected = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) - .expect("fall back to updater bundle"); + let selected = select_packaged_resources( + DESKTOP_VERSION, + &expected_manifest_sha256, + vec![Ok(direct), Ok(updater)], + ) + .expect("fall back to updater bundle"); assert_eq!(selected.label, "_up_/resources"); assert_eq!(selected.rejected.len(), 1); assert!(selected.rejected[0] .reason - .contains("Desktop version mismatch")); + .contains("runtime manifest identity mismatch")); + } + + #[test] + fn executable_manifest_identity_rejects_a_coherent_same_version_bundle() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let candidate = create_bundle_candidate( + &temp_dir, + "coherent-old", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + let different_executable_identity = "f".repeat(64); + + let error = validate_packaged_resource_candidate( + candidate, + DESKTOP_VERSION, + &different_executable_identity, + ) + .expect_err("same-version bundle not bound to this executable must fail"); + + assert!(error.contains("runtime manifest identity mismatch")); + assert!(error.contains(&different_executable_identity)); + } + + #[test] + fn entry_only_change_produces_a_new_full_cache_identity() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let original = create_bundle_candidate( + &temp_dir, + "original", + "direct", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + let changed = create_bundle_candidate( + &temp_dir, + "changed", + "_up_/resources", + DESKTOP_VERSION, + CORE_VERSION, + "v4.27.4", + ); + replace_attested_entry(&changed, b"export const changed = true;\n"); + + let original_identity = candidate_manifest_sha256(&original); + let changed_identity = candidate_manifest_sha256(&changed); + + assert_ne!(original_identity, changed_identity); + assert_ne!( + packaged_webui_cache_version(&original_identity), + packaged_webui_cache_version(&changed_identity) + ); + assert_eq!( + packaged_webui_cache_version(&changed_identity), + format!("v1-{changed_identity}") + ); } #[test] @@ -762,9 +885,14 @@ mod tests { ); fs::remove_file(updater.backend_dir.join("runtime-manifest.json")) .expect("remove updater backend manifest"); + let expected_manifest_sha256 = candidate_manifest_sha256(&direct); - let failures = select_packaged_resources(DESKTOP_VERSION, vec![Ok(direct), Ok(updater)]) - .expect_err("partial roots must not be combined"); + let failures = select_packaged_resources( + DESKTOP_VERSION, + &expected_manifest_sha256, + vec![Ok(direct), Ok(updater)], + ) + .expect_err("partial roots must not be combined"); let error = packaged_resources_unavailable_error(DESKTOP_VERSION, &failures); assert_eq!(failures.len(), 2); @@ -825,7 +953,7 @@ mod tests { }; fs::remove_file(missing_path).expect("remove required fixture file"); - let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + let error = validate_fixture_candidate(candidate, DESKTOP_VERSION) .expect_err("incomplete candidate must fail"); assert!(error.contains(expected_error), "unexpected error: {error}"); } @@ -843,7 +971,7 @@ mod tests { "v4.27.3", ); - let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + let error = validate_fixture_candidate(candidate, DESKTOP_VERSION) .expect_err("version mismatch must fail"); assert!(error.contains("Core/WebUI attestation version mismatch")); @@ -862,7 +990,7 @@ mod tests { "v4.27.3", ); - let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + let error = validate_fixture_candidate(candidate, DESKTOP_VERSION) .expect_err("stable Desktop must match Core exactly"); assert!(error.contains("Stable Desktop/Core version mismatch")); @@ -891,7 +1019,7 @@ mod tests { ) .expect("write outside fixture"); set_manifest_field(&parent_candidate, "python", "../outside-python"); - let parent_error = validate_packaged_resource_candidate(parent_candidate, DESKTOP_VERSION) + let parent_error = validate_fixture_candidate(parent_candidate, DESKTOP_VERSION) .expect_err("parent traversal must fail"); assert!(parent_error.contains("python must be a canonical relative path")); @@ -911,9 +1039,8 @@ mod tests { "entrypoint", &absolute_entrypoint.to_string_lossy(), ); - let absolute_error = - validate_packaged_resource_candidate(absolute_candidate, DESKTOP_VERSION) - .expect_err("absolute path must fail"); + let absolute_error = validate_fixture_candidate(absolute_candidate, DESKTOP_VERSION) + .expect_err("absolute path must fail"); assert!(absolute_error.contains("entrypoint must be a canonical relative path")); } @@ -937,7 +1064,7 @@ mod tests { symlink(&outside_entrypoint, &symlink_path).expect("create escape symlink"); set_manifest_field(&candidate, "entrypoint", "linked-launch.py"); - let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + let error = validate_fixture_candidate(candidate, DESKTOP_VERSION) .expect_err("symlink escape must fail"); assert!(error.contains("entrypoint escapes packaged resource directory")); @@ -960,7 +1087,7 @@ mod tests { ) .expect("tamper WebUI entry fixture"); - let error = validate_packaged_resource_candidate(candidate, DESKTOP_VERSION) + let error = validate_fixture_candidate(candidate, DESKTOP_VERSION) .expect_err("tampered WebUI entry must fail"); assert!(error.contains("WebUI entry asset digest mismatch")); @@ -982,20 +1109,13 @@ mod tests { "v4.27.4", ); - let selected = validate_packaged_resource_candidate(candidate, desktop_version) + let selected = validate_fixture_candidate(candidate, desktop_version) .expect("derived Desktop may package a different Core version"); assert_eq!(selected.label, "direct"); assert_eq!( - packaged_webui_cache_version( - desktop_version, - &selected.core_version, - &selected.webui_index_sha256, - ), - format!( - "desktop-{desktop_version}-core-4.27.4-webui-{}", - &selected.webui_index_sha256[..16] - ) + packaged_webui_cache_version(&selected.runtime_manifest_sha256), + format!("v1-{}", selected.runtime_manifest_sha256) ); } } From 698e2e44313c0d8a353597482d54341f281a934f Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 18:16:22 +0800 Subject: [PATCH 05/11] fix(runtime): bound backend HTTP identity reads --- src-tauri/src/backend/http.rs | 141 ++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/backend/http.rs b/src-tauri/src/backend/http.rs index 69c68a48..4b77f4db 100644 --- a/src-tauri/src/backend/http.rs +++ b/src-tauri/src/backend/http.rs @@ -1,7 +1,7 @@ use std::{ io::{ErrorKind, Read, Write}, net::{TcpStream, ToSocketAddrs}, - time::Duration, + time::{Duration, Instant}, }; use url::Url; @@ -12,6 +12,19 @@ use crate::{ BackendState, DESKTOP_AUTH_REQUEST_TIMEOUT_MS, GRACEFUL_RESTART_START_TIME_TIMEOUT_MS, }; +const MAX_BACKEND_HTTP_HEADER_BYTES: usize = 64 * 1024; +const MAX_BACKEND_HTTP_BODY_BYTES: usize = 32 * 1024 * 1024; + +trait ResponseReader: Read { + fn set_response_read_timeout(&self, timeout: Duration) -> std::io::Result<()>; +} + +impl ResponseReader for TcpStream { + fn set_response_read_timeout(&self, timeout: Duration) -> std::io::Result<()> { + self.set_read_timeout(Some(timeout)) + } +} + #[derive(Default)] struct BackendRequestOptions<'a> { auth_token: Option<&'a str>, @@ -78,15 +91,23 @@ impl BackendState { let host = request_url.host_str()?; let port = request_url.port_or_known_default().unwrap_or(80); let timeout = Duration::from_millis(timeout_ms.max(50)); + let deadline = Instant::now().checked_add(timeout)?; let addrs = (host, port).to_socket_addrs().ok()?; let mut stream = addrs.into_iter().find_map(|address| { if options.require_loopback && !is_loopback_socket_address(&address) { return None; } - TcpStream::connect_timeout(&address, timeout).ok() + let remaining = deadline.checked_duration_since(Instant::now())?; + if remaining.is_zero() { + return None; + } + TcpStream::connect_timeout(&address, remaining).ok() })?; - let _ = stream.set_read_timeout(Some(timeout)); - let _ = stream.set_write_timeout(Some(timeout)); + let write_timeout = deadline.checked_duration_since(Instant::now())?; + if write_timeout.is_zero() { + return None; + } + let _ = stream.set_write_timeout(Some(write_timeout)); let mut request_target = request_url.path().to_string(); if let Some(query) = request_url.query() { @@ -129,7 +150,7 @@ Content-Length: {}\r\n\ return None; } - read_http_response_bytes(&mut stream) + read_http_response_bytes(&mut stream, deadline) } pub(crate) fn request_backend_with( @@ -289,23 +310,54 @@ fn sanitize_desktop_session_secret(value: &str) -> Option<&str> { Some(value) } -fn read_http_response_bytes(reader: &mut R) -> Option> { +fn response_exceeds_limits(raw: &[u8]) -> bool { + let Some(header_end) = raw.windows(4).position(|window| window == b"\r\n\r\n") else { + return raw.len() > MAX_BACKEND_HTTP_HEADER_BYTES; + }; + let header_length = header_end + 4; + if header_length > MAX_BACKEND_HTTP_HEADER_BYTES { + return true; + } + + let body_length = raw.len().saturating_sub(header_length); + if body_length > MAX_BACKEND_HTTP_BODY_BYTES { + return true; + } + + let header_text = String::from_utf8_lossy(&raw[..header_length]); + header_text + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .is_some_and(|declared| declared > MAX_BACKEND_HTTP_BODY_BYTES) +} + +fn read_http_response_bytes( + reader: &mut R, + deadline: Instant, +) -> Option> { let mut response = Vec::new(); let mut chunk = [0u8; 4096]; loop { + let remaining = deadline.checked_duration_since(Instant::now())?; + if remaining.is_zero() { + return None; + } + reader.set_response_read_timeout(remaining).ok()?; match reader.read(&mut chunk) { Ok(0) => break, Ok(read) => { response.extend_from_slice(&chunk[..read]); + if Instant::now() >= deadline || response_exceeds_limits(&response) { + return None; + } if is_complete_http_response(&response) { break; } } Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { - if response.is_empty() { - return None; - } - break; + return None; } Err(_) => return None, } @@ -406,12 +458,18 @@ mod tests { } #[test] - fn read_http_response_bytes_keeps_partial_data_on_timeout() { + fn read_http_response_bytes_rejects_partial_data_on_timeout() { struct TimeoutReader { chunks: Vec>, index: usize, } + impl ResponseReader for TimeoutReader { + fn set_response_read_timeout(&self, _timeout: Duration) -> std::io::Result<()> { + Ok(()) + } + } + impl Read for TimeoutReader { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { if self.index >= self.chunks.len() { @@ -437,7 +495,64 @@ mod tests { ], index: 0, }; - let bytes = read_http_response_bytes(&mut reader).expect("expected partial response"); - assert_eq!(bytes, b"HTTP/1.1 200 OK\r\n"); + assert!( + read_http_response_bytes(&mut reader, Instant::now() + Duration::from_secs(1)) + .is_none() + ); + } + + #[test] + fn response_limits_reject_oversized_headers_bodies_and_content_length() { + assert!(response_exceeds_limits(&vec![ + b'a'; + MAX_BACKEND_HTTP_HEADER_BYTES + + 1 + ])); + + let oversized_length = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + MAX_BACKEND_HTTP_BODY_BYTES + 1 + ); + assert!(response_exceeds_limits(oversized_length.as_bytes())); + + let mut oversized_body = b"HTTP/1.1 200 OK\r\n\r\n".to_vec(); + oversized_body.resize(oversized_body.len() + MAX_BACKEND_HTTP_BODY_BYTES + 1, b'x'); + assert!(response_exceeds_limits(&oversized_body)); + } + + #[test] + fn read_http_response_bytes_enforces_an_absolute_deadline_across_trickle_reads() { + struct TrickleReader { + chunks: Vec<&'static [u8]>, + index: usize, + } + + impl ResponseReader for TrickleReader { + fn set_response_read_timeout(&self, _timeout: Duration) -> std::io::Result<()> { + Ok(()) + } + } + + impl Read for TrickleReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.index >= self.chunks.len() { + return Ok(0); + } + std::thread::sleep(Duration::from_millis(35)); + let bytes = self.chunks[self.index]; + self.index += 1; + buf[..bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + } + + let mut reader = TrickleReader { + chunks: vec![b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n", b"test"], + index: 0, + }; + assert!( + read_http_response_bytes(&mut reader, Instant::now() + Duration::from_millis(50)) + .is_none() + ); } } From 21faf7ec75a65a57ae9f8048fc96c9733aba5637 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 18:15:15 +0800 Subject: [PATCH 06/11] build: require Core 4.26 for packaged identity --- scripts/prepare-resources/mode-dispatch.mjs | 3 + .../prepare-resources/mode-dispatch.test.mjs | 20 +++- .../prepare-resources/resource-identity.mjs | 110 +++++++++++++----- .../resource-identity.test.mjs | 49 ++++++++ 4 files changed, 149 insertions(+), 33 deletions(-) diff --git a/scripts/prepare-resources/mode-dispatch.mjs b/scripts/prepare-resources/mode-dispatch.mjs index a10aa5f9..d56da043 100644 --- a/scripts/prepare-resources/mode-dispatch.mjs +++ b/scripts/prepare-resources/mode-dispatch.mjs @@ -3,6 +3,7 @@ import { prepareWebui, validatePreparedResources, } from './mode-tasks.mjs'; +import { validatePackagedCoreVersion } from './resource-identity.mjs'; const VALID_MODES = new Set(['version', 'webui', 'backend', 'all']); @@ -38,6 +39,8 @@ export const runModeTasks = async ( return; } + validatePackagedCoreVersion(coreVersion); + if (mode === 'webui' || mode === 'all') { await taskRunner.prepareWebui({ sourceDir, diff --git a/scripts/prepare-resources/mode-dispatch.test.mjs b/scripts/prepare-resources/mode-dispatch.test.mjs index 73e2230e..492d4953 100644 --- a/scripts/prepare-resources/mode-dispatch.test.mjs +++ b/scripts/prepare-resources/mode-dispatch.test.mjs @@ -7,10 +7,10 @@ import { runModeTasks } from './mode-dispatch.mjs'; const createContext = (calls) => ({ sourceDir: '/tmp/source', projectRoot: '/tmp/project', - desktopVersion: '4.19.2', - coreVersion: '4.19.2', + desktopVersion: '4.27.5', + coreVersion: '4.27.5', sourceRepoCommit: 'a'.repeat(40), - sourceRepoRef: 'v4.19.2', + sourceRepoRef: 'v4.27.5', isSourceRepoRefVersionTag: true, isDesktopBridgeExpectationStrict: false, pythonBuildStandaloneRelease: '20260211', @@ -25,12 +25,24 @@ const createTaskRunner = (calls) => ({ test('runModeTasks skips handlers in version mode', async () => { const calls = []; + const context = { ...createContext(calls), coreVersion: '4.17.5' }; - await runModeTasks('version', createContext(calls), createTaskRunner(calls)); + await runModeTasks('version', context, createTaskRunner(calls)); assert.deepEqual(calls, []); }); +test('runModeTasks rejects an unsupported Core before preparing packaged resources', async () => { + const calls = []; + const context = { ...createContext(calls), coreVersion: 'v4.25.9' }; + + await assert.rejects( + runModeTasks('all', context, createTaskRunner(calls)), + /packaged resource identity requires Core 4\.26\.0 or newer/, + ); + assert.deepEqual(calls, []); +}); + test('runModeTasks runs webui handler in webui mode', async () => { const calls = []; diff --git a/scripts/prepare-resources/resource-identity.mjs b/scripts/prepare-resources/resource-identity.mjs index 4b651e7d..cfaeb08d 100644 --- a/scripts/prepare-resources/resource-identity.mjs +++ b/scripts/prepare-resources/resource-identity.mjs @@ -8,8 +8,9 @@ import { requiredRuntimeRelativePath } from '../backend/runtime-manifest.mjs'; const VERSION_PREFIX_PATTERN = /^v/i; const LOCAL_ENTRY_PATTERN = /\.(?:css|js)$/i; const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const SEMVER_CORE_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; -const SEMVER_IDENTIFIER_PATTERN = /^[0-9A-Za-z-]+$/; +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export const MINIMUM_PACKAGED_CORE_VERSION = '4.26.0'; const sha256 = (content) => createHash('sha256').update(content).digest('hex'); @@ -24,38 +25,89 @@ export const normalizeResourceVersion = (version) => { export const formatWebuiVersion = (coreVersion) => `v${normalizeResourceVersion(coreVersion)}`; -export const requiresDesktopCoreMatch = (desktopVersion) => { - const normalized = normalizeResourceVersion(desktopVersion); - const buildParts = normalized.split('+'); +const parseSemver = (version) => { + const normalized = normalizeResourceVersion(version); + const match = SEMVER_PATTERN.exec(normalized); + if (!match) { + throw new Error(`Core version ${version} is not valid semantic version.`); + } + const prerelease = match[4] ? match[4].split('.') : []; if ( - buildParts.length > 2 || - (buildParts.length === 2 && - (!buildParts[1] || - !buildParts[1].split('.').every((part) => SEMVER_IDENTIFIER_PATTERN.test(part)))) + prerelease.some( + (identifier) => + /^\d+$/.test(identifier) && + identifier.length > 1 && + identifier.startsWith('0'), + ) ) { - return true; + throw new Error(`Core version ${version} is not valid semantic version.`); } - const versionWithoutBuild = buildParts[0]; - const prereleaseSeparator = versionWithoutBuild.indexOf('-'); - const core = prereleaseSeparator < 0 - ? versionWithoutBuild - : versionWithoutBuild.slice(0, prereleaseSeparator); - if (!SEMVER_CORE_PATTERN.test(core)) { - return true; + return { + normalized, + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease, + }; +}; + +const compareSemver = (left, right) => { + for (let index = 0; index < left.core.length; index += 1) { + if (left.core[index] !== right.core[index]) { + return left.core[index] < right.core[index] ? -1 : 1; + } + } + if (left.prerelease.length === 0 && right.prerelease.length === 0) { + return 0; + } + if (left.prerelease.length === 0) { + return 1; + } + if (right.prerelease.length === 0) { + return -1; + } + const count = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < count; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + if (leftIdentifier === rightIdentifier) { + continue; + } + const leftIsNumeric = /^\d+$/.test(leftIdentifier); + const rightIsNumeric = /^\d+$/.test(rightIdentifier); + if (leftIsNumeric && rightIsNumeric) { + return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1; + } + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + return leftIdentifier < rightIdentifier ? -1 : 1; } - if (prereleaseSeparator < 0) { + return 0; +}; + +export const validatePackagedCoreVersion = (coreVersion) => { + const parsed = parseSemver(coreVersion); + const minimum = parseSemver(MINIMUM_PACKAGED_CORE_VERSION); + if (compareSemver(parsed, minimum) < 0) { + throw new Error( + `Packaged Core ${parsed.normalized} is unsupported: packaged resource identity requires Core ${MINIMUM_PACKAGED_CORE_VERSION} or newer because Desktop verifies /api/v1/stats/versions at startup.`, + ); + } + return parsed.normalized; +}; + +export const requiresDesktopCoreMatch = (desktopVersion) => { + try { + return parseSemver(desktopVersion).prerelease.length === 0; + } catch { + // Match semver::Version on the Rust side: invalid versions fail closed as stable. return true; } - const prerelease = versionWithoutBuild.slice(prereleaseSeparator + 1); - const identifiers = prerelease.split('.'); - const validPrerelease = identifiers.every( - (identifier) => - SEMVER_IDENTIFIER_PATTERN.test(identifier) && - (!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')), - ); - // Match semver::Version on the Rust side: invalid versions fail closed as - // stable, and build metadata alone does not make a release a prerelease. - return !validPrerelease; }; const normalizeLocalAssetReference = (reference) => { @@ -318,7 +370,7 @@ export const validatePreparedResourceBundle = async ({ requireWebuiAttestation = false, }) => { const normalizedDesktopVersion = normalizeResourceVersion(desktopVersion); - const normalizedCoreVersion = normalizeResourceVersion(coreVersion); + const normalizedCoreVersion = validatePackagedCoreVersion(coreVersion); const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')); const packageVersion = normalizeResourceVersion(packageJson.version); diff --git a/scripts/prepare-resources/resource-identity.test.mjs b/scripts/prepare-resources/resource-identity.test.mjs index 4981e098..d0f7b2f7 100644 --- a/scripts/prepare-resources/resource-identity.test.mjs +++ b/scripts/prepare-resources/resource-identity.test.mjs @@ -8,7 +8,9 @@ import { attestPreparedResourceBundle, extractWebuiEntryAssets, formatWebuiVersion, + MINIMUM_PACKAGED_CORE_VERSION, requiresDesktopCoreMatch, + validatePackagedCoreVersion, validatePreparedResourceBundle, validateWebuiResources, writeWebuiVersionMarker, @@ -72,6 +74,32 @@ test('requiresDesktopCoreMatch mirrors the runtime stable-version rule', () => { assert.equal(requiresDesktopCoreMatch('4.27.5-01'), true); }); +test('validatePackagedCoreVersion accepts the minimum and newer SemVer variants', () => { + assert.equal(MINIMUM_PACKAGED_CORE_VERSION, '4.26.0'); + assert.equal(validatePackagedCoreVersion('v4.26.0'), '4.26.0'); + assert.equal(validatePackagedCoreVersion('V4.26.0+desktop.1'), '4.26.0+desktop.1'); + assert.equal(validatePackagedCoreVersion('4.26.1-rc.1'), '4.26.1-rc.1'); + assert.equal(validatePackagedCoreVersion('5.0.0-alpha.1'), '5.0.0-alpha.1'); +}); + +test('validatePackagedCoreVersion rejects versions below the identity capability floor', () => { + for (const version of ['4.25.99', 'v4.26.0-rc.1']) { + assert.throws( + () => validatePackagedCoreVersion(version), + /packaged resource identity requires Core 4\.26\.0 or newer/, + ); + } +}); + +test('validatePackagedCoreVersion rejects malformed semantic versions', () => { + for (const version of ['4.26', '4.26.0-01', '04.26.0', 'not-semver']) { + assert.throws( + () => validatePackagedCoreVersion(version), + /is not valid semantic version/, + ); + } +}); + test('extractWebuiEntryAssets finds local JavaScript and CSS entries', () => { const entries = extractWebuiEntryAssets( '' + @@ -100,6 +128,27 @@ test('validatePreparedResourceBundle accepts a matching stable bundle', async () } }); +test('validatePreparedResourceBundle rejects a Core below the packaged identity minimum', async () => { + const fixture = await createBundleFixture({ + desktopVersion: '4.25.9', + coreVersion: '4.25.9', + }); + try { + await assert.rejects( + validatePreparedResourceBundle({ + projectRoot: fixture.projectRoot, + desktopVersion: '4.25.9', + coreVersion: 'v4.25.9', + sourceRepoRef: 'v4.25.9', + sourceRepoCommit: 'a'.repeat(40), + }), + /packaged resource identity requires Core 4\.26\.0 or newer/, + ); + } finally { + await rm(fixture.projectRoot, { recursive: true, force: true }); + } +}); + test('attestPreparedResourceBundle binds the runtime manifest to WebUI content', async () => { const fixture = await createBundleFixture(); try { From a8377a6598b46dceb5eb1a4fb6dd855673272b09 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 18:15:20 +0800 Subject: [PATCH 07/11] docs: explain packaged resource identity requirements --- docs/architecture.md | 34 ++++++++++++++++++++++++---------- docs/development.md | 17 ++++++++++++----- docs/repository-structure.md | 13 +++++++++++-- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2c75a133..b5ad2168 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,26 +130,36 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何 ### 3.3 资源与根目录解析 - `runtime_paths.rs` 负责 packaged root、workspace root 和资源路径探测。 -- Tauri 资源路径支持直接资源路径和 `_up_/resources` 回退路径。 -- `launch_plan.rs` 根据当前模式决定 backend cwd、root_dir 和 webui_dir。 +- Tauri 资源路径支持直接资源路径和 `_up_/resources` 候选;`launch_plan.rs` 会把每个候选视为完整的 backend/WebUI 根,不跨根混用资源。 +- 正式构建会把最终 `runtime-manifest.json` 的 SHA-256 编入可执行文件;打包态只接受 manifest 摘要与当前可执行文件一致的候选。 +- `launch_plan.rs` 校验 Desktop/Core/WebUI 版本、manifest 路径、WebUI marker、index 和入口摘要,再决定 backend cwd、root_dir 和 webui_dir。 +- packaged Core 最低要求为 `4.26.0`,因为打包态 readiness 必须通过 `/api/v1/stats/versions` 核对实际运行的 Core/code/WebUI。该限制不应用于 debug/dev 启动计划或显式外部 backend。 ## 4. 主要流程 -### 4.1 启动流程 +### 4.1 打包资源生成与身份绑定 + +1. `scripts/prepare-resources.mjs all` 从同一个 AstrBot checkout 依次准备 WebUI 和 backend,避免两次任务之间 source ref 漂移。 +2. `resource-identity.mjs` 要求 Core `>=4.26.0`,写入 WebUI `assets/version`,并校验 index 及其本地 JavaScript/CSS 入口。 +3. `runtime-manifest.mjs` 生成 backend manifest;最终 attestation 加入 Desktop/Core/source 信息以及 WebUI marker/index/入口摘要。 +4. `src-tauri/build.rs` 对最终 manifest 原始字节计算 SHA-256 并编入可执行文件;release 构建缺少 manifest 时直接失败。 + +### 4.2 启动流程 1. `app_runtime.rs` 初始化 Tauri 插件、窗口事件、页面加载事件和托盘。 -2. `startup_task.rs` 异步解析启动计划,执行 backend readiness 检查与必要拉起。 -3. backend ready 后导航主窗口;失败时进入 startup error 路径。 -4. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。 +2. `startup_task.rs` 异步解析启动计划;打包态从 direct / `_up_/resources` 中选取与可执行文件绑定的完整资源根,开发态仍使用独立的 dev/custom 计划。 +3. backend readiness 在接受已运行或刚拉起的打包 backend 前,校验 `/api/v1/stats/versions`,并核对实际送出的 index 和 manifest 声明的入口摘要。 +4. backend ready 后用 manifest 摘要生成的 `astrbot_bundle` 查询参数导航主窗口;失败时进入可见的 startup error 路径。 +5. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。 -### 4.2 bridge 注入与桌面交互流程 +### 4.3 bridge 注入与桌面交互流程 1. `bridge/origin_policy.rs` 判断当前页面是否允许注入 desktop bridge。 2. `bridge/desktop.rs` 把 bootstrap 脚本注入 WebView。 3. WebUI 通过 `bridge/commands.rs` 调用 desktop IPC。 4. tray / window 子系统根据当前 locale 和窗口状态刷新文案与可见性。 -### 4.3 更新检查/安装流程 +### 4.4 更新检查/安装流程 1. `bridge/commands.rs` 先用 `bridge/updater_mode.rs` 判定当前 updater 模式。 2. `ManualDownload` / `Unsupported` 直接短路,复用 `bridge/updater_messages.rs` 和 `bridge/updater_types.rs` 返回统一结果。 @@ -157,14 +167,14 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何 4. updater manifest endpoint 优先取 `ASTRBOT_DESKTOP_UPDATER_STABLE_ENDPOINT` / `ASTRBOT_DESKTOP_UPDATER_NIGHTLY_ENDPOINT`,否则回退到 `tauri.conf.json`。 5. 版本比较仍由 `update_channel.rs` 统一控制 stable / nightly 跨通道规则。 -### 4.4 重启流程 +### 4.5 重启流程 1. 触发源来自 tray 菜单或 bridge IPC。 2. `restart_backend_flow.rs` 统一处理并发门禁。 3. `backend/restart.rs` 和 `backend/restart_strategy.rs` 决定 graceful 或 fallback 路径。 4. 完成后刷新 bridge / tray 侧可观察状态。 -### 4.5 退出流程 +### 4.6 退出流程 1. `lifecycle/events.rs` 在 `ExitRequested` 阶段先阻止直接退出。 2. `exit_state.rs` 尝试进入清理态。 @@ -179,11 +189,15 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何 - 源码仓库 URL/ref、clone/fetch/checkout。 - `scripts/prepare-resources/version-sync.mjs` - 桌面版本同步。 +- `scripts/prepare-resources/resource-identity.mjs` + - packaged Core 最低能力门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI attestation。 - `scripts/prepare-resources/backend-runtime.mjs` - CPython runtime 准备。 - `scripts/prepare-resources/mode-tasks.mjs` - WebUI / backend 资源准备任务。 - `scripts/prepare-resources/desktop-bridge-checks.mjs` - bridge 工件校验。 +- `scripts/backend/runtime-manifest.mjs` + - backend runtime manifest 字段、相对路径和 source identity 生成规则。 当前本地和 CI 主要通过 `make lint`、`make test`、`check-rust.yml`、`check-scripts.yml` 维持这些边界。 diff --git a/docs/development.md b/docs/development.md index 1dff8fff..799ffd3b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,11 +95,13 @@ make prune ```bash make update -make update ASTRBOT_SOURCE_GIT_REF=v4.17.5 -make build ASTRBOT_DESKTOP_VERSION=v4.17.5 +make update ASTRBOT_SOURCE_GIT_REF=v4.26.0 +make build ASTRBOT_SOURCE_GIT_REF=v4.26.0 ASTRBOT_DESKTOP_VERSION=v4.26.0 make build ASTRBOT_BUILD_SOURCE_DIR=/path/to/AstrBot ``` +正式打包要求 AstrBot Core `>=4.26.0`。这是 `/api/v1/stats/versions` 首次可用于 Desktop 启动期 Core/code/WebUI 身份核对的版本;更早的 Core 会在 packaged resource 准备开始时明确失败。`make dev` 的开发启动计划和显式配置的外部 backend 不使用这条 packaged identity 门禁。 + 如果需要清理构建相关环境变量: ```bash @@ -118,9 +120,14 @@ beforeBuildCommand = pnpm run prepare:resources 构建时会自动完成以下步骤: 1. 拉取或更新 AstrBot 源码。 -2. 构建并同步 `resources/webui`。 -3. 准备 `resources/backend`(包括运行时与启动脚本)。 -4. 执行 Tauri 打包。 +2. 校验 packaged Core 至少为 `4.26.0`。 +3. 从同一个 source checkout 构建并同步 `resources/webui`,写入 `assets/version`,校验 index 与本地 JavaScript/CSS 入口。 +4. 准备 `resources/backend`(包括运行时与启动脚本),生成包含 Desktop/Core/source identity 的 `runtime-manifest.json`。 +5. 把 WebUI version/index/入口摘要写入最终 manifest,并再次校验整套资源。 +6. `src-tauri/build.rs` 把最终 manifest 的 SHA-256 编入可执行文件;release 构建缺少 manifest 时失败。 +7. 执行 Tauri 打包。 + +运行时不会把 direct 与 `_up_/resources` 下的 backend/WebUI 混用。打包启动计划只接受 manifest 摘要与当前可执行文件一致的完整资源根,并在导航前核对运行中 backend 的公开版本、served index 和入口资产。主窗口 URL 使用该 manifest 摘要作为 `astrbot_bundle` 缓存身份。 补充说明:主窗口当前显式设置了 `backgroundThrottling = "disabled"`,用于缓解 macOS 上窗口隐藏或转入后台后 `WKWebView` 被系统节流/挂起导致的前端假死问题。根据当前 Tauri 2 配置能力,该选项在 macOS 14+ 上生效;更早版本的 macOS 会回退到系统默认后台策略。 diff --git a/docs/repository-structure.md b/docs/repository-structure.md index 4ee7595b..dec8e1d7 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -80,7 +80,7 @@ - `backend/runtime.rs` - backend 运行时参数(timeout/readiness/ping)解析与缓存。 - `backend/readiness.rs` - - backend 就绪探测、等待轮询与超时日志收敛。 + - backend 就绪探测、等待轮询、打包态 live Core/WebUI identity 校验与超时日志收敛。 - `backend/restart.rs` - backend restart token 管理、graceful/fallback 策略与 bridge 状态组装。 - `backend/restart_strategy.rs` @@ -115,7 +115,7 @@ - `restart_backend_flow.rs` - backend 重启任务与并发判定流程封装。 - `launch_plan.rs` - - custom/packaged/dev 启动计划构建与路径解析。 + - custom/packaged/dev 启动计划构建;打包态完整资源候选选择、manifest 绑定和 WebUI 摘要校验。 - `startup_task.rs` - 启动阶段后端就绪等待与主线程导航分发。 - `app_runtime.rs` @@ -143,9 +143,18 @@ - `webui/backend/all` 任务实现。 - `desktop-bridge-checks.mjs` - bridge 相关校验。 +- `resource-identity.mjs` + - packaged Core 最低版本门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI bundle attestation。 - `*.test.mjs` - Node 行为测试。 +`scripts/backend/` 中与资源身份直接相关的模块: + +- `runtime-manifest.mjs` + - 生成 backend runtime manifest,规范 runtime 相对路径、Desktop/Core 版本和 source ref/commit 字段。 + +正式 `prepare:resources` 会按 WebUI -> backend -> 最终 attestation 的顺序运行;`src-tauri/build.rs` 再把最终 manifest 摘要编入可执行文件。`version` 单独模式以及 Rust 的 debug/dev/custom external backend 路径不使用 packaged identity 门禁。 + ## 4. 文档组织(`docs/`) - `architecture.md` From 3eb176fc12072aad7400c387b26c2a3c92aeffb7 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 18:42:08 +0800 Subject: [PATCH 08/11] test(ci): verify served WebUI resource identity --- scripts/ci/backend-smoke-test.mjs | 352 ++++++++++++++++++++++++- scripts/ci/backend-smoke-test.test.mjs | 266 ++++++++++++++++++- 2 files changed, 612 insertions(+), 6 deletions(-) diff --git a/scripts/ci/backend-smoke-test.mjs b/scripts/ci/backend-smoke-test.mjs index 97417d22..d504ba1b 100644 --- a/scripts/ci/backend-smoke-test.mjs +++ b/scripts/ci/backend-smoke-test.mjs @@ -5,12 +5,19 @@ import process from 'node:process'; import net from 'node:net'; import http from 'node:http'; import https from 'node:https'; +import { createHash } from 'node:crypto'; import { spawn } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; const defaultBackendDir = path.resolve('resources', 'backend'); const defaultWebuiDir = path.resolve('resources', 'webui'); +const versionsPath = '/api/v1/stats/versions'; +const webuiIndexPath = '/index.html'; +const maxVersionsResponseBytes = 64 * 1024; +const maxIndexResponseBytes = 4 * 1024 * 1024; +const maxEntryResponseBytes = 64 * 1024 * 1024; +const sha256Pattern = /^[0-9a-f]{64}$/; const usageMessage = () => ` Usage: node scripts/ci/backend-smoke-test.mjs [options] @@ -96,6 +103,320 @@ const assertPathExists = (fsLike, targetPath, description) => { } }; +const sha256 = (content) => createHash('sha256').update(content).digest('hex'); + +const normalizeVersion = (value, field) => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + const normalized = trimmed.replace(/^v/i, ''); + if (!normalized) { + throw new Error(`Backend runtime manifest ${field} must not be empty.`); + } + return normalized; +}; + +const normalizeSha256 = (value, field) => { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (!sha256Pattern.test(normalized)) { + throw new Error(`Backend runtime manifest ${field} must be a SHA-256 digest.`); + } + return normalized; +}; + +const normalizeEntryPath = (value) => { + const raw = typeof value === 'string' ? value.trim() : ''; + const portable = raw.replaceAll('\\', '/'); + const segments = portable.split('/'); + if ( + !portable || + raw.includes('\0') || + path.posix.isAbsolute(portable) || + path.win32.parse(raw).root || + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error( + 'Backend runtime manifest webui.entryAssets[].path must be a canonical relative path.', + ); + } + const extension = path.posix.extname(portable).toLowerCase(); + if (extension !== '.js' && extension !== '.css') { + throw new Error( + `Backend runtime manifest contains unsupported WebUI entry asset: ${raw}`, + ); + } + return portable; +}; + +const parseRuntimeIdentityManifest = (manifest, manifestPath) => { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error(`Invalid backend runtime manifest: ${manifestPath}`); + } + const coreVersion = normalizeVersion(manifest.coreVersion, 'coreVersion'); + const attestation = manifest.webui; + if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) { + throw new Error( + `Backend runtime manifest is missing the WebUI bundle attestation: ${manifestPath}`, + ); + } + const webuiVersion = normalizeVersion(attestation.version, 'webui.version'); + if (webuiVersion !== coreVersion) { + throw new Error( + `Backend runtime manifest Core/WebUI version mismatch: Core is ${coreVersion}, WebUI is ${webuiVersion}.`, + ); + } + const indexSha256 = normalizeSha256(attestation.indexSha256, 'webui.indexSha256'); + if (!Array.isArray(attestation.entryAssets)) { + throw new Error('Backend runtime manifest webui.entryAssets must be an array.'); + } + + const seenPaths = new Set(); + let hasJavascriptEntry = false; + const entryAssets = attestation.entryAssets.map((entry) => { + const entryPath = normalizeEntryPath(entry?.path); + if (seenPaths.has(entryPath)) { + throw new Error( + `Backend runtime manifest contains duplicate WebUI entry asset: ${entryPath}`, + ); + } + seenPaths.add(entryPath); + hasJavascriptEntry ||= entryPath.toLowerCase().endsWith('.js'); + return { + path: entryPath, + sha256: normalizeSha256(entry?.sha256, 'webui.entryAssets[].sha256'), + }; + }); + if (!hasJavascriptEntry) { + throw new Error('Backend runtime manifest WebUI attestation has no JavaScript entry asset.'); + } + + return { coreVersion, indexSha256, entryAssets }; +}; + +const fetchIdentityBytesWithTimeout = async (url, timeoutMs, maxResponseBytes) => + new Promise((resolve, reject) => { + const urlObject = new URL(url); + const client = urlObject.protocol === 'https:' ? https : http; + let settled = false; + let timer = null; + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + callback(value); + }; + const request = client.request( + urlObject, + { + method: 'GET', + headers: { + Accept: '*/*', + 'Accept-Encoding': 'identity', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + }, + (response) => { + const contentEncoding = String(response.headers['content-encoding'] || '') + .trim() + .toLowerCase(); + if (contentEncoding && contentEncoding !== 'identity') { + finish(reject, new Error(`Unexpected Content-Encoding: ${contentEncoding}`)); + response.destroy(); + return; + } + const declaredLength = Number(response.headers['content-length']); + if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) { + finish( + reject, + new Error( + `Response exceeds ${maxResponseBytes} bytes (Content-Length: ${declaredLength}).`, + ), + ); + response.destroy(); + return; + } + + const chunks = []; + let receivedBytes = 0; + response.on('data', (chunk) => { + receivedBytes += chunk.length; + if (receivedBytes > maxResponseBytes) { + finish( + reject, + new Error(`Response exceeds ${maxResponseBytes} bytes while streaming.`), + ); + response.destroy(); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + finish(resolve, { + status: response.statusCode || 0, + ok: Boolean( + response.statusCode && + response.statusCode >= 200 && + response.statusCode < 300, + ), + body: Buffer.concat(chunks), + }); + }); + response.on('aborted', () => { + finish(reject, new Error('Response ended before the complete body was received.')); + }); + response.on('close', () => { + if (!response.complete) { + finish(reject, new Error('Response closed before the complete body was received.')); + } + }); + response.on('error', (error) => finish(reject, error)); + }, + ); + timer = setTimeout(() => { + request.destroy(new Error(`Request timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + request.on('error', (error) => finish(reject, error)); + request.end(); + }); + +const requestIdentityResource = async ({ + backendUrl, + requestPath, + description, + timeoutMs, + maxResponseBytes, + notFoundMessage = '', + runtime, +}) => { + const url = new URL(requestPath, backendUrl).href; + let response; + try { + response = await runtime.fetchIdentityBytesWithTimeout( + url, + timeoutMs, + maxResponseBytes, + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot fetch ${description} at ${requestPath}: ${reason}`); + } + if (!response.ok) { + if (response.status === 404 && notFoundMessage) { + throw new Error(notFoundMessage); + } + throw new Error( + `Cannot fetch ${description} at ${requestPath}: HTTP ${response.status}.`, + ); + } + return response.body; +}; + +const normalizeRunningVersion = (value, field) => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + const normalized = trimmed.replace(/^v/i, ''); + if (!normalized) { + throw new Error(`Running backend version field ${field} is missing or empty.`); + } + return normalized; +}; + +const verifyRunningResourceIdentity = async ({ + backendUrl, + expectedIdentity, + timeoutMs, + runtime, +}) => { + const deadline = Date.now() + timeoutMs; + const remainingTimeoutMs = () => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Running resource identity check timed out after ${timeoutMs}ms.`); + } + return remaining; + }; + const versionsBody = await requestIdentityResource({ + backendUrl, + requestPath: versionsPath, + description: 'running AstrBot resource versions', + timeoutMs: remainingTimeoutMs(), + maxResponseBytes: maxVersionsResponseBytes, + runtime, + }); + let versionsPayload; + try { + versionsPayload = JSON.parse(versionsBody.toString('utf8')); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Running AstrBot resource versions response is invalid JSON: ${reason}`); + } + if (versionsPayload?.status !== 'ok' || !versionsPayload.data) { + throw new Error( + 'Running AstrBot resource versions endpoint did not return status=ok with data.', + ); + } + const runningVersions = { + core: normalizeRunningVersion( + versionsPayload.data.astrbot_version, + 'astrbot_version', + ), + code: normalizeRunningVersion( + versionsPayload.data.astrbot_code_version, + 'astrbot_code_version', + ), + webui: normalizeRunningVersion( + versionsPayload.data.webui_version, + 'webui_version', + ), + }; + if ( + runningVersions.core !== expectedIdentity.coreVersion || + runningVersions.code !== expectedIdentity.coreVersion || + runningVersions.webui !== expectedIdentity.coreVersion + ) { + throw new Error( + `Running Core/WebUI version mismatch: expected ${expectedIdentity.coreVersion}, got Core ${runningVersions.core}, code ${runningVersions.code}, WebUI ${runningVersions.webui}.`, + ); + } + + const indexBody = await requestIdentityResource({ + backendUrl, + requestPath: webuiIndexPath, + description: 'running WebUI index', + timeoutMs: remainingTimeoutMs(), + maxResponseBytes: maxIndexResponseBytes, + runtime, + }); + const runningIndexSha256 = sha256(indexBody); + if (runningIndexSha256 !== expectedIdentity.indexSha256) { + throw new Error( + `Running WebUI index SHA-256 mismatch: expected ${expectedIdentity.indexSha256}, got ${runningIndexSha256}.`, + ); + } + + for (const entry of expectedIdentity.entryAssets) { + const requestPath = `/${entry.path + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/')}`; + const entryBody = await requestIdentityResource({ + backendUrl, + requestPath, + description: `running WebUI entry ${entry.path}`, + timeoutMs: remainingTimeoutMs(), + maxResponseBytes: maxEntryResponseBytes, + notFoundMessage: `Running WebUI entry is missing: ${entry.path}.`, + runtime, + }); + const runningEntrySha256 = sha256(entryBody); + if (runningEntrySha256 !== entry.sha256) { + throw new Error( + `Running WebUI entry SHA-256 mismatch for ${entry.path}: expected ${entry.sha256}, got ${runningEntrySha256}.`, + ); + } + } +}; + const reserveLoopbackPort = async () => new Promise((resolve, reject) => { // NOTE: this reserve-then-bind pattern has a small race window by design. @@ -195,6 +516,7 @@ const createMainRuntime = (overrides = {}) => ({ spawn, reserveLoopbackPort, fetchWithTimeout, + fetchIdentityBytesWithTimeout, terminateChild, sleep, now: () => Date.now(), @@ -218,10 +540,17 @@ const main = async (options, runtime = createMainRuntime()) => { assertPathExists(runtime.fs, launcherPath, 'Backend launcher'); assertPathExists(runtime.fs, appMainPath, 'Backend app main.py'); - const manifest = JSON.parse(runtime.fs.readFileSync(manifestPath, 'utf8')); + let manifest; + try { + manifest = JSON.parse(runtime.fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid backend runtime manifest: ${manifestPath} (${reason})`); + } if (!manifest.python || typeof manifest.python !== 'string') { throw new Error(`Invalid runtime manifest python entry: ${manifestPath}`); } + const expectedIdentity = parseRuntimeIdentityManifest(manifest, manifestPath); const pythonPath = path.join(backendDir, manifest.python); assertPathExists(runtime.fs, pythonPath, 'Runtime python executable'); @@ -317,6 +646,16 @@ const main = async (options, runtime = createMainRuntime()) => { if (child.exitCode !== null) { throw new Error(`Backend crashed after readiness (exit=${child.exitCode}).`); } + const identityTimeoutMs = Math.min( + 10_000, + Math.max(1_200, options.startupTimeoutMs), + ); + await verifyRunningResourceIdentity({ + backendUrl, + expectedIdentity, + timeoutMs: identityTimeoutMs, + runtime, + }); console.log(`${tracePrefix} backend startup smoke test passed.`); } catch (error) { const details = childLogs.length @@ -385,4 +724,13 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(exitCode); } -export { createMainRuntime, main, parseCliOptions, runCli, usageMessage }; +export { + createMainRuntime, + fetchIdentityBytesWithTimeout, + main, + parseCliOptions, + parseRuntimeIdentityManifest, + runCli, + usageMessage, + verifyRunningResourceIdentity, +}; diff --git a/scripts/ci/backend-smoke-test.test.mjs b/scripts/ci/backend-smoke-test.test.mjs index fcb77d62..f9abcd75 100644 --- a/scripts/ci/backend-smoke-test.test.mjs +++ b/scripts/ci/backend-smoke-test.test.mjs @@ -2,11 +2,52 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { createServer } from 'node:http'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { test } from 'node:test'; -import { main, parseCliOptions, runCli, usageMessage } from './backend-smoke-test.mjs'; +import { + fetchIdentityBytesWithTimeout, + main, + parseCliOptions, + parseRuntimeIdentityManifest, + runCli, + usageMessage, + verifyRunningResourceIdentity, +} from './backend-smoke-test.mjs'; + +const sha256 = (content) => createHash('sha256').update(content).digest('hex'); + +const createIdentityScenario = () => { + const indexBody = Buffer.from( + '', + ); + const cssBody = Buffer.from('body { color: #123456; }\n'); + const javascriptBody = Buffer.from('console.log("current");\n'); + const manifest = { + python: 'python/python', + coreVersion: '4.27.5', + webui: { + version: '4.27.5', + indexSha256: sha256(indexBody), + entryAssets: [ + { path: 'assets/index.css', sha256: sha256(cssBody) }, + { path: 'assets/index.js', sha256: sha256(javascriptBody) }, + ], + }, + }; + return { + manifest, + expectedIdentity: parseRuntimeIdentityManifest(manifest, 'runtime-manifest.json'), + indexBody, + entryBodies: new Map([ + ['assets/index.css', cssBody], + ['assets/index.js', javascriptBody], + ]), + }; +}; const createFixtureLayout = async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'astrbot-backend-smoke-test-')); @@ -17,20 +58,64 @@ const createFixtureLayout = async () => { const launcherPath = path.join(backendDir, 'launch_backend.py'); const mainPath = path.join(appDir, 'main.py'); const pythonPath = path.join(pythonDir, 'python'); + const scenario = createIdentityScenario(); await mkdir(appDir, { recursive: true }); await mkdir(pythonDir, { recursive: true }); - await mkdir(webuiDir, { recursive: true }); + await mkdir(path.join(webuiDir, 'assets'), { recursive: true }); await writeFile(launcherPath, '# launcher', 'utf8'); await writeFile(mainPath, '# main', 'utf8'); await writeFile(pythonPath, '#!/bin/sh\n', 'utf8'); + await writeFile(path.join(webuiDir, 'index.html'), scenario.indexBody); + for (const [entryPath, body] of scenario.entryBodies) { + await writeFile(path.join(webuiDir, entryPath), body); + } await writeFile( path.join(backendDir, 'runtime-manifest.json'), - JSON.stringify({ python: 'python/python' }), + JSON.stringify(scenario.manifest), 'utf8', ); - return { root, backendDir, webuiDir }; + return { root, backendDir, webuiDir, ...scenario }; +}; + +const createIdentityResponse = (body, status = 200) => ({ + status, + ok: status >= 200 && status < 300, + body: Buffer.isBuffer(body) ? body : Buffer.from(body), +}); + +const createIdentityFetch = ({ + coreVersion = '4.27.5', + runningVersions = {}, + indexBody, + entryBodies, + entryStatuses = new Map(), +}) => async (url) => { + const requestPath = new URL(url).pathname; + if (requestPath === '/api/v1/stats/versions') { + return createIdentityResponse( + JSON.stringify({ + status: 'ok', + data: { + astrbot_version: runningVersions.core ?? coreVersion, + astrbot_code_version: runningVersions.code ?? coreVersion, + webui_version: runningVersions.webui ?? `v${coreVersion}`, + }, + }), + ); + } + if (requestPath === '/index.html') { + return createIdentityResponse(indexBody); + } + const entryPath = decodeURIComponent(requestPath.replace(/^\//, '')); + if (!entryBodies.has(entryPath)) { + return createIdentityResponse('missing', 404); + } + return createIdentityResponse( + entryBodies.get(entryPath), + entryStatuses.get(entryPath) ?? 200, + ); }; const createFakeChild = () => { @@ -483,6 +568,175 @@ test('main fails when manifest.python points to a non-existent executable', asyn } }); +test('runtime manifest rejects mismatched Core and WebUI attestation versions', () => { + const scenario = createIdentityScenario(); + assert.throws( + () => + parseRuntimeIdentityManifest( + { + ...scenario.manifest, + webui: { ...scenario.manifest.webui, version: '4.27.0' }, + }, + 'runtime-manifest.json', + ), + /Core\/WebUI version mismatch: Core is 4\.27\.5, WebUI is 4\.27\.0/, + ); +}); + +test('running resource identity accepts matching versions, index, and every entry asset', async () => { + const scenario = createIdentityScenario(); + const requestedPaths = []; + const fetchIdentity = createIdentityFetch(scenario); + + await verifyRunningResourceIdentity({ + backendUrl: 'http://127.0.0.1:6190/', + expectedIdentity: scenario.expectedIdentity, + timeoutMs: 2_000, + runtime: { + fetchIdentityBytesWithTimeout: async (...args) => { + requestedPaths.push(new URL(args[0]).pathname); + return fetchIdentity(...args); + }, + }, + }); + + assert.deepEqual(requestedPaths, [ + '/api/v1/stats/versions', + '/index.html', + '/assets/index.css', + '/assets/index.js', + ]); +}); + +test('identity requests disable caches and compression and enforce response size limits', async () => { + const server = createServer((request, response) => { + assert.equal(request.headers['accept-encoding'], 'identity'); + assert.equal(request.headers['cache-control'], 'no-cache'); + assert.equal(request.headers.pragma, 'no-cache'); + const body = request.url === '/oversized' ? 'four' : 'ok'; + response.writeHead(200, { 'Content-Length': Buffer.byteLength(body) }); + response.end(body); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + try { + const response = await fetchIdentityBytesWithTimeout( + `http://127.0.0.1:${address.port}/ok`, + 2_000, + 2, + ); + assert.equal(response.status, 200); + assert.equal(response.body.toString('utf8'), 'ok'); + await assert.rejects( + () => + fetchIdentityBytesWithTimeout( + `http://127.0.0.1:${address.port}/oversized`, + 2_000, + 3, + ), + /Response exceeds 3 bytes/, + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('running resource identity rejects a mismatched Core, code, or WebUI version', async () => { + const scenario = createIdentityScenario(); + await assert.rejects( + () => + verifyRunningResourceIdentity({ + backendUrl: 'http://127.0.0.1:6190/', + expectedIdentity: scenario.expectedIdentity, + timeoutMs: 2_000, + runtime: { + fetchIdentityBytesWithTimeout: createIdentityFetch({ + ...scenario, + runningVersions: { code: '4.27.0' }, + }), + }, + }), + (error) => + error instanceof Error && + error.message.includes('Running Core/WebUI version mismatch') && + error.message.includes('Core 4.27.5, code 4.27.0, WebUI 4.27.5'), + ); +}); + +test('running resource identity rejects mismatched WebUI index content', async () => { + const scenario = createIdentityScenario(); + await assert.rejects( + () => + verifyRunningResourceIdentity({ + backendUrl: 'http://127.0.0.1:6190/', + expectedIdentity: scenario.expectedIdentity, + timeoutMs: 2_000, + runtime: { + fetchIdentityBytesWithTimeout: createIdentityFetch({ + ...scenario, + indexBody: Buffer.from('stale'), + }), + }, + }), + (error) => + error instanceof Error && + error.message.includes('Running WebUI index SHA-256 mismatch') && + error.message.includes(scenario.expectedIdentity.indexSha256), + ); +}); + +test('running resource identity rejects mismatched WebUI entry content', async () => { + const scenario = createIdentityScenario(); + const staleEntries = new Map(scenario.entryBodies); + staleEntries.set('assets/index.js', Buffer.from('console.log("stale");\n')); + await assert.rejects( + () => + verifyRunningResourceIdentity({ + backendUrl: 'http://127.0.0.1:6190/', + expectedIdentity: scenario.expectedIdentity, + timeoutMs: 2_000, + runtime: { + fetchIdentityBytesWithTimeout: createIdentityFetch({ + ...scenario, + entryBodies: staleEntries, + }), + }, + }), + (error) => + error instanceof Error && + error.message.includes('Running WebUI entry SHA-256 mismatch for assets/index.js') && + error.message.includes(scenario.expectedIdentity.entryAssets[1].sha256), + ); +}); + +test('running resource identity rejects a missing attested WebUI entry', async () => { + const scenario = createIdentityScenario(); + const incompleteEntries = new Map(scenario.entryBodies); + incompleteEntries.delete('assets/index.js'); + await assert.rejects( + () => + verifyRunningResourceIdentity({ + backendUrl: 'http://127.0.0.1:6190/', + expectedIdentity: scenario.expectedIdentity, + timeoutMs: 2_000, + runtime: { + fetchIdentityBytesWithTimeout: createIdentityFetch({ + ...scenario, + entryBodies: incompleteEntries, + }), + }, + }), + (error) => + error instanceof Error && + error.message.includes('Running WebUI entry is missing: assets/index.js'), + ); +}); + test('main succeeds on readiness and always runs terminate/cleanup', async () => { const fixture = await createFixtureLayout(); try { @@ -503,6 +757,10 @@ test('main succeeds on readiness and always runs terminate/cleanup', async () => spawn: () => child, reserveLoopbackPort: async () => 6190, fetchWithTimeout: async () => ({ ok: true, status: 200 }), + fetchIdentityBytesWithTimeout: createIdentityFetch({ + indexBody: fixture.indexBody, + entryBodies: fixture.entryBodies, + }), terminateChild: async (actualChild) => { assert.equal(actualChild, child); terminated += 1; From fca8f58a57db5d0dc55af8e74de5aafab236cb38 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Tue, 1 Sep 2026 19:21:36 +0800 Subject: [PATCH 09/11] test(ci): align WebUI response size limits --- scripts/ci/backend-smoke-test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/backend-smoke-test.mjs b/scripts/ci/backend-smoke-test.mjs index d504ba1b..8ba24635 100644 --- a/scripts/ci/backend-smoke-test.mjs +++ b/scripts/ci/backend-smoke-test.mjs @@ -16,7 +16,9 @@ const versionsPath = '/api/v1/stats/versions'; const webuiIndexPath = '/index.html'; const maxVersionsResponseBytes = 64 * 1024; const maxIndexResponseBytes = 4 * 1024 * 1024; -const maxEntryResponseBytes = 64 * 1024 * 1024; +// Keep this in sync with MAX_BACKEND_HTTP_BODY_BYTES in src-tauri/src/backend/http.rs +// so a resource accepted by release smoke checks cannot fail runtime identity verification. +const maxEntryResponseBytes = 32 * 1024 * 1024; const sha256Pattern = /^[0-9a-f]{64}$/; const usageMessage = () => ` From ac38f14f4da3ea52b9c97fde0715f824289d1532 Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Thu, 3 Sep 2026 04:11:43 +0800 Subject: [PATCH 10/11] fix(runtime): reject overflowing chunk sizes --- src-tauri/src/backend/http_response.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/backend/http_response.rs b/src-tauri/src/backend/http_response.rs index 9d641895..8a9de785 100644 --- a/src-tauri/src/backend/http_response.rs +++ b/src-tauri/src/backend/http_response.rs @@ -96,15 +96,16 @@ fn decode_chunked_body(mut input: &[u8]) -> Option> { if chunk_size == 0 { return Some(output); } - if input.len() < chunk_size + 2 { + let required_length = chunk_size.checked_add(2)?; + if input.len() < required_length { return None; } output.extend_from_slice(&input[..chunk_size]); - if &input[chunk_size..chunk_size + 2] != b"\r\n" { + if &input[chunk_size..required_length] != b"\r\n" { return None; } - input = &input[chunk_size + 2..]; + input = &input[required_length..]; } } @@ -161,6 +162,18 @@ mod tests { assert!(parse_http_json_response(raw).is_none()); } + #[test] + fn parse_http_success_body_rejects_overflowing_chunk_size_without_panicking() { + let raw = format!( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\nx", + usize::MAX + ); + + let result = std::panic::catch_unwind(|| parse_http_success_body(raw.as_bytes())); + + assert!(matches!(result, Ok(None))); + } + #[test] fn parse_backend_start_time_accepts_i64_or_u64() { let signed = json!({ From ccd7cde3035e2075a60c0860c7a37aa4ffad8c8a Mon Sep 17 00:00:00 2001 From: JosephTian876 Date: Thu, 3 Sep 2026 04:12:03 +0800 Subject: [PATCH 11/11] fix(dev): ignore leftover packaged resources --- src-tauri/src/launch_plan.rs | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index 54edffaa..3eb10ade 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -19,6 +19,10 @@ const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); const PACKAGED_RUNTIME_MANIFEST_SHA256: &str = env!("ASTRBOT_RUNTIME_MANIFEST_SHA256"); +fn should_attempt_packaged_launch(debug_assertions_enabled: bool) -> bool { + !debug_assertions_enabled +} + #[derive(Debug)] struct PackagedResourceCandidate { label: &'static str, @@ -450,6 +454,11 @@ pub fn resolve_packaged_launch( where F: Fn(&str) + Copy, { + if !should_attempt_packaged_launch(cfg!(debug_assertions)) { + log("skipping packaged resource resolution in a debug/development build"); + return Ok(None); + } + let expected_desktop_version = app.package_info().version.to_string(); let candidates = [ PackagedResourceLocation::Direct, @@ -458,29 +467,12 @@ where .into_iter() .map(|location| resolve_packaged_resource_candidate(app, location)) .collect::>(); - let has_packaged_manifest = candidates.iter().any(|candidate| { - candidate.as_ref().is_ok_and(|candidate| { - candidate - .backend_dir - .join("runtime-manifest.json") - .is_file() - }) - }); let selected = match select_packaged_resources( &expected_desktop_version, PACKAGED_RUNTIME_MANIFEST_SHA256, candidates, ) { Ok(selected) => selected, - Err(failures) if cfg!(debug_assertions) && !has_packaged_manifest => { - for failure in failures { - log(&format!( - "packaged resource candidate {} unavailable in development: {}", - failure.label, failure.reason - )); - } - return Ok(None); - } Err(failures) => { return Err(packaged_resources_unavailable_error( &expected_desktop_version, @@ -734,6 +726,12 @@ mod tests { } } + #[test] + fn debug_builds_skip_packaged_launch_while_release_builds_keep_it() { + assert!(!should_attempt_packaged_launch(true)); + assert!(should_attempt_packaged_launch(false)); + } + #[test] fn valid_direct_bundle_is_preferred_over_valid_updater_bundle() { let temp_dir = tempfile::tempdir().expect("create temp dir");