diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index 87d4d6197..231407e61 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -28,6 +28,7 @@ function defaultRunCommand(command, args, options = {}) { encoding: 'utf8', stdio: 'pipe', timeout: options.timeout, + killSignal: options.killSignal, windowsHide: true, }); } @@ -323,6 +324,188 @@ export function findExtractedResourceDir(extractionRoot) { return candidates[0]; } +function yamlMappingEntries(lines, key, indent, start = 0, end = lines.length) { + const prefix = `${' '.repeat(indent)}${key}:`; + return lines + .map((line, index) => ({ index, line })) + .filter( + ({ index, line }) => + index >= start && + index < end && + line.startsWith(prefix) && + line.slice(prefix.length).match(/^(?:\s|$)/) && + line.length - line.trimStart().length === indent + ) + .map(({ index, line }) => { + let blockEnd = end; + for ( + let candidateIndex = index + 1; + candidateIndex < end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + return { + index, + end: blockEnd, + value: line.slice(prefix.length).trim(), + }; + }); +} + +function singleYamlMappingEntry(lines, key, indent, start, end) { + const entries = yamlMappingEntries(lines, key, indent, start, end); + return entries.length === 1 ? entries[0] : null; +} + +function yamlScalarEquals(value, expected) { + return ( + value === expected || + value === JSON.stringify(expected) || + value === `'${expected.replaceAll("'", "''")}'` + ); +} + +function yamlSequenceIncludes(lines, entry, expected) { + if (entry.value) { + if (!entry.value.startsWith('[') || !entry.value.endsWith(']')) { + return false; + } + return entry.value + .slice(1, -1) + .split(',') + .map((value) => value.trim()) + .some((value) => yamlScalarEquals(value, expected)); + } + return lines + .slice(entry.index + 1, entry.end) + .some((line) => + yamlScalarEquals(line.trim().replace(/^-\s*/, ''), expected) + ); +} + +export function validateExtractedSnapMetadata(extractionRoot) { + const snapYamlPath = path.join(extractionRoot, 'meta', 'snap.yaml'); + let stat; + try { + stat = fs.lstatSync(snapYamlPath); + } catch { + return [`Missing extracted Snap metadata: ${snapYamlPath}`]; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return [`Extracted Snap metadata must be a regular file: ${snapYamlPath}`]; + } + + let contents; + try { + contents = fs.readFileSync(snapYamlPath, 'utf8'); + } catch (error) { + return [ + `Unable to read extracted Snap metadata at ${snapYamlPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (contents.includes('\t')) { + return [ + `Extracted Snap metadata must use space indentation: ${snapYamlPath}`, + ]; + } + const lines = contents.split(/\r?\n/); + const errors = []; + const plugs = singleYamlMappingEntry( + lines, + 'plugs', + 0, + 0, + lines.length + ); + const sharedMemory = + plugs && + singleYamlMappingEntry( + lines, + 'shared-memory', + 2, + plugs.index + 1, + plugs.end + ); + if (!plugs || plugs.value || !sharedMemory || sharedMemory.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level shared-memory plug.' + ); + } else { + const interfaceEntry = singleYamlMappingEntry( + lines, + 'interface', + 4, + sharedMemory.index + 1, + sharedMemory.end + ); + const privateEntry = singleYamlMappingEntry( + lines, + 'private', + 4, + sharedMemory.index + 1, + sharedMemory.end + ); + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'shared-memory') + ) { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare interface: shared-memory.' + ); + } + if (!privateEntry || privateEntry.value !== 'true') { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare private: true.' + ); + } + } + + const apps = singleYamlMappingEntry(lines, 'apps', 0, 0, lines.length); + const app = + apps && + singleYamlMappingEntry( + lines, + 'iptvnator', + 2, + apps.index + 1, + apps.end + ); + const appPlugs = + app && + singleYamlMappingEntry( + lines, + 'plugs', + 4, + app.index + 1, + app.end + ); + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'shared-memory') + ) { + errors.push( + 'Extracted Snap iptvnator app must use the shared-memory plug.' + ); + } + return errors; +} + export function readElfArchitecture(binaryPath) { const descriptor = fs.openSync(binaryPath, 'r'); try { @@ -792,6 +975,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ { encoding: 'utf8', env: probeEnvironment, + killSignal: 'SIGKILL', timeout: RUNTIME_PROBE_TIMEOUT_MS, windowsHide: true, } @@ -837,6 +1021,18 @@ export function verifyLinuxFrameCopyArtifact({ destination: extractionDestination, runCommand, }); + if (format === 'snap') { + const snapMetadataErrors = + validateExtractedSnapMetadata(extractionRoot); + if (snapMetadataErrors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...snapMetadataErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + } const resourceDir = findExtractedResourceDir(extractionRoot); const metadata = metadataReader({ artifactPath: resolvedArtifactPath, diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index ae5cb831a..33bdb6654 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -14,6 +14,7 @@ import { readLinuxArtifactMetadata, readElfArchitecture, validateSystemPackageDependencies, + validateExtractedSnapMetadata, verifyExtractedLinuxFrameCopyRuntime, verifyLinuxFrameCopyArtifact, } from './verify-linux-frame-copy-runtime.mjs'; @@ -513,8 +514,189 @@ test('validates an x64 system payload and executes one bounded helper probe', () path.join(fixture.nativeDir, 'iptvnator_mpv_helper') ); assert.deepEqual(probeCalls[0].args, ['--runtime-probe']); - assert.equal(probeCalls[0].options.timeout, 3000); - assert.deepEqual(probeCalls[0].options.env, { PATH: '/usr/bin' }); + assert.deepEqual(probeCalls[0].options, { + encoding: 'utf8', + env: { PATH: '/usr/bin' }, + killSignal: 'SIGKILL', + timeout: 3000, + windowsHide: true, + }); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('rejects helper probes terminated by a signal or hard timeout', () => { + for (const probeResult of [ + { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + { + error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { + code: 'ETIMEDOUT', + }), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: ['libmpv2'], + elfInspector: validElfInspector, + probeRunner() { + return probeResult; + }, + }); + assert.match( + errors.join('\n'), + /(?:runtime probe terminated by signal SIGKILL|Unable to execute .*ETIMEDOUT)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('requires a private top-level shared-memory plug used by the Snap app', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-snap-metadata-') + ); + const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + + const validSnapYaml = [ + 'name: iptvnator', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' plugs:', + ' - desktop', + ' - shared-memory', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + '', + ].join('\n'); + + try { + fs.writeFileSync(snapYamlPath, validSnapYaml); + assert.deepEqual(validateExtractedSnapMetadata(root), []); + + for (const [mutate, expected] of [ + [ + (contents) => + contents.replace(' private: true', ' private: false'), + /private: true/, + ], + [ + (contents) => + contents.replace(' - shared-memory\n', ''), + /app.*shared-memory plug/i, + ], + [ + (contents) => + contents.replace( + ' shared-memory:\n interface: shared-memory\n private: true\n', + '' + ), + /top-level shared-memory plug/i, + ], + ]) { + fs.writeFileSync(snapYamlPath, mutate(validSnapYaml)); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + expected + ); + } + + fs.rmSync(snapYamlPath); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /Missing extracted Snap metadata/ + ); + + fs.writeFileSync(path.join(root, 'outside.yaml'), validSnapYaml); + fs.symlinkSync( + path.join(root, 'outside.yaml'), + snapYamlPath, + process.platform === 'win32' ? 'file' : undefined + ); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /regular file/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('artifact verification rejects Snap metadata before accepting its payload', () => { + const fixture = createSystemPayload(); + const artifactPath = path.join(fixture.root, 'package.snap'); + const snapYamlPath = path.join(fixture.root, 'meta', 'snap.yaml'); + fs.writeFileSync(artifactPath, 'fixture'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.writeFileSync( + snapYamlPath, + [ + 'name: iptvnator', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' plugs:', + ' - desktop', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + '', + ].join('\n') + ); + let payloadVerifierCalls = 0; + + const verify = () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'portable', + extractArtifact() { + return fixture.root; + }, + metadataReader() { + return { declaredArch: 'x64', dependencies: [] }; + }, + payloadVerifier() { + payloadVerifierCalls += 1; + return []; + }, + }); + + try { + assert.throws(verify, /app.*shared-memory plug/i); + assert.equal(payloadVerifierCalls, 0); + + fs.writeFileSync( + snapYamlPath, + fs + .readFileSync(snapYamlPath, 'utf8') + .replace(' - desktop\n', ' - shared-memory\n') + ); + assert.deepEqual(verify(), { + artifactPath, + format: 'snap', + profileName: 'portable', + architecture: 'x64', + }); + assert.equal(payloadVerifierCalls, 1); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }