Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
16 changes: 16 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
107 changes: 107 additions & 0 deletions src/manifest/operations.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const blobs = new Set<string>()

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<string> // PURLs
removed: Set<string>
modified: Set<string>
}

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<string>()
const removed = new Set<string>()
const modified = new Set<string>()

// 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<PatchManifest | null> {
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<void> {
const content = JSON.stringify(manifest, null, 2)
await fs.writeFile(path, content, 'utf-8')
}
238 changes: 238 additions & 0 deletions src/manifest/recovery.ts
Original file line number Diff line number Diff line change
@@ -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<PatchData | null>

/**
* 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<RecoveryResult> {
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<string, unknown>)
: 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<string, PatchRecord> = {}
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,
}
}
Loading