mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(packaging): harden Linux frame-copy delivery
This commit is contained in:
1 parent
f0c8f686c2
commit
b6a7b87a61
38 files changed
+4016
-3026
No files matched your search
@@ -80,5 +80,7 @@ jobs:
|
||||
while IFS= read -r SNAP_NAME; do
|
||||
SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}"
|
||||
echo "Publishing public release asset: ${SNAP_NAME}"
|
||||
# Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
|
||||
# GitHub Actions never promotes automatically.
|
||||
snapcraft upload --release=edge "${SNAP_FILE}"
|
||||
done
|
||||
@@ -252,8 +252,10 @@ Key files:
|
||||
`linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
|
||||
run while retaining exact commit/submodule records. Automated Snap Store
|
||||
publication is allowed only after a public `v*` GitHub release contains
|
||||
both the Snap assets and exactly one matching source archive. Canonical
|
||||
maintenance docs:
|
||||
both the Snap assets and exactly one matching source archive. The workflow
|
||||
uploads only to the Store's edge channel. Candidate/stable promotion is
|
||||
manual after installed-Snap frame-copy and missing-runtime fallback smoke;
|
||||
GitHub Actions never promotes automatically. Canonical maintenance docs:
|
||||
`docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
|
||||
|
||||
@@ -645,9 +645,11 @@ engine` (restart required) or
|
||||
VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current
|
||||
checkout while preserving exact commit/submodule records. Snap Store
|
||||
publication runs only from a public `v*` GitHub release that already
|
||||
contains the Snap assets and exactly one source archive. On Windows, package
|
||||
validation requires the exact MPV DLL named by the helper's PE import table
|
||||
beside the executable.
|
||||
contains the Snap assets and exactly one source archive. The workflow uploads
|
||||
only to the Store's edge channel. Candidate/stable promotion is manual after
|
||||
installed-Snap frame-copy and missing-runtime fallback smoke; GitHub Actions
|
||||
never promotes automatically. On Windows, package validation requires the
|
||||
exact MPV DLL named by the helper's PE import table beside the executable.
|
||||
Backend adapter:
|
||||
`apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;
|
||||
shared-controls adapter:
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
@@ -98,7 +100,9 @@
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
@@ -193,11 +197,23 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint apps/electron-backend/**/*.ts"
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\""
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"inputs": [
|
||||
"default",
|
||||
"^production",
|
||||
"{workspaceRoot}/jest.preset.js",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "apps/electron-backend/jest.config.ts"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,307 @@
|
||||
import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs');
|
||||
import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types';
|
||||
|
||||
interface RuntimeProfileContract {
|
||||
origin: string;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
targets: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json';
|
||||
export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node';
|
||||
export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node';
|
||||
export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper';
|
||||
export const RUNTIME_PROBE_PROTOCOL = 1;
|
||||
export const { RUNTIME_PROBE_TIMEOUT_MS } = runtimeProbeContract;
|
||||
export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
|
||||
export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
|
||||
export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
|
||||
export const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
|
||||
export const SUBMODULE_RECORD_PATTERN =
|
||||
/^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)(?:\s+\(.+\))?$/;
|
||||
export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/;
|
||||
|
||||
export const GLIBC_TOOLCHAIN_ALLOWLIST = [
|
||||
'ld-linux-x86-64.so.2',
|
||||
'libc.so.6',
|
||||
'libdl.so.2',
|
||||
'libgcc_s.so.1',
|
||||
'libm.so.6',
|
||||
'libpthread.so.0',
|
||||
'librt.so.1',
|
||||
'libstdc++.so.6',
|
||||
] as const;
|
||||
|
||||
export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [
|
||||
{
|
||||
name: 'libEGL.so.1',
|
||||
interface: 'EGL',
|
||||
reason: 'System graphics-driver interface used by the frame-copy helper.',
|
||||
},
|
||||
{
|
||||
name: 'libGL.so.1',
|
||||
interface: 'OpenGL',
|
||||
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libGLX.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libOpenGL.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libasound.so.2',
|
||||
interface: 'ALSA',
|
||||
reason: 'Linux system audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libdrm.so.2',
|
||||
interface: 'DRM',
|
||||
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
|
||||
},
|
||||
{
|
||||
name: 'libgbm.so.1',
|
||||
interface: 'GBM',
|
||||
reason: 'System graphics-buffer interface used by headless EGL rendering.',
|
||||
},
|
||||
{
|
||||
name: 'libpulse.so.0',
|
||||
interface: 'PulseAudio',
|
||||
reason: 'Linux desktop audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libva-drm.so.2',
|
||||
interface: 'VA-API DRM',
|
||||
reason: 'System VA-API DRM interface used for hardware decoding.',
|
||||
},
|
||||
{
|
||||
name: 'libva.so.2',
|
||||
interface: 'VA-API',
|
||||
reason: 'System video-acceleration interface used for hardware decoding.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set<string>([
|
||||
...GLIBC_TOOLCHAIN_ALLOWLIST,
|
||||
...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
|
||||
]);
|
||||
|
||||
export const PORTABLE_ABI_BASELINE = {
|
||||
distribution: 'Ubuntu 22.04',
|
||||
glibcMaximum: '2.35',
|
||||
glibcxxMaximum: '3.4.30',
|
||||
} as const;
|
||||
|
||||
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
|
||||
freetype: {
|
||||
version: '2.13.3',
|
||||
sourceUrl:
|
||||
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
|
||||
sourceSha256:
|
||||
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
|
||||
license: 'FreeType License (FTL)',
|
||||
},
|
||||
fribidi: {
|
||||
version: '1.0.16',
|
||||
sourceUrl:
|
||||
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
|
||||
sourceSha256:
|
||||
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
harfbuzz: {
|
||||
version: '8.5.0',
|
||||
sourceUrl:
|
||||
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
|
||||
sourceSha256:
|
||||
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
|
||||
license: 'MIT',
|
||||
},
|
||||
expat: {
|
||||
version: '2.8.2',
|
||||
sourceUrl:
|
||||
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
|
||||
sourceSha256:
|
||||
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
|
||||
license: 'MIT',
|
||||
},
|
||||
fontconfig: {
|
||||
version: '2.16.0',
|
||||
sourceUrl:
|
||||
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
|
||||
sourceSha256:
|
||||
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
|
||||
license: 'MIT',
|
||||
},
|
||||
libass: {
|
||||
version: '0.17.3',
|
||||
sourceUrl:
|
||||
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
|
||||
sourceSha256:
|
||||
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
|
||||
license: 'ISC',
|
||||
},
|
||||
openssl: {
|
||||
version: '3.5.7',
|
||||
sourceUrl:
|
||||
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
|
||||
sourceSha256:
|
||||
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
ffmpeg: {
|
||||
version: '8.1',
|
||||
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
|
||||
sourceSha256:
|
||||
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
libplacebo: {
|
||||
version: '7.360.1',
|
||||
sourceUrl: 'https://github.com/haasn/libplacebo.git',
|
||||
sourceTag: 'v7.360.1',
|
||||
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
hwdata: {
|
||||
version: '0.409',
|
||||
sourceUrl:
|
||||
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
|
||||
sourceSha256:
|
||||
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
|
||||
buildInput: {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: 'pnp.ids',
|
||||
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
},
|
||||
license: 'GPL-2.0-or-later OR XFree86-1.0',
|
||||
},
|
||||
'libdisplay-info': {
|
||||
version: '0.1.1',
|
||||
sourceUrl:
|
||||
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
|
||||
sourceSha256:
|
||||
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
|
||||
license: 'MIT',
|
||||
},
|
||||
mpv: {
|
||||
version: '0.41.0',
|
||||
sourceUrl:
|
||||
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
|
||||
sourceSha256:
|
||||
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
|
||||
license: 'LGPL-2.1-or-later with -Dgpl=false',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EXPECTED_ARTIFACTS = {
|
||||
addon: {
|
||||
name: FRAME_COPY_ADDON_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
frameReader: {
|
||||
name: FRAME_COPY_READER_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
helper: {
|
||||
name: FRAME_COPY_HELPER_NAME,
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
executable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPECTED_PROCESS_ISOLATION = {
|
||||
addonLoadsLibmpv: false,
|
||||
readerLoadsLibmpv: false,
|
||||
electronLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
};
|
||||
|
||||
export const EXPECTED_DEVELOPMENT_ARTIFACTS = {
|
||||
addon: FRAME_COPY_ADDON_NAME,
|
||||
frameReader: FRAME_COPY_READER_NAME,
|
||||
helper: FRAME_COPY_HELPER_NAME,
|
||||
};
|
||||
|
||||
export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = {
|
||||
addonLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
};
|
||||
|
||||
export const DEVELOPMENT_MANIFEST_FIELDS = [
|
||||
'allowedPackageRuntimeModes',
|
||||
'arch',
|
||||
'artifacts',
|
||||
'buildInputMode',
|
||||
'generatedAt',
|
||||
'libmpvSoname',
|
||||
'nativeViewFallback',
|
||||
'origin',
|
||||
'packageRuntimeAvailability',
|
||||
'platform',
|
||||
'processIsolation',
|
||||
'runtimeFiles',
|
||||
'runtimeTotalBytes',
|
||||
'schemaVersion',
|
||||
'sourceRuntime',
|
||||
'sourceRuntimeValidated',
|
||||
] as const;
|
||||
|
||||
export const SYSTEM_PACKAGE_DEPENDENCIES = {
|
||||
deb: 'libmpv2',
|
||||
rpm: 'mpv-libs',
|
||||
pacman: 'mpv',
|
||||
};
|
||||
|
||||
export const PROFILE_CONTRACTS = {
|
||||
system: {
|
||||
origin: 'system-libmpv-frame-copy',
|
||||
runtimeMode: 'system',
|
||||
targets: new Set(['deb', 'rpm', 'pacman']),
|
||||
},
|
||||
portable: {
|
||||
origin: 'bundled-lgpl-frame-copy',
|
||||
runtimeMode: 'bundled',
|
||||
targets: new Set(['appimage', 'snap']),
|
||||
},
|
||||
flatpak: {
|
||||
origin: 'bundled-lgpl-frame-copy',
|
||||
runtimeMode: 'bundled',
|
||||
targets: new Set(['flatpak']),
|
||||
},
|
||||
} as const satisfies Record<RuntimeProfile, RuntimeProfileContract>;
|
||||
|
||||
export const BASE_MANIFEST_FIELDS = [
|
||||
'arch',
|
||||
'artifacts',
|
||||
'generatedAt',
|
||||
'libmpvSoname',
|
||||
'nativeViewFallback',
|
||||
'origin',
|
||||
'packageDependencies',
|
||||
'platform',
|
||||
'processIsolation',
|
||||
'profile',
|
||||
'runtimeFiles',
|
||||
'runtimeMode',
|
||||
'runtimeTotalBytes',
|
||||
'schemaVersion',
|
||||
'targets',
|
||||
] as const;
|
||||
|
||||
export const BUNDLED_MANIFEST_FIELDS = [
|
||||
...BASE_MANIFEST_FIELDS,
|
||||
'externalSystemLibraries',
|
||||
'runtimeDependencyClosure',
|
||||
'sourceRuntime',
|
||||
] as const;
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
cloneManifest,
|
||||
createDevelopmentFixture,
|
||||
probeDevelopmentRuntime,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy development manifest', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)(
|
||||
'accepts an exact unpackaged %s build manifest and still runs the helper probe',
|
||||
(buildInputMode) => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
buildInputMode
|
||||
);
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(
|
||||
context.createProbe(),
|
||||
fixture.helperPath
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
runtimeMode:
|
||||
buildInputMode === 'bundled-runtime'
|
||||
? 'bundled'
|
||||
: 'system',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env:
|
||||
buildInputMode === 'bundled-runtime'
|
||||
? {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: path.join(
|
||||
fixture.nativeDir,
|
||||
'lib'
|
||||
),
|
||||
}
|
||||
: {
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the packaged manifest contract strict when a development manifest is present', () => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
'bundled-runtime'
|
||||
);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'origin',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.origin = 'system-libmpv-frame-copy';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'architecture',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.arch = 'arm64';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'artifact set',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
const artifacts = manifest.artifacts as Record<string, unknown>;
|
||||
artifacts.helper = 'other-helper';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'package availability',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.packageRuntimeAvailability = {
|
||||
system: true,
|
||||
bundled: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'unexpected field',
|
||||
mutate(manifest: Record<string, unknown>) {
|
||||
manifest.manifestContract = 'packaged';
|
||||
},
|
||||
},
|
||||
])(
|
||||
'rejects a development manifest with an invalid $label',
|
||||
({ mutate }) => {
|
||||
const fixture = createDevelopmentFixture(
|
||||
context.rootDir,
|
||||
'system-dev'
|
||||
);
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
mutate(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(
|
||||
context.createProbe(),
|
||||
fixture.helperPath
|
||||
)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a system development manifest with a private runtime directory', () => {
|
||||
const fixture = createDevelopmentFixture(context.rootDir, 'system-dev');
|
||||
mkdirSync(path.join(fixture.nativeDir, 'lib'));
|
||||
|
||||
expect(
|
||||
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-library-directory-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import path from 'path';
|
||||
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
|
||||
|
||||
describe('createLinuxFrameCopyHelperEnvironment', () => {
|
||||
it('removes ambient loader overrides for system packages', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
'/opt/iptvnator/native',
|
||||
'system'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps trusted Snap GL roots ahead of generic Snap libraries', () => {
|
||||
const snapRoot = '/snap/iptvnator/42';
|
||||
const nativeDir = path.join(
|
||||
snapRoot,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: [
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/tmp/hostile-gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
'/var/lib/snapd/lib/gl-evil',
|
||||
].join(':'),
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
nativeDir,
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/snap/bin:/usr/bin',
|
||||
SNAP: snapRoot,
|
||||
SNAP_LIBRARY_PATH: [
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/tmp/hostile-gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
'/var/lib/snapd/lib/gl-evil',
|
||||
].join(':'),
|
||||
LD_LIBRARY_PATH: [
|
||||
path.join(nativeDir, 'lib'),
|
||||
'/var/lib/snapd/lib/gl',
|
||||
'/var/lib/snapd/lib/gl/nvidia',
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
|
||||
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
|
||||
].join(':'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => {
|
||||
expect(
|
||||
createLinuxFrameCopyHelperEnvironment(
|
||||
{
|
||||
PATH: '/usr/bin',
|
||||
SNAP: '/snap/iptvnator/42',
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
|
||||
LD_LIBRARY_PATH: '/tmp/hostile-libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
'/opt/iptvnator/native',
|
||||
'bundled'
|
||||
)
|
||||
).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
SNAP: '/snap/iptvnator/42',
|
||||
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
|
||||
LD_LIBRARY_PATH: '/opt/iptvnator/native/lib',
|
||||
});
|
||||
});
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import path from 'path';
|
||||
import type { EmbeddedMpvFrameCopyRuntimeMode } from './types';
|
||||
|
||||
const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const;
|
||||
const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl';
|
||||
|
||||
function isPathInside(
|
||||
parentPath: string,
|
||||
candidatePath: string,
|
||||
allowEqual: boolean
|
||||
): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
if (relativePath === '') {
|
||||
return allowEqual;
|
||||
}
|
||||
return (
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedSnapRoot(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nativeDir: string
|
||||
): string | null {
|
||||
const declaredSnapRoot = environment.SNAP;
|
||||
if (
|
||||
!declaredSnapRoot ||
|
||||
!path.isAbsolute(declaredSnapRoot) ||
|
||||
!path.isAbsolute(nativeDir)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSnapRoot = path.resolve(declaredSnapRoot);
|
||||
const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some(
|
||||
(mountRoot) => {
|
||||
const relativePath = path.relative(mountRoot, normalizedSnapRoot);
|
||||
return (
|
||||
isPathInside(mountRoot, normalizedSnapRoot, false) &&
|
||||
relativePath.split(path.sep).filter(Boolean).length >= 2
|
||||
);
|
||||
}
|
||||
);
|
||||
if (
|
||||
!resemblesReadOnlySnapMount ||
|
||||
!isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSnapRoot;
|
||||
}
|
||||
|
||||
function getTrustedSnapLibraryPaths(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
snapRoot: string
|
||||
): string[] {
|
||||
const snapLibraryPaths = (environment.SNAP_LIBRARY_PATH ?? '')
|
||||
.split(':')
|
||||
.filter(Boolean)
|
||||
.filter((libraryPath) => path.isAbsolute(libraryPath))
|
||||
.map((libraryPath) => path.resolve(libraryPath))
|
||||
.filter((libraryPath) =>
|
||||
isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true)
|
||||
);
|
||||
|
||||
return [
|
||||
...snapLibraryPaths,
|
||||
path.join(snapRoot, 'lib'),
|
||||
path.join(snapRoot, 'usr', 'lib'),
|
||||
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
|
||||
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the loader environment shared by the bounded startup probe and each
|
||||
* real Linux helper session. The validated package profile is authoritative:
|
||||
* system packages use the system loader, while bundled packages start at
|
||||
* native/lib and may add only immutable-looking Snap runtime/GL roots.
|
||||
*/
|
||||
export function createLinuxFrameCopyHelperEnvironment(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
nativeDir: string,
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode
|
||||
): NodeJS.ProcessEnv {
|
||||
const helperEnvironment = { ...environment };
|
||||
delete helperEnvironment.LD_LIBRARY_PATH;
|
||||
delete helperEnvironment.LD_PRELOAD;
|
||||
|
||||
if (runtimeMode === 'system') {
|
||||
return helperEnvironment;
|
||||
}
|
||||
|
||||
const libraryPaths = [path.join(nativeDir, 'lib')];
|
||||
const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir);
|
||||
if (trustedSnapRoot) {
|
||||
libraryPaths.push(
|
||||
...getTrustedSnapLibraryPaths(environment, trustedSnapRoot)
|
||||
);
|
||||
}
|
||||
helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':');
|
||||
return helperEnvironment;
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { SUCCESS_OUTPUT } from './runtime.spec-data';
|
||||
import { createFixture } from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy helper failures', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('converts a thrown spawn failure into a stable result', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockImplementation(() => {
|
||||
throw new Error('spawn exploded');
|
||||
});
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'helper-probe-spawn-error',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'timeout',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: 'SIGTERM',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('timed out'), {
|
||||
code: 'ETIMEDOUT',
|
||||
}),
|
||||
},
|
||||
reason: 'helper-probe-timeout',
|
||||
},
|
||||
{
|
||||
label: 'spawn error',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('spawn failed'), {
|
||||
code: 'EACCES',
|
||||
}),
|
||||
},
|
||||
reason: 'helper-probe-spawn-error',
|
||||
},
|
||||
{
|
||||
label: 'nonzero exit',
|
||||
spawnResult: {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-failed',
|
||||
},
|
||||
{
|
||||
label: 'signal',
|
||||
spawnResult: {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-signaled',
|
||||
},
|
||||
{
|
||||
label: 'invalid JSON',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: 'not-json\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-invalid-output',
|
||||
},
|
||||
{
|
||||
label: 'multiple lines',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`,
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-invalid-output',
|
||||
},
|
||||
{
|
||||
label: 'wrong protocol',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-protocol-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'unusable success',
|
||||
spawnResult: {
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
|
||||
stderr: '',
|
||||
},
|
||||
reason: 'helper-probe-unusable',
|
||||
},
|
||||
])('fails closed on helper $label', ({ spawnResult, reason }) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
context.spawnRuntimeProbe.mockReturnValue(spawnResult);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({ usable: false, reason })
|
||||
);
|
||||
});
|
||||
});
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { unlinkSync, writeFileSync } from 'fs';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
mirrorBundledManifestFields,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy packaged manifest policy', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('rejects a missing or malformed manifest without throwing', () => {
|
||||
const missing = createFixture(context.rootDir);
|
||||
unlinkSync(missing.manifestPath);
|
||||
expect(context.createProbe()(missing.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-missing',
|
||||
});
|
||||
|
||||
const malformed = createFixture(context.rootDir, 'portable');
|
||||
writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 });
|
||||
expect(context.createProbe()(malformed.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['origin', 'system-libmpv-frame-copy'],
|
||||
['arch', 'arm64'],
|
||||
['runtimeMode', 'system'],
|
||||
['targets', ['deb']],
|
||||
['unexpectedField', true],
|
||||
])('rejects a bundled profile mismatch in %s', (field, value) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest[field] = value;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['system', ['deb', 'pacman']],
|
||||
['portable', ['appimage']],
|
||||
] as const)(
|
||||
'rejects an allowed subset of the exact %s profile targets',
|
||||
(profile, targets) => {
|
||||
const fixture = createFixture(context.rootDir, profile);
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest.targets = targets;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects a bundled closure dependency outside the deterministic system allowlist', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libambient-only.so.1'];
|
||||
closure.externalDependencies = ['libambient-only.so.1'];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires externalDependencies to exactly equal the sorted external closure', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
|
||||
closure.externalDependencies = [];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only the exact deterministic external-system library declaration', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
(manifest.externalSystemLibraries as unknown[]).pop();
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const closure = manifest.runtimeDependencyClosure as {
|
||||
entries: Array<{ needed: string[] }>;
|
||||
externalDependencies: string[];
|
||||
};
|
||||
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
|
||||
closure.externalDependencies = ['libEGL.so.1', 'libc.so.6'];
|
||||
mirrorBundledManifestFields(manifest);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({ usable: true, runtimeMode: 'bundled' })
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
BASE_MANIFEST_FIELDS,
|
||||
BUNDLED_MANIFEST_FIELDS,
|
||||
DEVELOPMENT_MANIFEST_FIELDS,
|
||||
EXPECTED_ARTIFACTS,
|
||||
EXPECTED_DEVELOPMENT_ARTIFACTS,
|
||||
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION,
|
||||
EXPECTED_PROCESS_ISOLATION,
|
||||
PROFILE_CONTRACTS,
|
||||
SYSTEM_PACKAGE_DEPENDENCIES,
|
||||
VERSIONED_LIBMPV_PATTERN,
|
||||
} from './contracts';
|
||||
import { validateRuntimeClosure } from './runtime-closure-validator';
|
||||
import { validateSourceRuntimePolicy } from './source-runtime-validator';
|
||||
import type { RuntimeProfile, ValidManifest, ValidationResult } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
validateRuntimeFiles,
|
||||
validateTargets,
|
||||
validationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
export function validatePackagedManifest(
|
||||
manifest: Record<string, unknown>
|
||||
): ValidationResult<ValidManifest> {
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.arch !== 'x64' ||
|
||||
typeof manifest.profile !== 'string' ||
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
PROFILE_CONTRACTS,
|
||||
manifest.profile
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
const profile = manifest.profile as RuntimeProfile;
|
||||
const contract = PROFILE_CONTRACTS[profile];
|
||||
if (
|
||||
manifest.origin !== contract.origin ||
|
||||
manifest.runtimeMode !== contract.runtimeMode ||
|
||||
!hasExactFields(
|
||||
manifest,
|
||||
contract.runtimeMode === 'bundled'
|
||||
? BUNDLED_MANIFEST_FIELDS
|
||||
: BASE_MANIFEST_FIELDS
|
||||
) ||
|
||||
typeof manifest.generatedAt !== 'string' ||
|
||||
manifest.generatedAt.trim() === '' ||
|
||||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
|
||||
!validateTargets(manifest.targets, contract.targets) ||
|
||||
!isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.processIsolation,
|
||||
EXPECTED_PROCESS_ISOLATION
|
||||
) ||
|
||||
manifest.nativeViewFallback !== 'process-isolated mpv --wid' ||
|
||||
typeof manifest.libmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
if (contract.runtimeMode === 'system') {
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
manifest.packageDependencies,
|
||||
SYSTEM_PACKAGE_DEPENDENCIES
|
||||
) ||
|
||||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
|
||||
manifest.runtimeTotalBytes !== 0
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
profile,
|
||||
runtimeMode: 'system',
|
||||
runtimeFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
|
||||
if (
|
||||
!runtimeFiles ||
|
||||
!isDeepStrictEqual(manifest.packageDependencies, {}) ||
|
||||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
|
||||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
|
||||
manifest.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!validateRuntimeClosure(
|
||||
manifest.runtimeDependencyClosure,
|
||||
runtimeFiles,
|
||||
manifest.libmpvSoname,
|
||||
manifest.externalSystemLibraries
|
||||
) ||
|
||||
!validateSourceRuntimePolicy(
|
||||
manifest.sourceRuntime,
|
||||
runtimeFiles,
|
||||
manifest.runtimeDependencyClosure,
|
||||
manifest.externalSystemLibraries
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
profile,
|
||||
runtimeMode: 'bundled',
|
||||
runtimeFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSystemDevelopmentSource(
|
||||
value: unknown,
|
||||
buildInputMode: 'system-dev' | 'system-build-inputs'
|
||||
): boolean {
|
||||
if (buildInputMode === 'system-dev') {
|
||||
return isDeepStrictEqual(value, {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
warning: 'Development-only unmanaged system libmpv toolchain.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, [
|
||||
'buildInputs',
|
||||
'linuxBackend',
|
||||
'sourceDistribution',
|
||||
]) ||
|
||||
value.linuxBackend !== 'process-isolated mpv --wid' ||
|
||||
typeof value.sourceDistribution !== 'string' ||
|
||||
value.sourceDistribution.trim() === '' ||
|
||||
!isObject(value.buildInputs) ||
|
||||
!hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => {
|
||||
const packageName = value.buildInputs[packageField];
|
||||
return typeof packageName === 'string' && packageName.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDevelopmentManifest(
|
||||
manifest: Record<string, unknown>
|
||||
): ValidationResult<ValidManifest> {
|
||||
const buildInputMode = manifest.buildInputMode;
|
||||
if (
|
||||
!hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) ||
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.origin !== 'linux-frame-copy-build' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.arch !== 'x64' ||
|
||||
typeof manifest.generatedAt !== 'string' ||
|
||||
manifest.generatedAt.trim() === '' ||
|
||||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
|
||||
!['system-dev', 'system-build-inputs', 'bundled-runtime'].includes(
|
||||
String(buildInputMode)
|
||||
) ||
|
||||
!isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [
|
||||
'system',
|
||||
'bundled',
|
||||
]) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.artifacts,
|
||||
EXPECTED_DEVELOPMENT_ARTIFACTS
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
manifest.processIsolation,
|
||||
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION
|
||||
) ||
|
||||
manifest.nativeViewFallback !== 'process-isolated mpv --wid'
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
buildInputMode === 'system-dev' ||
|
||||
buildInputMode === 'system-build-inputs'
|
||||
) {
|
||||
if (
|
||||
manifest.sourceRuntimeValidated !== false ||
|
||||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
|
||||
system: false,
|
||||
bundled: false,
|
||||
}) ||
|
||||
manifest.libmpvSoname !== null ||
|
||||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
|
||||
manifest.runtimeTotalBytes !== 0 ||
|
||||
!validateSystemDevelopmentSource(
|
||||
manifest.sourceRuntime,
|
||||
buildInputMode
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
profile: 'development',
|
||||
runtimeMode: 'system',
|
||||
runtimeFiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sourceRuntime = manifest.sourceRuntime;
|
||||
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
|
||||
if (
|
||||
buildInputMode !== 'bundled-runtime' ||
|
||||
manifest.sourceRuntimeValidated !== true ||
|
||||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
|
||||
system: true,
|
||||
bundled: true,
|
||||
}) ||
|
||||
typeof manifest.libmpvSoname !== 'string' ||
|
||||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) ||
|
||||
!runtimeFiles ||
|
||||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
|
||||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
|
||||
manifest.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!isObject(sourceRuntime) ||
|
||||
!validateRuntimeClosure(
|
||||
sourceRuntime.runtimeDependencyClosure,
|
||||
runtimeFiles,
|
||||
manifest.libmpvSoname,
|
||||
sourceRuntime.externalSystemLibraries
|
||||
) ||
|
||||
!validateSourceRuntimePolicy(
|
||||
sourceRuntime,
|
||||
runtimeFiles,
|
||||
sourceRuntime.runtimeDependencyClosure,
|
||||
sourceRuntime.externalSystemLibraries
|
||||
)
|
||||
) {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
profile: 'development',
|
||||
runtimeMode: 'bundled',
|
||||
runtimeFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import path from 'path';
|
||||
import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy package integrity', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('rejects system manifests with a private library directory', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
mkdirSync(path.join(fixture.nativeDir, 'lib'));
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-library-directory-invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing addon',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node'));
|
||||
},
|
||||
reason: 'runtime-artifact-missing',
|
||||
},
|
||||
{
|
||||
label: 'non-executable helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(fixture.helperPath, 0o644);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'wrong reader mode',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
chmodSync(
|
||||
path.join(
|
||||
fixture.nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
),
|
||||
0o600
|
||||
);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'symlinked helper',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const target = `${fixture.helperPath}.real`;
|
||||
writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 });
|
||||
unlinkSync(fixture.helperPath);
|
||||
symlinkSync(target, fixture.helperPath);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
{
|
||||
label: 'reader directory',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const readerPath = path.join(
|
||||
fixture.nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
);
|
||||
unlinkSync(readerPath);
|
||||
mkdirSync(readerPath);
|
||||
},
|
||||
reason: 'runtime-artifact-invalid',
|
||||
},
|
||||
])('rejects a $label', ({ mutate, reason }) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
mutate(fixture);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing declared library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'));
|
||||
},
|
||||
reason: 'runtime-library-missing',
|
||||
},
|
||||
{
|
||||
label: 'undeclared library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
writeFileSync(
|
||||
path.join(fixture.nativeDir, 'lib', 'libextra.so'),
|
||||
'extra'
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-undeclared',
|
||||
},
|
||||
{
|
||||
label: 'library size mismatch',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
writeFileSync(
|
||||
path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'),
|
||||
'different-length'
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-size-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'library hash mismatch',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
const original = readFileSync(runtimePath);
|
||||
writeFileSync(
|
||||
runtimePath,
|
||||
Buffer.from(original.map((value) => value ^ 0xff))
|
||||
);
|
||||
},
|
||||
reason: 'runtime-library-hash-mismatch',
|
||||
},
|
||||
{
|
||||
label: 'symlinked library',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
const targetPath = path.join(
|
||||
fixture.nativeDir,
|
||||
'libmpv-real.so.2'
|
||||
);
|
||||
writeFileSync(
|
||||
targetPath,
|
||||
fixture.runtimeContents['libmpv.so.2']
|
||||
);
|
||||
unlinkSync(runtimePath);
|
||||
symlinkSync(targetPath, runtimePath);
|
||||
},
|
||||
reason: 'runtime-library-invalid',
|
||||
},
|
||||
{
|
||||
label: 'library directory',
|
||||
mutate(fixture: RuntimeFixture) {
|
||||
const runtimePath = path.join(
|
||||
fixture.nativeDir,
|
||||
'lib',
|
||||
'libmpv.so.2'
|
||||
);
|
||||
unlinkSync(runtimePath);
|
||||
mkdirSync(runtimePath);
|
||||
},
|
||||
reason: 'runtime-library-invalid',
|
||||
},
|
||||
])('rejects a $label', ({ mutate, reason }) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
mutate(fixture);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])(
|
||||
'rejects unsafe runtime path %s',
|
||||
(unsafeName) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
const runtimeFiles = manifest.runtimeFiles as RuntimeFile[];
|
||||
runtimeFiles[0].name = unsafeName;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('uses read and execute access checks for declared artifacts', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const accessMock = context.fileSystem.accessSync as jest.Mock;
|
||||
expect(accessMock).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
fsConstants.R_OK | fsConstants.X_OK
|
||||
);
|
||||
expect(accessMock).toHaveBeenCalledWith(
|
||||
path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
fsConstants.R_OK
|
||||
);
|
||||
});
|
||||
});
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { createHash } from 'crypto';
|
||||
import * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
FRAME_COPY_ADDON_NAME,
|
||||
FRAME_COPY_HELPER_NAME,
|
||||
FRAME_COPY_READER_NAME,
|
||||
} from './contracts';
|
||||
import {
|
||||
validateDevelopmentManifest,
|
||||
validatePackagedManifest,
|
||||
} from './manifest-validator';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
RuntimeFile,
|
||||
ValidatedPackage,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
import {
|
||||
isMissingFileError,
|
||||
isValidationFailure,
|
||||
readManifest,
|
||||
validationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
function validateRegularArtifact(
|
||||
filePath: string,
|
||||
accessMode: number,
|
||||
expectedMode: number | null,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<nodeFileSystem.Stats> {
|
||||
let stat: nodeFileSystem.Stats;
|
||||
try {
|
||||
stat = fileSystem.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
return validationFailure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-artifact-missing'
|
||||
: 'runtime-artifact-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
!stat.isFile() ||
|
||||
(expectedMode !== null && (stat.mode & 0o777) !== expectedMode)
|
||||
) {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
try {
|
||||
fileSystem.accessSync(filePath, accessMode);
|
||||
} catch {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
return { value: stat };
|
||||
}
|
||||
|
||||
function pathExistsByLstat(
|
||||
filePath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): boolean {
|
||||
try {
|
||||
fileSystem.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBundledRuntimeFiles(
|
||||
nativeDir: string,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<true> {
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
let libStat: nodeFileSystem.Stats;
|
||||
try {
|
||||
libStat = fileSystem.lstatSync(libDir);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
if (libStat.isSymbolicLink() || !libStat.isDirectory()) {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
|
||||
let packagedNames: string[];
|
||||
try {
|
||||
packagedNames = fileSystem.readdirSync(libDir) as string[];
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
const declaredNames = new Set(runtimeFiles.map(({ name }) => name));
|
||||
if (packagedNames.some((name) => !declaredNames.has(name))) {
|
||||
return validationFailure('runtime-library-undeclared');
|
||||
}
|
||||
|
||||
for (const runtimeFile of runtimeFiles) {
|
||||
const runtimePath = path.join(libDir, runtimeFile.name);
|
||||
let stat: nodeFileSystem.Stats;
|
||||
try {
|
||||
stat = fileSystem.lstatSync(runtimePath);
|
||||
} catch (error) {
|
||||
return validationFailure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-library-missing'
|
||||
: 'runtime-library-invalid'
|
||||
);
|
||||
}
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
try {
|
||||
fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
if (stat.size !== runtimeFile.size) {
|
||||
return validationFailure('runtime-library-size-mismatch');
|
||||
}
|
||||
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fileSystem.readFileSync(runtimePath);
|
||||
} catch {
|
||||
return validationFailure('runtime-library-invalid');
|
||||
}
|
||||
if (contents.length !== runtimeFile.size) {
|
||||
return validationFailure('runtime-library-size-mismatch');
|
||||
}
|
||||
const actualSha256 = createHash('sha256')
|
||||
.update(contents)
|
||||
.digest('hex');
|
||||
if (actualSha256 !== runtimeFile.sha256) {
|
||||
return validationFailure('runtime-library-hash-mismatch');
|
||||
}
|
||||
}
|
||||
return { value: true };
|
||||
}
|
||||
|
||||
export function validatePackage(
|
||||
helperPath: string,
|
||||
manifestPath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract
|
||||
): ValidationResult<ValidatedPackage> {
|
||||
const nativeDir = path.dirname(helperPath);
|
||||
if (
|
||||
path.basename(helperPath) !== FRAME_COPY_HELPER_NAME ||
|
||||
path.dirname(manifestPath) !== nativeDir
|
||||
) {
|
||||
return validationFailure('runtime-artifact-invalid');
|
||||
}
|
||||
|
||||
const manifestArtifact = validateRegularArtifact(
|
||||
manifestPath,
|
||||
nodeFileSystem.constants.R_OK,
|
||||
0o644,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(manifestArtifact)) {
|
||||
return validationFailure(
|
||||
manifestArtifact.reason === 'runtime-artifact-missing'
|
||||
? 'runtime-manifest-missing'
|
||||
: 'runtime-manifest-invalid'
|
||||
);
|
||||
}
|
||||
const manifestResult = readManifest(manifestPath, fileSystem);
|
||||
if (isValidationFailure(manifestResult)) {
|
||||
return manifestResult;
|
||||
}
|
||||
const validManifest =
|
||||
manifestContract === 'packaged'
|
||||
? validatePackagedManifest(manifestResult.value)
|
||||
: validateDevelopmentManifest(manifestResult.value);
|
||||
if (isValidationFailure(validManifest)) {
|
||||
return validManifest;
|
||||
}
|
||||
|
||||
for (const [artifactPath, accessMode, expectedMode] of [
|
||||
[
|
||||
path.join(nativeDir, FRAME_COPY_ADDON_NAME),
|
||||
nodeFileSystem.constants.R_OK,
|
||||
null,
|
||||
],
|
||||
[
|
||||
path.join(nativeDir, FRAME_COPY_READER_NAME),
|
||||
nodeFileSystem.constants.R_OK,
|
||||
0o644,
|
||||
],
|
||||
[
|
||||
helperPath,
|
||||
nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK,
|
||||
0o755,
|
||||
],
|
||||
] as const) {
|
||||
const artifact = validateRegularArtifact(
|
||||
artifactPath,
|
||||
accessMode,
|
||||
expectedMode,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
if (validManifest.value.runtimeMode === 'system') {
|
||||
try {
|
||||
if (pathExistsByLstat(libDir, fileSystem)) {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
} catch {
|
||||
return validationFailure('runtime-library-directory-invalid');
|
||||
}
|
||||
} else {
|
||||
const bundledRuntime = validateBundledRuntimeFiles(
|
||||
nativeDir,
|
||||
validManifest.value.runtimeFiles,
|
||||
fileSystem
|
||||
);
|
||||
if (isValidationFailure(bundledRuntime)) {
|
||||
return bundledRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
manifest: validManifest.value,
|
||||
helperPath,
|
||||
nativeDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { chmodSync, lstatSync, readFileSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy runtime probe orchestration', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
|
||||
expect(probeRuntime(fixture.helperPath)).toEqual({
|
||||
usable: true,
|
||||
profile: 'system',
|
||||
runtimeMode: 'system',
|
||||
libmpv: '2.3',
|
||||
renderApi: 'egl',
|
||||
});
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
killSignal: 'SIGKILL',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(context.fileSystem.readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates a cached result when helper or manifest bytes change under identical stats', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const fixedStats = new Map([
|
||||
[fixture.helperPath, lstatSync(fixture.helperPath)],
|
||||
[fixture.manifestPath, lstatSync(fixture.manifestPath)],
|
||||
]);
|
||||
context.fileSystem = {
|
||||
...context.fileSystem,
|
||||
lstatSync: jest.fn(
|
||||
(filePath: string) =>
|
||||
fixedStats.get(filePath) ?? lstatSync(filePath)
|
||||
),
|
||||
};
|
||||
const probeRuntime = context.createProbe();
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const changedHelper = readFileSync(fixture.helperPath);
|
||||
changedHelper[0] ^= 0xff;
|
||||
writeFileSync(fixture.helperPath, changedHelper);
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z';
|
||||
writeManifest(fixture.manifestPath, fixture.manifest);
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(['portable', 'flatpak'] as const)(
|
||||
'validates the exact %s bundled closure and uses only its private library directory',
|
||||
(profile) => {
|
||||
const fixture = createFixture(context.rootDir, profile);
|
||||
const probeRuntime = context.createProbe();
|
||||
|
||||
expect(probeRuntime(fixture.helperPath)).toEqual(
|
||||
expect.objectContaining({
|
||||
usable: true,
|
||||
profile,
|
||||
runtimeMode: 'bundled',
|
||||
})
|
||||
);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
|
||||
fixture.helperPath,
|
||||
['--runtime-probe'],
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reprobes when the helper identity changes', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n');
|
||||
chmodSync(fixture.helperPath, 0o755);
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reprobes when the manifest identity changes', () => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
const probeRuntime = context.createProbe();
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
manifest.generatedAt = '2026-07-17T00:01:00.000Z';
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
|
||||
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['linux', 'arm64', 'unsupported-architecture'],
|
||||
['linux', 'arm', 'unsupported-architecture'],
|
||||
['darwin', 'x64', 'unsupported-platform'],
|
||||
] as const)(
|
||||
'does not probe unsupported %s/%s runtimes',
|
||||
(platform, arch, reason) => {
|
||||
const fixture = createFixture(context.rootDir);
|
||||
|
||||
expect(
|
||||
context.createProbe({ platform, arch })(fixture.helperPath)
|
||||
).toEqual({
|
||||
usable: false,
|
||||
reason,
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { spawnSync as nodeSpawnSync } from 'child_process';
|
||||
import * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
RUNTIME_MANIFEST_NAME,
|
||||
RUNTIME_PROBE_PROTOCOL,
|
||||
RUNTIME_PROBE_TIMEOUT_MS,
|
||||
} from './contracts';
|
||||
import { createLinuxFrameCopyHelperEnvironment } from './helper-environment';
|
||||
import { validatePackage } from './package-validator';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyManifestContract,
|
||||
EmbeddedMpvFrameCopyRuntimeDependencies,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
ValidManifest,
|
||||
ValidatedPackage,
|
||||
} from './types';
|
||||
import {
|
||||
failure,
|
||||
fileIdentity,
|
||||
hasExactFields,
|
||||
isMissingFileError,
|
||||
isObject,
|
||||
isValidationFailure,
|
||||
} from './validation-primitives';
|
||||
|
||||
function parseSuccessfulProbe(
|
||||
stdout: unknown,
|
||||
manifest: ValidManifest
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.slice(0, -1));
|
||||
} catch {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
if (!isObject(parsed)) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) {
|
||||
return failure('helper-probe-protocol-mismatch');
|
||||
}
|
||||
if (parsed.usable !== true) {
|
||||
return failure(
|
||||
parsed.usable === false
|
||||
? 'helper-probe-unusable'
|
||||
: 'helper-probe-invalid-output'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!hasExactFields(parsed, [
|
||||
'libmpv',
|
||||
'protocol',
|
||||
'renderApi',
|
||||
'usable',
|
||||
]) ||
|
||||
typeof parsed.libmpv !== 'string' ||
|
||||
parsed.libmpv.trim() === '' ||
|
||||
parsed.renderApi !== 'egl'
|
||||
) {
|
||||
return failure('helper-probe-invalid-output');
|
||||
}
|
||||
return {
|
||||
usable: true,
|
||||
profile: manifest.profile,
|
||||
runtimeMode: manifest.runtimeMode,
|
||||
libmpv: parsed.libmpv,
|
||||
renderApi: parsed.renderApi,
|
||||
};
|
||||
}
|
||||
|
||||
function runHelperProbe(
|
||||
runtimePackage: ValidatedPackage,
|
||||
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
const probeEnvironment = createLinuxFrameCopyHelperEnvironment(
|
||||
dependencies.env,
|
||||
runtimePackage.nativeDir,
|
||||
runtimePackage.manifest.runtimeMode
|
||||
);
|
||||
|
||||
let result: ReturnType<typeof nodeSpawnSync>;
|
||||
try {
|
||||
result = dependencies.spawnSync(
|
||||
runtimePackage.helperPath,
|
||||
['--runtime-probe'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: RUNTIME_PROBE_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
windowsHide: true,
|
||||
env: probeEnvironment,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return failure('helper-probe-spawn-error');
|
||||
}
|
||||
|
||||
if (
|
||||
result.error &&
|
||||
'code' in result.error &&
|
||||
result.error.code === 'ETIMEDOUT'
|
||||
) {
|
||||
return failure('helper-probe-timeout');
|
||||
}
|
||||
if (result.error) {
|
||||
return failure('helper-probe-spawn-error');
|
||||
}
|
||||
if (result.signal) {
|
||||
return failure('helper-probe-signaled');
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return failure('helper-probe-failed');
|
||||
}
|
||||
return parseSuccessfulProbe(result.stdout, runtimePackage.manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated probe/cache. Production uses the singleton below;
|
||||
* tests inject filesystem and spawn collaborators through this factory.
|
||||
*/
|
||||
export function createEmbeddedMpvFrameCopyRuntimeProbe(
|
||||
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
|
||||
): (
|
||||
helperPath: string,
|
||||
manifestContract?: EmbeddedMpvFrameCopyManifestContract
|
||||
) => EmbeddedMpvFrameCopyRuntimeResult {
|
||||
const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = {
|
||||
accessSync: (filePath, mode) =>
|
||||
nodeFileSystem.accessSync(filePath, mode),
|
||||
lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath),
|
||||
readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath),
|
||||
readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath),
|
||||
};
|
||||
const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
env: process.env,
|
||||
fileSystem: defaultFileSystem,
|
||||
spawnSync: nodeSpawnSync,
|
||||
...overrides,
|
||||
};
|
||||
const resultCache = new Map<string, EmbeddedMpvFrameCopyRuntimeResult>();
|
||||
|
||||
return (
|
||||
helperPath: string,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged'
|
||||
): EmbeddedMpvFrameCopyRuntimeResult => {
|
||||
if (dependencies.platform !== 'linux') {
|
||||
return failure('unsupported-platform');
|
||||
}
|
||||
if (dependencies.arch !== 'x64') {
|
||||
return failure('unsupported-architecture');
|
||||
}
|
||||
|
||||
const manifestPath = path.join(
|
||||
path.dirname(helperPath),
|
||||
RUNTIME_MANIFEST_NAME
|
||||
);
|
||||
let helperStat: nodeFileSystem.Stats;
|
||||
let manifestStat: nodeFileSystem.Stats;
|
||||
try {
|
||||
helperStat = dependencies.fileSystem.lstatSync(helperPath);
|
||||
} catch {
|
||||
return failure('runtime-artifact-missing');
|
||||
}
|
||||
try {
|
||||
manifestStat = dependencies.fileSystem.lstatSync(manifestPath);
|
||||
} catch (error) {
|
||||
return failure(
|
||||
isMissingFileError(error)
|
||||
? 'runtime-manifest-missing'
|
||||
: 'runtime-manifest-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
let helperContents: Buffer;
|
||||
let manifestContents: Buffer;
|
||||
try {
|
||||
helperContents = dependencies.fileSystem.readFileSync(helperPath);
|
||||
} catch {
|
||||
return failure('runtime-artifact-invalid');
|
||||
}
|
||||
try {
|
||||
manifestContents =
|
||||
dependencies.fileSystem.readFileSync(manifestPath);
|
||||
} catch {
|
||||
return failure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
const cacheKey = `${manifestContract}\0${fileIdentity(
|
||||
helperPath,
|
||||
helperStat,
|
||||
helperContents
|
||||
)}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`;
|
||||
const cached = resultCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result: EmbeddedMpvFrameCopyRuntimeResult;
|
||||
try {
|
||||
const runtimePackage = validatePackage(
|
||||
helperPath,
|
||||
manifestPath,
|
||||
dependencies.fileSystem,
|
||||
manifestContract
|
||||
);
|
||||
result = isValidationFailure(runtimePackage)
|
||||
? failure(runtimePackage.reason)
|
||||
: runHelperProbe(runtimePackage.value, dependencies);
|
||||
} catch {
|
||||
result = failure('runtime-probe-internal-error');
|
||||
}
|
||||
resultCache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe();
|
||||
|
||||
/**
|
||||
* Fail-closed, process-lifetime Linux runtime decision shared by startup and
|
||||
* the service gate. The helper and manifest identities scope cached results.
|
||||
*/
|
||||
export function probeEmbeddedMpvFrameCopyRuntime(
|
||||
helperPath: string,
|
||||
manifestContract: EmbeddedMpvFrameCopyManifestContract
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
return processRuntimeProbe(helperPath, manifestContract);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
ALLOWED_EXTERNAL_LIBRARY_NAMES,
|
||||
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES,
|
||||
SAFE_RUNTIME_NAME_PATTERN,
|
||||
SHARED_LIBRARY_PATTERN,
|
||||
} from './contracts';
|
||||
import type { RuntimeFile } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
isSafeRuntimeName,
|
||||
} from './validation-primitives';
|
||||
|
||||
export function validateRuntimeClosure(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
libmpvSoname: string,
|
||||
externalSystemLibraries: unknown
|
||||
): boolean {
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
externalSystemLibraries,
|
||||
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES
|
||||
) ||
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, ['entries', 'externalDependencies']) ||
|
||||
!Array.isArray(value.entries) ||
|
||||
!Array.isArray(value.externalDependencies) ||
|
||||
value.externalDependencies.some(
|
||||
(dependency) =>
|
||||
typeof dependency !== 'string' ||
|
||||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
|
||||
!SHARED_LIBRARY_PATTERN.test(dependency)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const runtimeNames = runtimeFiles.map(({ name }) => name);
|
||||
const runtimeNameSet = new Set(runtimeNames);
|
||||
const closureNames: string[] = [];
|
||||
const computedExternalDependencies = new Set<string>();
|
||||
for (const entry of value.entries) {
|
||||
if (
|
||||
!isObject(entry) ||
|
||||
!hasExactFields(entry, [
|
||||
'name',
|
||||
'needed',
|
||||
'rpath',
|
||||
'runpath',
|
||||
'soname',
|
||||
]) ||
|
||||
!isSafeRuntimeName(entry.name) ||
|
||||
(entry.soname !== null && !isSafeRuntimeName(entry.soname)) ||
|
||||
!Array.isArray(entry.needed) ||
|
||||
entry.needed.some(
|
||||
(dependency) =>
|
||||
typeof dependency !== 'string' ||
|
||||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
|
||||
!SHARED_LIBRARY_PATTERN.test(dependency)
|
||||
) ||
|
||||
!isDeepStrictEqual(entry.rpath, []) ||
|
||||
!isDeepStrictEqual(entry.runpath, ['$ORIGIN']) ||
|
||||
new Set(entry.needed).size !== entry.needed.length ||
|
||||
!isDeepStrictEqual([...entry.needed].sort(), entry.needed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
closureNames.push(entry.name);
|
||||
for (const dependency of entry.needed) {
|
||||
if (runtimeNameSet.has(dependency)) {
|
||||
continue;
|
||||
}
|
||||
if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) {
|
||||
return false;
|
||||
}
|
||||
computedExternalDependencies.add(dependency);
|
||||
}
|
||||
}
|
||||
|
||||
const linkerAlias = value.entries.find(
|
||||
(entry) => isObject(entry) && entry.name === 'libmpv.so'
|
||||
);
|
||||
return (
|
||||
isDeepStrictEqual(closureNames, runtimeNames) &&
|
||||
new Set(closureNames).size === closureNames.length &&
|
||||
isDeepStrictEqual(
|
||||
value.externalDependencies,
|
||||
[...computedExternalDependencies].sort()
|
||||
) &&
|
||||
isObject(linkerAlias) &&
|
||||
linkerAlias.soname === libmpvSoname
|
||||
);
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime';
|
||||
import {
|
||||
EXTERNAL_SYSTEM_LIBRARIES,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES,
|
||||
type RuntimeFile,
|
||||
type RuntimeFixture,
|
||||
} from './runtime.spec-data';
|
||||
|
||||
function sha256(contents: Buffer): string {
|
||||
return createHash('sha256').update(contents).digest('hex');
|
||||
}
|
||||
|
||||
function createRuntimeFiles(
|
||||
runtimeContents: Record<string, Buffer>
|
||||
): RuntimeFile[] {
|
||||
return Object.entries(runtimeContents)
|
||||
.map(([name, contents]) => ({
|
||||
name,
|
||||
size: contents.length,
|
||||
sha256: sha256(contents),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function createSourceRuntime(
|
||||
runtimeFiles: RuntimeFile[],
|
||||
runtimeDependencyClosure: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES);
|
||||
(
|
||||
packages.libplacebo as typeof packages.libplacebo & {
|
||||
sourceSubmodules: string[];
|
||||
}
|
||||
).sourceSubmodules = [`${'a'.repeat(40)} 3rdparty/example`];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
origin: 'vendored-lgpl-source-build',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
packages,
|
||||
ffmpeg: {
|
||||
...(packages.ffmpeg as Record<string, unknown>),
|
||||
configureFlags: ['--disable-gpl', '--disable-nonfree'],
|
||||
},
|
||||
mpv: {
|
||||
...(packages.mpv as Record<string, unknown>),
|
||||
mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'],
|
||||
},
|
||||
sourceDistribution:
|
||||
'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.',
|
||||
runtimeFiles,
|
||||
runtimeTotalBytes: runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
),
|
||||
runtimeAbi: {
|
||||
baseline: {
|
||||
distribution: 'Ubuntu 22.04',
|
||||
glibcMaximum: '2.35',
|
||||
glibcxxMaximum: '3.4.30',
|
||||
},
|
||||
files: runtimeFiles.map(({ name }) => ({
|
||||
name,
|
||||
requiredGlibc: '2.34',
|
||||
requiredGlibcxx: null,
|
||||
})),
|
||||
},
|
||||
runtimeDependencyClosure,
|
||||
externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFixture(
|
||||
rootDir: string,
|
||||
profile: 'system' | 'portable' | 'flatpak' = 'system'
|
||||
): RuntimeFixture {
|
||||
const nativeDir = path.join(rootDir, profile, 'native');
|
||||
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
|
||||
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
|
||||
mkdirSync(nativeDir, { recursive: true });
|
||||
writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
|
||||
mode: 0o644,
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
|
||||
'reader',
|
||||
{ mode: 0o644 }
|
||||
);
|
||||
writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 });
|
||||
|
||||
const bundled = profile !== 'system';
|
||||
const runtimeContents = bundled
|
||||
? {
|
||||
'libmpv.so': Buffer.from('libmpv-linker-alias'),
|
||||
'libmpv.so.2': Buffer.from('libmpv-soname'),
|
||||
}
|
||||
: {};
|
||||
const runtimeFiles = createRuntimeFiles(runtimeContents);
|
||||
const runtimeDependencyClosure = {
|
||||
entries: runtimeFiles.map(({ name }) => ({
|
||||
name,
|
||||
soname: name === 'libmpv.so' ? 'libmpv.so.2' : name,
|
||||
needed: [],
|
||||
rpath: [],
|
||||
runpath: ['$ORIGIN'],
|
||||
})),
|
||||
externalDependencies: [],
|
||||
};
|
||||
const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES);
|
||||
const sourceRuntime = createSourceRuntime(
|
||||
runtimeFiles,
|
||||
runtimeDependencyClosure
|
||||
);
|
||||
const manifest: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
origin: bundled
|
||||
? 'bundled-lgpl-frame-copy'
|
||||
: 'system-libmpv-frame-copy',
|
||||
generatedAt: '2026-07-17T00:00:00.000Z',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
profile,
|
||||
runtimeMode: bundled ? 'bundled' : 'system',
|
||||
targets:
|
||||
profile === 'system'
|
||||
? ['deb', 'pacman', 'rpm']
|
||||
: profile === 'portable'
|
||||
? ['appimage', 'snap']
|
||||
: ['flatpak'],
|
||||
artifacts: {
|
||||
addon: {
|
||||
name: 'embedded_mpv.node',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
frameReader: {
|
||||
name: 'embedded_mpv_frame_reader.node',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
},
|
||||
helper: {
|
||||
name: 'iptvnator_mpv_helper',
|
||||
regularFile: true,
|
||||
readable: true,
|
||||
executable: true,
|
||||
},
|
||||
},
|
||||
processIsolation: {
|
||||
addonLoadsLibmpv: false,
|
||||
readerLoadsLibmpv: false,
|
||||
electronLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
},
|
||||
nativeViewFallback: 'process-isolated mpv --wid',
|
||||
libmpvSoname: 'libmpv.so.2',
|
||||
packageDependencies: bundled
|
||||
? {}
|
||||
: {
|
||||
deb: 'libmpv2',
|
||||
rpm: 'mpv-libs',
|
||||
pacman: 'mpv',
|
||||
},
|
||||
runtimeFiles,
|
||||
runtimeTotalBytes: runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
),
|
||||
...(bundled
|
||||
? {
|
||||
runtimeDependencyClosure,
|
||||
externalSystemLibraries,
|
||||
sourceRuntime,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
writeManifest(manifestPath, manifest);
|
||||
if (bundled) {
|
||||
const libDir = path.join(nativeDir, 'lib');
|
||||
mkdirSync(libDir);
|
||||
for (const [name, contents] of Object.entries(runtimeContents)) {
|
||||
writeFileSync(path.join(libDir, name), contents, {
|
||||
mode: 0o644,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
nativeDir,
|
||||
helperPath,
|
||||
manifestPath,
|
||||
manifest,
|
||||
runtimeContents,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDevelopmentFixture(
|
||||
rootDir: string,
|
||||
buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime'
|
||||
): RuntimeFixture {
|
||||
const bundled = buildInputMode === 'bundled-runtime';
|
||||
const fixture = createFixture(rootDir, bundled ? 'portable' : 'system');
|
||||
const packagedSourceRuntime = fixture.manifest.sourceRuntime;
|
||||
const sourceRuntime =
|
||||
buildInputMode === 'system-dev'
|
||||
? {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
warning:
|
||||
'Development-only unmanaged system libmpv toolchain.',
|
||||
}
|
||||
: buildInputMode === 'system-build-inputs'
|
||||
? {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
buildInputs: {
|
||||
libmpvDevPackage: 'libmpv-dev',
|
||||
mpvPackage: 'mpv',
|
||||
},
|
||||
sourceDistribution:
|
||||
'Linux development inputs are supplied by the host package manager.',
|
||||
}
|
||||
: packagedSourceRuntime;
|
||||
const manifest: Record<string, unknown> = {
|
||||
schemaVersion: 1,
|
||||
origin: 'linux-frame-copy-build',
|
||||
generatedAt: '2026-07-17T00:00:00.000Z',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
buildInputMode,
|
||||
sourceRuntimeValidated: bundled,
|
||||
allowedPackageRuntimeModes: ['system', 'bundled'],
|
||||
packageRuntimeAvailability: {
|
||||
system: bundled,
|
||||
bundled,
|
||||
},
|
||||
artifacts: {
|
||||
addon: 'embedded_mpv.node',
|
||||
frameReader: 'embedded_mpv_frame_reader.node',
|
||||
helper: 'iptvnator_mpv_helper',
|
||||
},
|
||||
processIsolation: {
|
||||
addonLoadsLibmpv: false,
|
||||
helperLinksLibmpv: true,
|
||||
helperRunpath: ['$ORIGIN/lib'],
|
||||
},
|
||||
nativeViewFallback: 'process-isolated mpv --wid',
|
||||
libmpvSoname: bundled ? 'libmpv.so.2' : null,
|
||||
runtimeFiles: fixture.manifest.runtimeFiles,
|
||||
runtimeTotalBytes: fixture.manifest.runtimeTotalBytes,
|
||||
sourceRuntime,
|
||||
};
|
||||
fixture.manifest = manifest;
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export function probeDevelopmentRuntime(
|
||||
probeRuntime: ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>,
|
||||
helperPath: string
|
||||
) {
|
||||
return (
|
||||
probeRuntime as unknown as (
|
||||
path: string,
|
||||
contract: 'development'
|
||||
) => ReturnType<typeof probeRuntime>
|
||||
)(helperPath, 'development');
|
||||
}
|
||||
|
||||
export function mirrorBundledManifestFields(
|
||||
manifest: Record<string, unknown>
|
||||
): void {
|
||||
const sourceRuntime = manifest.sourceRuntime as Record<string, unknown>;
|
||||
sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles);
|
||||
sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes;
|
||||
sourceRuntime.runtimeDependencyClosure = cloneManifest(
|
||||
manifest.runtimeDependencyClosure
|
||||
);
|
||||
sourceRuntime.externalSystemLibraries = cloneManifest(
|
||||
manifest.externalSystemLibraries
|
||||
);
|
||||
}
|
||||
|
||||
export function writeManifest(
|
||||
manifestPath: string,
|
||||
manifest: Record<string, unknown>
|
||||
): void {
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
|
||||
mode: 0o644,
|
||||
});
|
||||
chmodSync(manifestPath, 0o644);
|
||||
}
|
||||
|
||||
export function cloneManifest<T>(manifest: T): T {
|
||||
return JSON.parse(JSON.stringify(manifest)) as T;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
accessSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
createEmbeddedMpvFrameCopyRuntimeProbe,
|
||||
type EmbeddedMpvFrameCopyRuntimeDependencies,
|
||||
} from '../embedded-mpv-frame-copy-runtime';
|
||||
import { SUCCESS_OUTPUT } from './runtime.spec-data';
|
||||
|
||||
export interface RuntimeTestContext {
|
||||
rootDir: string;
|
||||
spawnRuntimeProbe: jest.Mock;
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'];
|
||||
createProbe(
|
||||
overrides?: Partial<EmbeddedMpvFrameCopyRuntimeDependencies>
|
||||
): ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createRuntimeTestContext(): RuntimeTestContext {
|
||||
const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-'));
|
||||
const spawnRuntimeProbe = jest.fn(() => ({
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: SUCCESS_OUTPUT,
|
||||
stderr: '',
|
||||
}));
|
||||
const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = {
|
||||
accessSync: jest.fn((filePath: string, mode: number) =>
|
||||
accessSync(filePath, mode)
|
||||
),
|
||||
lstatSync: jest.fn((filePath: string) => lstatSync(filePath)),
|
||||
readFileSync: jest.fn((filePath: string) => readFileSync(filePath)),
|
||||
readdirSync: jest.fn((filePath: string) => readdirSync(filePath)),
|
||||
};
|
||||
const context: RuntimeTestContext = {
|
||||
rootDir,
|
||||
spawnRuntimeProbe,
|
||||
fileSystem,
|
||||
createProbe(
|
||||
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
|
||||
) {
|
||||
return createEmbeddedMpvFrameCopyRuntimeProbe({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
LD_LIBRARY_PATH: '/ambient/libs',
|
||||
LD_PRELOAD: '/tmp/inject.so',
|
||||
},
|
||||
fileSystem: context.fileSystem,
|
||||
spawnSync: context.spawnRuntimeProbe as typeof spawnSync,
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
rmSync(context.rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
return context;
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
export const SUCCESS_OUTPUT =
|
||||
'{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n';
|
||||
|
||||
export const EXTERNAL_SYSTEM_LIBRARIES = [
|
||||
{
|
||||
name: 'libEGL.so.1',
|
||||
interface: 'EGL',
|
||||
reason: 'System graphics-driver interface used by the frame-copy helper.',
|
||||
},
|
||||
{
|
||||
name: 'libGL.so.1',
|
||||
interface: 'OpenGL',
|
||||
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libGLX.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libOpenGL.so.0',
|
||||
interface: 'OpenGL',
|
||||
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
|
||||
},
|
||||
{
|
||||
name: 'libasound.so.2',
|
||||
interface: 'ALSA',
|
||||
reason: 'Linux system audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libdrm.so.2',
|
||||
interface: 'DRM',
|
||||
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
|
||||
},
|
||||
{
|
||||
name: 'libgbm.so.1',
|
||||
interface: 'GBM',
|
||||
reason: 'System graphics-buffer interface used by headless EGL rendering.',
|
||||
},
|
||||
{
|
||||
name: 'libpulse.so.0',
|
||||
interface: 'PulseAudio',
|
||||
reason: 'Linux desktop audio interface intentionally used by libmpv.',
|
||||
},
|
||||
{
|
||||
name: 'libva-drm.so.2',
|
||||
interface: 'VA-API DRM',
|
||||
reason: 'System VA-API DRM interface used for hardware decoding.',
|
||||
},
|
||||
{
|
||||
name: 'libva.so.2',
|
||||
interface: 'VA-API',
|
||||
reason: 'System video-acceleration interface used for hardware decoding.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
|
||||
freetype: {
|
||||
version: '2.13.3',
|
||||
sourceUrl:
|
||||
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
|
||||
sourceSha256:
|
||||
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
|
||||
license: 'FreeType License (FTL)',
|
||||
},
|
||||
fribidi: {
|
||||
version: '1.0.16',
|
||||
sourceUrl:
|
||||
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
|
||||
sourceSha256:
|
||||
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
harfbuzz: {
|
||||
version: '8.5.0',
|
||||
sourceUrl:
|
||||
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
|
||||
sourceSha256:
|
||||
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
|
||||
license: 'MIT',
|
||||
},
|
||||
expat: {
|
||||
version: '2.8.2',
|
||||
sourceUrl:
|
||||
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
|
||||
sourceSha256:
|
||||
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
|
||||
license: 'MIT',
|
||||
},
|
||||
fontconfig: {
|
||||
version: '2.16.0',
|
||||
sourceUrl:
|
||||
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
|
||||
sourceSha256:
|
||||
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
|
||||
license: 'MIT',
|
||||
},
|
||||
libass: {
|
||||
version: '0.17.3',
|
||||
sourceUrl:
|
||||
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
|
||||
sourceSha256:
|
||||
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
|
||||
license: 'ISC',
|
||||
},
|
||||
openssl: {
|
||||
version: '3.5.7',
|
||||
sourceUrl:
|
||||
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
|
||||
sourceSha256:
|
||||
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
ffmpeg: {
|
||||
version: '8.1',
|
||||
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
|
||||
sourceSha256:
|
||||
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
libplacebo: {
|
||||
version: '7.360.1',
|
||||
sourceUrl: 'https://github.com/haasn/libplacebo.git',
|
||||
sourceTag: 'v7.360.1',
|
||||
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
hwdata: {
|
||||
version: '0.409',
|
||||
sourceUrl:
|
||||
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
|
||||
sourceSha256:
|
||||
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
|
||||
buildInput: {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: 'pnp.ids',
|
||||
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
},
|
||||
license: 'GPL-2.0-or-later OR XFree86-1.0',
|
||||
},
|
||||
'libdisplay-info': {
|
||||
version: '0.1.1',
|
||||
sourceUrl:
|
||||
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
|
||||
sourceSha256:
|
||||
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
|
||||
license: 'MIT',
|
||||
},
|
||||
mpv: {
|
||||
version: '0.41.0',
|
||||
sourceUrl:
|
||||
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
|
||||
sourceSha256:
|
||||
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
|
||||
license: 'LGPL-2.1-or-later with -Dgpl=false',
|
||||
},
|
||||
};
|
||||
|
||||
export interface RuntimeFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface RuntimeFixture {
|
||||
nativeDir: string;
|
||||
helperPath: string;
|
||||
manifestPath: string;
|
||||
manifest: Record<string, unknown>;
|
||||
runtimeContents: Record<string, Buffer>;
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
cloneManifest,
|
||||
createFixture,
|
||||
writeManifest,
|
||||
} from './runtime-fixtures.test-helpers';
|
||||
import {
|
||||
createRuntimeTestContext,
|
||||
type RuntimeTestContext,
|
||||
} from './runtime-harness.test-helpers';
|
||||
|
||||
describe('embedded-mpv frame-copy source runtime policy', () => {
|
||||
let context: RuntimeTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createRuntimeTestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'pinned source identity',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.mpv.sourceSha256 = '0'.repeat(64);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned source URL',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.freetype.sourceUrl =
|
||||
'https://example.invalid/freetype.tar.xz';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned git tag',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.libplacebo.sourceTag = 'main';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'pinned hwdata build input',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.hwdata.buildInput = {
|
||||
consumer: 'libdisplay-info',
|
||||
relativePath: '../pnp.ids',
|
||||
purpose:
|
||||
'PNP vendor lookup table compiled into libdisplay-info.',
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'git submodule record',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
packages.libplacebo.sourceSubmodules = [
|
||||
`${'a'.repeat(40)} ../outside`,
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'duplicate git submodule records',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const packages = sourceRuntime.packages as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const record = `${'a'.repeat(40)} 3rdparty/example`;
|
||||
packages.libplacebo.sourceSubmodules = [record, record];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'portable ABI baseline',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const runtimeAbi = sourceRuntime.runtimeAbi as {
|
||||
baseline: Record<string, unknown>;
|
||||
};
|
||||
runtimeAbi.baseline.glibcMaximum = '9.99';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'source-distribution obligation',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
sourceRuntime.sourceDistribution = 'Sources available.';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'FFmpeg LGPL flags',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const ffmpeg = sourceRuntime.ffmpeg as {
|
||||
configureFlags: string[];
|
||||
};
|
||||
ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl'];
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'mpv LGPL flags',
|
||||
mutate(sourceRuntime: Record<string, unknown>) {
|
||||
const mpv = sourceRuntime.mpv as {
|
||||
mesonFlags: string[];
|
||||
};
|
||||
mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true'];
|
||||
},
|
||||
},
|
||||
])('rejects an invalid $label', ({ mutate }) => {
|
||||
const fixture = createFixture(context.rootDir, 'portable');
|
||||
const manifest = cloneManifest(fixture.manifest);
|
||||
mutate(manifest.sourceRuntime as Record<string, unknown>);
|
||||
writeManifest(fixture.manifestPath, manifest);
|
||||
|
||||
expect(context.createProbe()(fixture.helperPath)).toEqual({
|
||||
usable: false,
|
||||
reason: 'runtime-manifest-invalid',
|
||||
});
|
||||
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import path from 'path';
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
GIT_COMMIT_PATTERN,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES,
|
||||
PORTABLE_ABI_BASELINE,
|
||||
SUBMODULE_RECORD_PATTERN,
|
||||
VERSION_PATTERN,
|
||||
} from './contracts';
|
||||
import type { RuntimeFile } from './types';
|
||||
import {
|
||||
hasExactFields,
|
||||
isObject,
|
||||
isSafeRuntimeName,
|
||||
} from './validation-primitives';
|
||||
|
||||
function compareDottedVersions(left: string, right: string): number {
|
||||
const leftParts = left.split('.').map(Number);
|
||||
const rightParts = right.split('.').map(Number);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function validatesPinnedSourceIdentity(
|
||||
candidate: unknown,
|
||||
expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES]
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(candidate) ||
|
||||
candidate.version !== expected.version ||
|
||||
candidate.sourceUrl !== expected.sourceUrl ||
|
||||
candidate.license !== expected.license
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
('sourceTag' in expected
|
||||
? candidate.sourceTag !== expected.sourceTag
|
||||
: candidate.sourceTag !== undefined) ||
|
||||
('buildInput' in expected
|
||||
? !isDeepStrictEqual(candidate.buildInput, expected.buildInput)
|
||||
: candidate.buildInput !== undefined)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ('sourceSha256' in expected) {
|
||||
return (
|
||||
candidate.sourceSha256 === expected.sourceSha256 &&
|
||||
candidate.sourceGitCommit === undefined &&
|
||||
candidate.sourceSubmodules === undefined
|
||||
);
|
||||
}
|
||||
return (
|
||||
candidate.sourceGitCommit === expected.sourceGitCommit &&
|
||||
GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) &&
|
||||
candidate.sourceSha256 === undefined &&
|
||||
validatesGitSubmoduleRecords(candidate.sourceSubmodules)
|
||||
);
|
||||
}
|
||||
|
||||
function validatesGitSubmoduleRecords(value: unknown): boolean {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return value.every((record) => {
|
||||
if (typeof record !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const match = record.match(SUBMODULE_RECORD_PATTERN);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const submodulePath = match[1];
|
||||
return (
|
||||
!path.posix.isAbsolute(submodulePath) &&
|
||||
!submodulePath
|
||||
.split('/')
|
||||
.some((segment) => segment === '.' || segment === '..')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function validateStringFlags(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(flag) =>
|
||||
typeof flag === 'string' &&
|
||||
flag.length > 0 &&
|
||||
flag.trim() === flag
|
||||
) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
function validateRuntimeAbi(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[]
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactFields(value, ['baseline', 'files']) ||
|
||||
!isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) ||
|
||||
!Array.isArray(value.files)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const abiFileNames: string[] = [];
|
||||
for (const record of value.files) {
|
||||
if (
|
||||
!isObject(record) ||
|
||||
!hasExactFields(record, [
|
||||
'name',
|
||||
'requiredGlibc',
|
||||
'requiredGlibcxx',
|
||||
]) ||
|
||||
!isSafeRuntimeName(record.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const [field, maximum] of [
|
||||
['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum],
|
||||
['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum],
|
||||
] as const) {
|
||||
const version = record[field];
|
||||
if (
|
||||
version !== null &&
|
||||
(typeof version !== 'string' ||
|
||||
!VERSION_PATTERN.test(version) ||
|
||||
compareDottedVersions(version, maximum) > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
abiFileNames.push(record.name);
|
||||
}
|
||||
|
||||
return isDeepStrictEqual(
|
||||
abiFileNames,
|
||||
runtimeFiles.map(({ name }) => name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source builder and package verifier own exhaustive build-host, recipe,
|
||||
* tool-version, URL and external-configuration validation. Startup repeats
|
||||
* only the immutable policy boundary needed before sandbox relaxation:
|
||||
* pinned source identities/licenses, LGPL flags, portable ABI, display-data
|
||||
* distribution, and the exact runtime closure mirrored by the package.
|
||||
*/
|
||||
export function validateSourceRuntimePolicy(
|
||||
value: unknown,
|
||||
runtimeFiles: RuntimeFile[],
|
||||
runtimeDependencyClosure: unknown,
|
||||
externalSystemLibraries: unknown
|
||||
): boolean {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.origin !== 'vendored-lgpl-source-build' ||
|
||||
value.platform !== 'linux' ||
|
||||
value.arch !== 'x64' ||
|
||||
!isObject(value.packages) ||
|
||||
!isObject(value.ffmpeg) ||
|
||||
!isObject(value.mpv) ||
|
||||
!isDeepStrictEqual(
|
||||
Object.keys(value.packages).sort(),
|
||||
Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort()
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [packageName, expectedIdentity] of Object.entries(
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES
|
||||
)) {
|
||||
if (
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.packages[packageName],
|
||||
expectedIdentity
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.ffmpeg,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg
|
||||
) ||
|
||||
!validateStringFlags(value.ffmpeg.configureFlags) ||
|
||||
!value.ffmpeg.configureFlags.includes('--disable-gpl') ||
|
||||
!value.ffmpeg.configureFlags.includes('--disable-nonfree') ||
|
||||
value.ffmpeg.configureFlags.includes('--enable-gpl') ||
|
||||
value.ffmpeg.configureFlags.includes('--enable-nonfree') ||
|
||||
!validatesPinnedSourceIdentity(
|
||||
value.mpv,
|
||||
PINNED_SOURCE_PACKAGE_IDENTITIES.mpv
|
||||
) ||
|
||||
!validateStringFlags(value.mpv.mesonFlags) ||
|
||||
!value.mpv.mesonFlags.includes('-Dgpl=false') ||
|
||||
!value.mpv.mesonFlags.includes('-Dlibmpv=true') ||
|
||||
value.mpv.mesonFlags.includes('-Dgpl=true')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.sourceDistribution !== 'string' ||
|
||||
!/\bhwdata\b/i.test(value.sourceDistribution) ||
|
||||
!/\bpnp\.ids\b/i.test(value.sourceDistribution) ||
|
||||
!/\blibdisplay-info\b/i.test(value.sourceDistribution) ||
|
||||
value.runtimeTotalBytes !==
|
||||
runtimeFiles.reduce(
|
||||
(total, runtimeFile) => total + runtimeFile.size,
|
||||
0
|
||||
) ||
|
||||
!isDeepStrictEqual(value.runtimeFiles, runtimeFiles) ||
|
||||
!isDeepStrictEqual(
|
||||
value.runtimeDependencyClosure,
|
||||
runtimeDependencyClosure
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
value.externalSystemLibraries,
|
||||
externalSystemLibraries
|
||||
) ||
|
||||
!validateRuntimeAbi(value.runtimeAbi, runtimeFiles)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { spawnSync as nodeSpawnSync } from 'child_process';
|
||||
import type * as nodeFileSystem from 'fs';
|
||||
|
||||
export type EmbeddedMpvFrameCopyRuntimeFailureReason =
|
||||
| 'unsupported-platform'
|
||||
| 'unsupported-architecture'
|
||||
| 'runtime-manifest-missing'
|
||||
| 'runtime-manifest-invalid'
|
||||
| 'runtime-artifact-missing'
|
||||
| 'runtime-artifact-invalid'
|
||||
| 'runtime-library-directory-invalid'
|
||||
| 'runtime-library-missing'
|
||||
| 'runtime-library-undeclared'
|
||||
| 'runtime-library-invalid'
|
||||
| 'runtime-library-size-mismatch'
|
||||
| 'runtime-library-hash-mismatch'
|
||||
| 'helper-probe-timeout'
|
||||
| 'helper-probe-spawn-error'
|
||||
| 'helper-probe-signaled'
|
||||
| 'helper-probe-failed'
|
||||
| 'helper-probe-invalid-output'
|
||||
| 'helper-probe-protocol-mismatch'
|
||||
| 'helper-probe-unusable'
|
||||
| 'runtime-probe-internal-error';
|
||||
|
||||
export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development';
|
||||
export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled';
|
||||
|
||||
export type RuntimeProfile = 'system' | 'portable' | 'flatpak';
|
||||
export type RuntimeProbeProfile = RuntimeProfile | 'development';
|
||||
|
||||
export type EmbeddedMpvFrameCopyRuntimeResult =
|
||||
| {
|
||||
usable: true;
|
||||
profile: RuntimeProbeProfile;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
libmpv: string;
|
||||
renderApi: 'egl';
|
||||
}
|
||||
| {
|
||||
usable: false;
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
|
||||
};
|
||||
|
||||
export interface EmbeddedMpvFrameCopyRuntimeFileSystem {
|
||||
accessSync(filePath: string, mode: number): void;
|
||||
lstatSync(filePath: string): nodeFileSystem.Stats;
|
||||
readFileSync(filePath: string): Buffer;
|
||||
readdirSync(filePath: string): string[];
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvFrameCopyRuntimeDependencies {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem;
|
||||
spawnSync: typeof nodeSpawnSync;
|
||||
}
|
||||
|
||||
export interface RuntimeFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ValidManifest {
|
||||
profile: RuntimeProbeProfile;
|
||||
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
|
||||
runtimeFiles: RuntimeFile[];
|
||||
}
|
||||
|
||||
export interface ValidatedPackage {
|
||||
manifest: ValidManifest;
|
||||
helperPath: string;
|
||||
nativeDir: string;
|
||||
}
|
||||
|
||||
export interface ValidationSuccess<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface ValidationFailure {
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
|
||||
}
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { createHash } from 'crypto';
|
||||
import type * as nodeFileSystem from 'fs';
|
||||
import path from 'path';
|
||||
import { isDeepStrictEqual } from 'util';
|
||||
import {
|
||||
SAFE_RUNTIME_NAME_PATTERN,
|
||||
SHA256_PATTERN,
|
||||
SHARED_LIBRARY_PATTERN,
|
||||
} from './contracts';
|
||||
import type {
|
||||
EmbeddedMpvFrameCopyRuntimeFailureReason,
|
||||
EmbeddedMpvFrameCopyRuntimeFileSystem,
|
||||
EmbeddedMpvFrameCopyRuntimeResult,
|
||||
RuntimeFile,
|
||||
ValidationFailure,
|
||||
ValidationResult,
|
||||
} from './types';
|
||||
|
||||
export function failure(
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
|
||||
): EmbeddedMpvFrameCopyRuntimeResult {
|
||||
return { usable: false, reason };
|
||||
}
|
||||
|
||||
export function validationFailure(
|
||||
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
|
||||
): ValidationFailure {
|
||||
return { reason };
|
||||
}
|
||||
|
||||
export function isValidationFailure<T>(
|
||||
result: ValidationResult<T>
|
||||
): result is ValidationFailure {
|
||||
return 'reason' in result;
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function hasExactFields(
|
||||
value: Record<string, unknown>,
|
||||
fields: readonly string[]
|
||||
): boolean {
|
||||
return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort());
|
||||
}
|
||||
|
||||
export function isSafeRuntimeName(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
path.basename(value) === value &&
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\') &&
|
||||
SAFE_RUNTIME_NAME_PATTERN.test(value) &&
|
||||
SHARED_LIBRARY_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingFileError(error: unknown): boolean {
|
||||
return (
|
||||
isObject(error) &&
|
||||
typeof error.code === 'string' &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
);
|
||||
}
|
||||
|
||||
export function fileIdentity(
|
||||
filePath: string,
|
||||
stat: nodeFileSystem.Stats,
|
||||
contents: Buffer
|
||||
): string {
|
||||
return [
|
||||
path.resolve(filePath),
|
||||
stat.dev,
|
||||
stat.ino,
|
||||
stat.mode,
|
||||
stat.size,
|
||||
stat.mtimeMs,
|
||||
stat.ctimeMs,
|
||||
createHash('sha256').update(contents).digest('hex'),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function readManifest(
|
||||
manifestPath: string,
|
||||
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
|
||||
): ValidationResult<Record<string, unknown>> {
|
||||
let contents: Buffer;
|
||||
try {
|
||||
contents = fileSystem.readFileSync(manifestPath);
|
||||
} catch {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(contents.toString('utf8'));
|
||||
return isObject(parsed)
|
||||
? { value: parsed }
|
||||
: validationFailure('runtime-manifest-invalid');
|
||||
} catch {
|
||||
return validationFailure('runtime-manifest-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function validateTargets(
|
||||
targets: unknown,
|
||||
allowedTargets: ReadonlySet<string>
|
||||
): boolean {
|
||||
const expectedTargets = [...allowedTargets].sort();
|
||||
if (
|
||||
!Array.isArray(targets) ||
|
||||
targets.length !== expectedTargets.length ||
|
||||
targets.some(
|
||||
(target) =>
|
||||
typeof target !== 'string' ||
|
||||
target.trim() !== target ||
|
||||
target.toLowerCase() !== target ||
|
||||
!allowedTargets.has(target)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isDeepStrictEqual(targets, expectedTargets);
|
||||
}
|
||||
|
||||
export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeFiles: RuntimeFile[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
if (
|
||||
!isObject(candidate) ||
|
||||
!hasExactFields(candidate, ['name', 'sha256', 'size']) ||
|
||||
!isSafeRuntimeName(candidate.name) ||
|
||||
!Number.isSafeInteger(candidate.size) ||
|
||||
(candidate.size as number) <= 0 ||
|
||||
typeof candidate.sha256 !== 'string' ||
|
||||
!SHA256_PATTERN.test(candidate.sha256) ||
|
||||
names.has(candidate.name)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
names.add(candidate.name);
|
||||
runtimeFiles.push({
|
||||
name: candidate.name,
|
||||
size: candidate.size as number,
|
||||
sha256: candidate.sha256,
|
||||
});
|
||||
}
|
||||
return isDeepStrictEqual(
|
||||
runtimeFiles.map(({ name }) => name).sort(),
|
||||
runtimeFiles.map(({ name }) => name)
|
||||
)
|
||||
? runtimeFiles
|
||||
: null;
|
||||
}
|
||||
@@ -779,7 +779,10 @@ The build workflow creates a draft GitHub release but never publishes Snap in
|
||||
parallel with that draft. The separate Snap workflow runs only for a public
|
||||
`release.published` event whose tag starts with `v`; before any Store upload it
|
||||
requires at least one exact `.snap` asset and exactly one non-empty
|
||||
`linux-frame-copy-runtime-sources.tar.xz` in that public release.
|
||||
`linux-frame-copy-runtime-sources.tar.xz` in that public release. The workflow
|
||||
uploads only to the Store's edge channel. Candidate/stable promotion is manual
|
||||
after installed-Snap frame-copy and missing-runtime fallback smoke; GitHub
|
||||
Actions never promotes automatically.
|
||||
|
||||
During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts.
|
||||
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.46.2"
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"yaml": "2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
Generated
+3
@@ -447,6 +447,9 @@ importers:
|
||||
typescript-eslint:
|
||||
specifier: ^8.46.2
|
||||
version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
yaml:
|
||||
specifier: 2.8.2
|
||||
version: 2.8.2
|
||||
|
||||
packages:
|
||||
|
||||
|
||||
@@ -209,8 +209,10 @@ or hash-mismatched license files.
|
||||
Snap publication is a separate `release.published` workflow for public `v*`
|
||||
GitHub releases. It verifies that the public release already contains at least
|
||||
one Snap and exactly one non-empty
|
||||
`linux-frame-copy-runtime-sources.tar.xz` before uploading any Snap to the
|
||||
Store's edge channel.
|
||||
`linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The
|
||||
workflow uploads only to the Store's edge channel.
|
||||
Candidate/stable promotion is manual after installed-Snap frame-copy and
|
||||
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
|
||||
|
||||
Windows CI stages a checksum-pinned x64 LGPL archive. The DLL basename encoded
|
||||
in its import library is preserved and must be present beside
|
||||
|
||||
@@ -382,7 +382,7 @@ function pathInsideDestdir(destdir, absolutePath) {
|
||||
);
|
||||
}
|
||||
|
||||
function copyDirectoryContents(sourceDirectory, destinationDirectory) {
|
||||
export function copyDirectoryContents(sourceDirectory, destinationDirectory) {
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
return;
|
||||
}
|
||||
@@ -394,6 +394,7 @@ function copyDirectoryContents(sourceDirectory, destinationDirectory) {
|
||||
{
|
||||
recursive: true,
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -810,6 +810,42 @@ test('uses DESTDIR with standard fontconfig and OpenSSL runtime paths', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves relative library symlinks copied out of DESTDIR', async (t) => {
|
||||
const { copyDirectoryContents } = await import(
|
||||
pathToFileURL(builderScript).href
|
||||
);
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'iptvnator-destdir-links-')
|
||||
);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const sourceDirectory = path.join(root, 'destdir', 'lib');
|
||||
const destinationDirectory = path.join(root, 'prefix', 'lib');
|
||||
fs.mkdirSync(sourceDirectory, { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceDirectory, 'libcrypto.so.3'), 'crypto');
|
||||
fs.symlinkSync(
|
||||
'libcrypto.so.3',
|
||||
path.join(sourceDirectory, 'libcrypto.so')
|
||||
);
|
||||
|
||||
copyDirectoryContents(sourceDirectory, destinationDirectory);
|
||||
fs.rmSync(path.join(root, 'destdir'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fs.readlinkSync(path.join(destinationDirectory, 'libcrypto.so')),
|
||||
'libcrypto.so.3'
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(destinationDirectory, 'libcrypto.so'),
|
||||
'utf8'
|
||||
),
|
||||
'crypto'
|
||||
);
|
||||
});
|
||||
|
||||
test('uses an explicit LGPL FFmpeg HTTPS and HLS protocol baseline', () => {
|
||||
const required = [
|
||||
'--enable-shared',
|
||||
@@ -1557,4 +1593,16 @@ test('registers the builder script and focused test with package and Nx', () =>
|
||||
packagingProject.targets.test.options.command,
|
||||
/tools\/embedded-mpv\/build-linux-runtime\.test\.mjs/
|
||||
);
|
||||
|
||||
const lintInputs = packagingProject.targets.lint.inputs;
|
||||
for (const registeredInput of [
|
||||
'{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs',
|
||||
'{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs',
|
||||
]) {
|
||||
assert.ok(lintInputs.includes(registeredInput), registeredInput);
|
||||
}
|
||||
assert.match(
|
||||
packagingProject.targets.lint.command,
|
||||
/tools\/embedded-mpv\/build-linux-runtime\.\{mjs,test\.mjs\}/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
const RUNTIME_PROBE_CONTRACT = Object.freeze({
|
||||
RUNTIME_PROBE_TIMEOUT_MS: 3000,
|
||||
});
|
||||
|
||||
module.exports = RUNTIME_PROBE_CONTRACT;
|
||||
@@ -0,0 +1,5 @@
|
||||
declare const RUNTIME_PROBE_CONTRACT: Readonly<{
|
||||
readonly RUNTIME_PROBE_TIMEOUT_MS: 3000;
|
||||
}>;
|
||||
|
||||
export = RUNTIME_PROBE_CONTRACT;
|
||||
@@ -39,6 +39,8 @@
|
||||
"{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts",
|
||||
"{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs"
|
||||
],
|
||||
@@ -55,9 +57,13 @@
|
||||
"{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs",
|
||||
"{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs",
|
||||
"{workspaceRoot}/tools/packaging/release-snap-assets.cjs"
|
||||
"{workspaceRoot}/tools/packaging/release-snap-assets.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
|
||||
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
|
||||
],
|
||||
"command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\""
|
||||
"command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/runtime-probe-contract.cjs\""
|
||||
}
|
||||
},
|
||||
"tags": ["scope:tools", "domain:packaging", "type:tool"]
|
||||
|
||||
@@ -4,6 +4,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parse } from 'yaml';
|
||||
import {
|
||||
assertBuildSnapWorkflowPolicy,
|
||||
assertPublishSnapWorkflowPolicy,
|
||||
} from './snap-workflow-policy.test-helpers.mjs';
|
||||
|
||||
const workspaceRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -36,6 +41,64 @@ async function loadReleaseAssetHelper() {
|
||||
return import(pathToFileURL(releaseAssetHelperPath).href);
|
||||
}
|
||||
|
||||
function insertFirstJobStep(workflowText, stepSource) {
|
||||
const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText);
|
||||
assert.ok(stepsHeader, 'workflow must contain a steps list');
|
||||
const insertionIndex = stepsHeader.index + stepsHeader[0].length;
|
||||
const stepIndent = `${stepsHeader[1]} `;
|
||||
const indentedStep = stepSource
|
||||
.split('\n')
|
||||
.map((line) => `${stepIndent}${line}`)
|
||||
.join('\n');
|
||||
return `${workflowText.slice(
|
||||
0,
|
||||
insertionIndex
|
||||
)}${indentedStep}\n${workflowText.slice(insertionIndex)}`;
|
||||
}
|
||||
|
||||
function insertWorkflowJob(workflowText, jobSource) {
|
||||
const jobsHeader = /^([ \t]*)jobs:\r?\n/m.exec(workflowText);
|
||||
assert.ok(jobsHeader, 'workflow must contain a jobs mapping');
|
||||
const insertionIndex = jobsHeader.index + jobsHeader[0].length;
|
||||
const jobIndent = `${jobsHeader[1]} `;
|
||||
const indentedJob = jobSource
|
||||
.split('\n')
|
||||
.map((line) => `${jobIndent}${line}`)
|
||||
.join('\n');
|
||||
return `${workflowText.slice(
|
||||
0,
|
||||
insertionIndex
|
||||
)}${indentedJob}\n${workflowText.slice(insertionIndex)}`;
|
||||
}
|
||||
|
||||
function insertFirstJobField(workflowText, fieldSource) {
|
||||
const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText);
|
||||
assert.ok(stepsHeader, 'workflow must contain a steps list');
|
||||
const fieldIndent = stepsHeader[1];
|
||||
const indentedField = fieldSource
|
||||
.split('\n')
|
||||
.map((line) => `${fieldIndent}${line}`)
|
||||
.join('\n');
|
||||
return `${workflowText.slice(
|
||||
0,
|
||||
stepsHeader.index
|
||||
)}${indentedField}\n${workflowText.slice(stepsHeader.index)}`;
|
||||
}
|
||||
|
||||
function assertStepRejectedByBothPolicies(stepSource) {
|
||||
for (const [workflowPath, assertPolicy] of [
|
||||
[publishWorkflowPath, assertPublishSnapWorkflowPolicy],
|
||||
[buildWorkflowPath, assertBuildSnapWorkflowPolicy],
|
||||
]) {
|
||||
const workflowText = insertFirstJobStep(
|
||||
fs.readFileSync(workflowPath, 'utf8'),
|
||||
stepSource
|
||||
);
|
||||
assert.doesNotThrow(() => parse(workflowText));
|
||||
assert.throws(() => assertPolicy(workflowText));
|
||||
}
|
||||
}
|
||||
|
||||
test('publishes Snap only after a public v-tag release contains binary and source assets', () => {
|
||||
assert.equal(
|
||||
fs.existsSync(publishWorkflowPath),
|
||||
@@ -43,10 +106,6 @@ test('publishes Snap only after a public v-tag release contains binary and sourc
|
||||
'the release-published Snap workflow must exist'
|
||||
);
|
||||
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
|
||||
assert.match(
|
||||
workflowText,
|
||||
/^on:\n {4}release:\n {8}types:\n {12}- published$/m
|
||||
);
|
||||
assert.match(workflowText, /^permissions:\n {4}contents: read$/m);
|
||||
assert.match(
|
||||
workflowText,
|
||||
@@ -73,11 +132,183 @@ test('publishes Snap only after a public v-tag release contains binary and sourc
|
||||
);
|
||||
assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/);
|
||||
assert.match(workflowText, /release-snap-assets\.cjs verify/);
|
||||
assert.match(workflowText, /snapcraft upload --release=edge/);
|
||||
assertPublishSnapWorkflowPolicy(workflowText);
|
||||
const disabledWorkflow = workflowText.replace(
|
||||
'github.event.release.draft == false }}',
|
||||
'github.event.release.draft == false && false }}'
|
||||
);
|
||||
assert.notEqual(disabledWorkflow, workflowText);
|
||||
assert.doesNotThrow(() => parse(disabledWorkflow));
|
||||
assert.throws(() => assertPublishSnapWorkflowPolicy(disabledWorkflow));
|
||||
const extraTrigger = workflowText.replace(
|
||||
' - published',
|
||||
' - published\n - edited'
|
||||
);
|
||||
assert.doesNotThrow(() => parse(extraTrigger));
|
||||
assert.throws(() => assertPublishSnapWorkflowPolicy(extraTrigger));
|
||||
assert.match(
|
||||
workflowText,
|
||||
/Candidate\/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke/
|
||||
);
|
||||
|
||||
const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8');
|
||||
assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m);
|
||||
assert.doesNotMatch(buildWorkflow, /snapcraft upload/);
|
||||
assertBuildSnapWorkflowPolicy(buildWorkflow);
|
||||
});
|
||||
|
||||
test('rejects Snap uploads that target candidate or stable channels', () => {
|
||||
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
|
||||
for (const forbiddenReleaseArgument of [
|
||||
'--release=edge,candidate,stable',
|
||||
'--release edge,candidate,stable',
|
||||
'--release=candidate',
|
||||
'--release stable',
|
||||
]) {
|
||||
const mixedChannelWorkflow = workflowText.replace(
|
||||
'--release=edge',
|
||||
forbiddenReleaseArgument
|
||||
);
|
||||
assert.notEqual(mixedChannelWorkflow, workflowText);
|
||||
assert.doesNotThrow(() => parse(mixedChannelWorkflow));
|
||||
assert.throws(() =>
|
||||
assertPublishSnapWorkflowPolicy(mixedChannelWorkflow)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects edge upload text in non-executing shell contexts', () => {
|
||||
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
|
||||
const edgeUpload = 'snapcraft upload --release=edge "${SNAP_FILE}"';
|
||||
const blockIndent = ' '.repeat(18);
|
||||
for (const replacement of [
|
||||
`cat <<123\n${edgeUpload}\n123`,
|
||||
`cat <<\\EOF\n${edgeUpload}\nEOF`,
|
||||
`printf '%s\\n' "\n${edgeUpload}\n"`,
|
||||
`publish_snap() {\n${edgeUpload}\n}`,
|
||||
`if false; then\n${edgeUpload}\nfi`,
|
||||
`cat <<FIRST <<SECOND\nfirst\nFIRST\n${edgeUpload}\nSECOND`,
|
||||
`cat <<-'EOF'\n\t${edgeUpload}\n\tEOF`,
|
||||
`cat <<'EOF'\n${edgeUpload}\nEOF`,
|
||||
]) {
|
||||
const indentedReplacement = replacement.replaceAll(
|
||||
'\n',
|
||||
`\n${blockIndent}`
|
||||
);
|
||||
const mutatedWorkflow = workflowText.replace(
|
||||
edgeUpload,
|
||||
indentedReplacement
|
||||
);
|
||||
assert.notEqual(mutatedWorkflow, workflowText);
|
||||
assert.doesNotThrow(() => parse(mutatedWorkflow));
|
||||
assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra CLI tokens across wrappers, YAML forms, quotes, and heredocs', () => {
|
||||
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
|
||||
for (const stepSource of [
|
||||
'- run: command snapcraft upload --release=stable package.snap',
|
||||
'- run: |\n if true; then command snapcraft \\\n upload --release=edge,candidate,stable package.snap; fi',
|
||||
'- run: |\n snap\\\n craft upload --release=stable package.snap',
|
||||
'- run: |\n snapcraft up\\\n load --release=stable package.snap',
|
||||
'- run: |2\n snapcraft upload --release=stable package.snap',
|
||||
'- run: snapcraft upload --release=stable package.snap',
|
||||
'- { run: snapcraft upload --release=stable package.snap }',
|
||||
'- run: echo "snapcraft upload --release=edge package.snap"',
|
||||
"- run: |\n cat <<'EOF'\n snapcraft upload --release=edge package.snap\n EOF",
|
||||
'- run: command snapcraft release iptvnator stable',
|
||||
]) {
|
||||
const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource);
|
||||
assert.doesNotThrow(() => parse(mutatedWorkflow));
|
||||
assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects continued uploads and release commands in the build workflow', () => {
|
||||
const workflowText = fs.readFileSync(buildWorkflowPath, 'utf8');
|
||||
for (const stepSource of [
|
||||
'- run: |\n if true; then command snapcraft \\\n upload --release=edge package.snap; fi',
|
||||
'- run: command snapcraft release iptvnator edge',
|
||||
]) {
|
||||
const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource);
|
||||
assert.doesNotThrow(() => parse(mutatedWorkflow));
|
||||
assert.throws(() => assertBuildSnapWorkflowPolicy(mutatedWorkflow));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects encoded, indirect, and unknown actions in both workflows', () => {
|
||||
for (const stepSource of [
|
||||
'- uses: snapcore/action-publish@v1\n with:\n release: stable',
|
||||
'- "uses": snapcore/action-publish@v1\n with:\n release: stable',
|
||||
"- 'uses' : snapcore/action-publish@v1\n with:\n release: stable",
|
||||
'- "\\x75ses": snapcore/action-publish@v1\n with:\n release: stable',
|
||||
'- ? uses\n : snapcore/action-publish@v1\n with:\n release: stable',
|
||||
'- { uses: snapcore/action-publish@v1, with: { release: stable } }',
|
||||
'- uses: >-\n snapcore/action-publish@v1\n with:\n release: stable',
|
||||
'- name: Alias publisher\n env:\n PUBLISHER: &publisher snapcore/action-publish@v1\n uses: *publisher\n with:\n release: stable',
|
||||
'- uses: "\\x73napcore/action-publish@v1"\n with:\n release: stable',
|
||||
'- uses: example/action-publish@v1\n with:\n release: stable',
|
||||
]) {
|
||||
assertStepRejectedByBothPolicies(stepSource);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects job-level reusable workflow publication in both workflows', () => {
|
||||
const reusableJob =
|
||||
'synthetic-publisher:\n uses: snapcore/action-publish/.github/workflows/publish.yml@v1\n with:\n release: stable\n secrets: inherit';
|
||||
for (const [workflowPath, assertPolicy] of [
|
||||
[publishWorkflowPath, assertPublishSnapWorkflowPolicy],
|
||||
[buildWorkflowPath, assertBuildSnapWorkflowPolicy],
|
||||
]) {
|
||||
const workflowText = insertWorkflowJob(
|
||||
fs.readFileSync(workflowPath, 'utf8'),
|
||||
reusableJob
|
||||
);
|
||||
assert.doesNotThrow(() => parse(workflowText));
|
||||
assert.throws(() => assertPolicy(workflowText));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects command-bearing explicit shell templates in both workflows', () => {
|
||||
const maliciousShell = '"snapcraft release iptvnator stable; bash {0}"';
|
||||
for (const [workflowPath, assertPolicy] of [
|
||||
[publishWorkflowPath, assertPublishSnapWorkflowPolicy],
|
||||
[buildWorkflowPath, assertBuildSnapWorkflowPolicy],
|
||||
]) {
|
||||
const workflowText = fs.readFileSync(workflowPath, 'utf8');
|
||||
for (const mutatedWorkflow of [
|
||||
insertFirstJobStep(
|
||||
workflowText,
|
||||
`- shell: ${maliciousShell}\n run: echo safe`
|
||||
),
|
||||
`${workflowText}\ndefaults:\n run:\n shell: ${maliciousShell}\n`,
|
||||
insertFirstJobField(
|
||||
workflowText,
|
||||
`defaults:\n run:\n shell: ${maliciousShell}`
|
||||
),
|
||||
]) {
|
||||
assert.doesNotThrow(() => parse(mutatedWorkflow));
|
||||
assert.throws(() => assertPolicy(mutatedWorkflow));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores Snapcraft names in comments and allowlisted action uses', () => {
|
||||
const commentStep =
|
||||
'- run: |\n # snapcraft upload --release=stable package.snap\n # snapcraft release iptvnator stable';
|
||||
const publishWorkflow = insertFirstJobStep(
|
||||
fs.readFileSync(publishWorkflowPath, 'utf8'),
|
||||
commentStep
|
||||
);
|
||||
const buildWorkflow = insertFirstJobStep(
|
||||
fs.readFileSync(buildWorkflowPath, 'utf8'),
|
||||
commentStep
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() => parse(publishWorkflow));
|
||||
assert.doesNotThrow(() => parse(buildWorkflow));
|
||||
assert.doesNotThrow(() => assertPublishSnapWorkflowPolicy(publishWorkflow));
|
||||
assert.doesNotThrow(() => assertBuildSnapWorkflowPolicy(buildWorkflow));
|
||||
});
|
||||
|
||||
test('selects every exact Snap and exactly one compliance source asset', async () => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const PUBLISH_ACTION_ALLOWLIST = Object.freeze([
|
||||
'actions/checkout@v4',
|
||||
'samuelmeuli/action-snapcraft@v3',
|
||||
]);
|
||||
const BUILD_ACTION_ALLOWLIST = Object.freeze([
|
||||
'actions/cache/restore@v4',
|
||||
'actions/cache/save@v4',
|
||||
'actions/cache@v4',
|
||||
'actions/checkout@v4',
|
||||
'actions/download-artifact@v4',
|
||||
'actions/setup-node@v4',
|
||||
'actions/upload-artifact@v4',
|
||||
'pnpm/action-setup@v4',
|
||||
'softprops/action-gh-release@v2',
|
||||
]);
|
||||
const PUBLISH_JOB_ID = 'publish-snap';
|
||||
const PUBLISH_JOB_CONDITION =
|
||||
"${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}";
|
||||
const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge';
|
||||
const PUBLISH_STEP_CONTRACT = Object.freeze({
|
||||
name: PUBLISH_STEP_NAME,
|
||||
shell: 'bash',
|
||||
run: [
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
'node -e \\',
|
||||
` "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \\`,
|
||||
' "${RUNNER_TEMP}/selected-snap-release-assets.json" |',
|
||||
' while IFS= read -r SNAP_NAME; do',
|
||||
' SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}"',
|
||||
' echo "Publishing public release asset: ${SNAP_NAME}"',
|
||||
' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.',
|
||||
' # GitHub Actions never promotes automatically.',
|
||||
' snapcraft upload --release=edge "${SNAP_FILE}"',
|
||||
' done',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
function stripShellComment(line) {
|
||||
let quote = null;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index];
|
||||
if (quote === "'") {
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '\\') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
character === '#' &&
|
||||
(index === 0 || /[\s;|&()]/.test(line[index - 1]))
|
||||
) {
|
||||
return line.slice(0, index);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeRunSource(runSource) {
|
||||
return runSource
|
||||
.split('\n')
|
||||
.map(stripShellComment)
|
||||
.join('\n')
|
||||
.replace(/\\\r?\n[ \t]*/g, '');
|
||||
}
|
||||
|
||||
function collectDefaultShell(container, containerName, explicitShells) {
|
||||
if (!Object.hasOwn(container, 'defaults')) {
|
||||
return;
|
||||
}
|
||||
assert.ok(
|
||||
isRecord(container.defaults),
|
||||
`${containerName} defaults must be a mapping`
|
||||
);
|
||||
if (!Object.hasOwn(container.defaults, 'run')) {
|
||||
return;
|
||||
}
|
||||
assert.ok(
|
||||
isRecord(container.defaults.run),
|
||||
`${containerName} run defaults must be a mapping`
|
||||
);
|
||||
if (!Object.hasOwn(container.defaults.run, 'shell')) {
|
||||
return;
|
||||
}
|
||||
assert.equal(
|
||||
typeof container.defaults.run.shell,
|
||||
'string',
|
||||
`${containerName} default shell must resolve to a string`
|
||||
);
|
||||
explicitShells.push(container.defaults.run.shell);
|
||||
}
|
||||
|
||||
function collectWorkflowPolicyInputs(workflowText) {
|
||||
const workflow = parse(workflowText);
|
||||
assert.ok(isRecord(workflow), 'workflow must be a YAML mapping');
|
||||
assert.ok(isRecord(workflow.jobs), 'workflow jobs must be a YAML mapping');
|
||||
|
||||
const actions = [];
|
||||
const explicitShells = [];
|
||||
const jobActions = [];
|
||||
const jobsWithoutSteps = [];
|
||||
const runSources = [];
|
||||
collectDefaultShell(workflow, 'workflow', explicitShells);
|
||||
for (const [jobName, job] of Object.entries(workflow.jobs)) {
|
||||
assert.ok(isRecord(job), `workflow job "${jobName}" must be a mapping`);
|
||||
collectDefaultShell(job, `workflow job "${jobName}"`, explicitShells);
|
||||
if (Object.hasOwn(job, 'uses')) {
|
||||
assert.equal(
|
||||
typeof job.uses,
|
||||
'string',
|
||||
'reusable workflow identifiers must resolve to strings'
|
||||
);
|
||||
jobActions.push(job.uses);
|
||||
}
|
||||
if (!Array.isArray(job.steps)) {
|
||||
jobsWithoutSteps.push(jobName);
|
||||
continue;
|
||||
}
|
||||
for (const step of job.steps) {
|
||||
assert.ok(isRecord(step), 'workflow steps must be mappings');
|
||||
if (Object.hasOwn(step, 'uses')) {
|
||||
assert.equal(
|
||||
typeof step.uses,
|
||||
'string',
|
||||
'workflow action identifiers must resolve to strings'
|
||||
);
|
||||
actions.push(step.uses);
|
||||
}
|
||||
if (Object.hasOwn(step, 'run')) {
|
||||
assert.equal(
|
||||
typeof step.run,
|
||||
'string',
|
||||
'workflow run commands must resolve to strings'
|
||||
);
|
||||
runSources.push(normalizeRunSource(step.run));
|
||||
}
|
||||
if (Object.hasOwn(step, 'shell')) {
|
||||
assert.equal(
|
||||
typeof step.shell,
|
||||
'string',
|
||||
'workflow step shells must resolve to strings'
|
||||
);
|
||||
explicitShells.push(step.shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actions,
|
||||
commandSource: runSources.join('\n'),
|
||||
explicitShells,
|
||||
jobActions,
|
||||
jobsWithoutSteps,
|
||||
workflow,
|
||||
};
|
||||
}
|
||||
|
||||
function assertWorkflowExecutionShape({
|
||||
explicitShells,
|
||||
jobActions,
|
||||
jobsWithoutSteps,
|
||||
}) {
|
||||
assert.deepEqual(
|
||||
jobActions,
|
||||
[],
|
||||
'job-level reusable workflow delegation is not allowed'
|
||||
);
|
||||
assert.deepEqual(
|
||||
jobsWithoutSteps,
|
||||
[],
|
||||
'every workflow job must define a concrete steps sequence'
|
||||
);
|
||||
assert.deepEqual(
|
||||
explicitShells.filter((shell) => shell !== 'bash'),
|
||||
[],
|
||||
'every explicit workflow shell must be exactly "bash"'
|
||||
);
|
||||
}
|
||||
|
||||
function literalSnapcraftTokens(commandSource) {
|
||||
return commandSource.match(/\bsnapcraft\b/g) ?? [];
|
||||
}
|
||||
|
||||
export function assertPublishSnapWorkflowPolicy(workflowText) {
|
||||
const policyInputs = collectWorkflowPolicyInputs(workflowText);
|
||||
const { actions, commandSource, workflow } = policyInputs;
|
||||
assertWorkflowExecutionShape(policyInputs);
|
||||
assert.deepEqual(
|
||||
workflow.on,
|
||||
{ release: { types: ['published'] } },
|
||||
'the publish workflow must retain its exact release trigger'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...actions].sort(),
|
||||
[...PUBLISH_ACTION_ALLOWLIST].sort(),
|
||||
'the publish workflow must use exactly the allowlisted actions'
|
||||
);
|
||||
const publishJob = workflow.jobs[PUBLISH_JOB_ID];
|
||||
assert.ok(isRecord(publishJob), 'the canonical publish job must exist');
|
||||
assert.equal(
|
||||
publishJob.if,
|
||||
PUBLISH_JOB_CONDITION,
|
||||
'the publish job must retain its exact release condition'
|
||||
);
|
||||
assert.deepEqual(
|
||||
publishJob.steps.filter((step) => step.name === PUBLISH_STEP_NAME),
|
||||
[PUBLISH_STEP_CONTRACT],
|
||||
'the publish workflow must retain the exact reviewed publication step'
|
||||
);
|
||||
assert.equal(
|
||||
literalSnapcraftTokens(commandSource).length,
|
||||
1,
|
||||
'the publish workflow must contain exactly one literal Snapcraft command'
|
||||
);
|
||||
}
|
||||
|
||||
export function assertBuildSnapWorkflowPolicy(workflowText) {
|
||||
const policyInputs = collectWorkflowPolicyInputs(workflowText);
|
||||
const { actions, commandSource } = policyInputs;
|
||||
assertWorkflowExecutionShape(policyInputs);
|
||||
assert.deepEqual(
|
||||
actions.filter((action) => !BUILD_ACTION_ALLOWLIST.includes(action)),
|
||||
[],
|
||||
'the build workflow must use only allowlisted actions'
|
||||
);
|
||||
assert.equal(
|
||||
literalSnapcraftTokens(commandSource).length,
|
||||
0,
|
||||
'the build workflow must not contain a literal Snapcraft token'
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,11 @@ const {
|
||||
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
|
||||
resolveLinuxFrameCopyProfile,
|
||||
} = require('./linux-frame-copy-profile.cjs');
|
||||
const {
|
||||
RUNTIME_PROBE_TIMEOUT_MS,
|
||||
} = require('../embedded-mpv/runtime-probe-contract.cjs');
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const RUNTIME_PROBE_TIMEOUT_MS = 3000;
|
||||
const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/;
|
||||
|
||||
function defaultRunCommand(command, args, options = {}) {
|
||||
|
||||
@@ -19,6 +19,26 @@ import {
|
||||
verifyLinuxFrameCopyArtifact,
|
||||
} from './verify-linux-frame-copy-runtime.mjs';
|
||||
|
||||
const runtimeProbeContractUrl = new URL(
|
||||
'../embedded-mpv/runtime-probe-contract.cjs',
|
||||
import.meta.url
|
||||
);
|
||||
const runtimeProbeContractTypesUrl = new URL(
|
||||
'../embedded-mpv/runtime-probe-contract.d.cts',
|
||||
import.meta.url
|
||||
);
|
||||
const verifierUrl = new URL(
|
||||
'./verify-linux-frame-copy-runtime.mjs',
|
||||
import.meta.url
|
||||
);
|
||||
const backendRuntimeContractsUrl = new URL(
|
||||
'../../apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts',
|
||||
import.meta.url
|
||||
);
|
||||
const electronBackendProjectUrl = new URL(
|
||||
'../../apps/electron-backend/project.json',
|
||||
import.meta.url
|
||||
);
|
||||
const SYSTEM_TARGETS = ['deb', 'pacman', 'rpm'];
|
||||
const SYSTEM_MANIFEST = {
|
||||
schemaVersion: 1,
|
||||
@@ -65,6 +85,85 @@ const SYSTEM_MANIFEST = {
|
||||
runtimeTotalBytes: 0,
|
||||
};
|
||||
|
||||
test('uses the shared frozen 3000 ms runtime probe timeout contract', async () => {
|
||||
assert.equal(
|
||||
fs.existsSync(runtimeProbeContractUrl),
|
||||
true,
|
||||
'the shared runtime probe contract must exist'
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(runtimeProbeContractTypesUrl),
|
||||
true,
|
||||
'the shared runtime probe contract types must exist'
|
||||
);
|
||||
|
||||
const { default: runtimeProbeContract } = await import(
|
||||
runtimeProbeContractUrl.href
|
||||
);
|
||||
assert.equal(Object.isFrozen(runtimeProbeContract), true);
|
||||
assert.equal(runtimeProbeContract.RUNTIME_PROBE_TIMEOUT_MS, 3000);
|
||||
assert.match(
|
||||
fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'),
|
||||
/readonly RUNTIME_PROBE_TIMEOUT_MS: 3000/
|
||||
);
|
||||
|
||||
const verifierSource = fs.readFileSync(verifierUrl, 'utf8');
|
||||
assert.match(
|
||||
verifierSource,
|
||||
/require\(['"]\.\.\/embedded-mpv\/runtime-probe-contract\.cjs['"]\)/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
verifierSource,
|
||||
/const RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/
|
||||
);
|
||||
|
||||
const backendContractsSource = fs.readFileSync(
|
||||
backendRuntimeContractsUrl,
|
||||
'utf8'
|
||||
);
|
||||
assert.match(
|
||||
backendContractsSource,
|
||||
/import runtimeProbeContract = require\(['"][^'"]*\/runtime-probe-contract\.cjs['"]\)/
|
||||
);
|
||||
assert.match(
|
||||
backendContractsSource,
|
||||
/export const \{ RUNTIME_PROBE_TIMEOUT_MS \} = runtimeProbeContract/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
backendContractsSource,
|
||||
/RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks the shared probe contract in cached backend targets and runtime lint', () => {
|
||||
const backendProject = JSON.parse(
|
||||
fs.readFileSync(electronBackendProjectUrl, 'utf8')
|
||||
);
|
||||
const contractInputs = [
|
||||
'{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs',
|
||||
'{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts',
|
||||
];
|
||||
|
||||
for (const targetName of ['build', 'build-e2e', 'test', 'lint']) {
|
||||
const inputs = backendProject.targets[targetName]?.inputs ?? [];
|
||||
for (const contractInput of contractInputs) {
|
||||
assert.ok(
|
||||
inputs.includes(contractInput),
|
||||
`${targetName} must track ${contractInput}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.match(
|
||||
backendProject.targets.lint.command,
|
||||
/embedded-mpv-frame-copy-runtime\.ts/
|
||||
);
|
||||
assert.match(
|
||||
backendProject.targets.lint.command,
|
||||
/embedded-mpv-frame-copy-runtime\/\*\*\/\*\.ts/
|
||||
);
|
||||
});
|
||||
|
||||
function elfHeader(architecture) {
|
||||
const machines = {
|
||||
x64: 62,
|
||||
|
||||
Reference in new issue
Block a user