Files
qinglong/scripts/ql3-physical-edge-fault-probe.cjs
T

344 lines
10 KiB
JavaScript

#!/usr/bin/env node
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
canonicalDigest,
mountForPath,
parseMountTable,
writeNoReplace,
} = require('./ql3-physical-edge-evidence.cjs');
const MIB = 1024 * 1024;
const MAX_INPUT_BYTES = 16 * 1024;
const MANIFEST_KEYS = Object.freeze([
'deviceId',
'evidenceClass',
'expectedFilesystem',
'fault',
'maximumFilesystemBytes',
'probePath',
'profile',
'schemaVersion',
]);
const FAULTS = Object.freeze(['enospc_filesystem', 'read_only_filesystem']);
class QingLong3PhysicalFaultEvidenceError extends Error {
constructor(message) {
super(`QingLong 3.0 physical Edge fault evidence failed: ${message}`);
this.name = 'QingLong3PhysicalFaultEvidenceError';
}
}
function exactKeys(value, expected, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new QingLong3PhysicalFaultEvidenceError(`${label} must be an object`);
}
const actual = Object.keys(value).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new QingLong3PhysicalFaultEvidenceError(
`${label} keys must be exactly ${expected.join(', ')}`,
);
}
}
function normalizeFaultManifest(value) {
exactKeys(value, MANIFEST_KEYS, 'manifest');
if (value.schemaVersion !== 1) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest schemaVersion must be 1',
);
}
if (value.evidenceClass !== 'physical_edge_fault_candidate') {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest evidenceClass must be physical_edge_fault_candidate',
);
}
if (value.profile !== 'edge') {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest profile must be edge',
);
}
if (
typeof value.deviceId !== 'string' ||
!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(value.deviceId)
) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest deviceId is invalid',
);
}
if (!FAULTS.includes(value.fault)) {
throw new QingLong3PhysicalFaultEvidenceError(
`manifest fault must be one of ${FAULTS.join(', ')}`,
);
}
if (
typeof value.probePath !== 'string' ||
!path.isAbsolute(value.probePath) ||
value.probePath === '/' ||
value.probePath.length > 4096
) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest probePath must be a bounded absolute non-root path',
);
}
if (
typeof value.expectedFilesystem !== 'string' ||
!/^[a-z0-9][a-z0-9._-]{1,31}$/.test(value.expectedFilesystem)
) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest expectedFilesystem is invalid',
);
}
if (
!Number.isSafeInteger(value.maximumFilesystemBytes) ||
value.maximumFilesystemBytes < MIB ||
value.maximumFilesystemBytes > 256 * MIB
) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest maximumFilesystemBytes must be between 1 and 256 MiB',
);
}
return Object.freeze({
schemaVersion: 1,
evidenceClass: 'physical_edge_fault_candidate',
profile: 'edge',
deviceId: value.deviceId,
fault: value.fault,
probePath: value.probePath,
expectedFilesystem: value.expectedFilesystem,
maximumFilesystemBytes: value.maximumFilesystemBytes,
});
}
function parseArguments(argv) {
const options = { json: false };
let separatorSeen = false;
for (const argument of argv) {
if (argument === '--' && !separatorSeen) {
separatorSeen = true;
continue;
}
if (argument === '--json') {
options.json = true;
continue;
}
const separator = argument.indexOf('=');
if (separator < 1) {
throw new QingLong3PhysicalFaultEvidenceError(
`unsupported argument ${argument}`,
);
}
const name = argument.slice(0, separator);
const value = argument.slice(separator + 1);
if (name === '--manifest') options.manifestPath = value;
else if (name === '--output') options.outputPath = value;
else {
throw new QingLong3PhysicalFaultEvidenceError(
`unsupported argument ${argument}`,
);
}
}
for (const name of ['manifestPath', 'outputPath']) {
if (!path.isAbsolute(options[name] ?? '')) {
throw new QingLong3PhysicalFaultEvidenceError(`${name} must be absolute`);
}
}
return Object.freeze(options);
}
function readManifest(filePath) {
try {
const stat = fs.lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size > MAX_INPUT_BYTES
) {
throw new QingLong3PhysicalFaultEvidenceError(
'manifest must be a bounded regular file without symlinks',
);
}
const contents = fs.readFileSync(filePath, 'utf8');
if (Buffer.byteLength(contents) > MAX_INPUT_BYTES) {
throw new QingLong3PhysicalFaultEvidenceError('manifest is oversized');
}
return normalizeFaultManifest(JSON.parse(contents));
} catch (error) {
if (error instanceof QingLong3PhysicalFaultEvidenceError) throw error;
throw new QingLong3PhysicalFaultEvidenceError(
`manifest could not be read: ${error.message}`,
);
}
}
function validateFaultEnvelope(manifest, observed) {
const violations = [];
if (observed.platform !== 'linux') violations.push('platform must be Linux');
if (observed.probePath !== manifest.probePath) {
violations.push('probe path was not canonical');
}
if (observed.mountPath !== manifest.probePath) {
violations.push('probe path must be a dedicated mount point');
}
if (observed.filesystem !== manifest.expectedFilesystem) {
violations.push(
`filesystem ${observed.filesystem} did not equal ${manifest.expectedFilesystem}`,
);
}
if (observed.totalBytes > manifest.maximumFilesystemBytes) {
violations.push('fault filesystem exceeded its declared capacity');
}
if (manifest.fault === 'read_only_filesystem') {
if (!observed.mountOptions.includes('ro')) {
violations.push('fault filesystem was not mounted read-only');
}
if (observed.expectedErrorCode !== 'EROFS') {
violations.push('write probe did not return EROFS');
}
} else {
if (!observed.mountOptions.includes('rw')) {
violations.push('fault filesystem was not mounted read-write');
}
if (observed.availableBytes !== 0) {
violations.push('fault filesystem still had available blocks');
}
if (observed.processUid === 0) {
violations.push('ENOSPC probe must run as non-root');
}
if (observed.expectedErrorCode !== 'ENOSPC') {
violations.push('write probe did not return ENOSPC');
}
}
if (observed.probeEntryRemained) {
violations.push('fault probe entry was not cleaned up');
}
return Object.freeze(violations);
}
function collectFaultEnvelope(manifest) {
if (process.platform !== 'linux') {
throw new QingLong3PhysicalFaultEvidenceError('probe requires Linux');
}
const lstat = fs.lstatSync(manifest.probePath);
const probePath = fs.realpathSync(manifest.probePath);
if (
!lstat.isDirectory() ||
lstat.isSymbolicLink() ||
probePath !== manifest.probePath
) {
throw new QingLong3PhysicalFaultEvidenceError(
'probePath must be a canonical directory without symlinks',
);
}
const mounts = parseMountTable(fs.readFileSync('/proc/mounts', 'utf8'));
const mount = mountForPath(mounts, probePath);
if (!mount) {
throw new QingLong3PhysicalFaultEvidenceError(
'probe filesystem mount was not found',
);
}
const statfs = fs.statfsSync(probePath, { bigint: true });
const probeName = `.ql3-fault-${crypto.randomBytes(16).toString('hex')}`;
const probeFile = path.join(probePath, probeName);
let descriptor;
let expectedErrorCode = null;
let probeEntryCreated = false;
try {
descriptor = fs.openSync(probeFile, 'wx', 0o600);
probeEntryCreated = true;
fs.writeSync(descriptor, Buffer.from([0x51]), 0, 1, null);
fs.fsyncSync(descriptor);
} catch (error) {
expectedErrorCode = error?.code ?? null;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
if (probeEntryCreated) {
try {
fs.unlinkSync(probeFile);
} catch {
// The remaining entry is detected below and fails the evidence gate.
}
}
}
return Object.freeze({
platform: process.platform,
architecture: process.arch,
node: process.version,
bootId: fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
processUid: process.getuid?.() ?? null,
probePath,
mountPath: mount.path,
filesystem: mount.filesystem,
mountOptions: mount.options,
totalBytes: Number(statfs.blocks * statfs.bsize),
availableBytes: Number(statfs.bavail * statfs.bsize),
expectedErrorCode,
probeEntryCreated,
probeEntryRemained: fs.existsSync(probeFile),
});
}
function buildFaultEvidenceReport({ manifest, observed, generatedAt }) {
const violations = validateFaultEnvelope(manifest, observed);
const body = {
schemaVersion: 1,
evidenceClass: 'physical_edge_fault_candidate',
supported: false,
generatedAt,
manifest,
observed,
qualification: {
passed: violations.length === 0,
violations,
scope: manifest.fault,
doesNotProve: [
'main_data_filesystem_fault',
'application_level_recovery',
'power_loss_survival',
],
},
};
return Object.freeze({ ...body, sha256: canonicalDigest(body) });
}
function main() {
if (Number(process.versions.node.split('.')[0]) < 24) {
throw new QingLong3PhysicalFaultEvidenceError(
'Node.js 24 or newer is required',
);
}
const options = parseArguments(process.argv.slice(2));
const manifest = readManifest(options.manifestPath);
const observed = collectFaultEnvelope(manifest);
const report = buildFaultEvidenceReport({
manifest,
observed,
generatedAt: new Date().toISOString(),
});
const serialized = `${JSON.stringify(report, null, options.json ? 0 : 2)}\n`;
writeNoReplace(options.outputPath, serialized);
process.stdout.write(serialized);
if (!report.qualification.passed) process.exitCode = 1;
}
module.exports = {
QingLong3PhysicalFaultEvidenceError,
buildFaultEvidenceReport,
normalizeFaultManifest,
parseArguments,
validateFaultEnvelope,
};
if (require.main === module) {
try {
main();
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}