mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
test(packaging): harden Linux package probes
This commit is contained in:
1 parent
f8794dd0a0
commit
cc18cae320
2 files changed
+380
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user