@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.
- package/LICENSE +14 -0
- package/dist/bundle-manager.d.ts +183 -0
- package/dist/bundle-manager.d.ts.map +1 -0
- package/dist/bundle-manager.js +498 -0
- package/dist/bundle-verifier.d.ts +162 -0
- package/dist/bundle-verifier.d.ts.map +1 -0
- package/dist/bundle-verifier.js +401 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/opa-binary-manager.d.ts +76 -0
- package/dist/opa-binary-manager.d.ts.map +1 -0
- package/dist/opa-binary-manager.js +341 -0
- package/dist/opa-executor.d.ts +225 -0
- package/dist/opa-executor.d.ts.map +1 -0
- package/dist/opa-executor.js +427 -0
- package/dist/policy-loader.d.ts +90 -0
- package/dist/policy-loader.d.ts.map +1 -0
- package/dist/policy-loader.js +180 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/debug.d.ts +9 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +44 -0
- package/package.json +33 -0
- package/project.json +8 -0
- package/src/bundle-manager.test.ts +588 -0
- package/src/bundle-manager.ts +710 -0
- package/src/bundle-verifier.test.ts +903 -0
- package/src/bundle-verifier.ts +568 -0
- package/src/index.ts +38 -0
- package/src/opa-binary-manager.test.ts +208 -0
- package/src/opa-binary-manager.ts +417 -0
- package/src/opa-executor.test.ts +1802 -0
- package/src/opa-executor.ts +681 -0
- package/src/policy-loader.test.ts +469 -0
- package/src/policy-loader.ts +262 -0
- package/src/types.ts +43 -0
- package/src/utils/debug.ts +54 -0
- package/tsconfig.json +12 -0
- package/tsconfig.lib.json +9 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|