import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rm } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { extract as tarExtract } from 'tar'; import { z } from 'zod'; import { parseJsonWithValidation } from '../../validation/schemas'; import { computeFileChecksum } from './checksum'; import { classifyModulePath } from './package-rules'; import { verifySignature } from './signature'; /** * Integrity violation types */ export interface IntegrityViolation { /** * `stale-baseline` is not a file finding. It says the recorded checksums * describe a DIFFERENT version of the module than the one celilo has * installed, so every file finding beneath it is explained by the baseline * being old rather than by the files having changed. Only `auditModule` * produces it; package verification compares a package to its own manifest * and cannot be stale in this sense. */ type: 'missing' | 'modified' | 'extra' | 'stale-baseline'; /** * What the baseline says the file should hash to, and what it actually * hashes to. Both optional because not every violation is about a digest — * a `stale-baseline` finding is about the row, not a file. * * These exist so `module verify --json` can answer "is the installed tree * the 0.3.2 package" in one call. celilo#925 stalled for days on that * question because nothing could read a file on celilo-mgr, and the * tempting fix — a remote read primitive — is a real security surface (every * module's secrets and vault material live under the same tree) for a * question file hashes answer directly (D9). */ expectedDigest?: string; actualDigest?: string | null; path: string; message: string; } /** * Package extraction result */ export interface ExtractResult { success: boolean; tempDir?: string; checksums?: Record; violations?: IntegrityViolation[]; error?: string; } /** * Package verification result */ export interface VerifyResult { success: boolean; violations: IntegrityViolation[]; error?: string; } /** * Create temporary directory for extraction */ async function createTempDir(): Promise { const tempDir = join(process.cwd(), '.tmp-module-extract', Date.now().toString()); await mkdir(tempDir, { recursive: true }); return tempDir; } /** * Extract .netapp package to temporary directory * * @param packagePath - Path to .netapp file * @returns Extraction result with temp directory */ export async function extractPackage(packagePath: string): Promise { if (!existsSync(packagePath)) { return { success: false, error: `Package file not found: ${packagePath}` }; } const tempDir = await createTempDir(); try { // Extract tarball await tarExtract({ file: packagePath, cwd: tempDir, }); return { success: true, tempDir, }; } catch (error) { // Clean up on error await rm(tempDir, { recursive: true, force: true }); return { success: false, error: `Failed to extract package: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } /** * Verify package integrity after extraction * * @param tempDir - Temporary directory with extracted files * @param masterKeyPath - Optional master key path * @returns Verification result */ export async function verifyPackageIntegrity( tempDir: string, masterKeyPath?: string, ): Promise { const violations: IntegrityViolation[] = []; try { // 1. Read signature.sig first const signaturePath = join(tempDir, 'signature.sig'); if (!existsSync(signaturePath)) { return { success: false, violations: [], error: 'Missing signature.sig in package', }; } const signature = await readFile(signaturePath, 'utf-8'); // 2. Read checksums.json const checksumsPath = join(tempDir, 'checksums.json'); if (!existsSync(checksumsPath)) { return { success: false, violations: [], error: 'Missing checksums.json in package', }; } const checksumsJson = await readFile(checksumsPath, 'utf-8'); // 3. Verify signature IMMEDIATELY - fail fast const isValid = await verifySignature(checksumsJson, signature.trim(), masterKeyPath); if (!isValid) { return { success: false, violations: [], error: 'Signature verification failed - package may be tampered', }; } // 4. Parse checksums only after signature verified const ChecksumsFileSchema = z.object({ files: z.record(z.string(), z.string()), }); const checksumsData = parseJsonWithValidation( checksumsJson, ChecksumsFileSchema, 'package checksums.json', ); const expectedChecksums: Record = checksumsData.files; // 5. Validate all expected files exist and have correct checksums for (const [filePath, expectedChecksum] of Object.entries(expectedChecksums)) { const fullPath = join(tempDir, filePath); if (!existsSync(fullPath)) { violations.push({ type: 'missing', path: filePath, message: `Missing file: ${filePath}`, }); continue; } const actualChecksum = await computeFileChecksum(fullPath); if (actualChecksum !== expectedChecksum) { violations.push({ type: 'modified', path: filePath, message: `Checksum mismatch: ${filePath}`, }); } } // 6. Check for extra files (not in checksums.json) const actualFiles = await scanDirectory(tempDir, tempDir); const expectedFiles = new Set(Object.keys(expectedChecksums)); for (const file of actualFiles) { // `checksums.json` / `signature.sig` and the rest of the derived set are // never listed by the manifest they accompany. if (classifyModulePath(file) === 'derived') { continue; } if (!expectedFiles.has(file)) { violations.push({ type: 'extra', path: file, message: `Unexpected file: ${file}`, }); } } // 7. Return result if (violations.length > 0) { return { success: false, violations, error: `Integrity violations found:\n${violations.map((v) => ` - ${v.message}`).join('\n')}`, }; } return { success: true, violations: [], }; } catch (error) { return { success: false, violations, error: `Failed to verify package: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } /** * Recursively scan directory and return all file paths */ async function scanDirectory(dir: string, baseDir: string): Promise { const files: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); const relativePath = relative(baseDir, fullPath); if (entry.isDirectory()) { const subFiles = await scanDirectory(fullPath, baseDir); files.push(...subFiles); } else if (entry.isFile()) { files.push(relativePath); } } return files; } /** * Clean up temporary extraction directory */ export async function cleanupTempDir(tempDir: string): Promise { try { await rm(tempDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }