From 6f7ada2c237ab42af2c84e1ff5b8ab8fb60570ff Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Nov 2025 11:41:54 -0500 Subject: [PATCH] Refactor: Centralize manifest and blob management operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactoring consolidates duplicate manifest and blob management logic into reusable modules within socket-patch, establishing it as the single source of truth for these operations. ## Changes ### New Modules - **src/constants.ts**: Standardized path constants (DEFAULT_BLOB_FOLDER, etc.) - **src/manifest/operations.ts**: Core manifest utilities - getReferencedBlobs(): Extract all blob hashes from a manifest - diffManifests(): Calculate manifest differences - validateManifest(): Manifest validation - readManifest() / writeManifest(): Filesystem operations - **src/manifest/recovery.ts**: Robust manifest recovery with pluggable callbacks - Automatic repair of invalid patches - Dependency-agnostic design with event-based logging - Refetch function for external patch sources ### Updated Files - **src/utils/cleanup-blobs.ts**: Now uses getReferencedBlobs() utility - **src/patch/apply.ts**: Removed unused _pkgName parameter - **src/index.ts**: Export new manifest modules - **package.json**: Added exports for new modules ## Benefits - Eliminates ~200 lines of duplicate code - Single source of truth for manifest operations - Better testability with dependency injection - Reusable across CLI and service contexts - Standardized constants and utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 15 +++ src/constants.ts | 16 +++ src/index.ts | 7 ++ src/manifest/operations.ts | 107 +++++++++++++++++ src/manifest/recovery.ts | 238 +++++++++++++++++++++++++++++++++++++ src/patch/apply.ts | 6 +- src/utils/cleanup-blobs.ts | 15 +-- 7 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/manifest/operations.ts create mode 100644 src/manifest/recovery.ts diff --git a/package.json b/package.json index f1e9a269..2a32519f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,21 @@ "types": "./dist/patch/apply.d.ts", "require": "./dist/patch/apply.js", "import": "./dist/patch/apply.js" + }, + "./manifest/operations": { + "types": "./dist/manifest/operations.d.ts", + "require": "./dist/manifest/operations.js", + "import": "./dist/manifest/operations.js" + }, + "./manifest/recovery": { + "types": "./dist/manifest/recovery.d.ts", + "require": "./dist/manifest/recovery.js", + "import": "./dist/manifest/recovery.js" + }, + "./constants": { + "types": "./dist/constants.d.ts", + "require": "./dist/constants.js", + "import": "./dist/constants.js" } }, "scripts": { diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..0b5cc609 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,16 @@ +/** + * Standard paths and constants used throughout the socket-patch system + */ + +// Re-export from schema for convenience +export { DEFAULT_PATCH_MANIFEST_PATH } from './schema/manifest-schema.js' + +/** + * Default folder for storing patched file blobs + */ +export const DEFAULT_BLOB_FOLDER = '.socket/blob' + +/** + * Default Socket directory + */ +export const DEFAULT_SOCKET_DIR = '.socket' diff --git a/src/index.ts b/src/index.ts index b266e94f..3d250803 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,10 @@ export * from './hash/git-sha256.js' // Re-export patch application utilities export * from './patch/file-hash.js' export * from './patch/apply.js' + +// Re-export manifest utilities +export * from './manifest/operations.js' +export * from './manifest/recovery.js' + +// Re-export constants +export * from './constants.js' diff --git a/src/manifest/operations.ts b/src/manifest/operations.ts new file mode 100644 index 00000000..a5dc0c9c --- /dev/null +++ b/src/manifest/operations.ts @@ -0,0 +1,107 @@ +import * as fs from 'fs/promises' +import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js' +import { PatchManifestSchema } from '../schema/manifest-schema.js' + +/** + * Get all blob hashes referenced by a manifest + * Used for garbage collection and validation + */ +export function getReferencedBlobs(manifest: PatchManifest): Set { + const blobs = new Set() + + for (const patchRecord of Object.values(manifest.patches)) { + const record = patchRecord as PatchRecord + for (const fileInfo of Object.values(record.files)) { + blobs.add(fileInfo.beforeHash) + blobs.add(fileInfo.afterHash) + } + } + + return blobs +} + +/** + * Calculate differences between two manifests + */ +export interface ManifestDiff { + added: Set // PURLs + removed: Set + modified: Set +} + +export function diffManifests( + oldManifest: PatchManifest, + newManifest: PatchManifest, +): ManifestDiff { + const oldPurls = new Set(Object.keys(oldManifest.patches)) + const newPurls = new Set(Object.keys(newManifest.patches)) + + const added = new Set() + const removed = new Set() + const modified = new Set() + + // Find added and modified + for (const purl of newPurls) { + if (!oldPurls.has(purl)) { + added.add(purl) + } else { + const oldPatch = oldManifest.patches[purl] as PatchRecord + const newPatch = newManifest.patches[purl] as PatchRecord + if (oldPatch.uuid !== newPatch.uuid) { + modified.add(purl) + } + } + } + + // Find removed + for (const purl of oldPurls) { + if (!newPurls.has(purl)) { + removed.add(purl) + } + } + + return { added, removed, modified } +} + +/** + * Validate a parsed manifest object + */ +export function validateManifest(parsed: unknown): { + success: boolean + manifest?: PatchManifest + error?: string +} { + const result = PatchManifestSchema.safeParse(parsed) + if (result.success) { + return { success: true, manifest: result.data } + } + return { + success: false, + error: result.error.message, + } +} + +/** + * Read and parse a manifest from the filesystem + */ +export async function readManifest(path: string): Promise { + try { + const content = await fs.readFile(path, 'utf-8') + const parsed = JSON.parse(content) + const result = validateManifest(parsed) + return result.success ? result.manifest! : null + } catch { + return null + } +} + +/** + * Write a manifest to the filesystem + */ +export async function writeManifest( + path: string, + manifest: PatchManifest, +): Promise { + const content = JSON.stringify(manifest, null, 2) + await fs.writeFile(path, content, 'utf-8') +} diff --git a/src/manifest/recovery.ts b/src/manifest/recovery.ts new file mode 100644 index 00000000..af5a8943 --- /dev/null +++ b/src/manifest/recovery.ts @@ -0,0 +1,238 @@ +import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js' +import { PatchManifestSchema, PatchRecordSchema } from '../schema/manifest-schema.js' + +/** + * Result of manifest recovery operation + */ +export interface RecoveryResult { + manifest: PatchManifest + repairNeeded: boolean + invalidPatches: string[] + recoveredPatches: string[] + discardedPatches: string[] +} + +/** + * Options for manifest recovery + */ +export interface RecoveryOptions { + /** + * Optional function to refetch patch data from external source (e.g., database) + * Should return patch data or null if not found + * @param uuid - The patch UUID + * @param purl - The package URL (for context/validation) + */ + refetchPatch?: (uuid: string, purl?: string) => Promise + + /** + * Optional callback for logging recovery events + */ + onRecoveryEvent?: (event: RecoveryEvent) => void +} + +/** + * Patch data returned from external source + */ +export interface PatchData { + uuid: string + purl: string + publishedAt: string + files: Record< + string, + { + beforeHash?: string + afterHash?: string + } + > + vulnerabilities: Record< + string, + { + cves: string[] + summary: string + severity: string + description: string + } + > + description: string + license: string + tier: string +} + +/** + * Events emitted during recovery + */ +export type RecoveryEvent = + | { type: 'corrupted_manifest' } + | { type: 'invalid_patch'; purl: string; uuid: string | null } + | { type: 'recovered_patch'; purl: string; uuid: string } + | { type: 'discarded_patch_not_found'; purl: string; uuid: string } + | { type: 'discarded_patch_purl_mismatch'; purl: string; uuid: string; dbPurl: string } + | { type: 'discarded_patch_no_uuid'; purl: string } + | { type: 'recovery_error'; purl: string; uuid: string; error: string } + +/** + * Recover and validate manifest with automatic repair of invalid patches + * + * This function attempts to parse and validate a manifest. If the manifest + * contains invalid patches, it will attempt to recover them using the provided + * refetch function. Patches that cannot be recovered are discarded. + * + * @param parsed - The parsed manifest object (may be invalid) + * @param options - Recovery options including refetch function and event callback + * @returns Recovery result with repaired manifest and statistics + */ +export async function recoverManifest( + parsed: unknown, + options: RecoveryOptions = {}, +): Promise { + const { refetchPatch, onRecoveryEvent } = options + + // Try strict parse first (fast path for valid manifests) + const strictResult = PatchManifestSchema.safeParse(parsed) + if (strictResult.success) { + return { + manifest: strictResult.data, + repairNeeded: false, + invalidPatches: [], + recoveredPatches: [], + discardedPatches: [], + } + } + + // Extract patches object with safety checks + const patchesObj = + parsed && + typeof parsed === 'object' && + 'patches' in parsed && + parsed.patches && + typeof parsed.patches === 'object' + ? (parsed.patches as Record) + : null + + if (!patchesObj) { + // Completely corrupted manifest + onRecoveryEvent?.({ type: 'corrupted_manifest' }) + return { + manifest: { patches: {} }, + repairNeeded: true, + invalidPatches: [], + recoveredPatches: [], + discardedPatches: [], + } + } + + // Try to recover individual patches + const recoveredPatchesMap: Record = {} + const invalidPatches: string[] = [] + const recoveredPatches: string[] = [] + const discardedPatches: string[] = [] + + for (const [purl, patchData] of Object.entries(patchesObj)) { + // Try to parse this individual patch + const patchResult = PatchRecordSchema.safeParse(patchData) + + if (patchResult.success) { + // Valid patch, keep it as-is + recoveredPatchesMap[purl] = patchResult.data + } else { + // Invalid patch, try to recover from external source + const uuid = + patchData && + typeof patchData === 'object' && + 'uuid' in patchData && + typeof patchData.uuid === 'string' + ? patchData.uuid + : null + + invalidPatches.push(purl) + onRecoveryEvent?.({ type: 'invalid_patch', purl, uuid }) + + if (uuid && refetchPatch) { + try { + // Try to refetch from external source + const patchFromSource = await refetchPatch(uuid, purl) + + if (patchFromSource && patchFromSource.purl === purl) { + // Successfully recovered, reconstruct patch record + const manifestFiles: Record< + string, + { beforeHash: string; afterHash: string } + > = {} + for (const [filePath, fileInfo] of Object.entries( + patchFromSource.files, + )) { + if (fileInfo.beforeHash && fileInfo.afterHash) { + manifestFiles[filePath] = { + beforeHash: fileInfo.beforeHash, + afterHash: fileInfo.afterHash, + } + } + } + + recoveredPatchesMap[purl] = { + uuid: patchFromSource.uuid, + exportedAt: patchFromSource.publishedAt, + files: manifestFiles, + vulnerabilities: patchFromSource.vulnerabilities, + description: patchFromSource.description, + license: patchFromSource.license, + tier: patchFromSource.tier, + } + + recoveredPatches.push(purl) + onRecoveryEvent?.({ type: 'recovered_patch', purl, uuid }) + } else if (patchFromSource && patchFromSource.purl !== purl) { + // PURL mismatch - wrong package! + discardedPatches.push(purl) + onRecoveryEvent?.({ + type: 'discarded_patch_purl_mismatch', + purl, + uuid, + dbPurl: patchFromSource.purl, + }) + } else { + // Not found in external source (might be unpublished) + discardedPatches.push(purl) + onRecoveryEvent?.({ + type: 'discarded_patch_not_found', + purl, + uuid, + }) + } + } catch (error: unknown) { + // Error during recovery + discardedPatches.push(purl) + const errorMessage = error instanceof Error ? error.message : String(error) + onRecoveryEvent?.({ + type: 'recovery_error', + purl, + uuid, + error: errorMessage, + }) + } + } else { + // No UUID or no refetch function, can't recover + discardedPatches.push(purl) + if (!uuid) { + onRecoveryEvent?.({ type: 'discarded_patch_no_uuid', purl }) + } else { + onRecoveryEvent?.({ + type: 'discarded_patch_not_found', + purl, + uuid, + }) + } + } + } + } + + const repairNeeded = invalidPatches.length > 0 + + return { + manifest: { patches: recoveredPatchesMap }, + repairNeeded, + invalidPatches, + recoveredPatches, + discardedPatches, + } +} diff --git a/src/patch/apply.ts b/src/patch/apply.ts index f5af1896..a749d9f9 100644 --- a/src/patch/apply.ts +++ b/src/patch/apply.ts @@ -273,12 +273,11 @@ export async function findPackagesForPatches( if (!scopedEntry.isDirectory() && !scopedEntry.isSymbolicLink()) continue const pkgPath = path.join(dirPath, scopedEntry.name) - const pkgName = `${entry.name}/${scopedEntry.name}` - await checkPackage(pkgPath, pkgName, manifest, packages) + await checkPackage(pkgPath, manifest, packages) } } else { // Handle non-scoped packages - await checkPackage(dirPath, entry.name, manifest, packages) + await checkPackage(dirPath, manifest, packages) } } } catch { @@ -290,7 +289,6 @@ export async function findPackagesForPatches( async function checkPackage( pkgPath: string, - _pkgName: string, manifest: PatchManifest, packages: Map, ): Promise { diff --git a/src/utils/cleanup-blobs.ts b/src/utils/cleanup-blobs.ts index c8bf3e19..0998034e 100644 --- a/src/utils/cleanup-blobs.ts +++ b/src/utils/cleanup-blobs.ts @@ -1,6 +1,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import type { PatchManifest } from '../schema/manifest-schema.js' +import { getReferencedBlobs } from '../manifest/operations.js' export interface CleanupResult { blobsChecked: number @@ -25,19 +26,7 @@ export async function cleanupUnusedBlobs( dryRun: boolean = false, ): Promise { // Collect all blob hashes that are currently in use - const usedBlobs = new Set() - - for (const patch of Object.values(manifest.patches)) { - for (const fileInfo of Object.values(patch.files)) { - // Add both before and after hashes if they exist - if (fileInfo.beforeHash) { - usedBlobs.add(fileInfo.beforeHash) - } - if (fileInfo.afterHash) { - usedBlobs.add(fileInfo.afterHash) - } - } - } + const usedBlobs = getReferencedBlobs(manifest) // Check if blobs directory exists try {