mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
344 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|