@1nder-labs/schemalint 1.0.1
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-APACHE +176 -0
- package/LICENSE-MIT +19 -0
- package/README.md +52 -0
- package/bin/schemalint-zod.js +6 -0
- package/dist/discover.d.ts +43 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +145 -0
- package/dist/discover.js.map +1 -0
- package/dist/discover_ast.d.ts +30 -0
- package/dist/discover_ast.d.ts.map +1 -0
- package/dist/discover_ast.js +202 -0
- package/dist/discover_ast.js.map +1 -0
- package/dist/evaluate.d.ts +18 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +112 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +3 -0
- package/dist/main.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +82 -0
- package/dist/server.js.map +1 -0
- package/dist/target_emit.d.ts +12 -0
- package/dist/target_emit.d.ts.map +1 -0
- package/dist/target_emit.js +111 -0
- package/dist/target_emit.js.map +1 -0
- package/dist/target_imports.d.ts +19 -0
- package/dist/target_imports.d.ts.map +1 -0
- package/dist/target_imports.js +79 -0
- package/dist/target_imports.js.map +1 -0
- package/dist/target_resolution.d.ts +20 -0
- package/dist/target_resolution.d.ts.map +1 -0
- package/dist/target_resolution.js +174 -0
- package/dist/target_resolution.js.map +1 -0
- package/dist/targets.d.ts +5 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +116 -0
- package/dist/targets.js.map +1 -0
- package/index.cjs +504 -0
- package/package.json +34 -0
package/index.cjs
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const VERSION = require('./package.json').version;
|
|
12
|
+
if (!VERSION) {
|
|
13
|
+
throw new Error('Missing or invalid version in package.json');
|
|
14
|
+
}
|
|
15
|
+
const REPO = '1nder-labs/schemalint';
|
|
16
|
+
|
|
17
|
+
// [P1 — resource exhaustion] Hard cap on bytes accepted from any single
|
|
18
|
+
// download. 200 MiB is generous for a CLI binary archive and its tiny
|
|
19
|
+
// SHA-256 sidecar; both go through downloadFile so the cap applies to both.
|
|
20
|
+
const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
// [P0 — supply chain] Allowlist of hosts permitted for downloads and redirects.
|
|
23
|
+
// Only GitHub release delivery hosts are trusted; any other host (including
|
|
24
|
+
// same-registrable-domain lookalikes) is rejected before a connection is made.
|
|
25
|
+
const ALLOWED_HOSTS = new Set([
|
|
26
|
+
'github.com',
|
|
27
|
+
'objects.githubusercontent.com',
|
|
28
|
+
'release-assets.githubusercontent.com',
|
|
29
|
+
'codeload.github.com',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const TARGET_MAP = {
|
|
33
|
+
'darwin-x64': 'x86_64-apple-darwin',
|
|
34
|
+
'darwin-arm64': 'aarch64-apple-darwin',
|
|
35
|
+
'linux-x64': 'x86_64-unknown-linux-gnu',
|
|
36
|
+
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
|
37
|
+
'win32-x64': 'x86_64-pc-windows-msvc',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EXE_EXT = process.platform === 'win32' ? '.exe' : '';
|
|
41
|
+
|
|
42
|
+
function getTarget() {
|
|
43
|
+
const plat = `${process.platform}-${process.arch}`;
|
|
44
|
+
const target = TARGET_MAP[plat];
|
|
45
|
+
if (!target) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Unsupported platform: ${plat}. Supported: ${Object.keys(TARGET_MAP).join(', ')}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return target;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getCacheDir() {
|
|
54
|
+
const dir = process.env.SCHEMALINT_CACHE_DIR
|
|
55
|
+
|| path.join(os.homedir(), '.cache', 'schemalint-npm');
|
|
56
|
+
return path.join(dir, VERSION);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getBinaryPath() {
|
|
60
|
+
const target = getTarget();
|
|
61
|
+
return path.join(getCacheDir(), target, `schemalint${EXE_EXT}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute the SHA-256 hash of a file and return it as a lowercase hex string.
|
|
70
|
+
*/
|
|
71
|
+
function sha256File(filePath) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const hash = crypto.createHash('sha256');
|
|
74
|
+
const stream = fs.createReadStream(filePath);
|
|
75
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
76
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
77
|
+
stream.on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* [P0 — supply chain] Assert that a URL's hostname is in the allowed set.
|
|
83
|
+
* Parses with the WHATWG URL API so scheme, port, and hostname are all
|
|
84
|
+
* properly separated — substring/endsWith matching on the raw string is
|
|
85
|
+
* deliberately avoided to prevent `github.com.evil.com`-style bypasses.
|
|
86
|
+
* Throws for unparseable URLs or relative Location headers (fail closed).
|
|
87
|
+
*/
|
|
88
|
+
function assertAllowedHost(url) {
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = new URL(url);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`Refusing download: could not parse URL: ${url}`);
|
|
94
|
+
}
|
|
95
|
+
if (parsed.protocol !== 'https:') {
|
|
96
|
+
throw new Error(`Refusing non-HTTPS URL: ${url}`);
|
|
97
|
+
}
|
|
98
|
+
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Refusing download from disallowed host "${parsed.hostname}". ` +
|
|
101
|
+
`Allowed hosts: ${[...ALLOWED_HOSTS].join(', ')}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function downloadFile(url, destPath, depth = 0) {
|
|
107
|
+
// [P0 — supply chain] Validate initial URL and every redirect target before
|
|
108
|
+
// opening a connection. assertAllowedHost also enforces HTTPS, making the
|
|
109
|
+
// old ternary that conditionally required 'http' unnecessary.
|
|
110
|
+
assertAllowedHost(url);
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const request = https.get(url, (response) => {
|
|
114
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
115
|
+
response.resume();
|
|
116
|
+
if (depth > 5) {
|
|
117
|
+
reject(new Error('too many redirects'));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// assertAllowedHost is called at the top of the recursive call, which
|
|
121
|
+
// covers both the https-only and host-allowlist checks for the redirect.
|
|
122
|
+
// The try/catch converts any synchronous throw (e.g. disallowed host,
|
|
123
|
+
// unparseable Location) into a rejection rather than an uncaught exception
|
|
124
|
+
// escaping the response callback.
|
|
125
|
+
try {
|
|
126
|
+
downloadFile(response.headers.location, destPath, depth + 1).then(resolve).catch(reject);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
reject(e);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (response.statusCode !== 200) {
|
|
133
|
+
response.resume();
|
|
134
|
+
reject(new Error(`HTTP ${response.statusCode}`));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// [P1 — resource exhaustion] Fast-reject on Content-Length header when
|
|
139
|
+
// present. The header can be absent or lie, so we also enforce the cap
|
|
140
|
+
// on actual bytes received below.
|
|
141
|
+
const contentLength = parseInt(response.headers['content-length'] || '0', 10);
|
|
142
|
+
if (contentLength > MAX_DOWNLOAD_BYTES) {
|
|
143
|
+
response.destroy();
|
|
144
|
+
fs.unlink(destPath, () => {});
|
|
145
|
+
reject(new Error(
|
|
146
|
+
`Download refused: Content-Length ${contentLength} exceeds ` +
|
|
147
|
+
`the ${MAX_DOWNLOAD_BYTES}-byte cap`
|
|
148
|
+
));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// [P1 — resource exhaustion] Track cumulative bytes received. If the
|
|
153
|
+
// server lies about Content-Length (or omits it) and streams more data
|
|
154
|
+
// than the cap allows, destroy the request and reject.
|
|
155
|
+
let bytesReceived = 0;
|
|
156
|
+
const file = fs.createWriteStream(destPath);
|
|
157
|
+
|
|
158
|
+
function abortOversized() {
|
|
159
|
+
request.destroy();
|
|
160
|
+
file.destroy();
|
|
161
|
+
fs.unlink(destPath, () => {});
|
|
162
|
+
reject(new Error(
|
|
163
|
+
`Download aborted: received more than ${MAX_DOWNLOAD_BYTES} bytes`
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
response.on('data', (chunk) => {
|
|
168
|
+
bytesReceived += chunk.length;
|
|
169
|
+
if (bytesReceived > MAX_DOWNLOAD_BYTES) {
|
|
170
|
+
abortOversized();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
response.pipe(file);
|
|
175
|
+
file.on('finish', () => {
|
|
176
|
+
file.close((err) => {
|
|
177
|
+
if (err) reject(err);
|
|
178
|
+
else resolve();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
file.on('error', (err) => {
|
|
182
|
+
file.destroy();
|
|
183
|
+
fs.unlink(destPath, () => {});
|
|
184
|
+
reject(err);
|
|
185
|
+
});
|
|
186
|
+
response.on('error', (err) => {
|
|
187
|
+
file.destroy();
|
|
188
|
+
fs.unlink(destPath, () => {});
|
|
189
|
+
reject(err);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
request.on('error', (err) => {
|
|
193
|
+
fs.unlink(destPath, () => {});
|
|
194
|
+
reject(err);
|
|
195
|
+
});
|
|
196
|
+
request.setTimeout(120000, () => {
|
|
197
|
+
request.destroy();
|
|
198
|
+
fs.unlink(destPath, () => {});
|
|
199
|
+
reject(new Error('Request timeout'));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Recursively search `dir` for a file named `binaryName`, up to `maxDepth`
|
|
206
|
+
* levels deep. Symlinked directories are NOT followed (Zip-Slip guard).
|
|
207
|
+
* Returns all matching file paths (not directories, not symlinks-to-files).
|
|
208
|
+
*/
|
|
209
|
+
function findBinaryInDir(dir, binaryName, maxDepth, currentDepth = 0) {
|
|
210
|
+
const results = [];
|
|
211
|
+
let entries;
|
|
212
|
+
try {
|
|
213
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
214
|
+
} catch {
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const fullPath = path.join(dir, entry.name);
|
|
219
|
+
if (entry.isFile() && entry.name === binaryName) {
|
|
220
|
+
results.push(fullPath);
|
|
221
|
+
} else if (entry.isDirectory() && !entry.isSymbolicLink() && currentDepth < maxDepth) {
|
|
222
|
+
// entry.isSymbolicLink() is always false when using withFileTypes on a
|
|
223
|
+
// plain entry because isDirectory() and isSymbolicLink() are mutually
|
|
224
|
+
// exclusive for Dirent. Explicitly check via lstatSync for safety.
|
|
225
|
+
const stat = (() => { try { return fs.lstatSync(fullPath); } catch { return null; } })();
|
|
226
|
+
if (stat && stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
227
|
+
results.push(...findBinaryInDir(fullPath, binaryName, maxDepth, currentDepth + 1));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Monotonic counter to disambiguate temp dirs created within the same process
|
|
235
|
+
// during the same millisecond (rare but possible in test harnesses).
|
|
236
|
+
let _tmpCounter = 0;
|
|
237
|
+
|
|
238
|
+
async function ensureBinary() {
|
|
239
|
+
const binPath = getBinaryPath();
|
|
240
|
+
const sentinelPath = binPath + '.verified';
|
|
241
|
+
|
|
242
|
+
// [P0 — integrity] Cache-hit path: re-verify the cached binary against the
|
|
243
|
+
// SHA-256 recorded in the sentinel on every invocation. An empty, unreadable,
|
|
244
|
+
// or malformed sentinel is treated as a mismatch and triggers a fresh
|
|
245
|
+
// download+verify cycle.
|
|
246
|
+
if (fs.existsSync(binPath) && fs.existsSync(sentinelPath)) {
|
|
247
|
+
let recordedHash = '';
|
|
248
|
+
try {
|
|
249
|
+
recordedHash = fs.readFileSync(sentinelPath, 'utf8').trim();
|
|
250
|
+
} catch {
|
|
251
|
+
// Unreadable sentinel — treat as mismatch; fall through.
|
|
252
|
+
}
|
|
253
|
+
if (/^[0-9a-f]{64}$/.test(recordedHash)) {
|
|
254
|
+
let actualHash = '';
|
|
255
|
+
try {
|
|
256
|
+
actualHash = await sha256File(binPath);
|
|
257
|
+
} catch {
|
|
258
|
+
// Unreadable binary — fall through to fresh download.
|
|
259
|
+
}
|
|
260
|
+
if (actualHash === recordedHash) {
|
|
261
|
+
return binPath;
|
|
262
|
+
}
|
|
263
|
+
// Hash mismatch or unreadable binary: wipe both so the clean-slate
|
|
264
|
+
// unlinks below complete a tidy state before re-downloading.
|
|
265
|
+
}
|
|
266
|
+
// Sentinel absent, empty, malformed, or hash mismatch — fall through.
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Cache miss or failed integrity check — clean slate.
|
|
270
|
+
try { fs.unlinkSync(sentinelPath); } catch {}
|
|
271
|
+
try { fs.unlinkSync(binPath); } catch {}
|
|
272
|
+
|
|
273
|
+
const target = getTarget();
|
|
274
|
+
const ext = process.platform === 'win32' ? '.zip' : '.tar.gz';
|
|
275
|
+
const archiveName = `schemalint-${target}${ext}`;
|
|
276
|
+
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
|
277
|
+
const cacheDir = path.dirname(binPath);
|
|
278
|
+
|
|
279
|
+
// Ensure the final destination directory exists before the temp-dir sibling
|
|
280
|
+
// is created (both share the same parent, so mkdirSync is only called once).
|
|
281
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
282
|
+
|
|
283
|
+
// [Concurrency] Use a unique temp work directory that is a SIBLING of
|
|
284
|
+
// cacheDir (same parent → same filesystem), so the final fs.renameSync into
|
|
285
|
+
// binPath is a within-filesystem atomic operation (never EXDEV).
|
|
286
|
+
// The pid + monotonic counter + random suffix make collisions practically
|
|
287
|
+
// impossible even under a test harness that spawns many processes rapidly.
|
|
288
|
+
const randomSuffix = crypto.randomBytes(4).toString('hex');
|
|
289
|
+
const workDir = path.join(
|
|
290
|
+
path.dirname(cacheDir),
|
|
291
|
+
`schemalint-tmp.${process.pid}.${++_tmpCounter}.${randomSuffix}`
|
|
292
|
+
);
|
|
293
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// All temp artifacts (archive, checksum, extracted tree) live inside
|
|
297
|
+
// workDir so that a crashed/killed process never leaves partial state in
|
|
298
|
+
// cacheDir visible to a concurrent healthy process.
|
|
299
|
+
const archivePath = path.join(workDir, archiveName);
|
|
300
|
+
|
|
301
|
+
// Download with retry (exponential backoff, max 3 attempts).
|
|
302
|
+
let lastError = null;
|
|
303
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
304
|
+
try {
|
|
305
|
+
await downloadFile(url, archivePath);
|
|
306
|
+
lastError = null;
|
|
307
|
+
break;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
lastError = e.message;
|
|
310
|
+
if (attempt < 3) {
|
|
311
|
+
try { fs.unlinkSync(archivePath); } catch {}
|
|
312
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
313
|
+
await sleep(delayMs);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (lastError !== null) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Failed to download schemalint binary from ${url}: ${lastError}. ` +
|
|
321
|
+
`Make sure the GitHub Release for v${VERSION} exists.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Verify archive integrity against the published per-artifact SHA-256
|
|
326
|
+
// checksum. cargo-dist (v0.31.0) emits a <archive>.sha256 sidecar for
|
|
327
|
+
// every archive and uploads it alongside the archive in the GitHub Release.
|
|
328
|
+
const checksumUrl = `${url}.sha256`;
|
|
329
|
+
const checksumPath = archivePath + '.sha256';
|
|
330
|
+
try {
|
|
331
|
+
// Download checksum with retry (same backoff shape as archive download).
|
|
332
|
+
let checksumLastError = null;
|
|
333
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
334
|
+
try {
|
|
335
|
+
await downloadFile(checksumUrl, checksumPath);
|
|
336
|
+
checksumLastError = null;
|
|
337
|
+
break;
|
|
338
|
+
} catch (e) {
|
|
339
|
+
checksumLastError = e.message;
|
|
340
|
+
if (attempt < 3) {
|
|
341
|
+
try { fs.unlinkSync(checksumPath); } catch {}
|
|
342
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
343
|
+
await sleep(delayMs);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (checksumLastError !== null) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Failed to download checksum from ${checksumUrl}: ${checksumLastError}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const checksumContent = fs.readFileSync(checksumPath, 'utf8');
|
|
354
|
+
// Handles both bare-hash and "hash filename" formats.
|
|
355
|
+
const expectedHash = checksumContent.trim().split(/\s+/)[0].toLowerCase();
|
|
356
|
+
const actualHash = await sha256File(archivePath);
|
|
357
|
+
if (actualHash !== expectedHash) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`SHA-256 mismatch for ${archiveName}: expected ${expectedHash}, got ${actualHash}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
// Always remove the checksum file — it is ephemeral and must not linger.
|
|
364
|
+
try { fs.unlinkSync(checksumPath); } catch {}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Extract into workDir. Every platform extracts to the same isolated
|
|
368
|
+
// temp tree; the archive is then deleted, and the binary is searched for
|
|
369
|
+
// within that tree regardless of how cargo-dist laid out the subdirectory.
|
|
370
|
+
try {
|
|
371
|
+
if (process.platform === 'win32') {
|
|
372
|
+
const result = spawnSync(
|
|
373
|
+
'powershell',
|
|
374
|
+
['-Command', 'Expand-Archive', '-Path', archivePath, '-DestinationPath', workDir],
|
|
375
|
+
{ stdio: 'pipe' }
|
|
376
|
+
);
|
|
377
|
+
if (result.error) throw result.error;
|
|
378
|
+
if (result.status !== 0) throw new Error(result.stderr.toString().trim());
|
|
379
|
+
} else {
|
|
380
|
+
const result = spawnSync(
|
|
381
|
+
'tar',
|
|
382
|
+
['-xzf', archivePath, '-C', workDir],
|
|
383
|
+
{ stdio: 'pipe' }
|
|
384
|
+
);
|
|
385
|
+
if (result.error) throw result.error;
|
|
386
|
+
if (result.status !== 0) throw new Error(result.stderr.toString().trim());
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
const reason = e.stderr ? e.stderr.toString().trim() : e.message;
|
|
390
|
+
throw new Error(`Failed to extract schemalint binary: ${reason}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Archive is no longer needed once extraction succeeds.
|
|
394
|
+
try { fs.unlinkSync(archivePath); } catch {}
|
|
395
|
+
|
|
396
|
+
// [P0 — layout-agnostic binary location] cargo-dist 0.31 archives may
|
|
397
|
+
// place the binary at the archive root OR inside a `schemalint-<target>/`
|
|
398
|
+
// subdirectory. Search workDir recursively (bounded to depth 3) for a file
|
|
399
|
+
// named `schemalint[.exe]`, then enforce exactly one match.
|
|
400
|
+
const binaryName = `schemalint${EXE_EXT}`;
|
|
401
|
+
const candidates = findBinaryInDir(workDir, binaryName, 3);
|
|
402
|
+
|
|
403
|
+
if (candidates.length === 0) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Binary "${binaryName}" not found anywhere inside the extracted archive. ` +
|
|
406
|
+
`Searched up to depth 3 in ${workDir}.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (candidates.length > 1) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Ambiguous extraction: found ${candidates.length} files named "${binaryName}" ` +
|
|
412
|
+
`inside the archive: ${candidates.join(', ')}`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const foundPath = candidates[0];
|
|
417
|
+
|
|
418
|
+
// [P0 — path traversal] Resolve the found path through realpathSync and
|
|
419
|
+
// verify it is inside workDir before trusting it. This guards against
|
|
420
|
+
// Tar-Slip / Zip-Slip attacks that place a symlink pointing outside the
|
|
421
|
+
// extraction directory. Note: GNU tar `--no-absolute-filenames` is
|
|
422
|
+
// deliberately NOT used because macOS ships bsdtar which does not
|
|
423
|
+
// recognise that long option. The realpath containment check is the
|
|
424
|
+
// portable guard for both tar and Expand-Archive paths.
|
|
425
|
+
let realFound;
|
|
426
|
+
try {
|
|
427
|
+
realFound = fs.realpathSync(foundPath);
|
|
428
|
+
} catch (e) {
|
|
429
|
+
throw new Error(`Binary not accessible after extraction: ${e.message}`);
|
|
430
|
+
}
|
|
431
|
+
const realWorkDir = fs.realpathSync(workDir);
|
|
432
|
+
if (!realFound.startsWith(realWorkDir + path.sep)) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Path traversal detected: extracted binary "${realFound}" escapes ` +
|
|
435
|
+
`work directory "${realWorkDir}"`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// [P0 — defense in depth] Hard-link guard. A legitimate cargo-dist binary
|
|
440
|
+
// extracted from a freshly-downloaded archive always has nlink === 1 (a
|
|
441
|
+
// single directory entry pointing to the inode). A file with nlink > 1 is
|
|
442
|
+
// either a tar hard-link member aliasing another path in the archive, or an
|
|
443
|
+
// attempt by a compromised archive to alias an existing on-disk file. The
|
|
444
|
+
// archive has already been SHA-256-verified, so this is defense-in-depth,
|
|
445
|
+
// but it is cheap and closes the edge case.
|
|
446
|
+
const lstat = fs.lstatSync(realFound);
|
|
447
|
+
if (lstat.nlink > 1) {
|
|
448
|
+
try { fs.unlinkSync(realFound); } catch {}
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Hard-link detected: extracted binary "${realFound}" has nlink=${lstat.nlink} ` +
|
|
451
|
+
`(expected 1). Archive rejected as potentially unsafe.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Make executable on Unix before moving into place.
|
|
456
|
+
if (process.platform !== 'win32') {
|
|
457
|
+
fs.chmodSync(realFound, 0o755);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// [Concurrency] Atomically rename the verified binary into its final
|
|
461
|
+
// location. Because workDir is a sibling of cacheDir (same filesystem),
|
|
462
|
+
// renameSync is always within-device and therefore atomic. A racing process
|
|
463
|
+
// that already placed binPath will simply have its file replaced by an
|
|
464
|
+
// equally valid binary — both came from the same verified archive.
|
|
465
|
+
// The sentinel is written AFTER the rename, so the cache-hit path (which
|
|
466
|
+
// requires both binPath and sentinelPath to exist) can never observe a
|
|
467
|
+
// partial state left by a crash between the two writes.
|
|
468
|
+
fs.renameSync(realFound, binPath);
|
|
469
|
+
|
|
470
|
+
// [P0 — integrity] Write the SHA-256 of the installed binary into the
|
|
471
|
+
// sentinel. On future cache hits this hash is re-verified against the live
|
|
472
|
+
// binary before the cached path is returned, ensuring a tampered binary is
|
|
473
|
+
// caught on the very next invocation.
|
|
474
|
+
const binaryHash = await sha256File(binPath);
|
|
475
|
+
fs.writeFileSync(sentinelPath, binaryHash);
|
|
476
|
+
|
|
477
|
+
} finally {
|
|
478
|
+
// Always clean up the temp work directory, even on error. This removes
|
|
479
|
+
// any partially extracted or partially downloaded artifacts so a failed
|
|
480
|
+
// run never leaves corrupt state visible to concurrent or subsequent runs.
|
|
481
|
+
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return binPath;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
(async () => {
|
|
488
|
+
try {
|
|
489
|
+
const binPath = await ensureBinary();
|
|
490
|
+
const result = spawnSync(binPath, process.argv.slice(2), {
|
|
491
|
+
stdio: 'inherit',
|
|
492
|
+
env: {
|
|
493
|
+
...process.env,
|
|
494
|
+
SCHEMALINT_ZOD_HELPER:
|
|
495
|
+
process.env.SCHEMALINT_ZOD_HELPER ||
|
|
496
|
+
path.join(__dirname, 'bin', 'schemalint-zod.js'),
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
process.exit(result.status ?? 1);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error(`schemalint: ${err.message}`);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@1nder-labs/schemalint",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Lint JSON Schema and Zod schemas before OpenAI or Anthropic structured-output APIs reject them.",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
6
|
+
"author": "Lahfir",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/1nder-labs/schemalint.git",
|
|
10
|
+
"directory": "npm/schemalint"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://1nder-labs.github.io/schemalint",
|
|
13
|
+
"bugs": { "url": "https://github.com/1nder-labs/schemalint/issues" },
|
|
14
|
+
"keywords": ["json-schema", "zod", "openai", "anthropic", "structured-output", "llm", "schema", "lint", "validation", "pydantic"],
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": { "schemalint": "./index.cjs" },
|
|
18
|
+
"files": ["index.cjs", "dist/", "bin/", "LICENSE-MIT", "LICENSE-APACHE"],
|
|
19
|
+
"dependencies": { "picomatch": "^4.0.0", "zod-to-json-schema": "^3.24.0" },
|
|
20
|
+
"peerDependencies": { "typescript": ">=4.9", "zod": ">=3.20" },
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"typescript": { "optional": true },
|
|
23
|
+
"zod": { "optional": true }
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^25.6.0",
|
|
27
|
+
"tsx": "^4.21.0",
|
|
28
|
+
"typescript": "^5.5.0",
|
|
29
|
+
"vitest": "^2.0.0",
|
|
30
|
+
"zod": "^3.23.0"
|
|
31
|
+
},
|
|
32
|
+
"scripts": { "build": "tsc", "test": "vitest run" },
|
|
33
|
+
"publishConfig": { "access": "public" }
|
|
34
|
+
}
|