mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-13 13:17:55 +02:00
fix: harden Nasiko artifact lifecycle
This commit is contained in:
+203
-231
@@ -5,57 +5,46 @@ const fs = require('fs');
|
||||
const https = require('https');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const REGISTRY_ORIGIN = 'https://registry.nasiko.dev';
|
||||
const REPOSITORY = 'nasiko/nasiko';
|
||||
const SOURCE_URL = 'https://github.com/Nasiko-Labs/nasiko';
|
||||
const LICENSE = 'Apache-2.0';
|
||||
const METADATA_FILENAME = '.ecc-nasiko-install.json';
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_BINARY_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_METADATA_BYTES = 64 * 1024;
|
||||
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
||||
|
||||
const QUALIFIED_RELEASES = Object.freeze({
|
||||
'v0.1.0': Object.freeze({
|
||||
'linux/amd64': 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de',
|
||||
'linux/arm64': 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249',
|
||||
'darwin/amd64': 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06',
|
||||
'darwin/arm64': 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0',
|
||||
'windows/amd64': 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045',
|
||||
'linux/amd64': Object.freeze({ manifestDigest: 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', binaryDigest: 'sha256:94a2bcab2d3832257e0111480bb7c3dcac81d63a7ae9bac53206a92c0957ee0f' }),
|
||||
'linux/arm64': Object.freeze({ manifestDigest: 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', binaryDigest: 'sha256:85f9fa5cfbed6c276fce6df2d71e9d0c66d7d8e8d46a40297464833d38db7a7e' }),
|
||||
'darwin/amd64': Object.freeze({ manifestDigest: 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', binaryDigest: 'sha256:ed6232e0bb96a2dcfd86d3c25f86021091600d52f09250403a54528bfe8100a3' }),
|
||||
'darwin/arm64': Object.freeze({ manifestDigest: 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', binaryDigest: 'sha256:3c60f862b04eea1b9a633593b39f1a443d9ca2123cf3edfd150d313e95f3894b' }),
|
||||
'windows/amd64': Object.freeze({ manifestDigest: 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', binaryDigest: 'sha256:0f57672d24fc3c70e4cbbf22864e65b35b9978ddaae3793803841536e682929b' }),
|
||||
}),
|
||||
});
|
||||
|
||||
function normalizePlatform(platform = process.platform, architecture = process.arch) {
|
||||
const osName = platform === 'win32' ? 'windows' : platform;
|
||||
if (!['linux', 'darwin', 'windows'].includes(osName)) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
if (!['linux', 'darwin', 'windows'].includes(osName)) throw new Error(`Unsupported platform: ${platform}`);
|
||||
const arch = architecture === 'x64' ? 'amd64' : architecture;
|
||||
if (!['amd64', 'arm64'].includes(arch)) {
|
||||
throw new Error(`Unsupported architecture: ${architecture}`);
|
||||
}
|
||||
if (osName === 'windows' && arch !== 'amd64') {
|
||||
throw new Error(`Unsupported architecture for Windows: ${architecture}`);
|
||||
}
|
||||
return {
|
||||
os: osName,
|
||||
arch,
|
||||
binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko',
|
||||
};
|
||||
if (!['amd64', 'arm64'].includes(arch)) throw new Error(`Unsupported architecture: ${architecture}`);
|
||||
if (osName === 'windows' && arch !== 'amd64') throw new Error(`Unsupported architecture for Windows: ${architecture}`);
|
||||
return { os: osName, arch, binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko' };
|
||||
}
|
||||
|
||||
function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) {
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) {
|
||||
throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.');
|
||||
}
|
||||
const release = QUALIFIED_RELEASES[version];
|
||||
if (!release) {
|
||||
throw new Error(`Nasiko ${version} is not qualified by this ECC release.`);
|
||||
}
|
||||
const normalized = normalizePlatform(platform, architecture);
|
||||
const manifestDigest = release[`${normalized.os}/${normalized.arch}`];
|
||||
if (!manifestDigest) {
|
||||
throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`);
|
||||
}
|
||||
return { version, ...normalized, manifestDigest };
|
||||
const qualification = QUALIFIED_RELEASES[version]?.[`${normalized.os}/${normalized.arch}`];
|
||||
if (!qualification) throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`);
|
||||
return { version, ...normalized, ...qualification, license: LICENSE, sourceUrl: SOURCE_URL };
|
||||
}
|
||||
|
||||
function digestBytes(bytes) {
|
||||
@@ -63,22 +52,14 @@ function digestBytes(bytes) {
|
||||
}
|
||||
|
||||
function assertDigest(bytes, expectedDigest, label) {
|
||||
if (!SHA256_PATTERN.test(expectedDigest)) {
|
||||
throw new Error(`${label} has an invalid expected digest.`);
|
||||
}
|
||||
const actualDigest = digestBytes(bytes);
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actualDigest}.`);
|
||||
}
|
||||
if (!SHA256_PATTERN.test(expectedDigest)) throw new Error(`${label} has an invalid expected digest.`);
|
||||
const actual = digestBytes(bytes);
|
||||
if (actual !== expectedDigest) throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actual}.`);
|
||||
}
|
||||
|
||||
function validateManifest(manifestBytes) {
|
||||
function validateManifest(bytes) {
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
||||
} catch (_error) {
|
||||
throw new Error('Nasiko manifest is not valid JSON.');
|
||||
}
|
||||
try { manifest = JSON.parse(bytes.toString('utf8')); } catch (_error) { throw new Error('Nasiko manifest is not valid JSON.'); }
|
||||
if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) {
|
||||
throw new Error('Nasiko manifest must contain exactly one OCI layer.');
|
||||
}
|
||||
@@ -92,98 +73,63 @@ function validateManifest(manifestBytes) {
|
||||
return { digest: layer.digest, size: layer.size };
|
||||
}
|
||||
|
||||
function validateArchiveEntries(entries, expectedBinaryName) {
|
||||
if (!Array.isArray(entries) || entries.length !== 1) {
|
||||
throw new Error('Unsafe archive: expected exactly one binary file.');
|
||||
function readTarString(block, offset, length) {
|
||||
return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '');
|
||||
}
|
||||
|
||||
function extractQualifiedTarGzip(archiveBytes, expectedName) {
|
||||
let tar;
|
||||
try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); }
|
||||
catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); }
|
||||
let offset = 0;
|
||||
let binary = null;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every(byte => byte === 0)) break;
|
||||
const name = readTarString(header, 0, 100);
|
||||
const prefix = readTarString(header, 345, 155);
|
||||
const type = String.fromCharCode(header[156] || 48);
|
||||
const rawSize = readTarString(header, 124, 12).trim();
|
||||
const size = Number.parseInt(rawSize || '0', 8);
|
||||
const start = offset + 512;
|
||||
const end = start + size;
|
||||
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.');
|
||||
const payload = tar.subarray(start, end);
|
||||
const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0');
|
||||
const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024;
|
||||
const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024
|
||||
&& !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8'));
|
||||
if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload);
|
||||
else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
|
||||
offset = start + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
const [entry] = entries;
|
||||
const normalizedPath = String(entry.path || '').replace(/^\.\//, '');
|
||||
if (normalizedPath !== expectedBinaryName || normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) {
|
||||
throw new Error('Unsafe archive path: expected only the Nasiko binary.');
|
||||
}
|
||||
if (entry.type !== 'file') {
|
||||
throw new Error('Nasiko archive entry must be a regular file.');
|
||||
}
|
||||
return true;
|
||||
if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
|
||||
return binary;
|
||||
}
|
||||
|
||||
function fetchBytes(url, options = {}) {
|
||||
const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES;
|
||||
const timeoutMs = options.timeoutMs || 15000;
|
||||
const parsed = new URL(url);
|
||||
if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') {
|
||||
return Promise.reject(new Error('Nasiko download origin is not allowed.'));
|
||||
}
|
||||
if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') return Promise.reject(new Error('Nasiko download origin is not allowed.'));
|
||||
const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(parsed, {
|
||||
headers: options.accept ? { Accept: options.accept } : {},
|
||||
}, response => {
|
||||
if (response.statusCode >= 300 && response.statusCode < 400) {
|
||||
response.resume();
|
||||
reject(new Error('Nasiko registry redirects are not allowed.'));
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`));
|
||||
return;
|
||||
}
|
||||
const request = https.get(parsed, { headers: options.accept ? { Accept: options.accept } : {} }, response => {
|
||||
if (response.statusCode >= 300 && response.statusCode < 400) { response.resume(); reject(new Error('Nasiko registry redirects are not allowed.')); return; }
|
||||
if (response.statusCode !== 200) { response.resume(); reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); return; }
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
let total = 0;
|
||||
response.on('data', chunk => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
request.destroy(new Error('Nasiko registry response exceeded the size limit.'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) request.destroy(new Error('Nasiko registry response exceeded the size limit.'));
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
response.on('error', reject);
|
||||
});
|
||||
request.setTimeout(timeoutMs, () => request.destroy(new Error('Nasiko registry request timed out.')));
|
||||
request.setTimeout(options.timeoutMs || 15000, () => request.destroy(new Error('Nasiko registry request timed out.')));
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function inspectArchive(archivePath) {
|
||||
const result = spawnSync('tar', ['-tvzf', archivePath], {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error('Nasiko archive inspection failed.');
|
||||
}
|
||||
return result.stdout.split(/\r?\n/).filter(Boolean).map(line => {
|
||||
const typeMarker = line[0];
|
||||
const entryPath = line.trim().split(/\s+/).at(-1);
|
||||
return {
|
||||
path: entryPath,
|
||||
type: typeMarker === '-' ? 'file' : typeMarker === 'l' ? 'symlink' : 'other',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, destination) {
|
||||
const result = spawnSync('tar', ['-xzf', archivePath, '-C', destination], {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: 30000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error('Nasiko archive extraction failed.');
|
||||
}
|
||||
}
|
||||
|
||||
function runVersion(executable) {
|
||||
return spawnSync(executable, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) {
|
||||
if (normalized.os === 'windows') {
|
||||
if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.');
|
||||
@@ -192,132 +138,158 @@ function defaultInstallDirectory(normalized, environment = process.env, homeDire
|
||||
return path.join(homeDirectory, '.local', 'bin');
|
||||
}
|
||||
|
||||
function validateInstallDirectory(installDirectory) {
|
||||
if (typeof installDirectory !== 'string' || installDirectory.includes('\0') || !path.isAbsolute(installDirectory)) {
|
||||
throw new Error('Nasiko install directory must be an absolute path.');
|
||||
}
|
||||
const resolved = path.resolve(installDirectory);
|
||||
if (resolved === path.parse(resolved).root) {
|
||||
throw new Error('Nasiko cannot install directly into a filesystem root.');
|
||||
}
|
||||
return resolved;
|
||||
function validateInstallDirectory(directory) {
|
||||
if (typeof directory !== 'string' || directory.includes('\0') || !path.isAbsolute(directory)) throw new Error('Nasiko install directory must be an absolute path.');
|
||||
if (/^(?:\\\\|\\\\\?\\|\\\\\.\\)/.test(directory)) throw new Error('Nasiko install directory must be on a local filesystem.');
|
||||
const resolved = path.resolve(directory);
|
||||
if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.');
|
||||
let ancestor = resolved;
|
||||
while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
||||
const canonical = fs.realpathSync(ancestor);
|
||||
return path.join(canonical, path.relative(ancestor, resolved));
|
||||
}
|
||||
|
||||
function assertDirectoryNotSymlink(directoryPath) {
|
||||
if (!fs.existsSync(directoryPath)) return;
|
||||
const stats = fs.lstatSync(directoryPath);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error('Nasiko install directory must be a real directory, not a symlink.');
|
||||
function assertPrivateInstallDirectory(directory) {
|
||||
const stats = fs.lstatSync(directory);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Nasiko install directory must be a real directory, not a symlink.');
|
||||
if (process.platform !== 'win32') {
|
||||
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) throw new Error('Nasiko install directory must be owned by the current user.');
|
||||
if ((stats.mode & 0o022) !== 0) throw new Error('Nasiko install directory must not be group- or world-writable.');
|
||||
}
|
||||
}
|
||||
|
||||
function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); }
|
||||
|
||||
function readMetadata(executable) {
|
||||
try {
|
||||
const metadataPath = metadataPathFor(executable);
|
||||
const stats = fs.lstatSync(metadataPath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_METADATA_BYTES) return null;
|
||||
return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||
}
|
||||
catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) {
|
||||
if (!executable || !fs.existsSync(executable)) return { installed: false, qualified: false, version: null, executable: executable || null };
|
||||
const stats = fs.lstatSync(executable);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('Nasiko executable must be a regular file, not a symlink.');
|
||||
if (stats.size <= 0 || stats.size > MAX_BINARY_BYTES) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) };
|
||||
const binaryDigest = digestBytes(fs.readFileSync(executable));
|
||||
const metadata = readMetadata(executable);
|
||||
let release = null;
|
||||
try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; }
|
||||
const qualified = Boolean(release
|
||||
&& metadata.installedPath === executable
|
||||
&& metadata.manifestDigest === release.manifestDigest
|
||||
&& metadata.binaryDigest === release.binaryDigest
|
||||
&& binaryDigest === release.binaryDigest
|
||||
&& metadata.license === release.license
|
||||
&& metadata.sourceUrl === release.sourceUrl);
|
||||
return { installed: true, qualified, version: qualified ? metadata.version : null, executable, binaryDigest, metadataPath: metadataPathFor(executable) };
|
||||
}
|
||||
|
||||
function writeMetadataExclusive(metadataPath, metadata) {
|
||||
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
|
||||
function acquireLifecycleLock(installDirectory) {
|
||||
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
|
||||
let descriptor;
|
||||
try { descriptor = fs.openSync(lockPath, 'wx', 0o600); }
|
||||
catch (error) {
|
||||
if (error.code === 'EEXIST') throw new Error('Another Nasiko lifecycle operation is already in progress.');
|
||||
throw error;
|
||||
}
|
||||
return () => {
|
||||
fs.closeSync(descriptor);
|
||||
fs.rmSync(lockPath, { force: true });
|
||||
};
|
||||
}
|
||||
|
||||
async function installNasiko(options = {}, dependencies = {}) {
|
||||
const version = options.version || 'v0.1.0';
|
||||
const qualified = getQualifiedRelease(
|
||||
version,
|
||||
dependencies.platform || process.platform,
|
||||
dependencies.arch || process.arch
|
||||
);
|
||||
const release = dependencies.releaseOverride
|
||||
? { ...qualified, ...dependencies.releaseOverride }
|
||||
: qualified;
|
||||
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(
|
||||
release,
|
||||
dependencies.environment || process.env,
|
||||
dependencies.homeDirectory || os.homedir()
|
||||
));
|
||||
const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
|
||||
const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base;
|
||||
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir()));
|
||||
const destination = path.join(installDirectory, release.binaryName);
|
||||
const plan = {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
version,
|
||||
platform: release.os,
|
||||
architecture: release.arch,
|
||||
manifestDigest: release.manifestDigest,
|
||||
registryOrigin: REGISTRY_ORIGIN,
|
||||
destination,
|
||||
};
|
||||
const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl };
|
||||
if (options.dryRun) return plan;
|
||||
if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.');
|
||||
|
||||
assertDirectoryNotSymlink(installDirectory);
|
||||
fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 });
|
||||
assertDirectoryNotSymlink(installDirectory);
|
||||
if (fs.existsSync(destination)) {
|
||||
if (fs.lstatSync(destination).isSymbolicLink()) {
|
||||
throw new Error('Refusing to replace a symlinked Nasiko executable.');
|
||||
}
|
||||
const existing = (dependencies.runVersion || runVersion)(destination);
|
||||
const output = `${existing.stdout || ''}\n${existing.stderr || ''}`;
|
||||
if (existing.status === 0 && output.includes(version)) {
|
||||
return { ...plan, dryRun: false, installed: true, reused: true };
|
||||
}
|
||||
throw new Error('An incompatible Nasiko executable already exists at the destination.');
|
||||
}
|
||||
|
||||
const retrieve = dependencies.fetchBytes || fetchBytes;
|
||||
const manifestUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`;
|
||||
const manifestBytes = await retrieve(manifestUrl, {
|
||||
accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||
maxBytes: MAX_MANIFEST_BYTES,
|
||||
});
|
||||
assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest');
|
||||
const layer = validateManifest(manifestBytes);
|
||||
const archiveUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`;
|
||||
const archiveBytes = await retrieve(archiveUrl, { maxBytes: MAX_ARCHIVE_BYTES });
|
||||
if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.');
|
||||
assertDigest(archiveBytes, layer.digest, 'Nasiko archive');
|
||||
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-install-'));
|
||||
assertPrivateInstallDirectory(installDirectory);
|
||||
const releaseLock = acquireLifecycleLock(installDirectory);
|
||||
const metadataPath = metadataPathFor(destination);
|
||||
let destinationOwned = false;
|
||||
let metadataOwned = false;
|
||||
try {
|
||||
const archivePath = path.join(temporaryDirectory, 'nasiko.tar.gz');
|
||||
const extractionDirectory = path.join(temporaryDirectory, 'extract');
|
||||
fs.mkdirSync(extractionDirectory, { mode: 0o700 });
|
||||
fs.writeFileSync(archivePath, archiveBytes, { mode: 0o600 });
|
||||
const inspect = dependencies.inspectArchive || inspectArchive;
|
||||
validateArchiveEntries(inspect(archivePath), release.binaryName);
|
||||
(dependencies.extractArchive || extractArchive)(archivePath, extractionDirectory);
|
||||
const extractedBinary = path.join(extractionDirectory, release.binaryName);
|
||||
const extractedStats = fs.lstatSync(extractedBinary);
|
||||
if (!extractedStats.isFile() || extractedStats.isSymbolicLink()) {
|
||||
throw new Error('Extracted Nasiko binary is not a regular file.');
|
||||
if (fs.existsSync(destination) || fs.existsSync(metadataPath)) {
|
||||
const existing = inspectInstalledNasiko(destination);
|
||||
if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true };
|
||||
throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.');
|
||||
}
|
||||
fs.chmodSync(extractedBinary, 0o755);
|
||||
const stagedDestination = path.join(installDirectory, `.${release.binaryName}.tmp-${process.pid}`);
|
||||
fs.copyFileSync(extractedBinary, stagedDestination, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(stagedDestination, 0o755);
|
||||
fs.renameSync(stagedDestination, destination);
|
||||
const versionResult = (dependencies.runVersion || runVersion)(destination);
|
||||
const versionOutput = `${versionResult.stdout || ''}\n${versionResult.stderr || ''}`;
|
||||
if (versionResult.status !== 0 || !versionOutput.includes(version)) {
|
||||
fs.rmSync(destination, { force: true });
|
||||
throw new Error('Installed Nasiko binary did not report the qualified version.');
|
||||
}
|
||||
const metadata = {
|
||||
version,
|
||||
platform: release.os,
|
||||
architecture: release.arch,
|
||||
manifestDigest: release.manifestDigest,
|
||||
artifactDigest: layer.digest,
|
||||
installedPath: destination,
|
||||
};
|
||||
const metadataPath = path.join(installDirectory, '.ecc-nasiko-install.json');
|
||||
const temporaryMetadata = `${metadataPath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(temporaryMetadata, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporaryMetadata, metadataPath);
|
||||
const retrieve = dependencies.fetchBytes || fetchBytes;
|
||||
const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES });
|
||||
assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest');
|
||||
const layer = validateManifest(manifestBytes);
|
||||
const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES });
|
||||
if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.');
|
||||
assertDigest(archiveBytes, layer.digest, 'Nasiko archive');
|
||||
const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName);
|
||||
assertDigest(binary, release.binaryDigest, 'Nasiko binary');
|
||||
if (dependencies.beforePublish) dependencies.beforePublish(destination);
|
||||
const descriptor = fs.openSync(destination, 'wx', 0o700);
|
||||
destinationOwned = true;
|
||||
try { fs.writeFileSync(descriptor, binary); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
||||
assertDigest(fs.readFileSync(destination), release.binaryDigest, 'Published Nasiko binary');
|
||||
const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl };
|
||||
(dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata);
|
||||
metadataOwned = true;
|
||||
return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest };
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (metadataOwned) fs.rmSync(metadataPath, { force: true });
|
||||
if (destinationOwned) fs.rmSync(destination, { force: true });
|
||||
throw error;
|
||||
} finally { releaseLock(); }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QUALIFIED_RELEASES,
|
||||
REGISTRY_ORIGIN,
|
||||
digestBytes,
|
||||
fetchBytes,
|
||||
getQualifiedRelease,
|
||||
installNasiko,
|
||||
normalizePlatform,
|
||||
validateArchiveEntries,
|
||||
validateInstallDirectory,
|
||||
};
|
||||
function uninstallNasiko(options = {}, dependencies = {}) {
|
||||
const version = options.version || 'v0.1.0';
|
||||
const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
|
||||
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir()));
|
||||
const destination = path.join(installDirectory, release.binaryName);
|
||||
const plan = { dryRun: Boolean(options.dryRun), version, destination };
|
||||
if (options.dryRun) return plan;
|
||||
if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.');
|
||||
if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false };
|
||||
assertPrivateInstallDirectory(installDirectory);
|
||||
const releaseLock = acquireLifecycleLock(installDirectory);
|
||||
const metadataPath = metadataPathFor(destination);
|
||||
const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
||||
const binaryTombstone = `${destination}.remove-${suffix}`;
|
||||
const metadataTombstone = `${metadataPath}.remove-${suffix}`;
|
||||
let binaryStaged = false;
|
||||
let metadataStaged = false;
|
||||
const rename = dependencies.rename || fs.renameSync;
|
||||
try {
|
||||
const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination);
|
||||
if (!status.installed) return { ...plan, dryRun: false, removed: false };
|
||||
if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.');
|
||||
rename(destination, binaryTombstone);
|
||||
binaryStaged = true;
|
||||
rename(metadataPath, metadataTombstone);
|
||||
metadataStaged = true;
|
||||
const cleanupPending = [];
|
||||
try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
|
||||
metadataStaged = false;
|
||||
try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
|
||||
binaryStaged = false;
|
||||
return { ...plan, dryRun: false, removed: true, cleanupPending };
|
||||
} catch (error) {
|
||||
if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath);
|
||||
if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination);
|
||||
throw error;
|
||||
} finally { releaseLock(); }
|
||||
}
|
||||
|
||||
module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory };
|
||||
|
||||
+46
-28
@@ -4,10 +4,11 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const {
|
||||
inspectInstalledNasiko,
|
||||
installNasiko,
|
||||
normalizePlatform,
|
||||
uninstallNasiko,
|
||||
validateInstallDirectory,
|
||||
} = require('./lib/nasiko-release');
|
||||
|
||||
@@ -16,9 +17,10 @@ function helpText() {
|
||||
ECC Nasiko control-plane bridge
|
||||
|
||||
Usage:
|
||||
ecc nasiko status [--json]
|
||||
ecc nasiko status [--install-dir <absolute-path>] [--json]
|
||||
ecc nasiko install --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
|
||||
ecc nasiko install --version v0.1.0 --dry-run [--install-dir <absolute-path>] [--json]
|
||||
ecc nasiko uninstall --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
|
||||
|
||||
The installer is opt-in, accepts only ECC-qualified pinned releases, downloads
|
||||
content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256
|
||||
@@ -26,7 +28,7 @@ digests before extraction, and never executes fetched shell or PowerShell code.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseInstallArguments(argumentsList) {
|
||||
function parseMutationArguments(argumentsList, command = 'install') {
|
||||
let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false };
|
||||
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||
const argument = argumentsList[index];
|
||||
@@ -45,10 +47,10 @@ function parseInstallArguments(argumentsList) {
|
||||
} else if (argument === '--json') {
|
||||
options = { ...options, json: true };
|
||||
} else {
|
||||
throw new Error(`Unknown Nasiko install argument: ${argument}`);
|
||||
throw new Error(`Unknown Nasiko ${command} argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
if (!options.version) throw new Error('Nasiko install requires --version v0.1.0.');
|
||||
if (!options.version) throw new Error(`Nasiko ${command} requires --version v0.1.0.`);
|
||||
if (options.installDir) validateInstallDirectory(options.installDir);
|
||||
return options;
|
||||
}
|
||||
@@ -63,9 +65,12 @@ function defaultExecutablePath() {
|
||||
return path.join(os.homedir(), '.local', 'bin', normalized.binaryName);
|
||||
}
|
||||
|
||||
function resolveExecutable() {
|
||||
function resolveExecutable(options = {}) {
|
||||
const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE;
|
||||
const candidate = configured || defaultExecutablePath();
|
||||
const normalized = normalizePlatform();
|
||||
const candidate = options.installDir
|
||||
? path.join(validateInstallDirectory(options.installDir), normalized.binaryName)
|
||||
: configured || defaultExecutablePath();
|
||||
if (!candidate) return null;
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.');
|
||||
@@ -78,21 +83,25 @@ function resolveExecutable() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readStatus() {
|
||||
const executable = resolveExecutable();
|
||||
function readStatus(options = {}) {
|
||||
const executable = resolveExecutable(options);
|
||||
if (!executable) return { installed: false, version: null, executable: null };
|
||||
const result = spawnSync(executable, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
shell: false,
|
||||
timeout: 10000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error('Nasiko executable failed its version check.');
|
||||
return inspectInstalledNasiko(executable);
|
||||
}
|
||||
|
||||
function parseStatusArguments(argumentsList) {
|
||||
let options = { installDir: undefined, json: false };
|
||||
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||
const argument = argumentsList[index];
|
||||
if (argument === '--json') options = { ...options, json: true };
|
||||
else if (argument === '--install-dir') {
|
||||
const value = argumentsList[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error('Missing value for --install-dir.');
|
||||
options = { ...options, installDir: validateInstallDirectory(value) };
|
||||
index += 1;
|
||||
} else throw new Error(`Unknown Nasiko status argument: ${argument}`);
|
||||
}
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
const version = output.match(/\bv\d+\.\d+\.\d+\b/)?.[0] || null;
|
||||
if (!version) throw new Error('Nasiko executable returned an unrecognized version.');
|
||||
return { installed: true, version, executable };
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main(argumentsList = process.argv.slice(2)) {
|
||||
@@ -102,23 +111,32 @@ async function main(argumentsList = process.argv.slice(2)) {
|
||||
return 0;
|
||||
}
|
||||
if (command === 'status') {
|
||||
const unknown = rest.filter(argument => argument !== '--json');
|
||||
if (unknown.length > 0) throw new Error(`Unknown Nasiko status argument: ${unknown[0]}`);
|
||||
const status = readStatus();
|
||||
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
||||
else process.stdout.write(status.installed
|
||||
? `Nasiko ${status.version} is installed at ${status.executable}.\n`
|
||||
const options = parseStatusArguments(rest);
|
||||
const status = readStatus(options);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
||||
else process.stdout.write(status.qualified
|
||||
? `Qualified Nasiko ${status.version} is installed at ${status.executable}.\n`
|
||||
: status.installed
|
||||
? `An unqualified Nasiko file exists at ${status.executable}; it was not executed.\n`
|
||||
: 'Nasiko is not installed in the ECC-qualified location.\n');
|
||||
return 0;
|
||||
}
|
||||
if (command === 'install') {
|
||||
const options = parseInstallArguments(rest);
|
||||
const options = parseMutationArguments(rest, 'install');
|
||||
const result = await installNasiko(options);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`);
|
||||
else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`);
|
||||
return 0;
|
||||
}
|
||||
if (command === 'uninstall') {
|
||||
const options = parseMutationArguments(rest, 'uninstall');
|
||||
const result = uninstallNasiko(options);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
else if (result.dryRun) process.stdout.write(`Would uninstall Nasiko ${result.version} from ${result.destination}.\n`);
|
||||
else process.stdout.write(result.removed ? `Uninstalled Nasiko ${result.version}.\n` : 'Nasiko was not installed.\n');
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`Unsupported Nasiko command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -131,4 +149,4 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main, parseInstallArguments, readStatus, resolveExecutable };
|
||||
module.exports = { main, parseInstallArguments: parseMutationArguments, parseMutationArguments, parseStatusArguments, readStatus, resolveExecutable };
|
||||
|
||||
Reference in New Issue
Block a user