@eddacraft/anvil-policy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +14 -0
  2. package/dist/bundle-manager.d.ts +183 -0
  3. package/dist/bundle-manager.d.ts.map +1 -0
  4. package/dist/bundle-manager.js +498 -0
  5. package/dist/bundle-verifier.d.ts +162 -0
  6. package/dist/bundle-verifier.d.ts.map +1 -0
  7. package/dist/bundle-verifier.js +401 -0
  8. package/dist/index.d.ts +16 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/opa-binary-manager.d.ts +76 -0
  12. package/dist/opa-binary-manager.d.ts.map +1 -0
  13. package/dist/opa-binary-manager.js +341 -0
  14. package/dist/opa-executor.d.ts +225 -0
  15. package/dist/opa-executor.d.ts.map +1 -0
  16. package/dist/opa-executor.js +427 -0
  17. package/dist/policy-loader.d.ts +90 -0
  18. package/dist/policy-loader.d.ts.map +1 -0
  19. package/dist/policy-loader.js +180 -0
  20. package/dist/types.d.ts +40 -0
  21. package/dist/types.d.ts.map +1 -0
  22. package/dist/types.js +6 -0
  23. package/dist/utils/debug.d.ts +9 -0
  24. package/dist/utils/debug.d.ts.map +1 -0
  25. package/dist/utils/debug.js +44 -0
  26. package/package.json +33 -0
  27. package/project.json +8 -0
  28. package/src/bundle-manager.test.ts +588 -0
  29. package/src/bundle-manager.ts +710 -0
  30. package/src/bundle-verifier.test.ts +903 -0
  31. package/src/bundle-verifier.ts +568 -0
  32. package/src/index.ts +38 -0
  33. package/src/opa-binary-manager.test.ts +208 -0
  34. package/src/opa-binary-manager.ts +417 -0
  35. package/src/opa-executor.test.ts +1802 -0
  36. package/src/opa-executor.ts +681 -0
  37. package/src/policy-loader.test.ts +469 -0
  38. package/src/policy-loader.ts +262 -0
  39. package/src/types.ts +43 -0
  40. package/src/utils/debug.ts +54 -0
  41. package/tsconfig.json +12 -0
  42. package/tsconfig.lib.json +9 -0
  43. package/tsconfig.lib.tsbuildinfo +1 -0
  44. package/tsconfig.tsbuildinfo +1 -0
  45. package/vitest.config.ts +8 -0
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 EddaCraft. All rights reserved.
2
+
3
+ PROPRIETARY AND CONFIDENTIAL
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ exclusive property of EddaCraft. Unauthorised copying, modification,
7
+ distribution, or use of this Software, via any medium, is strictly prohibited
8
+ without the express written permission of EddaCraft.
9
+
10
+ The Software is provided for evaluation and testing purposes only to authorised
11
+ beta testers. No licence is granted to use, copy, modify, or distribute the
12
+ Software for any other purpose.
13
+
14
+ For licensing enquiries, contact: legal@eddacraft.com
@@ -0,0 +1,183 @@
1
+ /**
2
+ * OPA Bundle Manager - Download, cache, and manage OPA policy bundles
3
+ *
4
+ * Handles downloading, caching, and validating OPA policy bundles from remote
5
+ * servers. Bundles are tarball files containing .rego policy files and optional
6
+ * data.json files.
7
+ */
8
+ /**
9
+ * Authentication configuration for bundle downloads
10
+ */
11
+ export interface BundleAuthConfig {
12
+ /** Authentication type */
13
+ type: 'basic' | 'bearer';
14
+ /** Username for basic auth */
15
+ username?: string;
16
+ /** Environment variable name containing the password for basic auth */
17
+ password_env?: string;
18
+ /** Environment variable name containing the token for bearer auth */
19
+ token_env?: string;
20
+ }
21
+ /**
22
+ * Configuration for a single policy bundle
23
+ */
24
+ export interface BundleConfig {
25
+ /** Unique name for this bundle */
26
+ name: string;
27
+ /** URL to download the bundle from */
28
+ url: string;
29
+ /** How often to check for updates (ms) */
30
+ refresh_interval_ms?: number;
31
+ /** Public key for signature verification (PEM format or path to key file) */
32
+ signature_key?: string;
33
+ /** Expected SHA256 checksum of the bundle */
34
+ checksum?: string;
35
+ /** HTTP headers to include in download request */
36
+ headers?: Record<string, string>;
37
+ /** Authentication configuration */
38
+ auth?: BundleAuthConfig;
39
+ }
40
+ /**
41
+ * Cache index entry for a downloaded bundle
42
+ */
43
+ export interface BundleCacheEntry {
44
+ /** Bundle name */
45
+ name: string;
46
+ /** Original download URL */
47
+ url: string;
48
+ /** Local path to extracted bundle */
49
+ path: string;
50
+ /** When the bundle was downloaded */
51
+ downloaded_at: number;
52
+ /** When to check for updates */
53
+ expires_at: number;
54
+ /** SHA256 checksum of the downloaded tarball */
55
+ checksum: string;
56
+ /** Size in bytes of the downloaded tarball */
57
+ size_bytes: number;
58
+ /** Whether signature was verified */
59
+ signature_verified: boolean;
60
+ /** ETag from server for conditional requests */
61
+ etag?: string;
62
+ /** Last-Modified header from server */
63
+ last_modified?: string;
64
+ }
65
+ /**
66
+ * Bundle manager configuration
67
+ */
68
+ export interface BundleManagerConfig {
69
+ /** Directory to store cached bundles */
70
+ cacheDir?: string;
71
+ /** Bundle configurations */
72
+ bundles?: BundleConfig[];
73
+ /** Whether to verify signatures when available */
74
+ verifySignatures?: boolean;
75
+ /** Connection timeout in ms */
76
+ timeoutMs?: number;
77
+ }
78
+ /**
79
+ * Result of a bundle sync operation
80
+ */
81
+ export interface BundleSyncResult {
82
+ /** Bundle name */
83
+ name: string;
84
+ /** Whether sync was successful */
85
+ success: boolean;
86
+ /** Whether bundle was updated */
87
+ updated: boolean;
88
+ /** Error message if failed */
89
+ error?: string;
90
+ /** Path to the bundle if successful */
91
+ path?: string;
92
+ }
93
+ /**
94
+ * Manages OPA policy bundle download, caching, and updates
95
+ */
96
+ export declare class BundleManager {
97
+ private readonly cacheDir;
98
+ private readonly bundles;
99
+ private readonly verifySignatures;
100
+ private readonly timeoutMs;
101
+ private index;
102
+ private indexDirty;
103
+ private readonly indexPath;
104
+ constructor(config?: BundleManagerConfig);
105
+ /**
106
+ * Add or update a bundle configuration
107
+ */
108
+ addBundle(config: BundleConfig): void;
109
+ /**
110
+ * Remove a bundle configuration
111
+ */
112
+ removeBundle(name: string): boolean;
113
+ /**
114
+ * Get all configured bundle names
115
+ */
116
+ getBundleNames(): string[];
117
+ /**
118
+ * Sync all configured bundles, downloading or updating as needed
119
+ */
120
+ syncAll(): Promise<BundleSyncResult[]>;
121
+ /**
122
+ * Download or update a specific bundle
123
+ */
124
+ downloadBundle(name: string): Promise<BundleSyncResult>;
125
+ /**
126
+ * Get the cached path for a bundle, returning null if not cached
127
+ */
128
+ getBundle(name: string): Promise<string | null>;
129
+ /**
130
+ * Get cache entry metadata for a bundle
131
+ */
132
+ getBundleEntry(name: string): Promise<BundleCacheEntry | null>;
133
+ /**
134
+ * Invalidate a specific bundle cache, removing downloaded files
135
+ */
136
+ invalidateBundle(name: string): Promise<boolean>;
137
+ /**
138
+ * Clear all cached bundles
139
+ */
140
+ clearCache(): Promise<void>;
141
+ /**
142
+ * Get cache statistics
143
+ */
144
+ getCacheStats(): Promise<{
145
+ bundleCount: number;
146
+ totalSizeBytes: number;
147
+ lastSync: number;
148
+ }>;
149
+ /**
150
+ * Ensure cache directory exists
151
+ */
152
+ private ensureCacheDir;
153
+ /**
154
+ * Load cache index from disk
155
+ */
156
+ private loadIndex;
157
+ /**
158
+ * Save cache index to disk
159
+ */
160
+ private saveIndex;
161
+ /**
162
+ * Build authorization header from auth config
163
+ */
164
+ private buildAuthHeader;
165
+ /**
166
+ * Download a file from URL to destination
167
+ */
168
+ private downloadFile;
169
+ /**
170
+ * Compute SHA256 checksum of a file
171
+ */
172
+ private computeChecksum;
173
+ /**
174
+ * Verify signature of a file using public key
175
+ */
176
+ private verifySignature;
177
+ /**
178
+ * Extract a tarball to destination directory
179
+ */
180
+ private extractBundle;
181
+ }
182
+ export declare function getBundleManager(config?: BundleManagerConfig): BundleManager;
183
+ //# sourceMappingURL=bundle-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle-manager.d.ts","sourceRoot":"","sources":["../src/bundle-manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,498 @@
1
+ /**
2
+ * OPA Bundle Manager - Download, cache, and manage OPA policy bundles
3
+ *
4
+ * Handles downloading, caching, and validating OPA policy bundles from remote
5
+ * servers. Bundles are tarball files containing .rego policy files and optional
6
+ * data.json files.
7
+ */
8
+ import { existsSync, mkdirSync, unlinkSync, readFileSync, createWriteStream, rmSync, } from 'node:fs';
9
+ import { readFile, writeFile, rm, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import { createHash, createVerify } from 'node:crypto';
13
+ import https from 'node:https';
14
+ import http from 'node:http';
15
+ import { pipeline } from 'node:stream/promises';
16
+ import { createGunzip } from 'node:zlib';
17
+ import { extract } from 'tar';
18
+ import { createDebugger } from './utils/debug.js';
19
+ const debug = createDebugger('policy');
20
+ /**
21
+ * Default cache directory for policy bundles
22
+ */
23
+ const DEFAULT_CACHE_DIR = join(homedir(), '.anvil', 'policy-cache', 'bundles');
24
+ /**
25
+ * Default refresh interval: 5 minutes
26
+ */
27
+ const DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
28
+ /**
29
+ * Manages OPA policy bundle download, caching, and updates
30
+ */
31
+ export class BundleManager {
32
+ cacheDir;
33
+ bundles;
34
+ verifySignatures;
35
+ timeoutMs;
36
+ index = null;
37
+ indexDirty = false;
38
+ indexPath;
39
+ constructor(config = {}) {
40
+ this.cacheDir = config.cacheDir || DEFAULT_CACHE_DIR;
41
+ this.indexPath = join(this.cacheDir, 'index.json');
42
+ this.verifySignatures = config.verifySignatures ?? true;
43
+ this.timeoutMs = config.timeoutMs ?? 30000;
44
+ this.bundles = new Map();
45
+ if (config.bundles) {
46
+ for (const bundle of config.bundles) {
47
+ this.bundles.set(bundle.name, bundle);
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Add or update a bundle configuration
53
+ */
54
+ addBundle(config) {
55
+ this.bundles.set(config.name, config);
56
+ }
57
+ /**
58
+ * Remove a bundle configuration
59
+ */
60
+ removeBundle(name) {
61
+ return this.bundles.delete(name);
62
+ }
63
+ /**
64
+ * Get all configured bundle names
65
+ */
66
+ getBundleNames() {
67
+ return Array.from(this.bundles.keys());
68
+ }
69
+ /**
70
+ * Sync all configured bundles, downloading or updating as needed
71
+ */
72
+ async syncAll() {
73
+ const results = [];
74
+ for (const name of this.bundles.keys()) {
75
+ const result = await this.downloadBundle(name);
76
+ results.push(result);
77
+ }
78
+ return results;
79
+ }
80
+ /**
81
+ * Download or update a specific bundle
82
+ */
83
+ async downloadBundle(name) {
84
+ const config = this.bundles.get(name);
85
+ if (!config) {
86
+ return {
87
+ name,
88
+ success: false,
89
+ updated: false,
90
+ error: `Bundle configuration not found: ${name}`,
91
+ };
92
+ }
93
+ try {
94
+ await this.ensureCacheDir();
95
+ const index = await this.loadIndex();
96
+ const existingEntry = index.entries[name];
97
+ const bundleDir = join(this.cacheDir, name);
98
+ // Check if we have a valid cached bundle that hasn't expired
99
+ if (existingEntry && existsSync(bundleDir)) {
100
+ if (Date.now() < existingEntry.expires_at) {
101
+ debug(`Bundle ${name} is still valid, skipping download`);
102
+ return {
103
+ name,
104
+ success: true,
105
+ updated: false,
106
+ path: existingEntry.path,
107
+ };
108
+ }
109
+ }
110
+ // Validate URL: enforce HTTPS (allow localhost for development/testing)
111
+ const parsedBundleUrl = new URL(config.url);
112
+ const isLocalhost = parsedBundleUrl.hostname === 'localhost' || parsedBundleUrl.hostname === '127.0.0.1';
113
+ if (parsedBundleUrl.protocol !== 'https:' && !isLocalhost) {
114
+ throw new Error(`Bundle URL must use HTTPS: ${config.url}`);
115
+ }
116
+ // Download the bundle
117
+ debug(`Downloading bundle ${name} from ${config.url}`);
118
+ const tempFile = join(this.cacheDir, `${name}.tar.gz.tmp`);
119
+ try {
120
+ const downloadResult = await this.downloadFile(config.url, tempFile, config.headers, existingEntry?.etag, existingEntry?.last_modified, config.auth);
121
+ // Handle 304 Not Modified
122
+ if (downloadResult.notModified && existingEntry) {
123
+ // Update expiration time
124
+ existingEntry.expires_at =
125
+ Date.now() + (config.refresh_interval_ms || DEFAULT_REFRESH_INTERVAL_MS);
126
+ this.indexDirty = true;
127
+ await this.saveIndex();
128
+ return {
129
+ name,
130
+ success: true,
131
+ updated: false,
132
+ path: existingEntry.path,
133
+ };
134
+ }
135
+ // Verify checksum if provided
136
+ const actualChecksum = await this.computeChecksum(tempFile);
137
+ if (config.checksum && actualChecksum !== config.checksum) {
138
+ throw new Error(`Checksum mismatch for bundle ${name}: expected ${config.checksum}, got ${actualChecksum}`);
139
+ }
140
+ // Verify signature if key is provided
141
+ let signatureVerified = false;
142
+ if (config.signature_key && this.verifySignatures) {
143
+ signatureVerified = await this.verifySignature(tempFile, `${tempFile}.sig`, config.signature_key);
144
+ if (!signatureVerified) {
145
+ throw new Error(`Signature verification failed for bundle ${name}`);
146
+ }
147
+ }
148
+ // Remove old bundle directory
149
+ if (existsSync(bundleDir)) {
150
+ rmSync(bundleDir, { recursive: true, force: true });
151
+ }
152
+ // Extract bundle
153
+ mkdirSync(bundleDir, { recursive: true });
154
+ await this.extractBundle(tempFile, bundleDir);
155
+ // Update cache index
156
+ const stats = readFileSync(tempFile);
157
+ const entry = {
158
+ name,
159
+ url: config.url,
160
+ path: bundleDir,
161
+ downloaded_at: Date.now(),
162
+ expires_at: Date.now() + (config.refresh_interval_ms || DEFAULT_REFRESH_INTERVAL_MS),
163
+ checksum: actualChecksum,
164
+ size_bytes: stats.length,
165
+ signature_verified: signatureVerified,
166
+ etag: downloadResult.etag,
167
+ last_modified: downloadResult.lastModified,
168
+ };
169
+ index.entries[name] = entry;
170
+ index.last_sync = Date.now();
171
+ this.indexDirty = true;
172
+ await this.saveIndex();
173
+ // Clean up temp file
174
+ if (existsSync(tempFile)) {
175
+ unlinkSync(tempFile);
176
+ }
177
+ debug(`Bundle ${name} downloaded and extracted to ${bundleDir}`);
178
+ return {
179
+ name,
180
+ success: true,
181
+ updated: true,
182
+ path: bundleDir,
183
+ };
184
+ }
185
+ finally {
186
+ // Clean up temp file on error
187
+ if (existsSync(tempFile)) {
188
+ try {
189
+ unlinkSync(tempFile);
190
+ }
191
+ catch {
192
+ // Ignore cleanup errors
193
+ }
194
+ }
195
+ }
196
+ }
197
+ catch (error) {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ debug(`Failed to download bundle ${name}: ${message}`);
200
+ return {
201
+ name,
202
+ success: false,
203
+ updated: false,
204
+ error: message,
205
+ };
206
+ }
207
+ }
208
+ /**
209
+ * Get the cached path for a bundle, returning null if not cached
210
+ */
211
+ async getBundle(name) {
212
+ const index = await this.loadIndex();
213
+ const entry = index.entries[name];
214
+ if (!entry) {
215
+ return null;
216
+ }
217
+ if (!existsSync(entry.path)) {
218
+ // Cache entry exists but files are missing
219
+ delete index.entries[name];
220
+ this.indexDirty = true;
221
+ await this.saveIndex();
222
+ return null;
223
+ }
224
+ return entry.path;
225
+ }
226
+ /**
227
+ * Get cache entry metadata for a bundle
228
+ */
229
+ async getBundleEntry(name) {
230
+ const index = await this.loadIndex();
231
+ return index.entries[name] || null;
232
+ }
233
+ /**
234
+ * Invalidate a specific bundle cache, removing downloaded files
235
+ */
236
+ async invalidateBundle(name) {
237
+ const index = await this.loadIndex();
238
+ const entry = index.entries[name];
239
+ if (!entry) {
240
+ return false;
241
+ }
242
+ // Remove bundle directory
243
+ const bundleDir = join(this.cacheDir, name);
244
+ if (existsSync(bundleDir)) {
245
+ try {
246
+ rmSync(bundleDir, { recursive: true, force: true });
247
+ }
248
+ catch (error) {
249
+ debug(`Failed to remove bundle directory ${bundleDir}`, error);
250
+ }
251
+ }
252
+ // Remove from index
253
+ delete index.entries[name];
254
+ this.indexDirty = true;
255
+ await this.saveIndex();
256
+ return true;
257
+ }
258
+ /**
259
+ * Clear all cached bundles
260
+ */
261
+ async clearCache() {
262
+ try {
263
+ await rm(this.cacheDir, { recursive: true, force: true });
264
+ this.index = null;
265
+ this.indexDirty = false;
266
+ debug('Bundle cache cleared');
267
+ }
268
+ catch (error) {
269
+ debug('Failed to clear bundle cache', error);
270
+ }
271
+ }
272
+ /**
273
+ * Get cache statistics
274
+ */
275
+ async getCacheStats() {
276
+ const index = await this.loadIndex();
277
+ const entries = Object.values(index.entries);
278
+ return {
279
+ bundleCount: entries.length,
280
+ totalSizeBytes: entries.reduce((sum, e) => sum + e.size_bytes, 0),
281
+ lastSync: index.last_sync,
282
+ };
283
+ }
284
+ /**
285
+ * Ensure cache directory exists
286
+ */
287
+ async ensureCacheDir() {
288
+ if (!existsSync(this.cacheDir)) {
289
+ await mkdir(this.cacheDir, { recursive: true });
290
+ }
291
+ }
292
+ /**
293
+ * Load cache index from disk
294
+ */
295
+ async loadIndex() {
296
+ if (this.index) {
297
+ return this.index;
298
+ }
299
+ try {
300
+ const content = await readFile(this.indexPath, 'utf-8');
301
+ this.index = JSON.parse(content);
302
+ // Validate structure
303
+ if (!this.index.version || !this.index.entries) {
304
+ throw new Error('Invalid index structure');
305
+ }
306
+ }
307
+ catch (error) {
308
+ debug('Cache index missing or corrupted, creating new one', error);
309
+ this.index = {
310
+ version: 1,
311
+ entries: {},
312
+ last_sync: 0,
313
+ };
314
+ this.indexDirty = true;
315
+ }
316
+ return this.index;
317
+ }
318
+ /**
319
+ * Save cache index to disk
320
+ */
321
+ async saveIndex() {
322
+ if (!this.indexDirty || !this.index) {
323
+ return;
324
+ }
325
+ await this.ensureCacheDir();
326
+ await writeFile(this.indexPath, JSON.stringify(this.index, null, 2), 'utf-8');
327
+ this.indexDirty = false;
328
+ }
329
+ /**
330
+ * Build authorization header from auth config
331
+ */
332
+ buildAuthHeader(auth) {
333
+ if (auth.type === 'basic') {
334
+ const username = auth.username || '';
335
+ const passwordEnv = auth.password_env || '';
336
+ const password = passwordEnv ? process.env[passwordEnv] || '' : '';
337
+ const credentials = Buffer.from(`${username}:${password}`).toString('base64');
338
+ return `Basic ${credentials}`;
339
+ }
340
+ if (auth.type === 'bearer') {
341
+ const tokenEnv = auth.token_env || '';
342
+ const token = tokenEnv ? process.env[tokenEnv] || '' : '';
343
+ if (token) {
344
+ return `Bearer ${token}`;
345
+ }
346
+ }
347
+ return null;
348
+ }
349
+ /**
350
+ * Download a file from URL to destination
351
+ */
352
+ downloadFile(url, dest, headers, etag, lastModified, auth) {
353
+ return new Promise((resolve, reject) => {
354
+ const parsedUrl = new URL(url);
355
+ const isHttps = parsedUrl.protocol === 'https:';
356
+ const httpModule = isHttps ? https : http;
357
+ const requestHeaders = {
358
+ 'User-Agent': 'Anvil-BundleManager/1.0',
359
+ ...headers,
360
+ };
361
+ // Add authentication header if configured
362
+ if (auth) {
363
+ const authHeader = this.buildAuthHeader(auth);
364
+ if (authHeader) {
365
+ requestHeaders['Authorization'] = authHeader;
366
+ }
367
+ }
368
+ // Add conditional request headers
369
+ if (etag) {
370
+ requestHeaders['If-None-Match'] = etag;
371
+ }
372
+ if (lastModified) {
373
+ requestHeaders['If-Modified-Since'] = lastModified;
374
+ }
375
+ const options = {
376
+ hostname: parsedUrl.hostname,
377
+ port: parsedUrl.port || (isHttps ? 443 : 80),
378
+ path: parsedUrl.pathname + parsedUrl.search,
379
+ method: 'GET',
380
+ headers: requestHeaders,
381
+ timeout: this.timeoutMs,
382
+ };
383
+ const request = httpModule.request(options, (response) => {
384
+ // Handle redirects
385
+ if (response.statusCode === 301 || response.statusCode === 302) {
386
+ const redirectUrl = response.headers.location;
387
+ if (!redirectUrl) {
388
+ reject(new Error('Redirect without location header'));
389
+ return;
390
+ }
391
+ this.downloadFile(redirectUrl, dest, headers, etag, lastModified, auth)
392
+ .then(resolve)
393
+ .catch(reject);
394
+ return;
395
+ }
396
+ // Handle 304 Not Modified
397
+ if (response.statusCode === 304) {
398
+ resolve({ notModified: true });
399
+ return;
400
+ }
401
+ if (response.statusCode !== 200) {
402
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
403
+ return;
404
+ }
405
+ const file = createWriteStream(dest);
406
+ response.pipe(file);
407
+ file.on('finish', () => {
408
+ file.close();
409
+ resolve({
410
+ notModified: false,
411
+ etag: response.headers.etag,
412
+ lastModified: response.headers['last-modified'],
413
+ });
414
+ });
415
+ file.on('error', (err) => {
416
+ file.close();
417
+ if (existsSync(dest)) {
418
+ unlinkSync(dest);
419
+ }
420
+ reject(err);
421
+ });
422
+ });
423
+ request.on('error', (err) => {
424
+ if (existsSync(dest)) {
425
+ unlinkSync(dest);
426
+ }
427
+ reject(err);
428
+ });
429
+ request.on('timeout', () => {
430
+ request.destroy();
431
+ if (existsSync(dest)) {
432
+ unlinkSync(dest);
433
+ }
434
+ reject(new Error(`Download timeout after ${this.timeoutMs}ms`));
435
+ });
436
+ request.end();
437
+ });
438
+ }
439
+ /**
440
+ * Compute SHA256 checksum of a file
441
+ */
442
+ async computeChecksum(filePath) {
443
+ const content = readFileSync(filePath);
444
+ return createHash('sha256').update(content).digest('hex');
445
+ }
446
+ /**
447
+ * Verify signature of a file using public key
448
+ */
449
+ async verifySignature(filePath, signaturePath, publicKey) {
450
+ try {
451
+ if (!existsSync(signaturePath)) {
452
+ debug(`Signature file not found: ${signaturePath}`);
453
+ return false;
454
+ }
455
+ const fileContent = readFileSync(filePath);
456
+ const signature = readFileSync(signaturePath);
457
+ const verify = createVerify('SHA256');
458
+ verify.update(fileContent);
459
+ verify.end();
460
+ return verify.verify(publicKey, signature);
461
+ }
462
+ catch (error) {
463
+ debug('Signature verification error', error);
464
+ return false;
465
+ }
466
+ }
467
+ /**
468
+ * Extract a tarball to destination directory
469
+ */
470
+ async extractBundle(tarPath, destDir) {
471
+ const { resolve, sep } = await import('node:path');
472
+ const resolvedDest = resolve(destDir);
473
+ await pipeline(createReadStream(tarPath), createGunzip(), extract({
474
+ cwd: destDir,
475
+ strip: 0,
476
+ filter: (entryPath) => {
477
+ const resolved = resolve(destDir, entryPath);
478
+ if (resolved !== resolvedDest && !resolved.startsWith(resolvedDest + sep)) {
479
+ debug(`Zip-slip blocked: ${entryPath} escapes ${destDir}`);
480
+ return false;
481
+ }
482
+ return true;
483
+ },
484
+ }));
485
+ }
486
+ }
487
+ // Import createReadStream for extraction
488
+ import { createReadStream } from 'node:fs';
489
+ /**
490
+ * Create a singleton bundle manager
491
+ */
492
+ let defaultManager = null;
493
+ export function getBundleManager(config) {
494
+ if (!defaultManager || config) {
495
+ defaultManager = new BundleManager(config);
496
+ }
497
+ return defaultManager;
498
+ }