diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 0c943edf9..72e0ea762 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -999,6 +999,8 @@ jobs: '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }' snap connections iptvnator | awk \ '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' xvfb-run -a env \ LIBGL_ALWAYS_SOFTWARE=1 \ __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ diff --git a/AGENTS.md b/AGENTS.md index 1c30ad323..68e060a6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,8 +278,10 @@ Key files: runs the complete cached manifest/hash/helper gate before BrowserWindow startup and exits with one availability JSON line. A nonzero helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is present only - for an exact protocol-v1 line carrying a fixed allowlisted reason. Any loader - failure remains a stable native-view fallback, never a flag-enabled success. + for an exact protocol-v1 line carrying a fixed allowlisted reason, and its + optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid + detail suppresses both helper fields. Any loader failure remains a stable + native-view fallback, never a flag-enabled success. - In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL extension loader path comes from the sandbox cache. Flatpak CI must invoke diff --git a/CLAUDE.md b/CLAUDE.md index 373d22e0a..5c059f334 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -670,8 +670,9 @@ engine` (restart required) or before BrowserWindow startup and emits one availability JSON line. A nonzero helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is present only for an exact protocol-v1 line carrying a fixed allowlisted - reason. The exact packaged Flatpak `/app` context reconstructs only - Freedesktop Platform 24.08's immutable + reason, and its optional `helperDetail` must be 1–1024 printable ASCII + characters. Invalid detail suppresses both helper fields. The exact packaged + Flatpak `/app` context reconstructs only Freedesktop Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that application-level probe instead of the helper directly. Bundled Linux packages carry hash-validated diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts index 6e5ecff9e..0a5bdc476 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts @@ -134,6 +134,10 @@ describe('embedded-mpv frame-copy helper failures', () => { 'shared-memory-initialize-failed', ])('preserves the allowlisted helper reason %s', (helperReason) => { const fixture = createFixture(context.rootDir); + const helperDetail = + helperReason === 'mpv-create-failed' + ? undefined + : 'EGL initialization failed: display unavailable'; context.spawnRuntimeProbe.mockReturnValue({ status: 1, signal: null, @@ -141,11 +145,7 @@ describe('embedded-mpv frame-copy helper failures', () => { protocol: 1, usable: false, reason: helperReason, - ...(helperReason === 'mpv-create-failed' - ? {} - : { - detail: 'diagnostic detail is intentionally not propagated', - }), + ...(helperDetail ? { detail: helperDetail } : {}), })}\n`, stderr: '', }); @@ -154,6 +154,32 @@ describe('embedded-mpv frame-copy helper failures', () => { usable: false, reason: 'helper-probe-failed', helperReason, + ...(helperDetail ? { helperDetail } : {}), + }); + }); + + it.each([ + ['one printable ASCII character', 'x'], + ['1024 printable ASCII characters', 'x'.repeat(1024)], + ])('preserves %s as helper detail', (_label, helperDetail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail: helperDetail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + helperDetail, }); }); @@ -197,4 +223,34 @@ describe('embedded-mpv frame-copy helper failures', () => { reason: 'helper-probe-failed', }); }); + + it.each([ + ['empty', ''], + ['control character', 'EGL\tfailure'], + ['trailing line feed', 'EGL failure\n'], + ['DEL character', `EGL${String.fromCharCode(0x7f)}failure`], + ['non-ASCII character', 'EGL échoué'], + ['1025 characters', 'x'.repeat(1025)], + ])( + 'does not propagate any helper fields from %s detail', + (_label, detail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + } + ); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts index 4623d7f14..f39bbfb72 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -31,9 +31,21 @@ const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set( Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS) ); -function parseFailedProbeReason( - stdout: unknown -): EmbeddedMpvHelperRuntimeProbeFailureReason | null { +interface ParsedFailedProbe { + helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; +} + +function isSafeHelperDetail(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 1024 && + !/[^\x20-\x7e]/.test(value) + ); +} + +function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null { if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { return null; } @@ -58,12 +70,15 @@ function parseFailedProbeReason( parsed.usable !== false || typeof parsed.reason !== 'string' || !HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) || - (hasDetail && - (typeof parsed.detail !== 'string' || parsed.detail.length === 0)) + (hasDetail && !isSafeHelperDetail(parsed.detail)) ) { return null; } - return parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason; + return { + helperReason: + parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason, + ...(hasDetail ? { helperDetail: parsed.detail as string } : {}), + }; } function parseSuccessfulProbe( @@ -157,12 +172,12 @@ function runHelperProbe( return failure('helper-probe-signaled'); } if (result.status !== 0) { - const helperReason = parseFailedProbeReason(result.stdout); - return helperReason + const helperFailure = parseFailedProbe(result.stdout); + return helperFailure ? { usable: false, reason: 'helper-probe-failed', - helperReason, + ...helperFailure, } : failure('helper-probe-failed'); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts index 3e3c741f2..8108dd3c1 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -55,6 +55,7 @@ export type EmbeddedMpvFrameCopyRuntimeResult = usable: false; reason: EmbeddedMpvFrameCopyRuntimeFailureReason; helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; }; export interface EmbeddedMpvFrameCopyRuntimeFileSystem { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts index 6e2b89c72..c90ca51d1 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts @@ -91,6 +91,7 @@ describe('embedded MPV runtime diagnostic', () => { usable: false, reason: 'helper-probe-failed', helperReason: 'gl-context-create-failed', + helperDetail: 'EGL initialization failed: display unavailable', }; const harness = createHarness(availability); @@ -101,7 +102,7 @@ describe('embedded MPV runtime diagnostic', () => { expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); expect(harness.writeStdout).toHaveBeenCalledWith( - '{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed"}\n' + '{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed","helperDetail":"EGL initialization failed: display unavailable"}\n' ); expect(harness.writeStdout).toHaveBeenCalledTimes(1); expect(harness.exit).toHaveBeenCalledWith(1); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index cbae9215c..aab5ada81 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -278,8 +278,11 @@ failures emit the stable helper reasons `shared-memory-create-failed` and JSON line and return zero. When the helper exits nonzero with an otherwise exact failure line, the application availability diagnostic keeps the fail-closed top-level reason `helper-probe-failed` and may add only the -allowlisted helper reason as `helperReason`; malformed, multi-line, -wrong-protocol, or unknown failure output never reaches that field. +allowlisted helper reason as `helperReason`. An optional `helperDetail` is +copied only from the same exact line when it contains 1–1024 printable ASCII +characters; an invalid detail rejects both helper fields. Malformed, +multi-line, wrong-protocol, or unknown failure output never reaches either +field. The startup probe and every playback helper session use the same sanitized loader environment selected by the validated manifest's cached `runtimeMode`. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 57d245778..f1163abb5 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -234,7 +234,9 @@ missing file, hash mismatch, unusable graphics path, or shm lifecycle failure returns a stable reason and keeps the BrowserWindow sandbox enabled. The application diagnostic retains `helper-probe-failed` as the top-level reason for nonzero helper exits and adds `helperReason` only when the helper emitted -one exact protocol-v1 line with a fixed allowlisted reason. The +one exact protocol-v1 line with a fixed allowlisted reason. Its optional +`helperDetail` is restricted to 1–1024 printable ASCII characters; invalid +detail suppresses both helper fields. The installed-Snap probe therefore tests the private shared-memory confinement needed by playback rather than only loader and graphics startup. Packaging CI invokes the same gate through diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 6ddfa75b8..99a9e1051 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -650,6 +650,10 @@ test('Snap verifier preserves fail-closed status while exposing captured diagnos snapStep, /\$2 == "iptvnator:gnome-3-28-1804" && \$3 == "gnome-3-28-1804:gnome-3-28-1804"/ ); + assert.match( + snapStep, + /\$1 == "shared-memory" && \$2 == "iptvnator:shared-memory" && \$3 == ":shared-memory"/ + ); assert.match( snapStep, /sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22/ @@ -688,9 +692,22 @@ test('Snap verifier preserves fail-closed status while exposing captured diagnos const successfulRuntimeProbe = snapStep.lastIndexOf( 'snap run iptvnator --embedded-mpv-runtime-probe' ); + const graphicsConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22"' + ); + const gnomeConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804"' + ); + const sharedMemoryConnectionAssertion = snapStep.indexOf( + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory"' + ); assert.ok(firstRuntimeProbe < secondGraphicsConnect); assert.ok(firstRuntimeProbe < successfulRuntimeProbe); assert.ok(secondGraphicsConnect < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < graphicsConnectionAssertion); + assert.ok(graphicsConnectionAssertion < gnomeConnectionAssertion); + assert.ok(gnomeConnectionAssertion < sharedMemoryConnectionAssertion); + assert.ok(sharedMemoryConnectionAssertion < successfulRuntimeProbe); assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); for (const hostileOverride of [ '__EGL_VENDOR_LIBRARY_FILENAMES',