protoml-parser
Version:
ProtoML is a lightweight, declarative markup language designed for writing and structuring meeting protocols, notes and task lists in a human-readable and machine-parseable format.
1,021 lines (888 loc) • 31.9 kB
JavaScript
const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const { tokenize } = require("./tokenizer");
const { parseBlocks } = require("./blockParser");
const { resolveMacroPath } = require("../cli/options");
const { BUILTIN_MACRO_DIR, BUILTIN_MACRO_HASHES } = require("./builtinMacroTrust");
const SIGNATURE_SUFFIX = ".sig.json";
function normalizeFileRef(value) {
return String(value || "").trim().replace(/^["']|["']$/g, "");
}
function isUrl(value) {
return /^https?:\/\//i.test(String(value || ""));
}
function sha256Buffer(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function normalizeRelativePath(value) {
return String(value || "").replace(/\\/g, "/");
}
function getSignatureFilePath(filePath) {
return `${filePath}${SIGNATURE_SUFFIX}`;
}
function safeReadJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
return null;
}
}
function normalizeRegistrySource(source, baseDir = process.cwd()) {
if (!source) return null;
if (typeof source === "string") {
return {
source,
local: !isUrl(source),
resolvedSource: isUrl(source) ? source : path.resolve(baseDir, source),
};
}
const rawSource = source.source || source.path || source.url;
if (!rawSource) return null;
const effectiveBaseDir = source.baseDir || baseDir;
return {
source: rawSource,
local: !isUrl(rawSource),
resolvedSource: isUrl(rawSource) ? rawSource : path.resolve(effectiveBaseDir, rawSource),
};
}
function resolveRegistryFilePath(normalizedSource) {
if (!normalizedSource || !normalizedSource.local) {
return null;
}
const resolved = normalizedSource.resolvedSource;
if (!fs.existsSync(resolved)) {
return null;
}
if (fs.statSync(resolved).isDirectory()) {
return path.join(resolved, "protoml.registry.json");
}
return resolved;
}
function loadRegistrySourceSync(source, baseDir = process.cwd()) {
const normalized = normalizeRegistrySource(source, baseDir);
if (!normalized) return null;
if (!normalized.local) {
return {
source: normalized.source,
authors: [],
skipped: true,
reason: "Remote trust sources require the explicit trust/verify command.",
};
}
const registryFile = resolveRegistryFilePath(normalized);
if (!registryFile || !fs.existsSync(registryFile)) {
return {
source: normalized.source,
authors: [],
skipped: true,
reason: "Registry file not found.",
};
}
const raw = safeReadJson(registryFile) || {};
return {
source: normalized.source,
file: registryFile,
authors: Array.isArray(raw.authors) ? raw.authors : [],
raw,
};
}
function fetchJson(url, redirectCount = 0) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https://") ? https : http;
const request = client.get(url, (response) => {
const statusCode = response.statusCode || 0;
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
if (redirectCount >= 5) {
reject(new Error(`Too many redirects while loading registry: ${url}`));
return;
}
const nextUrl = new URL(response.headers.location, url).toString();
response.resume();
resolve(fetchJson(nextUrl, redirectCount + 1));
return;
}
if (statusCode < 200 || statusCode >= 300) {
response.resume();
reject(new Error(`Registry request failed (${statusCode}): ${url}`));
return;
}
let body = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
body += chunk;
});
response.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error(`Invalid registry JSON from ${url}: ${error.message}`));
}
});
});
request.on("error", (error) => {
reject(new Error(`Failed to load registry ${url}: ${error.message}`));
});
});
}
async function loadRegistrySourceAsync(source, baseDir = process.cwd()) {
const normalized = normalizeRegistrySource(source, baseDir);
if (!normalized) return null;
if (normalized.local) {
return loadRegistrySourceSync(source, baseDir);
}
const raw = await fetchJson(normalized.resolvedSource);
return {
source: normalized.source,
file: normalized.resolvedSource,
authors: Array.isArray(raw.authors) ? raw.authors : [],
raw,
};
}
function findNearestProjectRegistryConfig(startDir = process.cwd()) {
let currentDir = path.resolve(startDir);
while (true) {
const configFile = path.join(currentDir, "protoml.macros.json");
if (fs.existsSync(configFile)) {
return configFile;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
function gatherDefaultRegistrySources(baseDir = process.cwd()) {
const configFile = findNearestProjectRegistryConfig(baseDir);
if (!configFile) {
return [];
}
const config = safeReadJson(configFile) || {};
if (!Array.isArray(config.registries)) {
return [];
}
const configDir = path.dirname(configFile);
return config.registries.map((entry) => {
if (typeof entry === "string") {
return { source: entry, baseDir: configDir };
}
if (entry && typeof entry === "object") {
const source = entry.source || entry.path || entry.url;
return source ? { ...entry, source, baseDir: configDir } : entry;
}
return entry;
}).filter(Boolean);
}
function dedupeRegistrySources(sources) {
const seen = new Set();
const result = [];
for (const source of Array.isArray(sources) ? sources : []) {
const key = typeof source === "string"
? source
: JSON.stringify(source);
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(source);
}
return result;
}
function getDefaultRegistryBaseDir(filePath, fallbackDir = process.cwd()) {
if (!filePath) {
return fallbackDir;
}
const absoluteFile = path.resolve(filePath);
return fs.existsSync(absoluteFile) && fs.statSync(absoluteFile).isDirectory()
? absoluteFile
: path.dirname(absoluteFile);
}
function resolveConfiguredRegistrySources(filePath, options = {}) {
const explicitSources = Array.isArray(options.registrySources) ? options.registrySources : [];
const discoveredSources = options.includeProjectRegistries === false
? []
: gatherDefaultRegistrySources(getDefaultRegistryBaseDir(filePath, process.cwd()));
return dedupeRegistrySources([
...explicitSources,
...discoveredSources,
]);
}
function normalizePublicKeyPem(value) {
return String(value || "").replace(/\r/g, "").trim();
}
function findAuthorTrust(signatureInfo, registrySources) {
if (!signatureInfo || signatureInfo.status !== "valid") {
return {
author: signatureInfo?.author || null,
author_trust: "unknown",
matched_registry: null,
matched_key: null,
};
}
const signatureKey = normalizePublicKeyPem(signatureInfo.public_key_pem);
for (const registry of registrySources || []) {
for (const author of registry.authors || []) {
if (String(author.name || "").trim() !== String(signatureInfo.author || "").trim()) {
continue;
}
const keys = Array.isArray(author.keys)
? author.keys
: Array.isArray(author.public_keys)
? author.public_keys
: [];
for (const key of keys) {
const keyPem = normalizePublicKeyPem(key.public_key || key.pem || key.value);
const keyId = key.id || key.key_id || null;
const keyMatches = signatureKey && keyPem && signatureKey === keyPem;
const idMatches = signatureInfo.key_id && keyId && signatureInfo.key_id === keyId;
if (!keyMatches && !idMatches) {
continue;
}
return {
author: author.name,
author_trust: ["trusted", "untrusted", "unknown"].includes(author.trust)
? author.trust
: "unknown",
matched_registry: registry.source || registry.file || null,
matched_key: keyId,
};
}
}
}
return {
author: signatureInfo.author || null,
author_trust: "unknown",
matched_registry: null,
matched_key: null,
};
}
function readSignatureFile(filePath, type) {
const signatureFile = getSignatureFilePath(filePath);
if (!fs.existsSync(signatureFile)) {
return {
status: "missing",
signature_file: signatureFile,
author: null,
key_id: null,
algorithm: null,
public_key_pem: null,
sha256: null,
};
}
const payload = safeReadJson(signatureFile);
if (!payload || typeof payload !== "object") {
return {
status: "invalid",
signature_file: signatureFile,
error: "Signature file is not valid JSON.",
author: null,
key_id: null,
algorithm: null,
public_key_pem: null,
sha256: null,
};
}
if (payload.type && payload.type !== type) {
return {
status: "invalid",
signature_file: signatureFile,
error: `Signature type mismatch: expected ${type}, got ${payload.type}.`,
author: payload.author || null,
key_id: payload.key_id || null,
algorithm: payload.algorithm || null,
public_key_pem: payload.public_key_pem || null,
sha256: payload.sha256 || null,
};
}
return {
status: "present",
signature_file: signatureFile,
payload,
author: payload.author || null,
key_id: payload.key_id || null,
algorithm: payload.algorithm || null,
public_key_pem: payload.public_key_pem || null,
sha256: payload.sha256 || null,
};
}
function verifyDetachedSignature(filePath, type) {
const signatureInfo = readSignatureFile(filePath, type);
if (signatureInfo.status !== "present") {
return signatureInfo;
}
const payload = signatureInfo.payload;
const fileBuffer = fs.readFileSync(filePath);
const actualHash = sha256Buffer(fileBuffer);
const expectedHash = String(payload.sha256 || "");
if (!payload.public_key_pem || !payload.signature || !payload.author) {
return {
...signatureInfo,
status: "invalid",
error: "Signature payload is missing required fields.",
actual_sha256: actualHash,
};
}
if (expectedHash !== actualHash) {
return {
...signatureInfo,
status: "invalid",
error: "File hash does not match signature payload.",
actual_sha256: actualHash,
};
}
const canonical = JSON.stringify({
type,
file: path.basename(filePath),
sha256: actualHash,
author: payload.author,
key_id: payload.key_id || "",
});
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(canonical);
verifier.end();
let verified = false;
try {
verified = verifier.verify(payload.public_key_pem, Buffer.from(payload.signature, "base64"));
} catch (error) {
return {
...signatureInfo,
status: "invalid",
error: error.message,
actual_sha256: actualHash,
};
}
return {
...signatureInfo,
status: verified ? "valid" : "invalid",
error: verified ? null : "Cryptographic signature verification failed.",
actual_sha256: actualHash,
};
}
function resolveMacroFilePath(baseDir, file) {
const projectMacroBase = path.resolve(__dirname, "..", "..", "macros");
const normalized = resolveMacroPath(String(file || ""), projectMacroBase);
if (path.isAbsolute(normalized)) {
return normalized;
}
return path.resolve(baseDir, normalizeFileRef(normalized));
}
function detectMacroRiskFlags(source) {
const text = String(source || "");
return {
html_capable: /<[a-z][^>]*>/i.test(text),
contains_script: /<script\b|javascript:|on[a-z]+\s*=/i.test(text),
contains_external_urls: /https?:\/\//i.test(text),
};
}
function getBuiltinMacroStatus(filePath) {
if (!filePath) {
return { status: "external", relative_path: null, expected_sha256: null, actual_sha256: null };
}
const absoluteFile = path.resolve(filePath);
const relativePath = normalizeRelativePath(path.relative(BUILTIN_MACRO_DIR, absoluteFile));
const insideBuiltinDir = relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
if (!insideBuiltinDir) {
return { status: "external", relative_path: null, expected_sha256: null, actual_sha256: null };
}
const expectedHash = BUILTIN_MACRO_HASHES[relativePath];
if (!expectedHash) {
return { status: "builtin_unlisted", relative_path: relativePath, expected_sha256: null, actual_sha256: null };
}
if (!fs.existsSync(absoluteFile)) {
return { status: "builtin_missing", relative_path: relativePath, expected_sha256: expectedHash, actual_sha256: null };
}
const actualHash = sha256Buffer(fs.readFileSync(absoluteFile));
if (actualHash !== expectedHash) {
return {
status: "builtin_modified",
relative_path: relativePath,
expected_sha256: expectedHash,
actual_sha256: actualHash,
};
}
return {
status: "builtin_trusted",
relative_path: relativePath,
expected_sha256: expectedHash,
actual_sha256: actualHash,
};
}
function deriveMacroTrust(flags, signatureStatus, authorTrust, builtinStatus = "external") {
const reasons = [];
if (flags.contains_script) reasons.push("contains_script");
if (flags.contains_external_urls) reasons.push("contains_external_urls");
if (signatureStatus === "invalid") reasons.push("invalid_signature");
if (builtinStatus === "builtin_modified") reasons.push("builtin_modified");
if (signatureStatus === "missing") reasons.push("unsigned");
if (authorTrust === "untrusted") reasons.push("author_marked_untrusted");
if (authorTrust === "unknown" && signatureStatus === "valid") reasons.push("author_not_listed");
if (
flags.contains_script ||
flags.contains_external_urls ||
signatureStatus === "invalid" ||
authorTrust === "untrusted" ||
builtinStatus === "builtin_modified"
) {
return { level: "untrusted", reasons };
}
if (builtinStatus === "builtin_trusted") {
return { level: "trusted", reasons };
}
if (signatureStatus === "valid" && authorTrust === "trusted") {
return { level: "trusted", reasons };
}
return { level: "unknown", reasons };
}
function derivePmlTrust(signatureStatus, authorTrust, macros, imports) {
const reasons = [];
const hasUntrustedMacro = (macros || []).some((entry) => entry.effective_trust === "untrusted");
const hasUntrustedImport = (imports || []).some((entry) => entry.effective_trust === "untrusted");
if (hasUntrustedMacro) reasons.push("uses_untrusted_macros");
if (hasUntrustedImport) reasons.push("imports_untrusted_pml");
if (signatureStatus === "invalid") reasons.push("invalid_signature");
if (signatureStatus === "missing") reasons.push("unsigned");
if (authorTrust === "untrusted") reasons.push("author_marked_untrusted");
if (authorTrust === "unknown" && signatureStatus === "valid") reasons.push("author_not_listed");
if (hasUntrustedMacro || hasUntrustedImport || signatureStatus === "invalid" || authorTrust === "untrusted") {
return { level: "untrusted", reasons };
}
if (signatureStatus === "valid" && authorTrust === "trusted") {
return { level: "trusted", reasons };
}
return { level: "unknown", reasons };
}
function findRegisteredMacros(ast, baseDir, ownerFile) {
const registered = [];
for (const [name, entry] of Object.entries(ast.inline_macros || {})) {
registered.push({
alias: name,
actual_name: name,
path: `${ownerFile}:${entry.line || 0}`,
inline: true,
template: entry.template || "",
line: entry.line || 0,
});
}
for (const [alias, file] of Object.entries(ast.macros || {})) {
const fullPath = resolveMacroFilePath(baseDir, file);
registered.push({
alias,
actual_name: alias,
path: fullPath,
inline: false,
file: normalizeFileRef(file),
});
}
for (const importFile of Array.isArray(ast.macros_import) ? ast.macros_import : []) {
const fullPath = path.resolve(baseDir, normalizeFileRef(importFile));
if (!fs.existsSync(fullPath)) continue;
const importedAst = parseBlocks(tokenize(fs.readFileSync(fullPath, "utf8")));
for (const [alias, file] of Object.entries(importedAst.macros || {})) {
const macroPath = resolveMacroFilePath(path.dirname(fullPath), file);
registered.push({
alias,
actual_name: alias,
path: macroPath,
inline: false,
file: normalizeFileRef(file),
via_import: normalizeFileRef(importFile),
});
}
}
return registered;
}
function findUsedMacroNames(ast) {
const used = new Set();
for (const line of Array.isArray(ast.meeting) ? ast.meeting : []) {
const text = String(line || "");
for (const match of text.matchAll(/@@macro=([\w-]+):?/g)) {
used.add(match[1]);
}
}
return used;
}
function analyzeMacroEntry(entry, registrySources) {
const source = entry.inline
? String(entry.template || "")
: fs.existsSync(entry.path)
? fs.readFileSync(entry.path, "utf8")
: "";
const flags = detectMacroRiskFlags(entry.inline ? entry.template : source);
const signature = entry.inline
? {
status: "missing",
signature_file: null,
author: null,
key_id: null,
algorithm: null,
public_key_pem: null,
sha256: null,
}
: verifyDetachedSignature(entry.path, "macro");
const builtin = entry.inline
? { status: "inline", relative_path: null, expected_sha256: null, actual_sha256: null }
: getBuiltinMacroStatus(entry.path);
const authorInfo = findAuthorTrust(signature, registrySources);
const derivedAuthorTrust = builtin.status === "builtin_trusted"
? "trusted"
: authorInfo.author_trust;
const trust = deriveMacroTrust(flags, signature.status, derivedAuthorTrust, builtin.status);
return {
alias: entry.alias,
actual_name: entry.actual_name,
path: entry.path,
inline: entry.inline,
via_import: entry.via_import || null,
flags,
signature_status: signature.status,
signature_file: signature.signature_file || null,
author: authorInfo.author || signature.author || null,
author_trust: derivedAuthorTrust,
matched_registry: authorInfo.matched_registry,
matched_key: authorInfo.matched_key,
builtin_status: builtin.status,
builtin_relative_path: builtin.relative_path,
effective_trust: trust.level,
reasons: trust.reasons,
};
}
function analyzePmlTrustSync(filename, options = {}, visited = new Set()) {
const fullPath = path.resolve(filename);
const baseDir = path.dirname(fullPath);
const raw = fs.readFileSync(fullPath, "utf8");
const ast = parseBlocks(tokenize(raw));
const visitKey = fullPath.toLowerCase();
if (visited.has(visitKey)) {
return {
path: fullPath,
type: "pml",
effective_trust: "unknown",
signature_status: "missing",
author: null,
author_trust: "unknown",
reasons: ["recursive_import_cycle"],
macros: [],
imports: [],
registry_sources: [],
skipped: true,
};
}
visited.add(visitKey);
const configuredSources = resolveConfiguredRegistrySources(fullPath, options);
const registrySources = configuredSources
.map((source) => loadRegistrySourceSync(source, process.cwd()))
.filter(Boolean);
const registeredMacros = findRegisteredMacros(ast, baseDir, fullPath);
const usedMacroNames = findUsedMacroNames(ast);
const macros = registeredMacros
.filter((entry) => usedMacroNames.has(entry.alias) || usedMacroNames.has(entry.actual_name))
.map((entry) => analyzeMacroEntry(entry, registrySources));
const imports = [];
for (const [name, entry] of Object.entries(ast.imports || {})) {
const format = String(entry.format || "text").toLowerCase();
if (format !== "pml") continue;
const importPath = path.resolve(baseDir, normalizeFileRef(entry.file));
if (!fs.existsSync(importPath)) {
imports.push({
name,
path: importPath,
type: "pml",
effective_trust: "untrusted",
signature_status: "missing",
author: null,
author_trust: "unknown",
reasons: ["missing_import"],
});
continue;
}
imports.push({
name,
...analyzePmlTrustSync(importPath, options, visited),
});
}
const signature = verifyDetachedSignature(fullPath, "pml");
const authorInfo = findAuthorTrust(signature, registrySources);
const trust = derivePmlTrust(signature.status, authorInfo.author_trust, macros, imports);
return {
path: fullPath,
type: "pml",
effective_trust: trust.level,
signature_status: signature.status,
signature_file: signature.signature_file || null,
author: authorInfo.author || signature.author || null,
author_trust: authorInfo.author_trust,
matched_registry: authorInfo.matched_registry,
matched_key: authorInfo.matched_key,
reasons: trust.reasons,
macros,
imports,
registry_sources: registrySources.map((entry) => ({
source: entry.source || null,
file: entry.file || null,
skipped: Boolean(entry.skipped),
reason: entry.reason || null,
})),
};
}
async function analyzePmlTrust(filename, options = {}) {
const fullPath = path.resolve(filename);
const baseDir = path.dirname(fullPath);
const raw = fs.readFileSync(fullPath, "utf8");
const ast = parseBlocks(tokenize(raw));
const registrySourceInputs = resolveConfiguredRegistrySources(fullPath, options);
const registrySources = (await Promise.all(
registrySourceInputs.map((source) => loadRegistrySourceAsync(source, process.cwd()).catch((error) => ({
source: typeof source === "string" ? source : source?.source || null,
authors: [],
skipped: true,
reason: error.message,
})))
)).filter(Boolean);
const registeredMacros = findRegisteredMacros(ast, baseDir, fullPath);
const usedMacroNames = findUsedMacroNames(ast);
const macros = registeredMacros
.filter((entry) => usedMacroNames.has(entry.alias) || usedMacroNames.has(entry.actual_name))
.map((entry) => analyzeMacroEntry(entry, registrySources));
const imports = [];
for (const [name, entry] of Object.entries(ast.imports || {})) {
const format = String(entry.format || "text").toLowerCase();
if (format !== "pml") continue;
const importPath = path.resolve(baseDir, normalizeFileRef(entry.file));
if (!fs.existsSync(importPath)) {
imports.push({
name,
path: importPath,
type: "pml",
effective_trust: "untrusted",
signature_status: "missing",
author: null,
author_trust: "unknown",
reasons: ["missing_import"],
});
continue;
}
imports.push({
name,
...analyzePmlTrustSync(importPath, { ...options, registrySources: registrySourceInputs }),
});
}
const signature = verifyDetachedSignature(fullPath, "pml");
const authorInfo = findAuthorTrust(signature, registrySources);
const trust = derivePmlTrust(signature.status, authorInfo.author_trust, macros, imports);
return {
path: fullPath,
type: "pml",
effective_trust: trust.level,
signature_status: signature.status,
signature_file: signature.signature_file || null,
author: authorInfo.author || signature.author || null,
author_trust: authorInfo.author_trust,
matched_registry: authorInfo.matched_registry,
matched_key: authorInfo.matched_key,
reasons: trust.reasons,
macros,
imports,
registry_sources: registrySources.map((entry) => ({
source: entry.source || null,
file: entry.file || null,
skipped: Boolean(entry.skipped),
reason: entry.reason || null,
})),
};
}
function signFile(filePath, type, privateKeyPath, author, keyId = "") {
const absoluteFile = path.resolve(filePath);
const absoluteKey = path.resolve(privateKeyPath);
if (!fs.existsSync(absoluteFile)) {
throw new Error(`File not found: ${absoluteFile}`);
}
if (!fs.existsSync(absoluteKey)) {
throw new Error(`Private key not found: ${absoluteKey}`);
}
if (!author) {
throw new Error("Author is required for signing.");
}
const fileBuffer = fs.readFileSync(absoluteFile);
const sha256 = sha256Buffer(fileBuffer);
const privateKeyPem = fs.readFileSync(absoluteKey, "utf8");
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: "spki", format: "pem" }).toString();
const payload = {
version: 1,
type,
file: path.basename(absoluteFile),
sha256,
author,
key_id: keyId || "",
algorithm: "rsa-sha256",
signed_at: new Date().toISOString(),
public_key_pem: publicKeyPem,
};
const signer = crypto.createSign("RSA-SHA256");
signer.update(JSON.stringify({
type: payload.type,
file: payload.file,
sha256: payload.sha256,
author: payload.author,
key_id: payload.key_id,
}));
signer.end();
payload.signature = signer.sign(privateKeyPem).toString("base64");
const targetFile = getSignatureFilePath(absoluteFile);
fs.writeFileSync(targetFile, JSON.stringify(payload, null, 2) + "\n", "utf8");
return {
file: absoluteFile,
signature_file: targetFile,
sha256,
author,
key_id: keyId || "",
type,
};
}
function verifyFileTrustSync(filePath, type, options = {}) {
const absoluteFile = path.resolve(filePath);
const configuredSources = resolveConfiguredRegistrySources(absoluteFile, options);
const registrySources = configuredSources
.map((source) => loadRegistrySourceSync(source, process.cwd()))
.filter(Boolean);
const signature = verifyDetachedSignature(absoluteFile, type);
const authorInfo = findAuthorTrust(signature, registrySources);
const builtin = type === "macro"
? getBuiltinMacroStatus(absoluteFile)
: { status: "external", relative_path: null };
return {
path: absoluteFile,
type,
signature_status: signature.status,
signature_file: signature.signature_file || null,
author: authorInfo.author || signature.author || null,
author_trust: authorInfo.author_trust,
matched_registry: authorInfo.matched_registry,
matched_key: authorInfo.matched_key,
builtin_status: builtin.status,
builtin_relative_path: builtin.relative_path,
sha256: signature.actual_sha256 || signature.sha256 || null,
error: signature.error || null,
registry_sources: registrySources.map((entry) => ({
source: entry.source || null,
file: entry.file || null,
skipped: Boolean(entry.skipped),
reason: entry.reason || null,
})),
};
}
async function verifyFileTrust(filePath, type, options = {}) {
const absoluteFile = path.resolve(filePath);
const configuredSources = resolveConfiguredRegistrySources(absoluteFile, options);
const registrySources = (await Promise.all(
configuredSources.map((source) => loadRegistrySourceAsync(source, process.cwd()).catch((error) => ({
source: typeof source === "string" ? source : source?.source || null,
authors: [],
skipped: true,
reason: error.message,
})))
)).filter(Boolean);
const signature = verifyDetachedSignature(absoluteFile, type);
const authorInfo = findAuthorTrust(signature, registrySources);
const builtin = type === "macro"
? getBuiltinMacroStatus(absoluteFile)
: { status: "external", relative_path: null };
return {
path: absoluteFile,
type,
signature_status: signature.status,
signature_file: signature.signature_file || null,
author: authorInfo.author || signature.author || null,
author_trust: authorInfo.author_trust,
matched_registry: authorInfo.matched_registry,
matched_key: authorInfo.matched_key,
builtin_status: builtin.status,
builtin_relative_path: builtin.relative_path,
sha256: signature.actual_sha256 || signature.sha256 || null,
error: signature.error || null,
registry_sources: registrySources.map((entry) => ({
source: entry.source || null,
file: entry.file || null,
skipped: Boolean(entry.skipped),
reason: entry.reason || null,
})),
};
}
function formatTrustReport(report, verbosity = 0) {
const lines = [];
lines.push(`Trust report: ${report.path}`);
lines.push(`Type: ${report.type}`);
lines.push(`Effective trust: ${report.effective_trust}`);
lines.push(`Signature: ${report.signature_status}`);
lines.push(`Author: ${report.author || "(none)"}`);
lines.push(`Author trust: ${report.author_trust || "unknown"}`);
if (report.reasons?.length) {
lines.push(`Reasons: ${report.reasons.join(", ")}`);
}
if (verbosity >= 1 && Array.isArray(report.macros)) {
const counts = report.macros.reduce((acc, entry) => {
acc[entry.effective_trust] = (acc[entry.effective_trust] || 0) + 1;
return acc;
}, {});
lines.push(`Macros: ${report.macros.length}`);
if (Object.keys(counts).length) {
lines.push(`Macro trust counts: ${Object.entries(counts).map(([key, value]) => `${key}=${value}`).join(", ")}`);
}
}
if (verbosity >= 1 && Array.isArray(report.imports) && report.imports.length) {
lines.push(`Imported PML files: ${report.imports.length}`);
}
if (verbosity >= 2 && Array.isArray(report.macros) && report.macros.length) {
lines.push("Macros:");
for (const entry of report.macros) {
const flags = Object.entries(entry.flags || {})
.filter(([, value]) => value)
.map(([key]) => key);
const builtin = entry.builtin_status && entry.builtin_status !== "external" && entry.builtin_status !== "inline"
? `, builtin=${entry.builtin_status}`
: "";
lines.push(`- ${entry.alias}: ${entry.effective_trust} (signature=${entry.signature_status}, author=${entry.author_trust}${builtin}${flags.length ? `, flags=${flags.join("|")}` : ""})`);
}
}
if (verbosity >= 2 && Array.isArray(report.imports) && report.imports.length) {
lines.push("Imported PML:");
for (const entry of report.imports) {
lines.push(`- ${entry.name}: ${entry.effective_trust} (${entry.path})`);
}
}
if (verbosity >= 3 && Array.isArray(report.registry_sources) && report.registry_sources.length) {
lines.push("Registry sources:");
for (const entry of report.registry_sources) {
lines.push(`- ${entry.source || entry.file || "(unknown)"}${entry.skipped ? ` skipped=${entry.reason}` : ""}`);
}
}
return lines.join("\n");
}
function formatVerificationReport(report) {
const lines = [];
lines.push(`Verification: ${report.path}`);
lines.push(`Type: ${report.type}`);
lines.push(`Signature: ${report.signature_status}`);
lines.push(`Author: ${report.author || "(none)"}`);
lines.push(`Author trust: ${report.author_trust || "unknown"}`);
if (report.type === "macro" && report.builtin_status && report.builtin_status !== "external") {
lines.push(`Built-in macro status: ${report.builtin_status}${report.builtin_relative_path ? ` (${report.builtin_relative_path})` : ""}`);
}
if (report.sha256) lines.push(`SHA256: ${report.sha256}`);
if (report.signature_file) lines.push(`Signature file: ${report.signature_file}`);
if (report.matched_registry) lines.push(`Matched registry: ${report.matched_registry}`);
if (report.error) lines.push(`Error: ${report.error}`);
return lines.join("\n");
}
module.exports = {
analyzePmlTrustSync,
analyzePmlTrust,
signFile,
verifyFileTrustSync,
verifyFileTrust,
formatTrustReport,
formatVerificationReport,
getSignatureFilePath,
};