Skip to content

Commit b99e3eb

Browse files
Merge pull request #4 from SocketDev/refactor/centralize-manifest-operations
Refactor: Centralize manifest and blob management operations
2 parents 01380b1 + 6f7ada2 commit b99e3eb

File tree

7 files changed

+387
-17
lines changed

7 files changed

+387
-17
lines changed

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@
2727
"types": "./dist/patch/apply.d.ts",
2828
"require": "./dist/patch/apply.js",
2929
"import": "./dist/patch/apply.js"
30+
},
31+
"./manifest/operations": {
32+
"types": "./dist/manifest/operations.d.ts",
33+
"require": "./dist/manifest/operations.js",
34+
"import": "./dist/manifest/operations.js"
35+
},
36+
"./manifest/recovery": {
37+
"types": "./dist/manifest/recovery.d.ts",
38+
"require": "./dist/manifest/recovery.js",
39+
"import": "./dist/manifest/recovery.js"
40+
},
41+
"./constants": {
42+
"types": "./dist/constants.d.ts",
43+
"require": "./dist/constants.js",
44+
"import": "./dist/constants.js"
3045
}
3146
},
3247
"scripts": {

src/constants.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Standard paths and constants used throughout the socket-patch system
3+
*/
4+
5+
// Re-export from schema for convenience
6+
export { DEFAULT_PATCH_MANIFEST_PATH } from './schema/manifest-schema.js'
7+
8+
/**
9+
* Default folder for storing patched file blobs
10+
*/
11+
export const DEFAULT_BLOB_FOLDER = '.socket/blob'
12+
13+
/**
14+
* Default Socket directory
15+
*/
16+
export const DEFAULT_SOCKET_DIR = '.socket'

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ export * from './hash/git-sha256.js'
88
// Re-export patch application utilities
99
export * from './patch/file-hash.js'
1010
export * from './patch/apply.js'
11+
12+
// Re-export manifest utilities
13+
export * from './manifest/operations.js'
14+
export * from './manifest/recovery.js'
15+
16+
// Re-export constants
17+
export * from './constants.js'

src/manifest/operations.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as fs from 'fs/promises'
2+
import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js'
3+
import { PatchManifestSchema } from '../schema/manifest-schema.js'
4+
5+
/**
6+
* Get all blob hashes referenced by a manifest
7+
* Used for garbage collection and validation
8+
*/
9+
export function getReferencedBlobs(manifest: PatchManifest): Set<string> {
10+
const blobs = new Set<string>()
11+
12+
for (const patchRecord of Object.values(manifest.patches)) {
13+
const record = patchRecord as PatchRecord
14+
for (const fileInfo of Object.values(record.files)) {
15+
blobs.add(fileInfo.beforeHash)
16+
blobs.add(fileInfo.afterHash)
17+
}
18+
}
19+
20+
return blobs
21+
}
22+
23+
/**
24+
* Calculate differences between two manifests
25+
*/
26+
export interface ManifestDiff {
27+
added: Set<string> // PURLs
28+
removed: Set<string>
29+
modified: Set<string>
30+
}
31+
32+
export function diffManifests(
33+
oldManifest: PatchManifest,
34+
newManifest: PatchManifest,
35+
): ManifestDiff {
36+
const oldPurls = new Set(Object.keys(oldManifest.patches))
37+
const newPurls = new Set(Object.keys(newManifest.patches))
38+
39+
const added = new Set<string>()
40+
const removed = new Set<string>()
41+
const modified = new Set<string>()
42+
43+
// Find added and modified
44+
for (const purl of newPurls) {
45+
if (!oldPurls.has(purl)) {
46+
added.add(purl)
47+
} else {
48+
const oldPatch = oldManifest.patches[purl] as PatchRecord
49+
const newPatch = newManifest.patches[purl] as PatchRecord
50+
if (oldPatch.uuid !== newPatch.uuid) {
51+
modified.add(purl)
52+
}
53+
}
54+
}
55+
56+
// Find removed
57+
for (const purl of oldPurls) {
58+
if (!newPurls.has(purl)) {
59+
removed.add(purl)
60+
}
61+
}
62+
63+
return { added, removed, modified }
64+
}
65+
66+
/**
67+
* Validate a parsed manifest object
68+
*/
69+
export function validateManifest(parsed: unknown): {
70+
success: boolean
71+
manifest?: PatchManifest
72+
error?: string
73+
} {
74+
const result = PatchManifestSchema.safeParse(parsed)
75+
if (result.success) {
76+
return { success: true, manifest: result.data }
77+
}
78+
return {
79+
success: false,
80+
error: result.error.message,
81+
}
82+
}
83+
84+
/**
85+
* Read and parse a manifest from the filesystem
86+
*/
87+
export async function readManifest(path: string): Promise<PatchManifest | null> {
88+
try {
89+
const content = await fs.readFile(path, 'utf-8')
90+
const parsed = JSON.parse(content)
91+
const result = validateManifest(parsed)
92+
return result.success ? result.manifest! : null
93+
} catch {
94+
return null
95+
}
96+
}
97+
98+
/**
99+
* Write a manifest to the filesystem
100+
*/
101+
export async function writeManifest(
102+
path: string,
103+
manifest: PatchManifest,
104+
): Promise<void> {
105+
const content = JSON.stringify(manifest, null, 2)
106+
await fs.writeFile(path, content, 'utf-8')
107+
}

src/manifest/recovery.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js'
2+
import { PatchManifestSchema, PatchRecordSchema } from '../schema/manifest-schema.js'
3+
4+
/**
5+
* Result of manifest recovery operation
6+
*/
7+
export interface RecoveryResult {
8+
manifest: PatchManifest
9+
repairNeeded: boolean
10+
invalidPatches: string[]
11+
recoveredPatches: string[]
12+
discardedPatches: string[]
13+
}
14+
15+
/**
16+
* Options for manifest recovery
17+
*/
18+
export interface RecoveryOptions {
19+
/**
20+
* Optional function to refetch patch data from external source (e.g., database)
21+
* Should return patch data or null if not found
22+
* @param uuid - The patch UUID
23+
* @param purl - The package URL (for context/validation)
24+
*/
25+
refetchPatch?: (uuid: string, purl?: string) => Promise<PatchData | null>
26+
27+
/**
28+
* Optional callback for logging recovery events
29+
*/
30+
onRecoveryEvent?: (event: RecoveryEvent) => void
31+
}
32+
33+
/**
34+
* Patch data returned from external source
35+
*/
36+
export interface PatchData {
37+
uuid: string
38+
purl: string
39+
publishedAt: string
40+
files: Record<
41+
string,
42+
{
43+
beforeHash?: string
44+
afterHash?: string
45+
}
46+
>
47+
vulnerabilities: Record<
48+
string,
49+
{
50+
cves: string[]
51+
summary: string
52+
severity: string
53+
description: string
54+
}
55+
>
56+
description: string
57+
license: string
58+
tier: string
59+
}
60+
61+
/**
62+
* Events emitted during recovery
63+
*/
64+
export type RecoveryEvent =
65+
| { type: 'corrupted_manifest' }
66+
| { type: 'invalid_patch'; purl: string; uuid: string | null }
67+
| { type: 'recovered_patch'; purl: string; uuid: string }
68+
| { type: 'discarded_patch_not_found'; purl: string; uuid: string }
69+
| { type: 'discarded_patch_purl_mismatch'; purl: string; uuid: string; dbPurl: string }
70+
| { type: 'discarded_patch_no_uuid'; purl: string }
71+
| { type: 'recovery_error'; purl: string; uuid: string; error: string }
72+
73+
/**
74+
* Recover and validate manifest with automatic repair of invalid patches
75+
*
76+
* This function attempts to parse and validate a manifest. If the manifest
77+
* contains invalid patches, it will attempt to recover them using the provided
78+
* refetch function. Patches that cannot be recovered are discarded.
79+
*
80+
* @param parsed - The parsed manifest object (may be invalid)
81+
* @param options - Recovery options including refetch function and event callback
82+
* @returns Recovery result with repaired manifest and statistics
83+
*/
84+
export async function recoverManifest(
85+
parsed: unknown,
86+
options: RecoveryOptions = {},
87+
): Promise<RecoveryResult> {
88+
const { refetchPatch, onRecoveryEvent } = options
89+
90+
// Try strict parse first (fast path for valid manifests)
91+
const strictResult = PatchManifestSchema.safeParse(parsed)
92+
if (strictResult.success) {
93+
return {
94+
manifest: strictResult.data,
95+
repairNeeded: false,
96+
invalidPatches: [],
97+
recoveredPatches: [],
98+
discardedPatches: [],
99+
}
100+
}
101+
102+
// Extract patches object with safety checks
103+
const patchesObj =
104+
parsed &&
105+
typeof parsed === 'object' &&
106+
'patches' in parsed &&
107+
parsed.patches &&
108+
typeof parsed.patches === 'object'
109+
? (parsed.patches as Record<string, unknown>)
110+
: null
111+
112+
if (!patchesObj) {
113+
// Completely corrupted manifest
114+
onRecoveryEvent?.({ type: 'corrupted_manifest' })
115+
return {
116+
manifest: { patches: {} },
117+
repairNeeded: true,
118+
invalidPatches: [],
119+
recoveredPatches: [],
120+
discardedPatches: [],
121+
}
122+
}
123+
124+
// Try to recover individual patches
125+
const recoveredPatchesMap: Record<string, PatchRecord> = {}
126+
const invalidPatches: string[] = []
127+
const recoveredPatches: string[] = []
128+
const discardedPatches: string[] = []
129+
130+
for (const [purl, patchData] of Object.entries(patchesObj)) {
131+
// Try to parse this individual patch
132+
const patchResult = PatchRecordSchema.safeParse(patchData)
133+
134+
if (patchResult.success) {
135+
// Valid patch, keep it as-is
136+
recoveredPatchesMap[purl] = patchResult.data
137+
} else {
138+
// Invalid patch, try to recover from external source
139+
const uuid =
140+
patchData &&
141+
typeof patchData === 'object' &&
142+
'uuid' in patchData &&
143+
typeof patchData.uuid === 'string'
144+
? patchData.uuid
145+
: null
146+
147+
invalidPatches.push(purl)
148+
onRecoveryEvent?.({ type: 'invalid_patch', purl, uuid })
149+
150+
if (uuid && refetchPatch) {
151+
try {
152+
// Try to refetch from external source
153+
const patchFromSource = await refetchPatch(uuid, purl)
154+
155+
if (patchFromSource && patchFromSource.purl === purl) {
156+
// Successfully recovered, reconstruct patch record
157+
const manifestFiles: Record<
158+
string,
159+
{ beforeHash: string; afterHash: string }
160+
> = {}
161+
for (const [filePath, fileInfo] of Object.entries(
162+
patchFromSource.files,
163+
)) {
164+
if (fileInfo.beforeHash && fileInfo.afterHash) {
165+
manifestFiles[filePath] = {
166+
beforeHash: fileInfo.beforeHash,
167+
afterHash: fileInfo.afterHash,
168+
}
169+
}
170+
}
171+
172+
recoveredPatchesMap[purl] = {
173+
uuid: patchFromSource.uuid,
174+
exportedAt: patchFromSource.publishedAt,
175+
files: manifestFiles,
176+
vulnerabilities: patchFromSource.vulnerabilities,
177+
description: patchFromSource.description,
178+
license: patchFromSource.license,
179+
tier: patchFromSource.tier,
180+
}
181+
182+
recoveredPatches.push(purl)
183+
onRecoveryEvent?.({ type: 'recovered_patch', purl, uuid })
184+
} else if (patchFromSource && patchFromSource.purl !== purl) {
185+
// PURL mismatch - wrong package!
186+
discardedPatches.push(purl)
187+
onRecoveryEvent?.({
188+
type: 'discarded_patch_purl_mismatch',
189+
purl,
190+
uuid,
191+
dbPurl: patchFromSource.purl,
192+
})
193+
} else {
194+
// Not found in external source (might be unpublished)
195+
discardedPatches.push(purl)
196+
onRecoveryEvent?.({
197+
type: 'discarded_patch_not_found',
198+
purl,
199+
uuid,
200+
})
201+
}
202+
} catch (error: unknown) {
203+
// Error during recovery
204+
discardedPatches.push(purl)
205+
const errorMessage = error instanceof Error ? error.message : String(error)
206+
onRecoveryEvent?.({
207+
type: 'recovery_error',
208+
purl,
209+
uuid,
210+
error: errorMessage,
211+
})
212+
}
213+
} else {
214+
// No UUID or no refetch function, can't recover
215+
discardedPatches.push(purl)
216+
if (!uuid) {
217+
onRecoveryEvent?.({ type: 'discarded_patch_no_uuid', purl })
218+
} else {
219+
onRecoveryEvent?.({
220+
type: 'discarded_patch_not_found',
221+
purl,
222+
uuid,
223+
})
224+
}
225+
}
226+
}
227+
}
228+
229+
const repairNeeded = invalidPatches.length > 0
230+
231+
return {
232+
manifest: { patches: recoveredPatchesMap },
233+
repairNeeded,
234+
invalidPatches,
235+
recoveredPatches,
236+
discardedPatches,
237+
}
238+
}

0 commit comments

Comments
 (0)