mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 20:45:11 +02:00
215 lines
9.6 KiB
JavaScript
215 lines
9.6 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const Ajv = require('ajv');
|
|
const { SUPPORTED_INSTALL_TARGETS } = require('./install-manifests');
|
|
|
|
const DEFAULT_REPO_ROOT = path.resolve(__dirname, '../..');
|
|
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
const MAX_SOURCE_FILES = 10000;
|
|
const MAX_DIRECTORY_ENTRIES = 10000;
|
|
const MAX_TRAVERSAL_OPERATIONS = 20000;
|
|
const TARGETS = Object.freeze([...new Set([...SUPPORTED_INSTALL_TARGETS, 'pi'])].sort());
|
|
const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules', '__pycache__', '.pytest_cache']);
|
|
|
|
function stableValue(value) {
|
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
if (!value || typeof value !== 'object') return value;
|
|
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
|
|
}
|
|
|
|
function stableStringify(value) { return JSON.stringify(stableValue(value)); }
|
|
function digest(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
|
|
function digestObject(value) { return digest(stableStringify(value)); }
|
|
|
|
function hasUnsafeControls(value, allowWhitespace = false) {
|
|
return [...value].some(character => {
|
|
const code = character.charCodeAt(0);
|
|
return (code < 32 && !(allowWhitespace && [9, 10, 13].includes(code))) || (code >= 127 && code <= 159);
|
|
});
|
|
}
|
|
|
|
function normalizeMetadataText(value, label) {
|
|
if (typeof value !== 'string' || !value.trim() || hasUnsafeControls(value, true)) {
|
|
throw new Error(`${label} metadata must be non-empty prose without terminal control characters`);
|
|
}
|
|
return value.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
// Match the installer's generated-file exclusions and npm's Python cache exclusions.
|
|
function isExcludedResource(relativePath) {
|
|
return relativePath.split('/').some(part => EXCLUDED_DIRECTORIES.has(part)
|
|
|| ['.gitignore', '.npmignore'].includes(part) || /\.(pyc|pyo|pyd)$/i.test(part));
|
|
}
|
|
|
|
function validateRelativePath(relativePath) {
|
|
if (typeof relativePath !== 'string' || relativePath.length === 0
|
|
|| relativePath.length > 4096 || /[\\<>:"|?*]/.test(relativePath) || hasUnsafeControls(relativePath)
|
|
|| path.posix.isAbsolute(relativePath)
|
|
|| relativePath.split('/').some(part => !part || part === '.' || part === '..'
|
|
|| /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
|
|
throw new Error('Source path must be a portable relative path');
|
|
}
|
|
}
|
|
|
|
function sameIdentity(before, after) {
|
|
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
|
|
}
|
|
|
|
function inspectSource(state, relativePath, kind) {
|
|
validateRelativePath(relativePath);
|
|
let current = state.root;
|
|
let stats = fs.lstatSync(current);
|
|
if (!sameIdentity(state.rootIdentity, stats)) throw new Error('Source root identity changed');
|
|
const chain = [{ path: current, stats }];
|
|
const segments = relativePath.split('/');
|
|
for (const [index, segment] of segments.entries()) {
|
|
current = path.join(current, segment);
|
|
stats = fs.lstatSync(current);
|
|
if (stats.isSymbolicLink()) throw new Error(`Symbolic link source is forbidden: ${relativePath}`);
|
|
if (index < segments.length - 1 && !stats.isDirectory()) throw new Error(`Source ancestor is not a directory: ${relativePath}`);
|
|
chain.push({ path: current, stats });
|
|
}
|
|
if (kind === 'file' && !stats.isFile()) throw new Error(`Source is not a regular file: ${relativePath}`);
|
|
if (kind === 'directory' && !stats.isDirectory()) throw new Error(`Source is not a directory: ${relativePath}`);
|
|
return { path: current, stats, chain };
|
|
}
|
|
|
|
function revalidateSource(source) {
|
|
for (const entry of source.chain) {
|
|
const current = fs.lstatSync(entry.path);
|
|
if (current.isSymbolicLink() || !sameIdentity(entry.stats, current)) {
|
|
throw new Error('Source ancestor or file identity changed during read');
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateOpenedFile(state, source, before, relativePath) {
|
|
// Recheck before the first byte read. O_NOFOLLOW only guards the leaf.
|
|
revalidateSource(source);
|
|
if (!sameIdentity(source.stats, before) || source.stats.size !== before.size
|
|
|| source.stats.mtimeMs !== before.mtimeMs || source.stats.ctimeMs !== before.ctimeMs) {
|
|
throw new Error(`Source identity changed before read: ${relativePath}`);
|
|
}
|
|
if (!before.isFile() || before.size > MAX_FILE_BYTES) throw new Error(`Source byte limit exceeded: ${relativePath}`);
|
|
if (state.totalBytes + before.size > MAX_TOTAL_BYTES) throw new Error('Cumulative source byte limit exceeded');
|
|
}
|
|
|
|
function readDescriptorBytes(descriptor, size) {
|
|
const buffer = Buffer.alloc(size + 1);
|
|
let bytes = 0;
|
|
while (bytes < buffer.length) {
|
|
const count = fs.readSync(descriptor, buffer, bytes, buffer.length - bytes, null);
|
|
if (!count) break;
|
|
bytes += count;
|
|
}
|
|
return buffer.subarray(0, bytes);
|
|
}
|
|
|
|
function readSourceFile(state, relativePath) {
|
|
if (state.cache.has(relativePath)) return state.cache.get(relativePath);
|
|
const source = inspectSource(state, relativePath, 'file');
|
|
if (state.cache.size >= MAX_SOURCE_FILES) throw new Error('Source file count limit exceeded');
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
|
const descriptor = fs.openSync(source.path, flags);
|
|
try {
|
|
const before = fs.fstatSync(descriptor);
|
|
validateOpenedFile(state, source, before, relativePath);
|
|
const content = readDescriptorBytes(descriptor, before.size);
|
|
const after = fs.fstatSync(descriptor);
|
|
revalidateSource(source);
|
|
if (content.length !== before.size || after.size !== before.size || before.mtimeMs !== after.mtimeMs
|
|
|| before.ctimeMs !== after.ctimeMs) throw new Error(`Source changed during read: ${relativePath}`);
|
|
const value = { path: relativePath, bytes: content.length, digest: digest(content), content };
|
|
state.totalBytes += content.length;
|
|
state.cache.set(relativePath, value);
|
|
return value;
|
|
} finally { fs.closeSync(descriptor); }
|
|
}
|
|
|
|
function chargeTraversal(state) {
|
|
state.traversalOperations++;
|
|
if (state.traversalOperations > MAX_TRAVERSAL_OPERATIONS) throw new Error('Source traversal operation limit exceeded');
|
|
}
|
|
|
|
function listSourceDirectory(state, relativePath) {
|
|
const source = inspectSource(state, relativePath, 'directory');
|
|
chargeTraversal(state); // Empty directories still consume a traversal operation.
|
|
const directory = fs.opendirSync(source.path, { bufferSize: 32 });
|
|
try {
|
|
revalidateSource(source);
|
|
const entries = [];
|
|
for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) {
|
|
if (entries.length >= MAX_DIRECTORY_ENTRIES) throw new Error('Source directory entry limit exceeded');
|
|
chargeTraversal(state); // Count all names before any generated-file filtering.
|
|
entries.push(entry.name);
|
|
}
|
|
revalidateSource(source);
|
|
return entries.sort();
|
|
} finally { directory.closeSync(); }
|
|
}
|
|
|
|
function walkSourceDirectory(state, relativePath, depth = 0) {
|
|
if (depth > 32) throw new Error('Source directory depth limit exceeded');
|
|
return listSourceDirectory(state, relativePath).flatMap(name => {
|
|
const child = `${relativePath}/${name}`;
|
|
if (isExcludedResource(child)) return [];
|
|
const source = inspectSource(state, child);
|
|
return source.stats.isDirectory() ? walkSourceDirectory(state, child, depth + 1) : [readSourceFile(state, child)];
|
|
});
|
|
}
|
|
|
|
function readSourceJson(state, relativePath) {
|
|
try { return JSON.parse(readSourceFile(state, relativePath).content.toString('utf8')); } catch (error) {
|
|
throw new Error(`Cannot read JSON source ${relativePath}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function createSourceReader(repoRoot = DEFAULT_REPO_ROOT) {
|
|
if (typeof repoRoot !== 'string' || !repoRoot.trim()) throw new Error('repoRoot must be a non-empty path');
|
|
const root = fs.realpathSync(repoRoot);
|
|
const rootIdentity = fs.lstatSync(root);
|
|
if (!rootIdentity.isDirectory()) throw new Error('repoRoot must be a directory');
|
|
const state = { root, rootIdentity, cache: new Map(), totalBytes: 0, traversalOperations: 0 };
|
|
return {
|
|
read: relativePath => readSourceFile(state, relativePath),
|
|
list: relativePath => listSourceDirectory(state, relativePath),
|
|
walk: (relativePath, depth = 0) => walkSourceDirectory(state, relativePath, depth),
|
|
json: relativePath => readSourceJson(state, relativePath),
|
|
resolve: (relativePath, kind) => inspectSource(state, relativePath, kind).path,
|
|
};
|
|
}
|
|
|
|
const schemaValidators = new Map();
|
|
function validateSchema(value, schemaName) {
|
|
if (!schemaValidators.has(schemaName)) {
|
|
const schema = JSON.parse(fs.readFileSync(path.join(DEFAULT_REPO_ROOT, 'schemas', schemaName), 'utf8'));
|
|
schemaValidators.set(schemaName, new Ajv({ allErrors: true, strict: true }).compile(schema));
|
|
}
|
|
const validate = schemaValidators.get(schemaName);
|
|
if (!validate(value)) throw new Error(`Invalid ${schemaName} schema: ${JSON.stringify(validate.errors)}`);
|
|
}
|
|
|
|
function validateTarget(target = 'codex') {
|
|
if (!TARGETS.includes(target)) throw new Error(`Unknown context target: ${target}`);
|
|
return target;
|
|
}
|
|
|
|
function compilerDigest() {
|
|
const sources = [
|
|
'scripts/lib/context-profile-support.js', 'scripts/lib/context-pack-registry.js',
|
|
'scripts/lib/context-profiles.js', 'schemas/context-pack-registry.schema.json',
|
|
'schemas/context-profile.schema.json', 'scripts/lib/install-manifests.js',
|
|
];
|
|
const reader = createSourceReader(DEFAULT_REPO_ROOT);
|
|
return digestObject(sources.map(source => ({ path: source, digest: reader.read(source).digest })));
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_REPO_ROOT, TARGETS, compilerDigest, createSourceReader, digestObject,
|
|
isExcludedResource, normalizeMetadataText, stableStringify, validateRelativePath, validateSchema, validateTarget,
|
|
};
|