fallow 3.16.0 → 3.18.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/README.md +1 -1
- package/capabilities.json +30 -16
- package/issue-registry.json +2 -2
- package/package.json +10 -10
- package/schema.json +4 -4
- package/scripts/lazy-verify.js +16 -1
- package/scripts/lazy-verify.test.js +79 -100
- package/scripts/run-binary.js +30 -10
- package/scripts/run-binary.test.js +22 -1
- package/scripts/sentinel-path.test.js +9 -2
- package/scripts/verify-binary.test.js +50 -4
- package/skills/fallow/SKILL.md +10 -14
- package/skills/fallow/references/cli-reference.md +5 -5
- package/skills/fallow/references/gotchas.md +1 -1
- package/skills/fallow/references/mcp.md +24 -1
- package/skills/fallow/references/patterns.md +2 -2
- package/types/output-contract.d.ts +164 -46
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npx fallow fix --dry-run # Preview automatic cleanup
|
|
|
28
28
|
|
|
29
29
|
## Output and exit codes
|
|
30
30
|
|
|
31
|
-
Add `--format json --quiet` to any command for one typed JSON document on stdout. Exit code 1 means findings, not failure; 0 is clean (or an audit pass or warn verdict); 2 is a validation or runtime error, reported as a JSON error envelope rather than a stack trace.
|
|
31
|
+
Add `--format json --quiet` to any command for one typed JSON document on stdout. Exit code 1 means findings, not failure; 0 is clean (or an audit pass or warn verdict); 2 is a validation or runtime error, reported as a JSON error envelope rather than a stack trace. License, coverage setup, network, and security-gate workflows use additional documented codes; read `fallow schema.exit_codes` instead of suppressing the process status.
|
|
32
32
|
|
|
33
33
|
Parsing the output in TypeScript? Import the typed shapes, version-pinned to the CLI you install:
|
|
34
34
|
|
package/capabilities.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fallow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"manifest_version": "1",
|
|
5
5
|
"description": "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
|
|
6
6
|
"global_flags": [
|
|
@@ -302,7 +302,7 @@
|
|
|
302
302
|
"name": "--report-path-prefix",
|
|
303
303
|
"type": "string",
|
|
304
304
|
"required": false,
|
|
305
|
-
"description": "Prefix prepended to every path in the CI-facing formats (`github-annotations`, `github-summary`, `codeclimate`, `review-github`, `review-gitlab`). CI platforms address files by repository-root-relative path, so when the analyzed project lives in a subdirectory (e.g. `packages/app/`), paths need that offset. fallow detects the offset via the git toplevel automatically; this flag overrides the detection. Pass an empty string to disable rebasing and emit paths relative to `--root`"
|
|
305
|
+
"description": "Prefix prepended to every path in the CI-facing formats (`github-annotations`, `github-summary`, `codeclimate`, `pr-comment-github`, `pr-comment-gitlab`, `review-github`, `review-gitlab`). CI platforms address files by repository-root-relative path, so when the analyzed project lives in a subdirectory (e.g. `packages/app/`), paths need that offset. fallow detects the offset via the git toplevel automatically; this flag overrides the detection. Pass an empty string to disable rebasing and emit paths relative to `--root`"
|
|
306
306
|
},
|
|
307
307
|
{
|
|
308
308
|
"name": "--fail-on-regression",
|
|
@@ -815,7 +815,7 @@
|
|
|
815
815
|
"name": "--unused-dependency-overrides",
|
|
816
816
|
"type": "bool",
|
|
817
817
|
"required": false,
|
|
818
|
-
"description": "Only report unused
|
|
818
|
+
"description": "Only report unused package-manager dependency overrides",
|
|
819
819
|
"possible_values": [
|
|
820
820
|
"true",
|
|
821
821
|
"false"
|
|
@@ -825,7 +825,7 @@
|
|
|
825
825
|
"name": "--misconfigured-dependency-overrides",
|
|
826
826
|
"type": "bool",
|
|
827
827
|
"required": false,
|
|
828
|
-
"description": "Only report misconfigured
|
|
828
|
+
"description": "Only report misconfigured package-manager dependency overrides",
|
|
829
829
|
"possible_values": [
|
|
830
830
|
"true",
|
|
831
831
|
"false"
|
|
@@ -1659,13 +1659,13 @@
|
|
|
1659
1659
|
"name": "--coverage",
|
|
1660
1660
|
"type": "string",
|
|
1661
1661
|
"required": false,
|
|
1662
|
-
"description": "Path to Istanbul-format coverage data (coverage-final.json) for accurate per-function CRAP scores in the health sub-analysis. Also configurable via FALLOW_COVERAGE"
|
|
1662
|
+
"description": "Path to Istanbul-format coverage data (coverage-final.json) for accurate per-function CRAP scores in the health sub-analysis. Also configurable via FALLOW_COVERAGE or health.coverage"
|
|
1663
1663
|
},
|
|
1664
1664
|
{
|
|
1665
1665
|
"name": "--coverage-root",
|
|
1666
1666
|
"type": "string",
|
|
1667
1667
|
"required": false,
|
|
1668
|
-
"description": "Absolute prefix to strip from coverage data paths before CRAP matching. Use when coverage was generated under a different checkout root in CI or Docker"
|
|
1668
|
+
"description": "Absolute prefix to strip from coverage data paths before CRAP matching. Use when coverage was generated under a different checkout root in CI or Docker. Also configurable via FALLOW_COVERAGE_ROOT or health.coverageRoot"
|
|
1669
1669
|
},
|
|
1670
1670
|
{
|
|
1671
1671
|
"name": "--no-css",
|
|
@@ -1897,7 +1897,7 @@
|
|
|
1897
1897
|
},
|
|
1898
1898
|
{
|
|
1899
1899
|
"name": "report",
|
|
1900
|
-
"description": "Render a saved `--format json` results file in another format without re-running analysis (analyze once, render
|
|
1900
|
+
"description": "Render a saved `--format json` results file in another format without re-running analysis (analyze once, then render every CI surface from the same file). Supports GitHub annotations/summary, CodeClimate, SARIF, and GitHub/GitLab PR-comment and review formats",
|
|
1901
1901
|
"flags": [
|
|
1902
1902
|
{
|
|
1903
1903
|
"name": "--from",
|
|
@@ -3094,7 +3094,7 @@
|
|
|
3094
3094
|
"rule_id": "fallow/unused-dependency-override",
|
|
3095
3095
|
"command": "dead-code",
|
|
3096
3096
|
"category": "Dependencies",
|
|
3097
|
-
"description": "
|
|
3097
|
+
"description": "Package-manager override target is not declared or resolved",
|
|
3098
3098
|
"label": "Unused Dependency Overrides",
|
|
3099
3099
|
"config_key": "unused-dependency-overrides",
|
|
3100
3100
|
"registry_index": 42,
|
|
@@ -3135,7 +3135,7 @@
|
|
|
3135
3135
|
"rule_id": "fallow/misconfigured-dependency-override",
|
|
3136
3136
|
"command": "dead-code",
|
|
3137
3137
|
"category": "Dependencies",
|
|
3138
|
-
"description": "
|
|
3138
|
+
"description": "Package-manager override has an unparsable key or value",
|
|
3139
3139
|
"label": "Misconfigured Dependency Overrides",
|
|
3140
3140
|
"config_key": "misconfigured-dependency-overrides",
|
|
3141
3141
|
"registry_index": 43,
|
|
@@ -5923,18 +5923,32 @@
|
|
|
5923
5923
|
"sarif",
|
|
5924
5924
|
"compact",
|
|
5925
5925
|
"markdown",
|
|
5926
|
+
"md",
|
|
5926
5927
|
"codeclimate",
|
|
5927
5928
|
"gitlab-codequality",
|
|
5929
|
+
"gitlab-code-quality",
|
|
5928
5930
|
"pr-comment-github",
|
|
5929
5931
|
"pr-comment-gitlab",
|
|
5930
5932
|
"review-github",
|
|
5931
5933
|
"review-gitlab",
|
|
5932
|
-
"badge"
|
|
5934
|
+
"badge",
|
|
5935
|
+
"github-annotations",
|
|
5936
|
+
"github-summary"
|
|
5933
5937
|
],
|
|
5934
5938
|
"exit_codes": {
|
|
5935
5939
|
"0": "Success (no error-severity issues found)",
|
|
5936
|
-
"1": "Error-severity issues found (per rules config, or --fail-on-issues promotes warn
|
|
5937
|
-
"2": "Error (invalid config, invalid input, etc.). When --format json is active, errors are emitted as structured JSON on stdout: {\"error\": true, \"message\": \"...\", \"exit_code\": 2}"
|
|
5940
|
+
"1": "Error-severity issues found (per rules config, or --fail-on-issues promotes warn to error)",
|
|
5941
|
+
"2": "Error (invalid config, invalid input, etc.). When --format json is active, errors are emitted as structured JSON on stdout: {\"error\": true, \"message\": \"...\", \"exit_code\": 2}",
|
|
5942
|
+
"3": "Requested resource unavailable: config --path found no config, or a license is missing, invalid, or beyond its offline hard-fail window",
|
|
5943
|
+
"4": "Runtime coverage sidecar is unavailable, unverifiable, protocol-incompatible, or terminated unexpectedly",
|
|
5944
|
+
"5": "Runtime coverage input could not be prepared or parsed",
|
|
5945
|
+
"6": "Runtime coverage sidecar reported an internal error",
|
|
5946
|
+
"7": "Network or cloud request failed during a license, coverage, or CI operation",
|
|
5947
|
+
"8": "Security gate matched a candidate selected by --gate",
|
|
5948
|
+
"10": "Coverage inventory or static-findings upload input or project validation failed",
|
|
5949
|
+
"11": "Coverage inventory or static-findings upload exceeded the server payload limit",
|
|
5950
|
+
"12": "Coverage inventory or static-findings upload authentication or authorization was rejected",
|
|
5951
|
+
"13": "Coverage inventory or static-findings upload failed after retries or returned another server error"
|
|
5938
5952
|
},
|
|
5939
5953
|
"environment_variables": {
|
|
5940
5954
|
"FALLOW_FORMAT": "Default output format (json/human/sarif/compact/markdown/codeclimate/gitlab-codequality/pr-comment-github/pr-comment-gitlab/review-github/review-gitlab/badge/github-annotations/github-summary). CLI --format flag overrides this.",
|
|
@@ -5949,14 +5963,14 @@
|
|
|
5949
5963
|
"FALLOW_PR_COMMENT_LAYOUT": "Sticky PR comment layout: default, compact, gate-only, or details.",
|
|
5950
5964
|
"FALLOW_CONSOLIDATED_STATUS": "When split PR gate check runs are enabled, truthy values add one aggregate Fallow check alongside the per-gate checks.",
|
|
5951
5965
|
"FALLOW_DIFF_CONTEXT": "Line radius around changed diff lines when scoping findings to a diff in the review/PR-comment formats (default 3).",
|
|
5952
|
-
"FALLOW_BOT_LOGIN": "
|
|
5966
|
+
"FALLOW_BOT_LOGIN": "Allowlisted posting username used to recognize fallow-owned finding roots and resolution replies. When set, it narrows ownership to that exact username (an empty value matches nobody); when unset, provider-native bot metadata is trusted. This is an authorship control, not a lifecycle or deduplication token.",
|
|
5953
5967
|
"FALLOW_API_RETRIES": "Maximum HTTP attempts for review-comment reconciliation API calls (default 3).",
|
|
5954
5968
|
"FALLOW_API_RETRY_DELAY": "Floor delay in seconds between HTTP retry attempts (default 2); a server-supplied Retry-After overrides it on 429 responses.",
|
|
5955
5969
|
"FALLOW_CACHE_DIR": "Directory for fallow's persistent analysis cache. Relative paths resolve from the project root and override cache.dir.",
|
|
5956
5970
|
"FALLOW_CACHE_MAX_SIZE": "Extraction cache size cap in megabytes (default 256). Wins over the cache.maxSizeMb config field.",
|
|
5957
5971
|
"FALLOW_EXTENDS_TIMEOUT_SECS": "Timeout in seconds for fetching https:// configs referenced via the extends field (default 5).",
|
|
5958
|
-
"FALLOW_COVERAGE": "Path to Istanbul coverage data (coverage-final.json) for accurate per-function CRAP scores. CLI --coverage flag overrides this.",
|
|
5959
|
-
"FALLOW_COVERAGE_ROOT": "Absolute coverage-data path prefix for rebasing Istanbul paths in CI or containers. CLI --coverage-root flag overrides this.",
|
|
5972
|
+
"FALLOW_COVERAGE": "Path to Istanbul coverage data (coverage-final.json) for accurate per-function CRAP scores. CLI --coverage flag overrides this; it wins over the health.coverage config field. Honored by the health, bare fallow, and audit CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage parameter overrides it.",
|
|
5973
|
+
"FALLOW_COVERAGE_ROOT": "Absolute coverage-data path prefix for rebasing Istanbul paths in CI or containers. CLI --coverage-root flag overrides this; it wins over the health.coverageRoot config field. Honored by the health, bare fallow, and audit CLI commands and by the MCP audit and check_health tools on both their typed route and their CLI fallback, where the explicit coverage_root parameter overrides it.",
|
|
5960
5974
|
"FALLOW_MAX_FILE_SIZE": "Per-file size ceiling in megabytes for source discovery (default 5; 0 = no limit). CLI --max-file-size flag overrides this.",
|
|
5961
5975
|
"FALLOW_TYPE_AWARE": "Enable or disable TypeScript semantic (type-aware) analysis for the run (true/false/1/0/yes/no/on/off). Precedence: --type-aware/--no-type-aware CLI flags, then FALLOW_TYPE_AWARE, then the audit.typeAware config field, then typeAware.enabled.",
|
|
5962
5976
|
"FALLOW_TYPE_AWARE_BIN": "Trusted executable override for the TypeScript semantic refinement sidecar used by dead-code --type-aware. Relative paths resolve from the caller's working directory before --root is applied; project node_modules and PATH are intentionally not searched. Default: sibling of the active Fallow executable.",
|
|
@@ -6837,7 +6851,7 @@
|
|
|
6837
6851
|
{
|
|
6838
6852
|
"name": "trace_symbol",
|
|
6839
6853
|
"kind": "trace",
|
|
6840
|
-
"description": "Trace an exact TypeScript symbol with checker-backed references, namespace identity, aliases, and re-export hops. Root trace fields preserve syntactic context; treat semantic.references, semantic.status, and semantic.identity as the authoritative exact evidence. This is project-wide evidence for Fallow decisions, not a compiler-diagnostic or lint-rule surface.",
|
|
6854
|
+
"description": "Trace an exact TypeScript symbol with checker-backed references, namespace identity, aliases, and re-export hops. Root trace fields preserve syntactic context; treat semantic.references, semantic.status, and semantic.identity as the authoritative exact evidence. The proof covers only the lane named by semantic.target.namespace, so a root trace that lists a reference the proof does not is wider evidence rather than stale. This is project-wide evidence for Fallow decisions, not a compiler-diagnostic or lint-rule surface.",
|
|
6841
6855
|
"cli_command": "fallow dead-code --type-aware --trace <file:export> --format json --quiet",
|
|
6842
6856
|
"key_params": [
|
|
6843
6857
|
"file",
|
package/issue-registry.json
CHANGED
|
@@ -1558,7 +1558,7 @@
|
|
|
1558
1558
|
"rule_id": "fallow/unused-dependency-override",
|
|
1559
1559
|
"command": "dead-code",
|
|
1560
1560
|
"category": "Dependencies",
|
|
1561
|
-
"description": "
|
|
1561
|
+
"description": "Package-manager override target is not declared or resolved",
|
|
1562
1562
|
"label": "Unused Dependency Overrides",
|
|
1563
1563
|
"config_key": "unused-dependency-overrides",
|
|
1564
1564
|
"registry_index": 42,
|
|
@@ -1599,7 +1599,7 @@
|
|
|
1599
1599
|
"rule_id": "fallow/misconfigured-dependency-override",
|
|
1600
1600
|
"command": "dead-code",
|
|
1601
1601
|
"category": "Dependencies",
|
|
1602
|
-
"description": "
|
|
1602
|
+
"description": "Package-manager override has an unparsable key or value",
|
|
1603
1603
|
"label": "Misconfigured Dependency Overrides",
|
|
1604
1604
|
"config_key": "misconfigured-dependency-overrides",
|
|
1605
1605
|
"registry_index": 43,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fallow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"description": "Codebase intelligence for TypeScript and JavaScript. Free static analysis of code and styles, optional paid runtime intelligence (Fallow Runtime). Quality, risk, architecture, dependencies, duplication, and design-system drift for humans, CI, and the agents writing your code. Zero-config framework support.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -87,14 +87,14 @@
|
|
|
87
87
|
"@tanstack/intent": "0.3.6"
|
|
88
88
|
},
|
|
89
89
|
"optionalDependencies": {
|
|
90
|
-
"@fallow-cli/darwin-arm64": "3.
|
|
91
|
-
"@fallow-cli/darwin-x64": "3.
|
|
92
|
-
"@fallow-cli/linux-x64-gnu": "3.
|
|
93
|
-
"@fallow-cli/linux-arm64-gnu": "3.
|
|
94
|
-
"@fallow-cli/linux-x64-musl": "3.
|
|
95
|
-
"@fallow-cli/linux-arm64-musl": "3.
|
|
96
|
-
"@fallow-cli/win32-arm64-msvc": "3.
|
|
97
|
-
"@fallow-cli/win32-x64-msvc": "3.
|
|
98
|
-
"fallow-type-aware": "3.
|
|
90
|
+
"@fallow-cli/darwin-arm64": "3.18.0",
|
|
91
|
+
"@fallow-cli/darwin-x64": "3.18.0",
|
|
92
|
+
"@fallow-cli/linux-x64-gnu": "3.18.0",
|
|
93
|
+
"@fallow-cli/linux-arm64-gnu": "3.18.0",
|
|
94
|
+
"@fallow-cli/linux-x64-musl": "3.18.0",
|
|
95
|
+
"@fallow-cli/linux-arm64-musl": "3.18.0",
|
|
96
|
+
"@fallow-cli/win32-arm64-msvc": "3.18.0",
|
|
97
|
+
"@fallow-cli/win32-x64-msvc": "3.18.0",
|
|
98
|
+
"fallow-type-aware": "3.18.0"
|
|
99
99
|
}
|
|
100
100
|
}
|
package/schema.json
CHANGED
|
@@ -1028,7 +1028,7 @@
|
|
|
1028
1028
|
"default": 60
|
|
1029
1029
|
},
|
|
1030
1030
|
"coverage": {
|
|
1031
|
-
"description": "Path to Istanbul-format coverage data for accurate per-function CRAP\nscores. Relative paths resolve against the project root. The CLI\n`--coverage` flag and `FALLOW_COVERAGE` environment variable override\nthis value.",
|
|
1031
|
+
"description": "Path to Istanbul-format coverage data for accurate per-function CRAP\nscores. Relative paths resolve against the project root. The CLI\n`--coverage` flag and `FALLOW_COVERAGE` environment variable override\nthis value. Consulted by `fallow health`, bare `fallow`, `fallow audit`,\nand the MCP `audit` / `check_health` tools.",
|
|
1032
1032
|
"type": [
|
|
1033
1033
|
"string",
|
|
1034
1034
|
"null"
|
|
@@ -1036,7 +1036,7 @@
|
|
|
1036
1036
|
"default": null
|
|
1037
1037
|
},
|
|
1038
1038
|
"coverageRoot": {
|
|
1039
|
-
"description": "Absolute prefix to strip from Istanbul file paths before CRAP matching.\nUse when coverage was generated under a different checkout root in CI\nor Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`\nenvironment variable override this value.",
|
|
1039
|
+
"description": "Absolute prefix to strip from Istanbul file paths before CRAP matching.\nUse when coverage was generated under a different checkout root in CI\nor Docker. The CLI `--coverage-root` flag and `FALLOW_COVERAGE_ROOT`\nenvironment variable override this value. Consulted by `fallow health`,\nbare `fallow`, `fallow audit`, and the MCP `audit` / `check_health`\ntools.",
|
|
1040
1040
|
"type": [
|
|
1041
1041
|
"string",
|
|
1042
1042
|
"null"
|
|
@@ -1458,12 +1458,12 @@
|
|
|
1458
1458
|
"default": "error"
|
|
1459
1459
|
},
|
|
1460
1460
|
"unused-dependency-overrides": {
|
|
1461
|
-
"description": "A pnpm
|
|
1461
|
+
"description": "A pnpm, npm, or Bun override entry whose target package no workspace\n`package.json` declares and the active readable lockfile does not\nresolve. Defaults to `warn`.",
|
|
1462
1462
|
"$ref": "#/$defs/Severity",
|
|
1463
1463
|
"default": "warn"
|
|
1464
1464
|
},
|
|
1465
1465
|
"misconfigured-dependency-overrides": {
|
|
1466
|
-
"description": "A pnpm
|
|
1466
|
+
"description": "A pnpm, npm, or Bun override or Bun `resolutions` entry whose key or\nvalue cannot be parsed in its declaration source's grammar. Defaults to\n`error`.",
|
|
1467
1467
|
"$ref": "#/$defs/Severity",
|
|
1468
1468
|
"default": "error"
|
|
1469
1469
|
},
|
package/scripts/lazy-verify.js
CHANGED
|
@@ -235,6 +235,9 @@ function isSkipRequested(env) {
|
|
|
235
235
|
// production install path always has fallowDigests)
|
|
236
236
|
// env - process.env (defaults to process.env)
|
|
237
237
|
// platform - process.platform (defaults to process.platform)
|
|
238
|
+
// homedir - os.homedir() override (tests only)
|
|
239
|
+
// isWritable - sentinel directory probe override (tests only)
|
|
240
|
+
// ensureDir - sentinel directory creation override (tests only)
|
|
238
241
|
// logger - function (line: string) -> void (defaults to stderr)
|
|
239
242
|
//
|
|
240
243
|
// Returns one of:
|
|
@@ -253,6 +256,16 @@ function buildVerifyOptions(input, manifest) {
|
|
|
253
256
|
return opts;
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
function buildSentinelOptions(input, platformPkgDir, packageName, env, platform) {
|
|
260
|
+
const opts = { platformPkgDir, packageName, env, platform };
|
|
261
|
+
for (const key of ["homedir", "isWritable", "ensureDir"]) {
|
|
262
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
263
|
+
opts[key] = input[key];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return opts;
|
|
267
|
+
}
|
|
268
|
+
|
|
256
269
|
// Persist the sentinel on a successful verify. Logs (warn-once) when the
|
|
257
270
|
// resolved cache location is read-only or every cascade step failed.
|
|
258
271
|
function persistSentinel(sentinel, platformPkgDir, manifest, platform) {
|
|
@@ -315,7 +328,9 @@ function ensureVerified(input) {
|
|
|
315
328
|
};
|
|
316
329
|
}
|
|
317
330
|
|
|
318
|
-
const sentinel = resolveSentinelPath(
|
|
331
|
+
const sentinel = resolveSentinelPath(
|
|
332
|
+
buildSentinelOptions(input || {}, platformPkgDir, packageName, env, platform),
|
|
333
|
+
);
|
|
319
334
|
|
|
320
335
|
// Cache hit: sentinel exists, schema matches, mtimes match, version matches.
|
|
321
336
|
if (sentinel.path && isSentinelValid(sentinel.path, platformPkgDir, manifest, platform)) {
|
|
@@ -16,6 +16,9 @@ const { _verifyWithKey, SKIP_ENV } = require("./verify-binary");
|
|
|
16
16
|
|
|
17
17
|
// ---- shared fixtures ------------------------------------------------------
|
|
18
18
|
|
|
19
|
+
const DEFAULT_PLATFORM = "linux";
|
|
20
|
+
const DEFAULT_PACKAGE_NAME = "@fallow-cli/test-platform";
|
|
21
|
+
|
|
19
22
|
function makeKeypair() {
|
|
20
23
|
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
21
24
|
const spki = publicKey.export({ format: "der", type: "spki" });
|
|
@@ -23,18 +26,18 @@ function makeKeypair() {
|
|
|
23
26
|
return { privateKey, rawPub };
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
29
|
+
function packageNameForPlatform(platform) {
|
|
30
|
+
return platform === "win32" ? "@fallow-cli/win32-x64-msvc" : DEFAULT_PACKAGE_NAME;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
function binaryNames() {
|
|
33
|
+
function binaryNames(platform) {
|
|
31
34
|
// Platform packages ship a single multicall `fallow` binary.
|
|
32
|
-
return [
|
|
35
|
+
return [platform === "win32" ? "fallow.exe" : "fallow"];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
function computeDigestsForDir(dir) {
|
|
38
|
+
function computeDigestsForDir(dir, platform) {
|
|
36
39
|
const out = {};
|
|
37
|
-
for (const base of binaryNames()) {
|
|
40
|
+
for (const base of binaryNames(platform)) {
|
|
38
41
|
const full = path.join(dir, base);
|
|
39
42
|
out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
|
|
40
43
|
}
|
|
@@ -43,8 +46,9 @@ function computeDigestsForDir(dir) {
|
|
|
43
46
|
|
|
44
47
|
function mkPlatformDir(privateKey, options) {
|
|
45
48
|
const opts = options || {};
|
|
49
|
+
const platform = opts.platform || DEFAULT_PLATFORM;
|
|
46
50
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
|
|
47
|
-
for (const base of binaryNames()) {
|
|
51
|
+
for (const base of binaryNames(platform)) {
|
|
48
52
|
const binaryPath = path.join(dir, base);
|
|
49
53
|
const content = Buffer.from(`mock ${base}`);
|
|
50
54
|
fs.writeFileSync(binaryPath, content);
|
|
@@ -56,9 +60,9 @@ function mkPlatformDir(privateKey, options) {
|
|
|
56
60
|
fs.writeFileSync(
|
|
57
61
|
path.join(dir, "package.json"),
|
|
58
62
|
JSON.stringify({
|
|
59
|
-
name: opts.packageName ||
|
|
63
|
+
name: opts.packageName || packageNameForPlatform(platform),
|
|
60
64
|
version: opts.version || "2.81.0",
|
|
61
|
-
fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir),
|
|
65
|
+
fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir, platform),
|
|
62
66
|
}),
|
|
63
67
|
);
|
|
64
68
|
return dir;
|
|
@@ -88,13 +92,15 @@ function setupCacheRoot(t) {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
function baseInput(dir, verifyFn, extras) {
|
|
95
|
+
const manifestPath = path.join(dir, "package.json");
|
|
96
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
91
97
|
return {
|
|
92
98
|
platformPkgDir: dir,
|
|
93
|
-
packageName:
|
|
94
|
-
manifestPath
|
|
99
|
+
packageName: manifest.name,
|
|
100
|
+
manifestPath,
|
|
95
101
|
verifyFn,
|
|
96
102
|
env: {},
|
|
97
|
-
platform:
|
|
103
|
+
platform: DEFAULT_PLATFORM,
|
|
98
104
|
...extras,
|
|
99
105
|
};
|
|
100
106
|
}
|
|
@@ -120,6 +126,29 @@ test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
|
|
|
120
126
|
assert.equal(Object.keys(sentinel.binaries).length, 1);
|
|
121
127
|
});
|
|
122
128
|
|
|
129
|
+
test("ensureVerified verifies and caches a win32 executable on any host", (t) => {
|
|
130
|
+
_resetWarningState();
|
|
131
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
132
|
+
const dir = mkPlatformDir(privateKey, { platform: "win32" });
|
|
133
|
+
t.after(() => cleanup(dir));
|
|
134
|
+
const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
|
|
135
|
+
platform: "win32",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const verified = ensureVerified(input);
|
|
139
|
+
assert.equal(verified.ok, true);
|
|
140
|
+
assert.equal(verified.cached, false);
|
|
141
|
+
const sentinel = JSON.parse(fs.readFileSync(verified.sentinelPath, "utf8"));
|
|
142
|
+
assert.deepEqual(Object.keys(sentinel.binaries), ["fallow.exe"]);
|
|
143
|
+
|
|
144
|
+
const cached = ensureVerified({
|
|
145
|
+
...input,
|
|
146
|
+
verifyFn: () => assert.fail("signature verification must not run on a cache hit"),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(cached.ok, true);
|
|
149
|
+
assert.equal(cached.cached, true);
|
|
150
|
+
});
|
|
151
|
+
|
|
123
152
|
test("ensureVerified returns cached:true on a valid sentinel", (t) => {
|
|
124
153
|
_resetWarningState();
|
|
125
154
|
const { privateKey, rawPub } = makeKeypair();
|
|
@@ -154,7 +183,7 @@ test("ensureVerified invalidates sentinel on mtime drift", (t) => {
|
|
|
154
183
|
|
|
155
184
|
// Bump the mtime of one binary; sentinel should now be stale.
|
|
156
185
|
const newTime = new Date(Date.now() + 10_000);
|
|
157
|
-
fs.utimesSync(path.join(dir,
|
|
186
|
+
fs.utimesSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]), newTime, newTime);
|
|
158
187
|
|
|
159
188
|
let verifyCallCount = 0;
|
|
160
189
|
const result = ensureVerified(
|
|
@@ -215,7 +244,9 @@ test("ensureVerified invalidates sentinel on packageName drift", (t) => {
|
|
|
215
244
|
packageVersion: "2.81.0",
|
|
216
245
|
packageName: "@fallow-cli/wrong-name",
|
|
217
246
|
binaries: {
|
|
218
|
-
[
|
|
247
|
+
[binaryNames(DEFAULT_PLATFORM)[0]]: {
|
|
248
|
+
mtimeMs: fs.statSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0])).mtimeMs,
|
|
249
|
+
},
|
|
219
250
|
},
|
|
220
251
|
}),
|
|
221
252
|
);
|
|
@@ -261,7 +292,7 @@ test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
|
|
|
261
292
|
test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
|
|
262
293
|
_resetWarningState();
|
|
263
294
|
const { privateKey, rawPub } = makeKeypair();
|
|
264
|
-
const dir = mkPlatformDir(privateKey, { corruptSigFor:
|
|
295
|
+
const dir = mkPlatformDir(privateKey, { corruptSigFor: binaryNames(DEFAULT_PLATFORM)[0] });
|
|
265
296
|
t.after(() => cleanup(dir));
|
|
266
297
|
|
|
267
298
|
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
@@ -289,89 +320,43 @@ test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) =>
|
|
|
289
320
|
|
|
290
321
|
test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
|
|
291
322
|
_resetWarningState();
|
|
292
|
-
if (process.platform === "win32") {
|
|
293
|
-
t.skip("Windows ACL chmod is not portable; covered by sentinel-path tests");
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
323
|
const { privateKey, rawPub } = makeKeypair();
|
|
297
324
|
const dir = mkPlatformDir(privateKey);
|
|
298
325
|
const cacheRoot = setupCacheRoot(t);
|
|
326
|
+
t.after(() => cleanup(dir));
|
|
299
327
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
|
|
309
|
-
} finally {
|
|
310
|
-
fs.chmodSync(dir, 0o755);
|
|
311
|
-
cleanup(dir);
|
|
312
|
-
}
|
|
328
|
+
const result = ensureVerified({
|
|
329
|
+
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
330
|
+
env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
|
|
331
|
+
isWritable: (candidate) => candidate !== dir,
|
|
332
|
+
});
|
|
333
|
+
assert.equal(result.ok, true);
|
|
334
|
+
assert.equal(result.cached, false);
|
|
335
|
+
assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
|
|
313
336
|
});
|
|
314
337
|
|
|
315
|
-
test("ensureVerified emits a single warning when sentinel
|
|
338
|
+
test("ensureVerified emits a single warning when no sentinel location is writable", (t) => {
|
|
316
339
|
_resetWarningState();
|
|
317
|
-
if (process.platform === "win32") {
|
|
318
|
-
t.skip("Windows ACL chmod is not portable");
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
340
|
const { privateKey, rawPub } = makeKeypair();
|
|
322
341
|
const dir = mkPlatformDir(privateKey);
|
|
323
342
|
const stderr = captureStderr(t);
|
|
343
|
+
t.after(() => cleanup(dir));
|
|
344
|
+
const input = {
|
|
345
|
+
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
346
|
+
homedir: undefined,
|
|
347
|
+
isWritable: () => false,
|
|
348
|
+
};
|
|
324
349
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// runs writeSentinel. Simpler: instead of fighting the cascade in env,
|
|
330
|
-
// simulate write failure via a non-writable FALLOW_VERIFY_CACHE_DIR that
|
|
331
|
-
// PASSES isWritable() but then has its perms revoked between resolve and
|
|
332
|
-
// write. Since that race is hard to script, we test the warn-once helper
|
|
333
|
-
// via a synthetic path that fails the rename atomically: pass a sentinel-
|
|
334
|
-
// chmod-locked dir.
|
|
335
|
-
//
|
|
336
|
-
// The portable shape: chmod the platform pkg dir read-only AND pass a
|
|
337
|
-
// FALLOW_VERIFY_CACHE_DIR that is a regular file (not a dir). isWritable
|
|
338
|
-
// returns false for both, so resolveSentinelPath falls through to XDG. If
|
|
339
|
-
// the user has no HOME (synthetic env), the cascade returns null. We can
|
|
340
|
-
// achieve this only by injecting both env.HOME='' AND env.XDG_CACHE_HOME=''
|
|
341
|
-
// AND ensuring os.homedir() is not consulted; ensureVerified does not
|
|
342
|
-
// accept a homedir override, so the warn-once path is unreachable via env
|
|
343
|
-
// alone on a machine with a real HOME. Skip this test on machines with a
|
|
344
|
-
// real homedir; the warn-once helper is otherwise covered by the
|
|
345
|
-
// "no-writable-location" branch in sentinel-path tests.
|
|
346
|
-
if (os.homedir() && os.homedir().length > 0) {
|
|
347
|
-
t.skip(
|
|
348
|
-
"warn-once-on-no-writable-cache requires homedir-override knob (covered by sentinel-path unit test)",
|
|
349
|
-
);
|
|
350
|
-
cleanup(dir);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
350
|
+
const result = ensureVerified(input);
|
|
351
|
+
assert.equal(result.sentinelPath, null);
|
|
352
|
+
const warnings = stderr.lines.filter((line) => line.includes("no writable cache location"));
|
|
353
|
+
assert.equal(warnings.length, 1);
|
|
353
354
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
env,
|
|
360
|
-
});
|
|
361
|
-
if (result.sentinelPath !== null) {
|
|
362
|
-
t.diagnostic(`sentinel landed at ${result.sentinelPath} despite empty HOME; skipping`);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const warnings = stderr.lines.filter((l) => l.includes("no writable cache location"));
|
|
366
|
-
assert.equal(warnings.length, 1);
|
|
367
|
-
// Second call: no new warning.
|
|
368
|
-
ensureVerified({ ...baseInput(dir, (p) => _verifyWithKey(p, rawPub)), env });
|
|
369
|
-
const warnings2 = stderr.lines.filter((l) => l.includes("no writable cache location"));
|
|
370
|
-
assert.equal(warnings2.length, 1);
|
|
371
|
-
} finally {
|
|
372
|
-
fs.chmodSync(dir, 0o755);
|
|
373
|
-
cleanup(dir);
|
|
374
|
-
}
|
|
355
|
+
ensureVerified(input);
|
|
356
|
+
const repeatedWarnings = stderr.lines.filter((line) =>
|
|
357
|
+
line.includes("no writable cache location"),
|
|
358
|
+
);
|
|
359
|
+
assert.equal(repeatedWarnings.length, 1);
|
|
375
360
|
});
|
|
376
361
|
|
|
377
362
|
// ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
|
|
@@ -457,10 +442,6 @@ test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
|
|
|
457
442
|
|
|
458
443
|
test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
|
|
459
444
|
_resetWarningState();
|
|
460
|
-
if (process.platform === "win32") {
|
|
461
|
-
t.skip("chmod-based read-only platform pkg dir is not portable on Windows");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
445
|
// Two installs of the same package + version. install A is clean and writes
|
|
465
446
|
// a sentinel to a shared cache; install B has a tampered binary at the same
|
|
466
447
|
// package name + version. B must NOT trust A's sentinel via cache hit even
|
|
@@ -471,17 +452,14 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
471
452
|
// Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
|
|
472
453
|
// for the original bytes (mkPlatformDir does not expose a corrupt-binary
|
|
473
454
|
// option, so simulate the attack by overwriting bytes here).
|
|
474
|
-
fs.writeFileSync(
|
|
455
|
+
fs.writeFileSync(
|
|
456
|
+
path.join(installB, binaryNames(DEFAULT_PLATFORM)[0]),
|
|
457
|
+
Buffer.from("tampered bytes"),
|
|
458
|
+
);
|
|
475
459
|
const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
|
|
460
|
+
const isWritable = (candidate) => candidate !== installA && candidate !== installB;
|
|
476
461
|
|
|
477
|
-
// Force the shared-cache cascade by making both platform pkg dirs
|
|
478
|
-
// non-writable (simulates yarn PnP / Docker layered images / pnpm
|
|
479
|
-
// verify-store invariants).
|
|
480
|
-
fs.chmodSync(installA, 0o555);
|
|
481
|
-
fs.chmodSync(installB, 0o555);
|
|
482
462
|
t.after(() => {
|
|
483
|
-
fs.chmodSync(installA, 0o755);
|
|
484
|
-
fs.chmodSync(installB, 0o755);
|
|
485
463
|
cleanup(installA);
|
|
486
464
|
cleanup(installB);
|
|
487
465
|
cleanup(sharedCache);
|
|
@@ -492,6 +470,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
492
470
|
const resultA = ensureVerified({
|
|
493
471
|
...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
|
|
494
472
|
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
473
|
+
isWritable,
|
|
495
474
|
});
|
|
496
475
|
assert.equal(resultA.ok, true);
|
|
497
476
|
assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
|
|
@@ -502,7 +481,6 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
502
481
|
// + SHA-256 binding must prevent that.
|
|
503
482
|
for (const name of binaryNames()) {
|
|
504
483
|
const aStat = fs.statSync(path.join(installA, name));
|
|
505
|
-
fs.chmodSync(path.join(installB, name), 0o644);
|
|
506
484
|
fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
|
|
507
485
|
}
|
|
508
486
|
|
|
@@ -513,6 +491,7 @@ test("ensureVerified rejects a sentinel written for a different install dir", (t
|
|
|
513
491
|
return _verifyWithKey(p, rawPub);
|
|
514
492
|
}),
|
|
515
493
|
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
494
|
+
isWritable,
|
|
516
495
|
});
|
|
517
496
|
assert.equal(resultB.ok, false);
|
|
518
497
|
assert.equal(resultB.code, "sig-invalid");
|
|
@@ -530,7 +509,7 @@ test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t)
|
|
|
530
509
|
|
|
531
510
|
// Tamper the binary in place AND restore the prior mtime, so the mtime
|
|
532
511
|
// pre-filter matches but the bytes do not.
|
|
533
|
-
const binPath = path.join(dir,
|
|
512
|
+
const binPath = path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]);
|
|
534
513
|
const before = fs.statSync(binPath);
|
|
535
514
|
fs.writeFileSync(binPath, Buffer.from("tampered"));
|
|
536
515
|
fs.utimesSync(binPath, before.atime, before.mtime);
|
package/scripts/run-binary.js
CHANGED
|
@@ -145,18 +145,38 @@ function resolveTypeAwareCompanion(
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function
|
|
148
|
+
function typeAwareCommand(
|
|
149
|
+
companion,
|
|
150
|
+
{ platform = process.platform, execPath = process.execPath } = {},
|
|
151
|
+
) {
|
|
152
|
+
return platform === "win32"
|
|
153
|
+
? { binary: execPath, script: companion }
|
|
154
|
+
: { binary: companion, script: undefined };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function childEnvironment(
|
|
158
|
+
resolvedVersion,
|
|
159
|
+
resolveCompanion = resolveTypeAwareCompanion,
|
|
160
|
+
commandOptions,
|
|
161
|
+
) {
|
|
149
162
|
if (process.env.FALLOW_TYPE_AWARE_BIN) return process.env;
|
|
150
163
|
const companion = resolveCompanion(resolvedVersion);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
if (companion === undefined) return process.env;
|
|
165
|
+
|
|
166
|
+
const command = typeAwareCommand(companion, commandOptions);
|
|
167
|
+
const environment = {
|
|
168
|
+
...process.env,
|
|
169
|
+
FALLOW_TYPE_AWARE_BIN: command.binary,
|
|
170
|
+
// Marks the wiring as launcher-provided node_modules resolution so
|
|
171
|
+
// `type-aware status` does not report it as a user-set override.
|
|
172
|
+
FALLOW_TYPE_AWARE_BIN_SOURCE: "npm-wrapper",
|
|
173
|
+
};
|
|
174
|
+
if (command.script) {
|
|
175
|
+
environment.FALLOW_TYPE_AWARE_SCRIPT = command.script;
|
|
176
|
+
} else {
|
|
177
|
+
delete environment.FALLOW_TYPE_AWARE_SCRIPT;
|
|
178
|
+
}
|
|
179
|
+
return environment;
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
// Swallow EPIPE on stdout. When fallow's output is piped into a reader that
|