@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6
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/README.md +9 -2
- package/bin/stasis.js +253 -22
- package/package.json +42 -10
- package/src/audit-corrections.js +72 -0
- package/src/audit.js +181 -112
- package/src/bundle-from-lockfile.js +3 -5
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +677 -402
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +22 -14
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +212 -127
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +9 -24
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +338 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +35 -19
- package/src/scan.js +211 -90
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +199 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +613 -0
- package/src/vfs-bundle.js +75 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Path checks shared by the source-language loaders.
|
|
2
|
+
|
|
3
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
6
|
+
|
|
7
|
+
// Reject absolute and `..`-escaping paths in a `.<lang>.txt` listing so a malicious or sloppy
|
|
8
|
+
// listing can't read files outside the listing's own directory.
|
|
9
|
+
export function assertWithinBase(baseDir, candidate, label) {
|
|
10
|
+
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
11
|
+
const rel = toPosix(relative(baseDir, resolve(baseDir, candidate)))
|
|
12
|
+
if (relativeEscapes(rel)) {
|
|
13
|
+
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Apply `segments` on `fromFile`'s directory, resolving '.'/'..'. Returns a baseDir-relative POSIX
|
|
18
|
+
// path ('' for baseDir itself), or null when traversal escapes the root.
|
|
19
|
+
export function applyToDir(fromFile, segments) {
|
|
20
|
+
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
21
|
+
const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
|
|
22
|
+
const resolved = []
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
if (part === '.' || part === '') continue
|
|
25
|
+
if (part === '..') {
|
|
26
|
+
if (resolved.length === 0) return null
|
|
27
|
+
resolved.pop()
|
|
28
|
+
} else {
|
|
29
|
+
resolved.push(part)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return resolved.join('/')
|
|
33
|
+
}
|
package/src/loaders/php.js
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
// (unresolved -> `missing`); Composer-autoloaded class refs are best-effort
|
|
4
4
|
// (unresolved -> silently skipped).
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { isUtf8 } from 'node:buffer'
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
7
8
|
import { readFile } from 'node:fs/promises'
|
|
8
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
9
11
|
|
|
10
|
-
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
12
|
+
import { assertRealPathWithinBase, isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
13
|
+
import { LockfileError, parseComposerLock } from '@preventive/lockfile/composer.js'
|
|
14
|
+
import { isDir, isFile } from '../resolve-typescript.js'
|
|
15
|
+
import { applyToDir } from './paths.js'
|
|
16
|
+
import { readUtf8OrNull } from './solidity-ownership.js'
|
|
17
|
+
import { nameErrors } from './toml.js'
|
|
11
18
|
|
|
12
19
|
// Cap concurrent reads per wave to avoid EMFILE on large directory globs.
|
|
13
20
|
const READ_CONCURRENCY = 64
|
|
@@ -120,6 +127,10 @@ function stringLiterals(code) {
|
|
|
120
127
|
return out
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
// Whether `pos` is inside one of `strings` (stringLiterals), and the literal starting at `pos`.
|
|
131
|
+
const inString = (strings, pos) => strings.some(({ start, end }) => pos > start && pos < end)
|
|
132
|
+
const stringAt = (strings, pos) => strings.find(({ start }) => start === pos)
|
|
133
|
+
|
|
123
134
|
// Include keyword. The lookbehind rejects method/scope-resolution calls
|
|
124
135
|
// (`$x->require`, `Foo::include`); the trailing `\b` stops matching inside
|
|
125
136
|
// `requireConfig`. String-literal matches are filtered out by the caller.
|
|
@@ -165,13 +176,16 @@ function matchDirAnchor(code, pos) {
|
|
|
165
176
|
return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
|
|
166
177
|
}
|
|
167
178
|
|
|
179
|
+
// A `./`/`../` specifier, as PHP tells one (a `.`-led NAME like `..cache/a.php` is a bare one).
|
|
180
|
+
const isDotRelative = (spec) => spec === '.' || spec === '..' || spec.startsWith('./') || spec.startsWith('../')
|
|
181
|
+
|
|
168
182
|
// Normalise an include path to a specifier. Dir-relative paths get a `./` prefix
|
|
169
183
|
// with the leading separator stripped; empty means the file's own dir (`.`).
|
|
170
184
|
function normalizeIncludePath(raw, dirRelative) {
|
|
171
185
|
if (!dirRelative) return raw
|
|
172
186
|
const rel = raw.replace(/^\/+/u, '')
|
|
173
187
|
if (rel === '') return '.'
|
|
174
|
-
return rel
|
|
188
|
+
return isDotRelative(rel) ? rel : `./${rel}`
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
// Parse an include/path argument at `start`: an optional directory anchor then a
|
|
@@ -179,7 +193,6 @@ function normalizeIncludePath(raw, dirRelative) {
|
|
|
179
193
|
// `dynamic` at the first variable/function/constant/interpolation; `prefix` is
|
|
180
194
|
// the static path up to there. Returns null with neither anchor nor static string.
|
|
181
195
|
function parseArgPath(code, start, strings) {
|
|
182
|
-
const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
|
|
183
196
|
let i = start
|
|
184
197
|
let dirRelative = false
|
|
185
198
|
let up = 0
|
|
@@ -196,7 +209,7 @@ function parseArgPath(code, start, strings) {
|
|
|
196
209
|
let dynamic = false
|
|
197
210
|
let sawStatic = false
|
|
198
211
|
for (;;) {
|
|
199
|
-
const lit = stringAt(i)
|
|
212
|
+
const lit = stringAt(strings, i)
|
|
200
213
|
if (!lit) { dynamic = true; break }
|
|
201
214
|
const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
|
|
202
215
|
if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
|
|
@@ -216,11 +229,10 @@ function parseArgPath(code, start, strings) {
|
|
|
216
229
|
function scanIncludes(content) {
|
|
217
230
|
const code = maskCommentsAndHeredocs(content)
|
|
218
231
|
const strings = stringLiterals(code)
|
|
219
|
-
const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
|
|
220
232
|
|
|
221
233
|
const out = []
|
|
222
234
|
for (const m of code.matchAll(PHP_INCLUDE_KEYWORD_RE)) {
|
|
223
|
-
if (inString(m.index)) continue
|
|
235
|
+
if (inString(strings, m.index)) continue
|
|
224
236
|
let i = skipWs(code, m.index + m[0].length)
|
|
225
237
|
if (code[i] === '(') i = skipWs(code, i + 1)
|
|
226
238
|
const arg = parseArgPath(code, i, strings)
|
|
@@ -247,13 +259,11 @@ function scanIncludes(content) {
|
|
|
247
259
|
function scanPathLiterals(content) {
|
|
248
260
|
const code = maskCommentsAndHeredocs(content)
|
|
249
261
|
const strings = stringLiterals(code)
|
|
250
|
-
const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
|
|
251
|
-
const stringAt = (pos) => strings.find(({ start }) => start === pos)
|
|
252
262
|
|
|
253
263
|
const out = []
|
|
254
264
|
// Dir-anchored literals: `__DIR__ . '/x.php'` (resolve against the file).
|
|
255
265
|
for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
|
|
256
|
-
if (inString(m.index)) continue
|
|
266
|
+
if (inString(strings, m.index)) continue
|
|
257
267
|
const arg = parseArgPath(code, m.index, strings)
|
|
258
268
|
if (!arg || arg.dynamic || !arg.sawStatic || !arg.dirRelative) continue
|
|
259
269
|
const spec = normalizeIncludePath(arg.prefix, true)
|
|
@@ -261,8 +271,8 @@ function scanPathLiterals(content) {
|
|
|
261
271
|
}
|
|
262
272
|
// Laravel path helpers: `base_path('routes/web.php')` (resolve against project root).
|
|
263
273
|
for (const m of code.matchAll(PHP_PATH_HELPER_FIND_RE)) {
|
|
264
|
-
if (inString(m.index)) continue
|
|
265
|
-
const lit = stringAt(skipWs(code, m.index + m[0].length))
|
|
274
|
+
if (inString(strings, m.index)) continue
|
|
275
|
+
const lit = stringAt(strings, skipWs(code, m.index + m[0].length))
|
|
266
276
|
if (!lit || (lit.quote === '"' && PHP_INTERPOLATION_RE.test(lit.value))) continue
|
|
267
277
|
const sub = PHP_PATH_HELPERS.get(m[1].toLowerCase())
|
|
268
278
|
const arg = lit.value.replace(/^\/+/u, '')
|
|
@@ -272,15 +282,17 @@ function scanPathLiterals(content) {
|
|
|
272
282
|
return out
|
|
273
283
|
}
|
|
274
284
|
|
|
285
|
+
const includeSpecs = (includes, kind) => includes.filter((e) => e.kind === kind).map((e) => e.spec)
|
|
286
|
+
|
|
275
287
|
// Statically-resolvable include specifiers (concrete files). See scanIncludes.
|
|
276
288
|
export function extractPhpImports(content) {
|
|
277
|
-
return scanIncludes(content)
|
|
289
|
+
return includeSpecs(scanIncludes(content), 'file')
|
|
278
290
|
}
|
|
279
291
|
|
|
280
292
|
// Directory specifiers for dynamic includes; every `.php` file directly inside is
|
|
281
293
|
// a candidate target.
|
|
282
294
|
export function extractPhpImportDirs(content) {
|
|
283
|
-
return scanIncludes(content)
|
|
295
|
+
return includeSpecs(scanIncludes(content), 'dir')
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
// `.php` path literals used outside a `require` -- dir-anchored (`anchor: 'file'`)
|
|
@@ -295,55 +307,35 @@ export function extractPhpPathRefs(content) {
|
|
|
295
307
|
})
|
|
296
308
|
}
|
|
297
309
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
function applyToDir(fromFile, segments) {
|
|
301
|
-
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
302
|
-
const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
|
|
303
|
-
const resolved = []
|
|
304
|
-
for (const part of parts) {
|
|
305
|
-
if (part === '.' || part === '') continue
|
|
306
|
-
if (part === '..') {
|
|
307
|
-
if (resolved.length === 0) return null
|
|
308
|
-
resolved.pop()
|
|
309
|
-
} else {
|
|
310
|
-
resolved.push(part)
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
return resolved.join('/')
|
|
314
|
-
}
|
|
310
|
+
const isFileOnDisk = (baseDir, rel) => isFile(join(baseDir, rel))
|
|
311
|
+
const isDirOnDisk = (baseDir, rel) => isDir(join(baseDir, rel))
|
|
315
312
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
return false
|
|
321
|
-
}
|
|
313
|
+
// Throws on a path holding `\`: no separator to PHP off Windows but part of a name, and stasis
|
|
314
|
+
// refuses such a name rather than resolve it, or take it for another path. `from`: the includer.
|
|
315
|
+
function refuseBackslash(path, from) {
|
|
316
|
+
if (path.includes('\\')) throw new Error(`[loader.php] path holds a '\\', which stasis refuses: ${path}${from ? ` (from ${from})` : ''}`)
|
|
322
317
|
}
|
|
323
318
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return false
|
|
329
|
-
}
|
|
319
|
+
// `abs` as a baseDir-relative POSIX path ('' for baseDir itself), or null when it escapes the root.
|
|
320
|
+
function projectRel(baseDir, abs) {
|
|
321
|
+
const rel = toPosix(relative(baseDir, abs))
|
|
322
|
+
return relativeEscapes(rel) ? null : rel
|
|
330
323
|
}
|
|
331
324
|
|
|
332
325
|
// Resolve a directory specifier to an existing baseDir-relative dir, or null.
|
|
333
326
|
// `./`/`../` resolve against the including file; a bare spec tries file- then
|
|
334
327
|
// project-relative.
|
|
335
328
|
export function resolvePhpDir(spec, fromFile, baseDir) {
|
|
336
|
-
|
|
329
|
+
refuseBackslash(spec, fromFile)
|
|
330
|
+
if (isDotRelative(spec)) {
|
|
337
331
|
const rel = applyToDir(fromFile, spec.split('/'))
|
|
338
332
|
return rel !== null && isDirOnDisk(baseDir, rel) ? rel : null
|
|
339
333
|
}
|
|
340
334
|
if (baseDir && !isAbsolute(spec)) {
|
|
341
335
|
const fileRel = applyToDir(fromFile, spec.split('/'))
|
|
342
336
|
if (fileRel && isDirOnDisk(baseDir, fileRel)) return fileRel
|
|
343
|
-
const projRel =
|
|
344
|
-
if (projRel
|
|
345
|
-
return projRel
|
|
346
|
-
}
|
|
337
|
+
const projRel = projectRel(baseDir, resolve(baseDir, spec))
|
|
338
|
+
if (projRel && isDirOnDisk(baseDir, projRel)) return projRel
|
|
347
339
|
}
|
|
348
340
|
return null
|
|
349
341
|
}
|
|
@@ -363,9 +355,11 @@ function listPhpFiles(baseDir, dir) {
|
|
|
363
355
|
// Resolve a PHP include specifier to a baseDir-relative POSIX path. `./`/`../`
|
|
364
356
|
// resolve against the including file (escaping the root -> null); a bare specifier
|
|
365
357
|
// tries a file next to the includer, then project-root-relative. Absolute
|
|
366
|
-
// specifiers are rejected. Returns null when nothing resolves.
|
|
358
|
+
// specifiers are rejected. Returns null when nothing resolves. One holding `\` throws
|
|
359
|
+
// (see refuseBackslash).
|
|
367
360
|
export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
368
|
-
|
|
361
|
+
refuseBackslash(specifier, fromFile)
|
|
362
|
+
if (isDotRelative(specifier)) {
|
|
369
363
|
return applyToDir(fromFile, specifier.split('/'))
|
|
370
364
|
}
|
|
371
365
|
|
|
@@ -373,10 +367,8 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
|
373
367
|
const fileRel = applyToDir(fromFile, specifier.split('/'))
|
|
374
368
|
if (fileRel && isFileOnDisk(baseDir, fileRel)) return fileRel
|
|
375
369
|
|
|
376
|
-
const projRel =
|
|
377
|
-
if (projRel
|
|
378
|
-
return projRel
|
|
379
|
-
}
|
|
370
|
+
const projRel = projectRel(baseDir, resolve(baseDir, specifier))
|
|
371
|
+
if (projRel && isFileOnDisk(baseDir, projRel)) return projRel
|
|
380
372
|
}
|
|
381
373
|
|
|
382
374
|
return null
|
|
@@ -385,12 +377,11 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
|
385
377
|
// Normalise a project-root-relative autoload path to POSIX baseDir-relative, or
|
|
386
378
|
// null when it escapes root. Re-derives via resolve() so interior `..` escapes
|
|
387
379
|
// (`src/foo/../../../secret.php`) are caught -- a leading-`..`/absolute check
|
|
388
|
-
// alone would let files outside the project into the bundle.
|
|
380
|
+
// alone would let files outside the project into the bundle. One holding `\`
|
|
381
|
+
// throws (see refuseBackslash).
|
|
389
382
|
function normalizeProjectRel(baseDir, p) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (rel.startsWith('..') || isAbsolute(rel)) return null
|
|
393
|
-
return rel
|
|
383
|
+
refuseBackslash(p)
|
|
384
|
+
return projectRel(baseDir, resolve(baseDir, p))
|
|
394
385
|
}
|
|
395
386
|
|
|
396
387
|
// Evaluate a generated-autoload path expr (`$baseDir . '/src'`, a bare quoted
|
|
@@ -453,13 +444,127 @@ function readJsonIfExists(file) {
|
|
|
453
444
|
}
|
|
454
445
|
}
|
|
455
446
|
|
|
447
|
+
// The root composer.json (null when absent or unparsable) and its baseDir-relative vendor dir.
|
|
448
|
+
function readComposerJson(baseDir) {
|
|
449
|
+
const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
|
|
450
|
+
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
451
|
+
return { composerJson, vendorDir }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The packages `vendor/composer/installed.json` lists, in the Composer 2 ({ packages }) or
|
|
455
|
+
// Composer 1 (flat array) shape.
|
|
456
|
+
function readInstalledPackages(baseDir, vendorDir) {
|
|
457
|
+
const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
458
|
+
return Array.isArray(json) ? json : (json?.packages ?? [])
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// JSON `text` with each integer as PHP holds it: one past ±(2^53 − 1) a BigInt, as
|
|
462
|
+
// @preventive/lockfile reads one, so two values compare equal only where PHP's do.
|
|
463
|
+
const parseJsonExact = (text) => JSON.parse(text, (key, value, { source }) =>
|
|
464
|
+
(typeof value === 'number' && !Number.isSafeInteger(value) && /^-?\d+$/u.test(source) ? BigInt(source) : value))
|
|
465
|
+
|
|
466
|
+
// Where installed.json says Composer installed package `p`, baseDir-relative: its install-path,
|
|
467
|
+
// from vendor/composer (null for none, as of a metapackage), or <vendor-dir>/<name> where it gives
|
|
468
|
+
// none (Composer 1's); null outside baseDir.
|
|
469
|
+
function installedDir(baseDir, vendorDir, p) {
|
|
470
|
+
const path = p['install-path']
|
|
471
|
+
if (path === undefined) return projectRel(baseDir, resolve(baseDir, vendorDir, p.name))
|
|
472
|
+
return typeof path === 'string' ? projectRel(baseDir, resolve(baseDir, vendorDir, 'composer', path)) : null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Where Composer's library installer puts a package of the lockfile, baseDir-relative
|
|
476
|
+
// (LibraryInstaller::getInstallPath): <vendor-dir>/<name>, under its target-dir where it has one; a
|
|
477
|
+
// metapackage nowhere (null). One of a type an installer plugin takes (composer/installers') goes
|
|
478
|
+
// elsewhere, where its own composer.json's name finds it.
|
|
479
|
+
function lockedDir(baseDir, vendorDir, p) {
|
|
480
|
+
return p.type === 'metapackage' ? null : projectRel(baseDir, resolve(baseDir, vendorDir, p.name, p.targetDir ?? ''))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// What Composer adds to a package of the lockfile as it installs it, in installed.json.
|
|
484
|
+
const INSTALL_FIELDS = new Set(['version_normalized', 'installation-source', 'install-path'])
|
|
485
|
+
|
|
486
|
+
// That vendor/composer/installed.json (`installedText`, at `installedFile`) is the install of
|
|
487
|
+
// composer.lock (`lockText`, read by @preventive/lockfile already): as Composer 2 writes it,
|
|
488
|
+
// `{ packages, dev, dev-package-names }`; each package of the lockfile's `packages`, and of its
|
|
489
|
+
// `packages-dev` where it was installed with dev, there once and exactly as locked but for
|
|
490
|
+
// INSTALL_FIELDS, and nothing else; and the names of those of `packages-dev`, in lowercase and
|
|
491
|
+
// sorted, as dev-package-names. Throws where not. Returns the installed.json.
|
|
492
|
+
function assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir }) {
|
|
493
|
+
const differ = (detail) => new Error(`${installedFile} is not the install of composer.lock: ${detail}${vendorDir ? `; remove ${vendorDir} and run \`composer install\`` : ''}`)
|
|
494
|
+
let installed
|
|
495
|
+
try {
|
|
496
|
+
installed = parseJsonExact(installedText)
|
|
497
|
+
} catch {
|
|
498
|
+
throw new Error(`${installedFile}: not JSON`) // without the parser's message, which quotes the text
|
|
499
|
+
}
|
|
500
|
+
const shape = ['packages', 'dev', 'dev-package-names']
|
|
501
|
+
if (!isPlainObject(installed) || Object.keys(installed).length !== shape.length || !shape.every((key) => Object.hasOwn(installed, key)) ||
|
|
502
|
+
!Array.isArray(installed.packages) || typeof installed.dev !== 'boolean' || !Array.isArray(installed['dev-package-names'])) {
|
|
503
|
+
throw differ(`it is not as Composer 2 writes one, { ${shape.join(', ')} }`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const lock = parseJsonExact(lockText)
|
|
507
|
+
const locked = new Map()
|
|
508
|
+
for (const [key, dev] of [['packages', false], ['packages-dev', true]]) {
|
|
509
|
+
for (const spec of lock[key]) locked.set(spec.name, { spec, dev })
|
|
510
|
+
}
|
|
511
|
+
const seen = new Set()
|
|
512
|
+
for (const [index, entry] of installed.packages.entries()) {
|
|
513
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string') throw differ(`packages[${index}] is not a package`)
|
|
514
|
+
const { name } = entry
|
|
515
|
+
const want = locked.get(name)
|
|
516
|
+
if (want === undefined) throw differ(`${JSON.stringify(name)} is installed, and not locked`)
|
|
517
|
+
if (seen.has(name)) throw differ(`${name} is installed twice`)
|
|
518
|
+
seen.add(name)
|
|
519
|
+
if (want.dev && !installed.dev) throw differ(`${name} is installed, and locked in packages-dev, which an install without dev leaves out`)
|
|
520
|
+
const spec = Object.fromEntries(Object.entries(entry).filter(([key]) => !INSTALL_FIELDS.has(key)))
|
|
521
|
+
const key = [...new Set([...Object.keys(spec), ...Object.keys(want.spec)])].find((k) => !isDeepStrictEqual(spec[k], want.spec[k]))
|
|
522
|
+
if (key === 'version' && typeof spec.version === 'string') throw differ(`${name} is installed at ${JSON.stringify(spec.version)}, and locked at ${want.spec.version}`)
|
|
523
|
+
// `composer install` takes a package installed at the version and reference locked as it is,
|
|
524
|
+
// whatever else of it the lockfile changes.
|
|
525
|
+
if (key !== undefined) throw differ(`${name}'s ${JSON.stringify(key)} is not as locked`)
|
|
526
|
+
}
|
|
527
|
+
for (const [name, { dev }] of locked) {
|
|
528
|
+
if (!seen.has(name) && (installed.dev || !dev)) throw differ(`${name} is locked, and not installed`)
|
|
529
|
+
}
|
|
530
|
+
const devNames = [...locked].filter(([, { dev }]) => dev && installed.dev).map(([name]) => name.toLowerCase()).toSorted()
|
|
531
|
+
if (!isDeepStrictEqual(installed['dev-package-names'], devNames)) throw differ("its dev-package-names are not the names of the lockfile's packages-dev installed")
|
|
532
|
+
return installed
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra }]`, `dir` where
|
|
536
|
+
// the package is installed, baseDir-relative (null for nowhere in baseDir, as a metapackage). Of
|
|
537
|
+
// its composer.lock where there is one, read by @preventive/lockfile beside composer.json as
|
|
538
|
+
// `composer install` reads them -- one it refuses stops the build -- each package where Composer
|
|
539
|
+
// installs it (lockedDir), so no install is needed; else of vendor/composer/installed.json, as
|
|
540
|
+
// it is. Where both are, installed.json must be the lockfile's install (assertInstalledAsLocked):
|
|
541
|
+
// its packages, each where installed.json says.
|
|
542
|
+
export function loadComposerPackages(baseDir) {
|
|
543
|
+
const { vendorDir } = readComposerJson(baseDir)
|
|
544
|
+
const lockText = readUtf8OrNull(join(baseDir, 'composer.lock'), 'composer.lock')
|
|
545
|
+
if (lockText === null) {
|
|
546
|
+
return readInstalledPackages(baseDir, vendorDir)
|
|
547
|
+
.filter((p) => p?.name && p.version)
|
|
548
|
+
.map((p) => ({ name: p.name, version: p.version, dir: installedDir(baseDir, vendorDir, p), extra: p.extra }))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const composerJson = readUtf8OrNull(join(baseDir, 'composer.json'), 'composer.json')
|
|
552
|
+
const lock = nameErrors('composer.lock', () => parseComposerLock(lockText, composerJson === null ? {} : { composerJson }), [LockfileError])
|
|
553
|
+
const installedFile = toPosix(join(vendorDir, 'composer', 'installed.json'))
|
|
554
|
+
const installedText = readUtf8OrNull(join(baseDir, installedFile), installedFile)
|
|
555
|
+
const installed = installedText === null ? null : assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir })
|
|
556
|
+
const dirs = installed === null ? null : new Map(installed.packages.map((p) => [p.name, installedDir(baseDir, vendorDir, p)]))
|
|
557
|
+
return Object.values(lock.packages)
|
|
558
|
+
.filter((p) => dirs === null || dirs.has(p.name))
|
|
559
|
+
.map((p) => ({ name: p.name, version: p.version, dir: dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name), extra: p.extra }))
|
|
560
|
+
}
|
|
561
|
+
|
|
456
562
|
// Build the autoload config for `baseDir`, merging the root composer.json
|
|
457
563
|
// `autoload`(+dev) with the generated `vendor/composer/autoload_*` maps. All
|
|
458
564
|
// paths normalised to POSIX baseDir-relative (escapers dropped). Returns
|
|
459
565
|
// `{ psr4, psr0, classmap, files }`, or null when no Composer config is found.
|
|
460
566
|
export function loadComposerAutoload(baseDir) {
|
|
461
|
-
const composerJson =
|
|
462
|
-
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
567
|
+
const { composerJson, vendorDir } = readComposerJson(baseDir)
|
|
463
568
|
|
|
464
569
|
const psr4 = new Map()
|
|
465
570
|
const psr0 = new Map()
|
|
@@ -467,6 +572,7 @@ export function loadComposerAutoload(baseDir) {
|
|
|
467
572
|
const files = new Set()
|
|
468
573
|
let found = false
|
|
469
574
|
|
|
575
|
+
const mergeDirs = (map, prefix, dirs) => map.set(prefix, [...new Set([...(map.get(prefix) ?? []), ...dirs])])
|
|
470
576
|
const addPrefixDirs = (map, prefix, paths) => {
|
|
471
577
|
const list = Array.isArray(paths) ? paths : [paths]
|
|
472
578
|
const dirs = []
|
|
@@ -474,8 +580,7 @@ export function loadComposerAutoload(baseDir) {
|
|
|
474
580
|
const rel = normalizeProjectRel(baseDir, String(p))
|
|
475
581
|
if (rel !== null) dirs.push(rel)
|
|
476
582
|
}
|
|
477
|
-
|
|
478
|
-
map.set(prefix, [...new Set([...prev, ...dirs])])
|
|
583
|
+
mergeDirs(map, prefix, dirs)
|
|
479
584
|
}
|
|
480
585
|
|
|
481
586
|
// Root composer.json autoload (+ dev). PSR-4 keys keep their trailing '\'.
|
|
@@ -493,37 +598,26 @@ export function loadComposerAutoload(baseDir) {
|
|
|
493
598
|
// Generated maps. `$vendorDir` = <baseDir>/<vendorDir>, `$baseDir` = <baseDir>.
|
|
494
599
|
const composerGenDir = join(baseDir, vendorDir, 'composer')
|
|
495
600
|
const vars = { baseDir: resolve(baseDir), vendorDir: resolve(baseDir, vendorDir) }
|
|
496
|
-
const toRel = (abs) =>
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (!existsSync(file)) return
|
|
601
|
+
const toRel = (abs) => projectRel(baseDir, abs) || null
|
|
602
|
+
// A generated map's entries; none when the file is absent.
|
|
603
|
+
const generated = (name, multi) => {
|
|
604
|
+
const file = join(composerGenDir, name)
|
|
605
|
+
if (!existsSync(file)) return new Map()
|
|
502
606
|
found = true
|
|
503
|
-
|
|
504
|
-
const rels = dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, ''))
|
|
505
|
-
const prev = target.get(prefix) ?? []
|
|
506
|
-
target.set(prefix, [...new Set([...prev, ...rels])])
|
|
507
|
-
}
|
|
607
|
+
return parseGeneratedMap(readFileSync(file, 'utf8'), vars, multi)
|
|
508
608
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const classmapFile = join(composerGenDir, 'autoload_classmap.php')
|
|
513
|
-
if (existsSync(classmapFile)) {
|
|
514
|
-
found = true
|
|
515
|
-
for (const [fqcn, abs] of parseGeneratedMap(readFileSync(classmapFile, 'utf8'), vars, false)) {
|
|
516
|
-
const rel = toRel(abs)
|
|
517
|
-
if (rel) classmap.set(fqcn.replace(/^\\+/u, ''), rel)
|
|
609
|
+
for (const [name, target] of [['autoload_psr4.php', psr4], ['autoload_namespaces.php', psr0]]) {
|
|
610
|
+
for (const [prefix, dirs] of generated(name, true)) {
|
|
611
|
+
mergeDirs(target, prefix, dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, '')))
|
|
518
612
|
}
|
|
519
613
|
}
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
614
|
+
for (const [fqcn, abs] of generated('autoload_classmap.php', false)) {
|
|
615
|
+
const rel = toRel(abs)
|
|
616
|
+
if (rel) classmap.set(fqcn.replace(/^\\+/u, ''), rel)
|
|
617
|
+
}
|
|
618
|
+
for (const [, abs] of generated('autoload_files.php', false)) {
|
|
619
|
+
const rel = toRel(abs)
|
|
620
|
+
if (rel) files.add(rel)
|
|
527
621
|
}
|
|
528
622
|
|
|
529
623
|
if (!found) return null
|
|
@@ -573,30 +667,21 @@ function matchingPrefixes(map, name) {
|
|
|
573
667
|
return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
|
|
574
668
|
}
|
|
575
669
|
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
function loadInstalledVersions(baseDir, vendorDir) {
|
|
580
|
-
const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
670
|
+
// The versions of the Composer `packages` (loadComposerPackages: the authoritative source; a
|
|
671
|
+
// package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
672
|
+
function packageVersions(packages) {
|
|
581
673
|
const byDir = new Map()
|
|
582
674
|
const byName = new Map()
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
if (
|
|
586
|
-
byName.set(p.name, p.version)
|
|
587
|
-
// install-path is relative to vendor/composer; default layout is vendor/<name>.
|
|
588
|
-
const abs = p['install-path']
|
|
589
|
-
? resolve(baseDir, vendorDir, 'composer', p['install-path'])
|
|
590
|
-
: resolve(baseDir, vendorDir, p.name)
|
|
591
|
-
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
592
|
-
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) byDir.set(rel, p.version)
|
|
675
|
+
for (const { name, version, dir } of packages) {
|
|
676
|
+
byName.set(name, version)
|
|
677
|
+
if (dir) byDir.set(dir, version)
|
|
593
678
|
}
|
|
594
679
|
return { byDir, byName }
|
|
595
680
|
}
|
|
596
681
|
|
|
597
682
|
// Walk up from a bundled file to the nearest named composer.json, returning
|
|
598
|
-
// { pkgDir, name, version } (pkgDir "." for the root; version from
|
|
599
|
-
// by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
683
|
+
// { pkgDir, name, version } (pkgDir "." for the root; version from composer.lock or
|
|
684
|
+
// installed.json by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
600
685
|
function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
601
686
|
let dir = dirname(fileRelPath)
|
|
602
687
|
for (;;) {
|
|
@@ -615,11 +700,9 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
|
615
700
|
// Group bundled PHP sources into per-package buckets keyed by the nearest
|
|
616
701
|
// composer.json's directory (vendor deps get their own; workspace files and
|
|
617
702
|
// orphans -> the root "." bucket with the placeholder identity). Returns a
|
|
618
|
-
// Map<dir, { name, version, files }>.
|
|
619
|
-
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
|
|
620
|
-
const
|
|
621
|
-
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
622
|
-
const installed = loadInstalledVersions(baseDir, vendorDir)
|
|
703
|
+
// Map<dir, { name, version, files }>. `packages`: loadComposerPackages(baseDir).
|
|
704
|
+
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion, packages = loadComposerPackages(baseDir)) {
|
|
705
|
+
const installed = packageVersions(packages)
|
|
623
706
|
|
|
624
707
|
const modules = new Map()
|
|
625
708
|
const ensureBucket = (dir, name, version, ecosystem) => {
|
|
@@ -647,22 +730,19 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
647
730
|
}
|
|
648
731
|
|
|
649
732
|
// Laravel auto-registers service providers nothing references statically. Returns
|
|
650
|
-
// discoverable provider files (from composer.json +
|
|
733
|
+
// discoverable provider files (from composer.json + the Composer `packages`'
|
|
651
734
|
// `extra.laravel.providers`, autoload-resolved) plus bootstrap/providers.php as
|
|
652
735
|
// extra collection roots. Best-effort: unresolvable classes skipped.
|
|
653
|
-
|
|
736
|
+
// `packages`: loadComposerPackages(baseDir).
|
|
737
|
+
export function loadLaravelProviderFiles(baseDir, autoload, packages = loadComposerPackages(baseDir)) {
|
|
654
738
|
const files = new Set()
|
|
655
739
|
if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
|
|
656
740
|
if (!autoload) return [...files]
|
|
657
741
|
|
|
658
|
-
const composerJson =
|
|
659
|
-
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
660
|
-
const installed = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
661
|
-
const installedPackages = Array.isArray(installed) ? installed : (installed?.packages ?? [])
|
|
662
|
-
|
|
742
|
+
const { composerJson } = readComposerJson(baseDir)
|
|
663
743
|
const fqcns = new Set(composerJson?.extra?.laravel?.providers ?? [])
|
|
664
|
-
for (const pkg of
|
|
665
|
-
for (const fqcn of pkg
|
|
744
|
+
for (const pkg of packages) {
|
|
745
|
+
for (const fqcn of pkg.extra?.laravel?.providers ?? []) fqcns.add(fqcn)
|
|
666
746
|
}
|
|
667
747
|
for (const fqcn of fqcns) {
|
|
668
748
|
const file = resolveClassFile(fqcn, autoload, baseDir)
|
|
@@ -781,9 +861,10 @@ export function phpClassDependencies(content) {
|
|
|
781
861
|
if (impl) for (const nm of impl[1].split(',')) add(nm)
|
|
782
862
|
}
|
|
783
863
|
|
|
784
|
-
// catch (A | B $e)
|
|
785
|
-
//
|
|
786
|
-
|
|
864
|
+
// catch (A | B $e), or PHP 8's catch (A | B) with no variable: capture the type
|
|
865
|
+
// list up to `$var` or `)`. One quantifier with nothing after it keeps matching
|
|
866
|
+
// linear on adversarial input.
|
|
867
|
+
for (const m of stripped.matchAll(/\bcatch\s*\(([^)$]*)/giu)) {
|
|
787
868
|
for (const nm of m[1].split('|')) add(nm)
|
|
788
869
|
}
|
|
789
870
|
|
|
@@ -852,7 +933,10 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
852
933
|
const readOne = async (relPath) => {
|
|
853
934
|
try {
|
|
854
935
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
855
|
-
|
|
936
|
+
const bytes = await readFile(join(baseDir, relPath))
|
|
937
|
+
// One that isn't UTF-8 is refused rather than carried with U+FFFD in it.
|
|
938
|
+
if (!isUtf8(bytes)) throw new Error(`PHP source is not valid UTF-8: ${relPath}`)
|
|
939
|
+
return [relPath, bytes.toString('utf8')]
|
|
856
940
|
} catch (err) {
|
|
857
941
|
// ENOENT (no file) / EISDIR (specifier resolved to a directory): not a
|
|
858
942
|
// loadable file -- warn and skip rather than crash (buildPhpTree surfaces
|
|
@@ -880,7 +964,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
880
964
|
if (!entry) continue
|
|
881
965
|
const [relPath, content] = entry
|
|
882
966
|
sources.set(relPath, content)
|
|
883
|
-
|
|
967
|
+
const includes = scanIncludes(content)
|
|
968
|
+
for (const spec of includeSpecs(includes, 'file')) {
|
|
884
969
|
const resolved = resolvePhpImport(spec, relPath, { baseDir })
|
|
885
970
|
if (resolved) {
|
|
886
971
|
if (!sources.has(resolved)) next.push(resolved)
|
|
@@ -890,7 +975,7 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
890
975
|
}
|
|
891
976
|
// Dynamic includes with a static dir prefix: the exact file is known only
|
|
892
977
|
// at runtime, so bundle every `.php` file in that directory as a candidate.
|
|
893
|
-
for (const dirSpec of
|
|
978
|
+
for (const dirSpec of includeSpecs(includes, 'dir')) {
|
|
894
979
|
const dir = resolvePhpDir(dirSpec, relPath, baseDir)
|
|
895
980
|
if (!dir) continue
|
|
896
981
|
for (const file of listPhpFiles(baseDir, dir)) {
|