freeharness 0.4.1 → 0.4.3
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/NOTICE +2 -1
- package/README.md +24 -19
- package/canonical/HARVEST.yaml +27 -16
- package/canonical/PROVENANCE.yaml +3 -3
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +14 -9
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +4155 -93
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1116 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +206 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +959 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +2 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +1 -1
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +10 -3
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3625 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +187 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +96 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +56 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4035 -327
- package/dist/adapters/harness/claude-code.d.ts +26 -3
- package/dist/adapters/harness/claude-code.js +214 -43
- package/dist/adapters/harness/opencode.js +17 -3
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +172 -33
- package/dist/adapters/memory/doctor.js +750 -139
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +15 -0
- package/dist/adapters/memory/paths.js +47 -0
- package/dist/adapters/memory/vendored.d.ts +52 -30
- package/dist/adapters/memory/vendored.js +161 -169
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +204 -75
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +199 -45
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +29 -0
- package/dist/cli/install-facts.js +68 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +252 -73
- package/dist/infra/fsutil.d.ts +30 -3
- package/dist/infra/fsutil.js +68 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +3 -3
- package/provenance/memory.PROVENANCE.yaml +472 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
- package/canonical/openspec/config.base.yaml +0 -89
- package/canonical/rules/safety.md +0 -41
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
// FreeHarness config guard (Claude Code PreToolUse hook, matcher Edit|Write|MultiEdit|Bash).
|
|
2
|
-
// Denies writes to
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
2
|
+
// Denies writes to live harness configuration, which runs code or loads instructions in every
|
|
3
|
+
// session, unless a fresh marker exists: <project>/.freeharness/allow-config-edit or
|
|
4
|
+
// ~/.claude/freeharness/allow-config-edit, valid --ttl minutes (240 by default) after its mtime
|
|
5
|
+
// or until an epoch-ms/ISO date in its body. The user opens the window with `touch`;
|
|
6
|
+
// `freeharness init`/`update` set and remove their own. The marker is itself protected, so the
|
|
7
|
+
// model cannot open the window.
|
|
8
|
+
// The live set is a set of locations (liveTarget), judged at the written and the real path: the
|
|
9
|
+
// user config dir's CLAUDE.md, settings, hooks and plugins, ~/.claude.json, any .mcp.json,
|
|
10
|
+
// .claude/settings.json or .claude/settings.local.json, the hooks dir of .claude, .freeharness,
|
|
11
|
+
// ~/.claude/freeharness or freeharness-plugin, a plugin's hooks/hooks.json, the dirs holding
|
|
12
|
+
// them, and the marker. A project CLAUDE.md, a settings.json or hook source elsewhere, or a copy
|
|
13
|
+
// in the temp root outside the home and project passes. Names fold like APFS (NFC, full case)
|
|
14
|
+
// and CLAUDE_CONFIG_DIR gets every ~/.claude rule.
|
|
15
|
+
// The guard is a second layer against the model's accidental writes; Claude Code's permission
|
|
16
|
+
// layer is the perimeter, so on doubt the guard allows. Bash is judged per simple command, and
|
|
17
|
+
// only a literal writer counts: an output redirect target, a tee, sed -i, perl -i, rm or touch
|
|
18
|
+
// operand, a cp or install destination, any mv operand, a protected literal inline python, node, perl or
|
|
19
|
+
// ruby code opens for write or hands to a module write, and the literal string of sh -c, run as
|
|
20
|
+
// a command of its own. Any other command, a word holding an expansion, a heredoc body and code
|
|
21
|
+
// fed on stdin allow. Paths are judged by location from the payload cwd moved by each literal cd,
|
|
22
|
+
// pushd or popd; after a cd the guard cannot follow, a relative path counts by its own segments.
|
|
23
|
+
// A shell function definition splits into commands of its own, so a literal write after it is
|
|
24
|
+
// judged; a line still unsplittable (an open quote, group, case pattern or heredoc, or a `'`
|
|
25
|
+
// inside a double-quoted `${...}`) allows unjudged, as does input past MAX_CHARS or
|
|
26
|
+
// MAX_COMMAND_BYTES. A payload that is no JSON object allows unjudged.
|
|
27
|
+
// Exit 0.
|
|
28
|
+
// The git guard denies history-destroying git commands, and git config that can add an alias or
|
|
29
|
+
// run code, whatever the marker says; the user runs an intended one with the `!` prompt prefix.
|
|
30
|
+
// Same-line values substitute into git slots before the check; interpreter code counts as git
|
|
31
|
+
// only with a spawn call and a git word; it is a second layer against mistakes, so an unknown
|
|
32
|
+
// command word, xargs input (an opaque operand, never pasted producer text) and a git judgment
|
|
33
|
+
// past its depth or size bound allow; a quoted git mention, push --force-with-lease,
|
|
34
|
+
// object-writing plumbing and a forced worktree remove under the temp root pass.
|
|
35
|
+
// A Bash judgment past MAX_JUDGE_MS or nested past MAX_DEPTH allows, as do an unreadable payload
|
|
36
|
+
// and any throw. Reasons escape invisible characters and name at most MAX_NAMED targets.
|
|
37
|
+
// Disable: FREEHARNESS_HOOK_CONFIG_GUARD=off (whole hook), FREEHARNESS_HOOK_GIT_GUARD=off (git).
|
|
13
38
|
import fs from "node:fs";
|
|
14
39
|
import os from "node:os";
|
|
15
40
|
import path from "node:path";
|
|
@@ -18,61 +43,4079 @@ import { fileURLToPath } from "node:url";
|
|
|
18
43
|
/** Marker file name (project: .freeharness/, user: ~/.claude/freeharness/). */
|
|
19
44
|
export const MARKER = "allow-config-edit";
|
|
20
45
|
/** Default marker lifetime in minutes. */
|
|
21
|
-
export const DEFAULT_TTL_MIN =
|
|
46
|
+
export const DEFAULT_TTL_MIN = 240;
|
|
22
47
|
|
|
23
|
-
/**
|
|
24
|
-
const
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
27
|
-
/**
|
|
28
|
-
const
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
48
|
+
/** Realpath calls per judgment, bounding latency; past them Edit/Write denies. */
|
|
49
|
+
const MAX_REALPATHS = 256;
|
|
50
|
+
/** Directories whose `hooks` subdirectory holds hook code wherever it lies. */
|
|
51
|
+
const HOOK_OWNERS = [".claude", ".freeharness", "freeharness-plugin"];
|
|
52
|
+
/** Protected basenames in Bash text; hook scripts and the marker count anywhere. */
|
|
53
|
+
const BASE = /^(?:settings\.json|settings\.local\.json|\.mcp\.json|\.claude\.json|claude\.md|hooks\.json|allow-config-edit|freeharness-[\w-]+\.mjs|freeharness-session-start)$/i;
|
|
54
|
+
/** One sample per BASE alternative, matched against a glob segment. */
|
|
55
|
+
const BASE_SAMPLES = ["settings.json", "settings.local.json", ".mcp.json", ".claude.json", "claude.md", "hooks.json", "allow-config-edit", "freeharness-x.mjs", "freeharness-session-start"];
|
|
56
|
+
/** Hook script names in BASE; repository sources carry them too. */
|
|
57
|
+
const SCRIPT = /^freeharness-(?:[\w-]+\.mjs|session-start)$/i;
|
|
58
|
+
/** HOOK_OWNERS for the name rules, plus a bare `freeharness`: the `.claude` above it may be out of sight. */
|
|
59
|
+
const HOOK_PARENTS = [...HOOK_OWNERS, "freeharness"];
|
|
60
|
+
const NAME_AT = /[A-Za-z_]\w*/y;
|
|
61
|
+
/** An expansion head: zsh `(flags)` and `=`, `~`, `^` prefixes, bash `#`/`!`, then a name. */
|
|
62
|
+
const EXP_FLAGS = /^(?:\([^)]*\))?[=~^#+!]*/;
|
|
63
|
+
const EXP_NAME = /[A-Za-z_]\w*|\d+|[@*#?$!-]/y;
|
|
64
|
+
/** Operator after an expansion head; the text after it can become the value. */
|
|
65
|
+
const EXP_OP = /^(?::?[-=+?]|::=|:|##?|%%?|\/[/#%]?|\^\^?|,,?|@)/;
|
|
66
|
+
/** Bounds on the word lists same-line values fill and on the text one printf body can grow to. */
|
|
67
|
+
const MAX_VARIANTS = 64;
|
|
68
|
+
const MAX_JOINED_CHARS = 1 << 20;
|
|
69
|
+
/** Raw-word separators; `|` and `()` stay inside glob words. */
|
|
70
|
+
const BRACES = /\{[^{}]*(?:,|\.\.)[^{}]*\}/;
|
|
71
|
+
const PURE_WILD = /^(?:[*?]|\[[^\]]*\])+$/;
|
|
72
|
+
/** A lone `*` as the last segment, its directory captured. */
|
|
73
|
+
const STAR = /^(?:(.*?)\/+)?\*\/*$/;
|
|
74
|
+
/** Longer input is not judged, so the judgment stays inside the hook timeout. */
|
|
75
|
+
const MAX_CHARS = 16_384;
|
|
76
|
+
/** Nesting bound for shell strings and substitutions; deeper input is not judged. */
|
|
77
|
+
const MAX_DEPTH = 8;
|
|
78
|
+
const MAX_NAMED = 10;
|
|
79
|
+
|
|
80
|
+
/** Redirects, zsh clobber forms included: the Bash tool may run zsh. */
|
|
81
|
+
const REDIRECT = /^(?:\d*)(>>!|>>\||>&!|>&\||>>|>\||>!|>&|>|<<<|<<-|<<|<>|<&|<|&>>!|&>>\||&>!|&>\||&>>|&>)$/;
|
|
82
|
+
const OUTPUT_OPS = new Set([">>!", ">>|", ">&!", ">&|", ">>", ">|", ">!", ">&", ">", "<>", "&>>!", "&>>|", "&>!", "&>|", "&>>", "&>"]);
|
|
83
|
+
const OPS = [">>!", ">>|", ">&!", ">&|", ">>", ">|", ">!", ">&", "<<<", "<<-", "<<", "<>", "<&", ">", "<"];
|
|
84
|
+
const AMP_OPS = ["&>>!", "&>>|", "&>!", "&>|", "&>>", "&>"];
|
|
85
|
+
const ASSIGN = /^[A-Za-z_]\w*(?:\[[^\]]*\])?\+?=/;
|
|
86
|
+
const KEYWORDS = new Set(["!", "{", "}", "if", "then", "else", "elif", "fi", "do", "done", "while", "until", "esac", "coproc"]);
|
|
87
|
+
const SYSTEM_BIN = /^\/(?:usr\/(?:local\/)?)?s?bin\/[^/]+$|^\/opt\/homebrew\/bin\/[^/]+$/;
|
|
88
|
+
/** A function definition's name and its `()`. */
|
|
89
|
+
const FUNC_NAME = /^[^\s$`'"=<>|&;(){}[\]*?]+$/;
|
|
90
|
+
const FUNC_PARENS = /^\(\s*\)/;
|
|
91
|
+
/** A line that may turn on dot-name matching for its globs. */
|
|
92
|
+
const DOTGLOB = /dotglob|globdots|glob_dots|GLOBIGNORE/i;
|
|
93
|
+
const SHELLS = /^(?:ba|z|da|k)?sh$/;
|
|
94
|
+
const INTERPRETERS = /^(?:python[\d.]*|node|perl)$/;
|
|
95
|
+
const STDIN_PATH = /^\/dev\/(?:stdin|fd\/\d+)$|^\/proc\/self\/fd\/\d+$/;
|
|
96
|
+
/** Code that can redefine a read call or run text as code. */
|
|
97
|
+
const REBIND = {
|
|
98
|
+
python: /\b(?:def|class)\s+(?:open|Path|read_text|read_bytes)\b|\bimport\s*\*|\b(?:builtins|__builtins__|__dict__|__class__|__getattribute__|setattr|globals|locals|vars|exec|eval|compile|__import__|importlib|modules)\b/,
|
|
99
|
+
node: /\bclass\b|\b(?:function|get|set|static|async)\s*\*?\s*(?:readFileSync|readFile|createReadStream|require)\b|[{,]\s*(?:readFileSync|readFile|createReadStream)\s*\(|\b(?:eval|Function|Reflect|Proxy|defineProperty|defineProperties|assign|fromEntries|create|setPrototypeOf|__proto__|prototype|globalThis|global|vm|module|with)\b|\]\s*[:=](?!=)/,
|
|
100
|
+
perl: /\b(?:sub|use|no|require|do|eval|BEGIN|CORE|local)\b|[*&]|::/,
|
|
101
|
+
};
|
|
102
|
+
/** Commands that change the shell's cwd. */
|
|
103
|
+
const MOVERS = new Set(["cd", "pushd", "popd"]);
|
|
104
|
+
/** Options that run a command, or resolve its paths, from another directory. */
|
|
105
|
+
/** Words opening and closing a compound command. */
|
|
106
|
+
const OPENERS = new Set(["if", "for", "while", "until", "select", "case", "{", "repeat", "foreach"]);
|
|
107
|
+
const CLOSERS = new Set(["fi", "done", "esac", "}", "end"]);
|
|
108
|
+
/** Candidate cwds kept per line; past them the cwd is unresolved. */
|
|
109
|
+
const MAX_CWDS = 8;
|
|
110
|
+
/** Entries one bare `*` lists; past them it counts. */
|
|
111
|
+
const MAX_LISTED = 1024;
|
|
112
|
+
/** Each simple command's place: `isolated` (pipeline, background), `piped` (not the first stage), `joint`/`next` separators, `shells` ids, `late` (in a background list). */
|
|
113
|
+
const PLACES = new WeakMap();
|
|
114
|
+
/** Indices of each simple command's redirect operators: a quoted `>` argument is a plain word. */
|
|
115
|
+
const OP_WORDS = new WeakMap();
|
|
116
|
+
/** Indices of each simple command's quoted heredoc bodies, which never expand. */
|
|
117
|
+
const QUOTED_DOCS = new WeakMap();
|
|
118
|
+
/** Offsets of each simple command's quoted or escaped `$` and backtick text, which never expands. */
|
|
119
|
+
const LITERALS = new WeakMap();
|
|
120
|
+
/** Words of the current judgment holding such text: a `${name}` in them may be literal, so it is never filled with a value. */
|
|
121
|
+
const LITERAL_WORDS = new Set();
|
|
122
|
+
/** Words whose every `$` and backtick is such text, and words with one that expands; a word in both can expand. */
|
|
123
|
+
const LITERAL_ONLY = new Set();
|
|
124
|
+
const EXPANDING = new Set();
|
|
125
|
+
const NAMED_EXP = /\$\{([A-Za-z_]\w*)\}/g;
|
|
126
|
+
|
|
127
|
+
/** Wrapper commands: [options taking a separate value, options without one]. */
|
|
128
|
+
const WRAPPERS = new Map([
|
|
129
|
+
["env", [/^(?:-[uCS]|--unset|--chdir|--split-string)$/, /^(?:-[i0v]+|-|-[uC].+|--(?:ignore-environment|null|debug|list-signal-handling|(?:unset|chdir)=.*|(?:default|ignore|block)-signal(?:=.*)?))$/]],
|
|
130
|
+
["command", [null, /^-p$/]],
|
|
131
|
+
["builtin", [null, null]],
|
|
132
|
+
["exec", [/^-a$/, /^-[cl]+$/]],
|
|
133
|
+
["nohup", [null, null]],
|
|
134
|
+
["time", [null, /^-p$/]],
|
|
135
|
+
["sudo", [/^(?:-[ughpCUrtDRT]|--(?:user|group|host|prompt|close-from|other-user|role|type|chdir|chroot|command-timeout))$/, /^(?:-[AbEHiKknPSsB]+|-[ughpCUrtDRT].+|--(?:[\w-]+=.*|preserve-env|login|shell|non-interactive|stdin|background|askpass|set-home|reset-timestamp|preserve-groups))$/]],
|
|
136
|
+
["doas", [/^-[uC]$/, /^-[ns]+$/]],
|
|
137
|
+
["nice", [/^(?:-n|--adjustment)$/, /^(?:-n.+|-\d+|--adjustment=.*)$/]],
|
|
138
|
+
["timeout", [/^(?:-[sk]|--signal|--kill-after)$/, /^(?:-[sk].+|-v|--(?:signal|kill-after)=.*|--(?:preserve-status|foreground|verbose))$/]],
|
|
139
|
+
["stdbuf", [/^(?:-[ioe]|--(?:input|output|error))$/, /^(?:-[ioe].+|--(?:input|output|error)=.*)$/]],
|
|
140
|
+
]);
|
|
141
|
+
/** env's split string, alone or ending a flag cluster (`-iS`); its value is the command. */
|
|
142
|
+
const ENV_SPLIT = /^(?:-[i0v]*S|--split-string=?)/;
|
|
143
|
+
const XARGS_VALUED =/^(?:-[IdEsnLPa]|--(?:max-args|max-procs|max-lines|delimiter|arg-file|max-chars|process-slot-var))$/;
|
|
144
|
+
const XARGS_FLAGS = /^(?:-[0rtpx]+|-[IdEsnLPaiel].*|--[\w-]+=.*|--(?:null|no-run-if-empty|verbose|interactive|exit|open-tty|show-limits|replace|eof))$/;
|
|
145
|
+
/** Readers no option or operand xargs takes from its input can turn into a writer. */
|
|
146
|
+
const XARGS_READERS = new Set(["cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "ls", "stat", "file", "du", "md5", "md5sum", "shasum", "sha1sum", "sha256sum", "echo", "basename", "dirname", "realpath", "readlink"]);
|
|
147
|
+
/** git global options that take a separate value. */
|
|
148
|
+
const GIT_VALUED = /^(?:-C|-c|--git-dir|--work-tree|--namespace|--config-env|--super-prefix|--attr-source)$/;
|
|
149
|
+
/** Code points a deny reason escapes: C0, DEL, C1, zero-width, bidirectional, separators, tags. */
|
|
150
|
+
const INVISIBLE = /[\u{0}-\u{1F}\u{7F}-\u{9F}\u{61C}\u{200B}-\u{200F}\u{2028}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{FEFF}\u{E0000}-\u{E007F}]/u;
|
|
151
|
+
/** git subcommands whose arguments decide the judgment; with appended arguments any of them denies. */
|
|
152
|
+
const GIT_LISTED = new Set(["reset", "push", "branch", "clean", "checkout", "switch", "restore", "stash", "reflog", "filter-branch", "worktree", "config"]);
|
|
153
|
+
/** Config keys a command line may set: none defines an alias or runs code. */
|
|
154
|
+
const GIT_SAFE_KEY = /^(?:(?:user|author|committer)\.(?:name|email)|color\..+|advice\..+|core\.(?:quotepath|autocrlf|safecrlf|abbrev|filemode|ignorecase|precomposeunicode|eol|whitespace)|init\.defaultbranch|(?:commit|tag)\.gpgsign|commit\.(?:verbose|cleanup|status)|log\.(?:date|decorate|follow|abbrevcommit|showroot|mailmap)|diff\.(?:noprefix|mnemonicprefix|renames|renamelimit|algorithm|colormoved|context|relative|indentheuristic|interhunkcontext)|pull\.(?:rebase|ff)|push\.(?:default|autosetupremote|followtags)|fetch\.(?:prune|prunetags|parallel)|merge\.(?:ff|conflictstyle|log|stat|renamelimit)|rebase\.(?:autosquash|autostash|stat)|status\.(?:short|branch|showuntrackedfiles|relativepaths|showstash|renamelimit)|column\.ui|i18n\.\w+|safe\.directory)$/;
|
|
155
|
+
const CONFIG_KEY = /^[a-z\d-]+\.(?:[^\n]*\.)?[a-z][a-z\d-]*$/;
|
|
156
|
+
/** Keys and variables naming a program, safe only with a plain pager or a no-op value. */
|
|
157
|
+
const GIT_PROGRAM_KEY = /^(?:core\.(?:pager|editor)|pager\.[\w-]+|sequence\.editor)$/;
|
|
158
|
+
/** A pager with letter flags only: `less -o` writes a log file, `less +cmd` runs a command. */
|
|
159
|
+
const SAFE_PROGRAM = /^(?:(?:cat|less|more)(?:[ \t]+-(?![^\s]*[ok])[a-z]+)*|true|false|:|yes|no|on|off|0|1)?$/i;
|
|
160
|
+
/** NAME= at a word start for variables that set git config or name a program. */
|
|
161
|
+
const GIT_ENV = /(?:^|[\s;&|(){}`<>'"])(GIT_(?:CONFIG(?:_KEY_\d+|_PARAMETERS|_GLOBAL|_SYSTEM)?|EXTERNAL_DIFF|EXEC_PATH|EDITOR|SEQUENCE_EDITOR|PAGER|SSH|SSH_COMMAND|ASKPASS|PROXY_COMMAND|TEMPLATE_DIR))\+?=((?:'[^']*'|"(?:[^"\\]|\\[\s\S])*"|\\[\s\S]|\S)*)/g;
|
|
162
|
+
/** Per listed subcommand: short and long options that take the next word as their value. */
|
|
163
|
+
const GIT_SUB_VALUED = new Map([
|
|
164
|
+
["push", ["o", ["push-option", "repo", "receive-pack", "exec"]]],
|
|
165
|
+
["checkout", ["bB", ["orphan", "conflict", "pathspec-from-file"]]],
|
|
166
|
+
["switch", ["cC", ["create", "force-create", "orphan", "conflict"]]],
|
|
167
|
+
["restore", ["s", ["source", "conflict", "pathspec-from-file"]]],
|
|
168
|
+
["stash", ["m", ["message", "pathspec-from-file"]]],
|
|
169
|
+
["worktree", ["bB", ["reason"]]],
|
|
170
|
+
["clean", ["e", ["exclude"]]],
|
|
171
|
+
["reset", ["", ["pathspec-from-file"]]],
|
|
172
|
+
["branch", ["u", ["set-upstream-to"]]],
|
|
173
|
+
["config", ["f", ["file", "blob", "type", "default", "comment", "value"]]],
|
|
174
|
+
]);
|
|
175
|
+
/** git queries whose output is a ref name or an object id, with the options each may take. */
|
|
176
|
+
const GIT_QUERY = new Map([
|
|
177
|
+
["branch", /^--show-current$/],
|
|
178
|
+
["symbolic-ref", /^(?:--short|-q|--quiet)$/],
|
|
179
|
+
["rev-parse", /^(?:--abbrev-ref(?:=(?:strict|loose))?|--short(?:=\d+)?|--verify|-q|--quiet|--symbolic-full-name)$/],
|
|
180
|
+
["merge-base", /^(?:--fork-point|-a|--all|--octopus|--independent)$/],
|
|
181
|
+
["for-each-ref", /^(?:--format=%\(refname(?::(?:short|[lr]strip=-?\d+))?\)|--sort=-?[\w:-]+|--count=\d+|--(?:no-)?merged|--contains|--points-at)$/],
|
|
182
|
+
["commit-tree", /^(?:-[pmF]|--no-gpg-sign)$/],
|
|
183
|
+
["mktree", /^(?:-z|--missing|--batch)$/],
|
|
184
|
+
["hash-object", /^(?:-[wt]|--stdin(?:-paths)?|--no-filters|--literally|--path=.*)$/],
|
|
185
|
+
]);
|
|
186
|
+
const GIT_WORD = /(?<![\w.-])git(?![\w-])/;
|
|
187
|
+
const SPAWN = /\b(?:subprocess|child_process|popen\w*|exec(?:Sync|File\w*|[lv]p?e?\s*\()|os\s*\.\s*(?:system|exec\w*))|spawn|create_subprocess_\w+|\bsystem\s*\(/;
|
|
188
|
+
const SPAWN_SCRIPT = new RegExp(String.raw`${SPAWN.source}|\x60|\b(?:qx|system|exec|readpipe|Open3)\b|%x|\bopen\b[^;\n]*?(?:["'][\s-]*\||\|[\s-]*["'])`);
|
|
189
|
+
const PY_ARGV = new Set(["run", "call", "check_call", "check_output", "Popen"]);
|
|
190
|
+
const PY_CONSTS = new Set(["PIPE", "DEVNULL", "STDOUT", "CalledProcessError", "TimeoutExpired", "CompletedProcess", "SubprocessError"]);
|
|
191
|
+
const PY_KW = new Set(["capture_output", "text", "check", "stdout", "stderr", "stdin", "input", "encoding", "errors", "timeout", "universal_newlines", "bufsize", "close_fds"]);
|
|
192
|
+
const JS_ARGV = new Set(["execFileSync", "execFile", "spawn", "spawnSync"]);
|
|
193
|
+
const JS_SHELL = new Set(["execSync", "exec"]);
|
|
194
|
+
const JS_KW = new Set(["encoding", "stdio", "timeout", "maxBuffer", "input", "windowsHide", "killSignal"]);
|
|
195
|
+
/** Names that reach a module, a spawn or code through a value the argv extractor cannot follow. */
|
|
196
|
+
const PY_DYNAMIC = /\b(?:exec|eval|getattr|vars|globals|locals|attrgetter|methodcaller|importlib|builtins|__(?:import|builtins|dict|getattribute|globals|subclasses)__)\b/;
|
|
197
|
+
const JS_DYNAMIC = /\b(?:eval|Function|globalThis|Reflect|import\s*\(|process\s*\.\s*(?:binding|dlopen|mainModule)|module\s*\.\s*(?:constructor|require)|require\s*\(\s*(?!["'][\w:./@-]+["']\s*\)))/;
|
|
198
|
+
const INTERP_GIT_READ = new Set(["status", "diff", "log", "show", "ls-files", "rev-parse", "blame", "grep", "cat-file", "merge-base", "for-each-ref"]);
|
|
199
|
+
const INTERP_GIT_BANNED = /^--(?:exec-path|git-dir|work-tree|config-env|namespace|super-prefix|attr-source)(?:=|$)/;
|
|
200
|
+
const INTERP_GIT_WRITES = /^(?:--output|--open-files-in-pager|--ext-diff|--textconv|-[^-]*O)/;
|
|
201
|
+
/** The git program a spawn may name: a relative or unknown path may be any program called git. */
|
|
202
|
+
const GIT_PROGRAM = /^(?:(?:\/usr(?:\/local)?|\/opt\/homebrew)?\/bin\/)?git$/;
|
|
203
|
+
// A read-only git call changes nothing only under the environment, directory and files it started with.
|
|
204
|
+
const PY_ALTERS = /\b(?:environb?|putenv|unsetenv|f?chdir|posix|nt|breakpoint)\b/;
|
|
205
|
+
const PY_PLAIN_MODULES = new Set(["subprocess", "os", "sys", "re", "json", "shlex", "ast", "tokenize", "keyword","collections", "itertools", "functools", "operator", "math", "textwrap", "difflib", "string", "datetime", "time", "typing", "dataclasses", "pprint", "statistics", "hashlib", "base64", "enum", "fnmatch", "glob", "csv", "unicodedata", "__future__"]);
|
|
206
|
+
const PY_OS_READ = new Set(["path", "getcwd", "getcwdb", "getenv", "listdir", "scandir", "walk", "sep", "linesep", "pathsep", "name", "devnull", "curdir", "pardir", "extsep", "altsep", "cpu_count", "getpid", "getppid", "stat", "lstat", "access", "fspath", "fsencode", "fsdecode", "get_terminal_size", "isatty", "R_OK", "W_OK", "X_OK", "F_OK"]);
|
|
207
|
+
const PY_OPEN_KW = new Set(["mode", "encoding", "errors", "newline", "buffering"]);
|
|
208
|
+
const JS_ALTERS = /\b(?:env|chdir|fs|vm|worker_threads|inspector|repl|wasi|cluster|module|v8|trace_events|net|Worker|global|this|constructor)\b|\bprocess\b(?!\s*\.\s*(?:argv|exit|exitCode|stdout|stderr|platform|version|versions|arch|cwd)\b)|\brequire\b(?!\s*\()/;
|
|
209
|
+
const JS_PLAIN_MODULES = /^(?:node:)?(?:child_process|path|os|util)$/;
|
|
210
|
+
const BRANCH_LIST = /^(?:--list|-[arvi]|-vv|--all|--remotes|--verbose|--ignore-case|--omit-empty|--points-at|--no-abbrev|--(?:no-)?(?:contains|merged|column|color|abbrev)(?:=.*)?|--(?:format|sort)=.*)$/;
|
|
211
|
+
const XARGS_NAME = "xargs_input";
|
|
212
|
+
const XARGS_INPUT = `\${${XARGS_NAME}}`;
|
|
213
|
+
/** Words that can bind a variable or change word splitting in ways the line text does not show. */
|
|
214
|
+
const BINDERS = /(?:^|[^\w.-])(?:eval|source|read|mapfile|readarray|getopts|printf|print|declare|typeset|local|export|readonly|integer|float|let|set|setopt|emulate|vared|zparseopts|sysread|zstat|zformat|strftime|zle|zstyle|zmodload|compgen|coproc|select|foreach|while|until|repeat|trap|shift|alias|function|fc|IFS)(?![\w(-])|\[\[|\(\(|(?:^|[\s;&|(){}])\.(?=[\s;&|(){}]|$)/;
|
|
215
|
+
/** Variables the shell sets itself, so their values are not a user's environment. */
|
|
216
|
+
const SHELL_SET = /^(?:_|REPLY|reply|OPTARG|OPTIND|MATCH|match|MBEGIN|MEND|mbegin|mend|argv|BASH\w*|ZSH\w*|history\w*|HIST\w*|FUNCNAME|funcstack|func\w*trace|COMP\w*|READLINE_\w+|MAPFILE|mapfile|PIPESTATUS|pipestatus|PWD|OLDPWD|commands|aliases|galiases|saliases|functions|options|parameters|builtins|jobtexts|dirstack|widgets|IFS)$/;
|
|
217
|
+
/** Words that cannot be a ref name, so git reads them as a pathspec. */
|
|
218
|
+
const NOT_REF = /^[.:/~]|[*?[\\]|\.\.|\/\/|\/$/;
|
|
219
|
+
/** zsh precommand modifiers. */
|
|
220
|
+
const PRECOMMANDS = new Set(["noglob", "nocorrect", "-"]);
|
|
221
|
+
const AWKS = new Set(["awk", "gawk", "nawk", "mawk"]);
|
|
222
|
+
|
|
223
|
+
const GIT_READ = new Set(["add", "blame", "cat-file", "check-ignore", "commit", "describe", "diff", "for-each-ref", "grep", "log", "ls-files", "ls-tree", "merge-base", "name-rev", "rev-list", "rev-parse", "shortlog", "show", "show-ref", "status"]);
|
|
224
|
+
const GH_READ = /^(?:(?:api|search|status|browse|label)(?: \S+)?|pr (?:view|list|diff|checks|status|create|comment|edit|review|ready|close|reopen)|issue (?:view|list|status|create|comment|edit|close|reopen)|(?:run|workflow|repo|release) (?:view|list))$/;
|
|
225
|
+
/** Milliseconds one Bash judgment may take, under the host's hook timeout. */
|
|
226
|
+
const MAX_JUDGE_MS = 2_000;
|
|
227
|
+
/** Bytes of a Bash command the guard judges; past them it allows unjudged. */
|
|
228
|
+
const MAX_COMMAND_BYTES = 262_144;
|
|
229
|
+
|
|
230
|
+
/** `performance.now()` deadline of the running Bash judgment; Infinity outside one. */
|
|
231
|
+
let deadline = Infinity;
|
|
232
|
+
|
|
233
|
+
/** Thrown past the deadline; respond reads it as an allow, as the host would at its timeout. */
|
|
234
|
+
class Late extends Error {}
|
|
235
|
+
|
|
236
|
+
/** Throws past the deadline; called in every loop that grows with the input. */
|
|
237
|
+
function tick() {
|
|
238
|
+
if (performance.now() >= deadline) throw new Late("judgment deadline passed");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Whether `arg` is a long option naming one of `names` or an abbreviation of one (getopt). */
|
|
242
|
+
function longOpt(arg, names) {
|
|
243
|
+
const m = /^--([^=]+)/.exec(arg);
|
|
244
|
+
return m !== null && names.some((n) => n.startsWith(m[1]));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Operands of an argument list: `-`, every word after `--`, and non-options no option consumes. */
|
|
248
|
+
function operands(args, valued) {
|
|
249
|
+
const out = [];
|
|
250
|
+
for (let i = 0; i < args.length; i++) {
|
|
251
|
+
const a = args[i];
|
|
252
|
+
if (a === "--") return out.concat(args.slice(i + 1));
|
|
253
|
+
if (a === "-" || !a.startsWith("-")) out.push(a);
|
|
254
|
+
else if (valued.test(a)) i++;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether git's global options and subcommand only read or commit. */
|
|
260
|
+
function gitReadOnly(args) {
|
|
261
|
+
let j = 0;
|
|
262
|
+
for (; j < args.length && args[j].startsWith("-"); j++) {
|
|
263
|
+
if (/^(?:-C|--git-dir|--work-tree|--namespace)$/.test(args[j])) j++;
|
|
264
|
+
else if (!/^(?:--(?:git-dir|work-tree|namespace)=.*|--no-pager|-P|-p|--paginate|--bare|--no-replace-objects|--literal-pathspecs|--no-optional-locks)$/.test(args[j])) return false;
|
|
265
|
+
}
|
|
266
|
+
const writes = (a) => /^(?:--output|-O|--open-files-in-pager|--ext-diff)/.test(a) || longOpt(a, ["output", "open-files-in-pager", "ext-diff"]);
|
|
267
|
+
const rest = args.slice(j + 1);
|
|
268
|
+
const remotes = args[j] === "remote" && (rest.every((a) => /^(?:-v|--verbose)$/.test(a)) || /^(?:show|get-url)$/.test(rest.find((a) => !a.startsWith("-")) ?? ""));
|
|
269
|
+
return (GIT_READ.has(args[j]) || remotes) && !rest.some(writes);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether sed only prints: no in-place edit, script file, `w`/`W` or `e`, and no brace, command or special-parameter expansion in a script. */
|
|
273
|
+
function sedReadOnly(args) {
|
|
274
|
+
const scripts = sedScripts(args);
|
|
275
|
+
return scripts !== null && scripts.every((s) => !/\$[{(\d@*#?$-]|`/.test(s) && sedWrites(s)?.length === 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** sed's scripts: the -e values, else the first operand; null on an in-place edit, a script file, another unknown option or one after an operand. */
|
|
279
|
+
function sedScripts(args) {
|
|
280
|
+
const given = [];
|
|
281
|
+
const operands = [];
|
|
282
|
+
for (let j = 0; j < args.length; j++) {
|
|
283
|
+
const a = args[j];
|
|
284
|
+
if (a === "--") {
|
|
285
|
+
operands.push(...args.slice(j + 1));
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (a === "-" || !a.startsWith("-")) operands.push(a);
|
|
289
|
+
else if (operands.length > 0) return null;
|
|
290
|
+
else if (/^--expression(?:=|$)/.test(a)) given.push(a.includes("=") ? a.slice(a.indexOf("=") + 1) : (args[++j] ?? ""));
|
|
291
|
+
else if (/^-[nErsuz]*e/.test(a)) given.push(a.indexOf("e") + 1 < a.length ? a.slice(a.indexOf("e") + 1) : (args[++j] ?? ""));
|
|
292
|
+
else if (!/^-[nErsuz]+$|^--(?:quiet|silent|regexp-extended|posix|debug|null-data|zero-terminated|separate|unbuffered|sandbox)$/.test(a)) return null;
|
|
293
|
+
}
|
|
294
|
+
return given.length > 0 ? given : [operands[0] ?? ""];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** awk's program texts; null past an option that loads code, edits in place or is unknown. */
|
|
298
|
+
function awkPrograms(args) {
|
|
299
|
+
const programs = [];
|
|
300
|
+
let j = 0;
|
|
301
|
+
for (; j < args.length && args[j].startsWith("-") && args[j] !== "-"; j++) {
|
|
302
|
+
const a = args[j];
|
|
303
|
+
if (a === "--") {
|
|
304
|
+
j++;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (/^(?:-[Fv]|--(?:field-separator|assign))$/.test(a)) j++;
|
|
308
|
+
else if (a === "-e" || a === "--source") programs.push(args[++j] ?? "");
|
|
309
|
+
else if (a.startsWith("--source=")) programs.push(a.slice(9));
|
|
310
|
+
else if (!/^-[Fv].|^--(?:field-separator|assign)=|^--(?:posix|traditional|sandbox)$/.test(a)) return null;
|
|
311
|
+
}
|
|
312
|
+
return programs.length > 0 ? programs : [args[j] ?? ""];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The name a command word runs by lookup, or null for a path outside the system bins. */
|
|
316
|
+
function commandName(word) {
|
|
317
|
+
return !word.includes("/") || SYSTEM_BIN.test(word) ? path.posix.basename(word) : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Where xargs's command starts at or after `from`, with its placeholder and input delimiter (`sep`:
|
|
322
|
+
* null for lines, the `-0` or `-d` character, false when unreadable); null past an unknown option.
|
|
323
|
+
*/
|
|
324
|
+
function xargsChild(args, from) {
|
|
325
|
+
let mark = null;
|
|
326
|
+
let sep = null;
|
|
327
|
+
for (let j = from; j < args.length; j++) {
|
|
328
|
+
const a = args[j];
|
|
329
|
+
if (a === "--" || !a.startsWith("-")) return { at: a === "--" ? j + 1 : j, mark, sep };
|
|
330
|
+
const m = /^(?:-I|-i|--replace=?|--r(?:e(?:p(?:l(?:a(?:ce?)?)?)?)?)?=)(.*)$/.exec(a);
|
|
331
|
+
if (m) mark = a === "-I" ? (args[j + 1] ?? null) : m[1] || "{}";
|
|
332
|
+
const d = /^(?:-d|--d(?:e(?:l(?:i(?:m(?:i(?:t(?:er?)?)?)?)?)?)?)?=)([\s\S]*)$/.exec(a);
|
|
333
|
+
if (d || a === "--delimiter") sep = xargsDelimiter(a === "-d" || a === "--delimiter" ? args[j + 1] : d[1]);
|
|
334
|
+
if (/^-[rtpx]*0[0rtpx]*$|^--null$/.test(a)) sep = "\0";
|
|
335
|
+
if (XARGS_VALUED.test(a)) j++;
|
|
336
|
+
else if (!XARGS_FLAGS.test(a)) return null;
|
|
337
|
+
}
|
|
338
|
+
return { at: args.length, mark, sep };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The byte GNU xargs reads from a `-d` value; false for an expansion, an invalid value or a non-ASCII byte. */
|
|
342
|
+
function xargsDelimiter(w) {
|
|
343
|
+
if (w === undefined || !literalWord(w)) return false;
|
|
344
|
+
if (w.length === 1) return w.charCodeAt(0) < 128 && w;
|
|
345
|
+
const m = /^\\(?:([abfnrtv\\])|x([0-9a-fA-F]+)$|([0-7]+)$)/.exec(w);
|
|
346
|
+
const code = m?.[2] !== undefined ? parseInt(m[2], 16) : m?.[3] !== undefined ? parseInt(m[3], 8) : NaN;
|
|
347
|
+
return m?.[1] !== undefined ? C_ESCAPES[m[1]] : code < 128 && String.fromCharCode(code);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether a word holds no expansion outside quotes, so its text is what the command sees. */
|
|
351
|
+
function literalWord(w) {
|
|
352
|
+
return !/[$`]/.test(w) || (LITERAL_ONLY.has(w) && !EXPANDING.has(w));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const C_ESCAPES = { a: "\x07", b: "\b", e: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "\\": "\\" };
|
|
356
|
+
|
|
357
|
+
/** Text with the escapes `echo -e` and `printf` decode. */
|
|
358
|
+
function decodeEscapes(t) {
|
|
359
|
+
return t.replace(/\\(?:0?([0-7]{1,3})|x([0-9A-Fa-f]{1,2})|([abefnrtv\\]))/g, (m, o, h, c) =>
|
|
360
|
+
o !== undefined ? String.fromCharCode(parseInt(o, 8)) : h !== undefined ? String.fromCharCode(parseInt(h, 16)) : C_ESCAPES[c],
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Bare `cat` and `tee` stages the git guard has judged, each with the simple command piping into it. */
|
|
365
|
+
const PASSED = new WeakMap();
|
|
366
|
+
|
|
367
|
+
/** Interpreters that may run what they read: any option, module or script can hand stdin to the language or to a shell. */
|
|
368
|
+
const STDIN_CODE = /^(?:python[\d.]*|node|perl|ruby|php|lua[\d.]*|tclsh[\d.]*|wish[\d.]*|expect|Rscript)$/;
|
|
369
|
+
/** Multi-call binaries: the first operand names the applet that runs. */
|
|
370
|
+
const APPLETS = /^(?:busybox|toybox)$/;
|
|
371
|
+
/** The command an applet word runs: their ash and hush shells read code as sh does. */
|
|
372
|
+
const appletName = (word) => path.posix.basename(word).replace(/^(?:a|hu)sh$/, "sh");
|
|
373
|
+
/** Whether the line hands a command stdin of its own: a heredoc, a here-string, an input redirect or a pipe. */
|
|
374
|
+
const fedStdin = (io) => (io.stdin ?? null) !== null || (io.inputs?.length ?? 0) > 0 || Boolean(io.feed);
|
|
375
|
+
/**
|
|
376
|
+
* Whether interpreter arguments start with stdin as the script (or are none), so fed code is the
|
|
377
|
+
* program in the language alone; any other first word (an option, a module, inline code or a
|
|
378
|
+
* script) may hand stdin to other code, a shell included.
|
|
379
|
+
*/
|
|
380
|
+
const plainStdin = (args) => args.length === 0 || args[0] === "-" || STDIN_PATH.test(args[0]);
|
|
381
|
+
const OPAQUE = Object.freeze({ text: "", texts: [] });
|
|
382
|
+
const PRINTF_PART = /\\c|\\(?:0?[0-7]{1,3}|x[0-9A-Fa-f]{1,2}|[\s\S])|%%|%([-+ #0]*)(\d*)(?:\.(\d*))?([a-zA-Z])|[^\\%]+|[\\%]/g;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Code a reader takes from outside its words (stdin, a `<(...)` script, a `-c` or `eval` code
|
|
386
|
+
* word with expansions), so both guards judge one body: literal as every text the shell can hand
|
|
387
|
+
* over, else opaque.
|
|
388
|
+
* @param {string[]} words The command word and its arguments, redirects dropped.
|
|
389
|
+
* @param {{stdin: string | null, quoted: boolean, herestring?: boolean, inputs: string[], feed?: string[] | null, source?: string[]}} io
|
|
390
|
+
* `source` is the split command the words come from, whose quoted spans tell a literal `$` apart.
|
|
391
|
+
* @returns {{text: string, texts: string[]} | null} Null for no reader, literal code in its
|
|
392
|
+
* arguments or a script path.
|
|
393
|
+
*/
|
|
394
|
+
function shellBody(words, io, ctx) {
|
|
395
|
+
let [name = "", ...args] = words;
|
|
396
|
+
name = path.posix.basename(name);
|
|
397
|
+
if (APPLETS.test(name) && args.length > 0) [name, ...args] = [appletName(args[0]), ...args.slice(1)];
|
|
398
|
+
if (name === "eval") {
|
|
399
|
+
// eval takes `--` as the end of its options, so it never reaches the code.
|
|
400
|
+
const code = args[0] === "--" ? args.slice(1) : args;
|
|
401
|
+
return code.length > 0 ? codeBody(code, io.source, ctx) : null;
|
|
402
|
+
}
|
|
403
|
+
const reader = SHELLS.test(name) || STDIN_CODE.test(name) || name === "ssh";
|
|
404
|
+
// Stdin the line feeds a reader is code whatever its options: a script, module or remote command may run it.
|
|
405
|
+
// One fed to an enclosing group or loop is stdin the reader's own words do not show.
|
|
406
|
+
const fed = !reader ? null : fedStdin(io) ? stdinBody(io, ctx) : groupFed(ctx.line ?? "") ? OPAQUE : null;
|
|
407
|
+
const from = bodySource(name, args);
|
|
408
|
+
if (from === null) return fed;
|
|
409
|
+
if (from.code !== undefined) return joinBodies(codeBody([from.code], io.source, ctx), fed);
|
|
410
|
+
const subs = (from.scripts ?? [from.script]).filter((s) => s?.startsWith("<(") && s.endsWith(")")).map((s) => substitutionBody(s.slice(2, -1), ctx));
|
|
411
|
+
// ssh's operands name a host and a remote command, never a script.
|
|
412
|
+
const script = name === "ssh" ? undefined : from.scripts === undefined ? from.script : scriptOperand(name, from.scripts);
|
|
413
|
+
const files = script === undefined || /^<\(/.test(script) || STDIN_PATH.test(script) ? [] : [writtenBody(script, ctx)];
|
|
414
|
+
if (from.scripts !== undefined || subs.length > 0) return [...subs, ...files, fed].reduce(joinBodies, null);
|
|
415
|
+
return from.script === undefined || STDIN_PATH.test(from.script) ? (fed ?? stdinBody(io, ctx)) : joinBodies(files[0] ?? null, fed);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** The body a script file runs: the guard tracks no writes, so no file has a known body. */
|
|
419
|
+
function writtenBody() {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Interpreter options whose operand is the program (no script runs), and options taking a value. */
|
|
424
|
+
const SCRIPT_OPTS = [
|
|
425
|
+
[/^python/, /^-[bBdEhiIOqsSuvxPR]*[cm]/, /^-[WX]$/],
|
|
426
|
+
[/^node$/, /^(?:-[pe]{1,2}|--eval|--print)(?:=|$)/, /^(?:-r|--require|--import|--loader|--experimental-loader|-C|--conditions)$/],
|
|
427
|
+
[/^perl$/, /^-[aclnpsTtuUwWX0-9]*[eE]/, /^-[IMm]$/],
|
|
428
|
+
[/^ruby$/, /^-[acdlnpsSvwW]*e/, /^-[rIC]$/],
|
|
429
|
+
[/^php$/, /^-r/, /^-[cdz]$/],
|
|
430
|
+
[/^lua/, /^-e/, /^-l$/],
|
|
431
|
+
[/^(?:Rscript|expect)$/, /^-[ec]$/, /(?!)/],
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
/** Only the script operand runs; any other operand is data. */
|
|
435
|
+
function scriptOperand(name, args) {
|
|
436
|
+
const [, inline, valued] = SCRIPT_OPTS.find(([n]) => n.test(name)) ?? [null, /(?!)/, /(?!)/];
|
|
437
|
+
for (let j = 0; j < args.length; j++) {
|
|
438
|
+
const a = args[j];
|
|
439
|
+
if (a === "--" || (a === "-f" && /^(?:php|expect)$/.test(name))) return args[j + 1];
|
|
440
|
+
if (a === "-" || inline.test(a)) return undefined;
|
|
441
|
+
if (!a.startsWith("-")) return a;
|
|
442
|
+
if (valued.test(a)) j++;
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** An unseen side keeps the other's texts, as they still run. */
|
|
448
|
+
function appendBodies(a, b) {
|
|
449
|
+
if (a.texts.length === 0 || b.texts.length === 0) return joinBodies(a, b);
|
|
450
|
+
const texts = [...new Set(a.texts.flatMap((x) => b.texts.map((y) => x + y)))];
|
|
451
|
+
return texts.length > MAX_VARIANTS ? OPAQUE : { text: texts[0] ?? "", texts };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** One reading of a stage's redirects, so producers and writers agree on its io; `outs` are [token, operator, target]. */
|
|
455
|
+
function stageIo(stage) {
|
|
456
|
+
const io = { stdin: null, quoted: false, herestring: false, inputs: [], feed: null, source: stage };
|
|
457
|
+
const argv = [];
|
|
458
|
+
const outs = [];
|
|
459
|
+
for (let i = 0; i < stage.length; i++) {
|
|
460
|
+
const op = i + 1 < stage.length ? REDIRECT.exec(stage[i]) : null;
|
|
461
|
+
if (op === null) argv.push(stage[i]);
|
|
462
|
+
else if (op[1].startsWith("<<")) {
|
|
463
|
+
io.stdin = stage[++i];
|
|
464
|
+
io.herestring = op[1] === "<<<";
|
|
465
|
+
io.quoted = !io.herestring && (QUOTED_DOCS.get(stage)?.has(i) ?? false);
|
|
466
|
+
} else if (!OUTPUT_OPS.has(op[1])) io.inputs.push(stage[++i]);
|
|
467
|
+
else outs.push([stage[i], op[1], stage[++i]]);
|
|
468
|
+
}
|
|
469
|
+
return { argv, io, outs };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Formatter text with each placeholder dropped; two entries are judged too, as one may end a
|
|
474
|
+
* command the next completes. -o leaves stdout unseen (format-patch prints file names). Null
|
|
475
|
+
* without --format, --pretty or an output file.
|
|
476
|
+
*/
|
|
477
|
+
function gitFormat(cmd) {
|
|
478
|
+
let j = 1;
|
|
479
|
+
for (; j < cmd.length && cmd[j].startsWith("-"); j++) if (GIT_VALUED.test(cmd[j])) j++;
|
|
480
|
+
let format;
|
|
481
|
+
let output;
|
|
482
|
+
let long = false;
|
|
483
|
+
for (let i = j + 1; i < cmd.length; i++) {
|
|
484
|
+
const a = cmd[i];
|
|
485
|
+
const m = /^--(format|pretty|output)=([\s\S]*)$/.exec(a);
|
|
486
|
+
if (m?.[1] === "output" || a === "-o" || a === "--output") [output, long] = [m?.[2] ?? cmd[++i], a !== "-o"];
|
|
487
|
+
else if (m !== null) format = m[1] === "format" ? m[2] : (/^t?format:([\s\S]*)$/.exec(m[2])?.[1] ?? (m[2].includes("%") ? m[2] : null));
|
|
488
|
+
}
|
|
489
|
+
if (format === undefined && output === undefined) return null;
|
|
490
|
+
const text = typeof format !== "string" ? null : format.replace(/%(?:(n)|(%)|x([0-9a-fA-F]{2})|[\s\S]?)/g, (m, n, pct, hex) => (n ? "\n" : pct ? "%" : hex ? String.fromCharCode(parseInt(hex, 16)) : ""));
|
|
491
|
+
const body = text === null ? OPAQUE : bodyOf([text, `${text}\n${text}`]);
|
|
492
|
+
return { output, body, stdout: output === undefined ? body : long ? bodyOf([""]) : OPAQUE };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Verdicts of groupFed per line text: every reader of a line asks again. */
|
|
496
|
+
const GROUP_FED = new Map();
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Whether `text` gives a group, loop or subshell stdin a reader inside may read: an input
|
|
500
|
+
* redirect on its closer or a pipe into its opener; unsplittable text may.
|
|
501
|
+
*/
|
|
502
|
+
function groupFed(text) {
|
|
503
|
+
if (GROUP_FED.has(text)) return GROUP_FED.get(text);
|
|
504
|
+
const input = (c) => c.some((w, i) => i + 1 < c.length && REDIRECT.exec(w)?.[1].startsWith("<") === true);
|
|
505
|
+
const cmds = splitCommands(text);
|
|
506
|
+
const fed = cmds === null || cmds.some((c) => (/^(?:\}|done|fi|esac)$/.test(c[0]) || REDIRECT.test(c[0]) ? input(c) : /^(?:\{|while|until|for|if|case|select)$/.test(c[0]) && PLACES.get(c)?.piped === true));
|
|
507
|
+
if (GROUP_FED.size >= 64) GROUP_FED.clear();
|
|
508
|
+
GROUP_FED.set(text, fed);
|
|
509
|
+
return fed;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** One body of two: literal only when both are, with the texts of both. */
|
|
513
|
+
function joinBodies(a, b) {
|
|
514
|
+
if (a === null || b === null) return a ?? b;
|
|
515
|
+
const texts = [...new Set([...a.texts, ...b.texts])];
|
|
516
|
+
if (texts.length > MAX_VARIANTS) return OPAQUE;
|
|
517
|
+
return { text: texts[0] ?? "", texts };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Where a reader's code comes from besides fed stdin: `{code}` (a -c word), `{script}`, `{scripts}` (every operand), `{}` (stdin), or null for none. */
|
|
521
|
+
function bodySource(name, args) {
|
|
522
|
+
if (SHELLS.test(name)) {
|
|
523
|
+
let j = 0;
|
|
524
|
+
let inline = false;
|
|
525
|
+
let stdin = false;
|
|
526
|
+
for (; j < args.length && /^[-+]/.test(args[j]); j++) {
|
|
527
|
+
const a = args[j];
|
|
528
|
+
if (a === "--" || a === "-") {
|
|
529
|
+
stdin ||= a === "-";
|
|
530
|
+
j++;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
if (/^[-+][oO]$|^--(?:rcfile|init-file)$/.test(a)) j++;
|
|
534
|
+
else if (/^-[a-zA-Z]*c/.test(a)) inline = true;
|
|
535
|
+
else if (/^-[a-zA-Z]*s/.test(a)) stdin = true;
|
|
536
|
+
}
|
|
537
|
+
if (inline) return args[j] === undefined ? null : { code: args[j] };
|
|
538
|
+
return stdin || args[j] === undefined ? {} : { script: args[j] };
|
|
539
|
+
}
|
|
540
|
+
if (name === "source" || name === ".") return args[0] === undefined ? null : { script: args[0] };
|
|
541
|
+
// Any operand of an interpreter or ssh may be a `<(...)` script.
|
|
542
|
+
return STDIN_CODE.test(name) || name === "ssh" ? { scripts: args } : null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** A reader's stdin: its heredoc or here-string, a `<(...)` input, or the literal text piped in; opaque for anything else or past MAX_DEPTH nested inputs. */
|
|
546
|
+
function stdinBody(io, ctx, nest = 0) {
|
|
547
|
+
const inputs = io.inputs ?? [];
|
|
548
|
+
if (nest > MAX_DEPTH) return OPAQUE;
|
|
549
|
+
if (io.stdin !== null && io.stdin !== undefined) return inputs.length > 0 ? OPAQUE : docBody(io.stdin, io, ctx);
|
|
550
|
+
const last = inputs.at(-1);
|
|
551
|
+
if (last !== undefined) return last.startsWith("<(") && last.endsWith(")") ? substitutionBody(last.slice(2, -1), ctx, nest + 1) : (writtenBody(last, ctx) ?? OPAQUE);
|
|
552
|
+
return io.feed ? producerBody(io.feed, ctx, nest + 1) : OPAQUE;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** A heredoc or here-string body: a quoted heredoc as written, a here-string as its word, an unquoted heredoc after the outer shell expands it. */
|
|
556
|
+
function docBody(text, { quoted, herestring, source }, ctx) {
|
|
557
|
+
if (quoted) return bodyOf([text]);
|
|
558
|
+
if (herestring) return codeBody([text], source, ctx) ?? bodyOf([text]);
|
|
559
|
+
const texts = outerFill(text, ctx);
|
|
560
|
+
return texts === null ? OPAQUE : bodyOf(texts);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** An unquoted heredoc as the outer shell expands it; null where a value is unknown. */
|
|
564
|
+
function outerFill(text, ctx) {
|
|
565
|
+
let out = [""];
|
|
566
|
+
let pending = "";
|
|
567
|
+
for (let i = 0; i < text.length; i++) {
|
|
568
|
+
const c = text[i];
|
|
569
|
+
if (c === "\\" && i + 1 < text.length && "$`\\\n".includes(text[i + 1])) {
|
|
570
|
+
if (text[++i] !== "\n") pending += text[i];
|
|
571
|
+
} else if (c === "`") return null;
|
|
572
|
+
else if (c === "$") {
|
|
573
|
+
const m = /^\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/.exec(text.slice(i, i + 256));
|
|
574
|
+
if (m === null) {
|
|
575
|
+
if (/[({@*#?$!\d-]/.test(text[i + 1] ?? "")) return null;
|
|
576
|
+
pending += c;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const values = bodyValues(m[1] ?? m[2], ctx);
|
|
580
|
+
if (values === null) return null;
|
|
581
|
+
out = out.flatMap((t) => values.map((v) => t + pending + v));
|
|
582
|
+
if (out.length > MAX_VARIANTS) return null;
|
|
583
|
+
pending = "";
|
|
584
|
+
i += m[0].length - 1;
|
|
585
|
+
} else pending += c;
|
|
586
|
+
}
|
|
587
|
+
return out.map((t) => t + pending);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const NAMED_AT = /\$\{([A-Za-z_]\w*)\}/y;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Texts a split word takes: each `${name}` outside its quoted spans filled with the name's literal
|
|
594
|
+
* same-line values; null when a value or another expansion is unknown, or when `source` spells the
|
|
595
|
+
* word with two different quotings.
|
|
596
|
+
*/
|
|
597
|
+
function wordValues(w, source, ctx, guess = false) {
|
|
598
|
+
if (!/[$`]/.test(w)) return [w];
|
|
599
|
+
const found = new Set((source ?? []).flatMap((x, i) => (x === w ? [JSON.stringify(LITERALS.get(source)?.get(i) ?? [])] : [])));
|
|
600
|
+
// A word the split never showed is read by the global marks, which only a literal word's text sets.
|
|
601
|
+
const spans = found.size === 1 ? JSON.parse([...found][0]) : found.size === 0 && !LITERAL_WORDS.has(w) ? [] : null;
|
|
602
|
+
if (spans === null) return literalWord(w) ? [w] : null;
|
|
603
|
+
let out = [""];
|
|
604
|
+
let last = 0;
|
|
605
|
+
for (let i = 0; i < w.length; i++) {
|
|
606
|
+
if ((w[i] !== "$" && w[i] !== "`") || spans.some(([a, b]) => i >= a && i < b)) continue;
|
|
607
|
+
NAMED_AT.lastIndex = i;
|
|
608
|
+
const m = NAMED_AT.exec(w);
|
|
609
|
+
// The xargs placeholder stays as written: the items it stands for are judged where they are pasted.
|
|
610
|
+
const values = m === null ? null : m[1] === XARGS_NAME ? [m[0]] : (bodyValues(m[1], ctx) ?? (guess ? guessedValues(m[1], ctx) : null));
|
|
611
|
+
if (values === null) return null;
|
|
612
|
+
const lit = w.slice(last, i);
|
|
613
|
+
out = out.flatMap((t) => values.map((v) => t + lit + v));
|
|
614
|
+
if (out.length > MAX_VARIANTS) return null;
|
|
615
|
+
last = i + m[0].length;
|
|
616
|
+
i = last - 1;
|
|
617
|
+
}
|
|
618
|
+
return out.map((t) => t + w.slice(last));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* `-c` or `eval` code words the shell joins and runs: each text their literal same-line values
|
|
623
|
+
* give, or opaque; null for literal words the caller reads as written. An opaque body still
|
|
624
|
+
* carries the texts the line's literal assignments would give when some binding is unsure, so
|
|
625
|
+
* they are still judged.
|
|
626
|
+
*/
|
|
627
|
+
function codeBody(words, source, ctx) {
|
|
628
|
+
if (words.every(literalWord)) return null;
|
|
629
|
+
const [word] = words;
|
|
630
|
+
if (words.length === 1 && word.startsWith("$(") && word[2] !== "(" && substitutionEnd(word, 0, false) === word.length) return substitutionBody(word.slice(2, -1), ctx);
|
|
631
|
+
for (const guess of [false, true]) {
|
|
632
|
+
let texts = [];
|
|
633
|
+
for (const [i, w] of words.entries()) {
|
|
634
|
+
const values = wordValues(w, source, ctx, guess);
|
|
635
|
+
texts = values === null ? null : i === 0 ? values : texts.flatMap((t) => values.map((v) => `${t} ${v}`));
|
|
636
|
+
if (texts === null || texts.length > MAX_VARIANTS) break;
|
|
637
|
+
}
|
|
638
|
+
if (texts !== null && texts.length <= MAX_VARIANTS) return guess ? { ...OPAQUE, texts: [...new Set(texts)] } : bodyOf(texts);
|
|
639
|
+
}
|
|
640
|
+
return OPAQUE;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Literal words assigned to `name` anywhere on the line, whatever else may bind it; null when none. */
|
|
644
|
+
function guessedValues(name, ctx) {
|
|
645
|
+
const line = ctx.line;
|
|
646
|
+
const out = [];
|
|
647
|
+
for (const m of line.matchAll(WORD_NAME)) {
|
|
648
|
+
if (m[0] !== name || line[m.index + name.length] !== "=") continue;
|
|
649
|
+
const cmds = splitCommands(rawWords(line, m.index + name.length + 1, true)?.[0] ?? "");
|
|
650
|
+
const w = cmds?.length === 1 && cmds[0].length === 1 ? cmds[0][0] : null;
|
|
651
|
+
if (w !== null && literalWord(w)) out.push(w);
|
|
652
|
+
if (out.length > MAX_VARIANTS) return null;
|
|
653
|
+
}
|
|
654
|
+
return out.length > 0 ? out : null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The output of a `<(...)` or `$(...)` body: literal only for one echo, printf or bare cat command. */
|
|
658
|
+
function substitutionBody(body, ctx, nest = 0) {
|
|
659
|
+
const cmds = splitCommands(body);
|
|
660
|
+
return cmds?.length === 1 ? producerBody(cmds[0], ctx, nest) : OPAQUE;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Text a producer writes: echo or printf output, or a bare cat or tee passing on its heredoc,
|
|
665
|
+
* input or the stage piping into it; opaque for any other command.
|
|
666
|
+
*/
|
|
667
|
+
function producerBody(words, ctx, nest = 0) {
|
|
668
|
+
for (let stage = words, hops = 0; stage && hops <= MAX_DEPTH; hops++) {
|
|
669
|
+
const { argv, io } = stageIo(stage);
|
|
670
|
+
const texts = printedTexts(argv, stage, ctx);
|
|
671
|
+
if (texts !== undefined) return texts === null ? formatterBody(argv, stage, ctx) : bodyOf(texts);
|
|
672
|
+
const git = path.posix.basename(argv[0] ?? "") === "git" ? gitFormat(argv) : null;
|
|
673
|
+
if (git !== null) return git.stdout;
|
|
674
|
+
// cat prints its file operands in turn; one the line wrote shows its body.
|
|
675
|
+
if (argv[0] === "cat" && argv.length > 1 && argv.slice(1).every((f) => !f.startsWith("-"))) return argv.slice(1).map((f) => writtenBody(f, ctx) ?? OPAQUE).reduce(appendBodies);
|
|
676
|
+
if (argv.length !== 1 || (argv[0] !== "cat" && argv[0] !== "tee")) return OPAQUE;
|
|
677
|
+
if (io.stdin !== null || io.inputs.length > 0) return stdinBody(io, ctx, nest + 1);
|
|
678
|
+
stage = PASSED.get(stage);
|
|
679
|
+
}
|
|
680
|
+
return OPAQUE;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** An echo or printf with a word the line does not show: opaque, still carrying the text its literal words print. */
|
|
684
|
+
function formatterBody(argv, source, ctx) {
|
|
685
|
+
const shown = argv.map((w, i) => (i > 0 && wordValues(w, source, ctx) === null ? "" : w));
|
|
686
|
+
return { ...OPAQUE, texts: shown[1] === "" && path.posix.basename(argv[0]) === "printf" ? [] : (printedTexts(shown, source, ctx) ?? []) };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Texts an echo or printf command prints, its words' same-line values filled; undefined for another command, null when unreadable. */
|
|
690
|
+
function printedTexts(argv, source, ctx) {
|
|
691
|
+
const name = path.posix.basename(argv[0] ?? "");
|
|
692
|
+
if (name !== "echo" && name !== "printf") return undefined;
|
|
693
|
+
let lists = [[]];
|
|
694
|
+
for (const w of argv.slice(1)) {
|
|
695
|
+
const values = wordValues(w, source, ctx);
|
|
696
|
+
// An unquoted value may split into more words or glob into file names.
|
|
697
|
+
if (values === null || (!literalWord(w) && values.some(hasGlob))) return null;
|
|
698
|
+
const forms = values.flatMap((v) => (!literalWord(w) && /\s/.test(v) ? [[v], v.split(/\s+/).filter(Boolean)] : [[v]]));
|
|
699
|
+
lists = lists.flatMap((l) => forms.map((f) => [...l, ...f]));
|
|
700
|
+
if (lists.length > MAX_VARIANTS) return null;
|
|
701
|
+
}
|
|
702
|
+
const out = [];
|
|
703
|
+
for (const args of lists) {
|
|
704
|
+
if (name === "printf") {
|
|
705
|
+
const text = args[0] === "--" ? printfOutput(args.slice(1)) : printfOutput(args);
|
|
706
|
+
if (text === null) return null;
|
|
707
|
+
out.push(text);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
let a = args;
|
|
711
|
+
while (/^-[neE]+$/.test(a[0] ?? "")) a = a.slice(1);
|
|
712
|
+
const raw = `${a.join(" ")}\n`;
|
|
713
|
+
// bash's echo prints escapes as written unless -e; zsh's and sh's decode them and stop at `\c`.
|
|
714
|
+
const stopped = /^(?:[^\\]|\\[^c])*(?=\\c)/.exec(raw)?.[0];
|
|
715
|
+
out.push(raw, decodeEscapes(raw), ...(stopped === undefined ? [] : [decodeEscapes(stopped)]));
|
|
716
|
+
}
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** What `printf format args...` prints, the format reused while arguments remain; null for an option or a conversion other than %s, %b, %c, %d and %i. */
|
|
721
|
+
function printfOutput([format, ...args]) {
|
|
722
|
+
if (format === undefined || (format.startsWith("-") && format !== "-")) return null;
|
|
723
|
+
let out = "";
|
|
724
|
+
let k = 0;
|
|
725
|
+
for (;;) {
|
|
726
|
+
let used = false;
|
|
727
|
+
for (const m of format.matchAll(PRINTF_PART)) {
|
|
728
|
+
const [part, flags, width, precision, conv] = m;
|
|
729
|
+
let piece = part;
|
|
730
|
+
if (part === "\\c") return out;
|
|
731
|
+
if (part.startsWith("\\")) piece = decodeEscapes(part);
|
|
732
|
+
else if (part === "%%") piece = "%";
|
|
733
|
+
else if (conv !== undefined) {
|
|
734
|
+
used = true;
|
|
735
|
+
const arg = args[k++];
|
|
736
|
+
if (conv === "s" || conv === "b") {
|
|
737
|
+
const text = conv === "b" ? decodeEscapes(/^(?:[^\\]|\\[^c])*/.exec(arg ?? "")[0]) : (arg ?? "");
|
|
738
|
+
piece = precision === undefined ? text : text.slice(0, Number(precision || 0));
|
|
739
|
+
if (conv === "b" && arg?.includes("\\c")) return out + piece;
|
|
740
|
+
} else if (conv === "c") piece = (arg ?? "").slice(0, 1);
|
|
741
|
+
else if (conv === "d" || conv === "i") {
|
|
742
|
+
if (arg !== undefined && !/^[-+]?\d+$/.test(arg)) return null;
|
|
743
|
+
piece = String(BigInt((arg ?? "0").replace(/^\+/, "")));
|
|
744
|
+
} else return null;
|
|
745
|
+
if (Number(width || 0) > MAX_JOINED_CHARS) return null;
|
|
746
|
+
piece = flags.includes("-") ? piece.padEnd(Number(width || 0)) : piece.padStart(Number(width || 0), flags.includes("0") && /[di]/.test(conv) ? "0" : " ");
|
|
747
|
+
}
|
|
748
|
+
out += piece;
|
|
749
|
+
if (out.length > MAX_JOINED_CHARS) return null;
|
|
750
|
+
}
|
|
751
|
+
if (!used || k >= args.length) return out;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Literal texts `name` holds on the line, each one quoted or plain word with no expansion; null when one is unknown. */
|
|
756
|
+
function bodyValues(name, ctx) {
|
|
757
|
+
const raw = SHELL_SET.test(name) ? null : lineValues(name, ctx);
|
|
758
|
+
if (!raw?.length) return null;
|
|
759
|
+
const out = [];
|
|
760
|
+
for (const r of raw) {
|
|
761
|
+
const cmds = splitCommands(r);
|
|
762
|
+
const w = cmds?.length === 1 && cmds[0].length === 1 ? cmds[0][0] : null;
|
|
763
|
+
if (w === null || !literalWord(w)) return null;
|
|
764
|
+
out.push(w);
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** A literal body of `texts`, opaque past MAX_VARIANTS. */
|
|
770
|
+
function bodyOf(texts) {
|
|
771
|
+
const all = [...new Set(texts)];
|
|
772
|
+
return all.length > MAX_VARIANTS ? OPAQUE : { text: all[0] ?? "", texts: all };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Whether xargs runs an XARGS_READERS command or none (echo); judgeXargs judges any other child. */
|
|
776
|
+
function xargsReadOnly(args) {
|
|
777
|
+
const cmd = xargsChild(args, 0);
|
|
778
|
+
if (cmd === null) return false;
|
|
779
|
+
const word = args[cmd.at];
|
|
780
|
+
return word === undefined || (!/[$`]/.test(word) && !hasGlob(word) && XARGS_READERS.has(commandName(word) ?? ""));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Read-only commands, each with an optional check of its arguments. */
|
|
784
|
+
const READ_ONLY = new Map([
|
|
785
|
+
...["cat", "tac", "nl", "head", "tail", "wc", "grep", "egrep", "fgrep", "jq", "diff", "cmp", "comm", "ls", "stat", "file", "du", "df", "echo", "cut", "tr", "paste", "column", "fold", "fmt", "expand", "rev", "od", "hexdump", "strings", "md5", "md5sum", "shasum", "sha1sum", "sha256sum", "basename", "dirname", "realpath", "readlink", "pwd", "which", "type", "test", "[", "[[", "true", "false", ":", "date", "id", "whoami", "uname", "printenv", "cd", "pushd", "popd", "sleep", "wait", "exit", "return", "break", "continue", "shift", "read", "for", "select", "case"].map((n) => [n, null]),
|
|
786
|
+
["printf", (a) => !a.some((x) => x.startsWith("-v"))],
|
|
787
|
+
["rg", (a) => !a.some((x) => /^--pre(?:=|$)/.test(x) || longOpt(x, ["pre"]))],
|
|
788
|
+
["sort", (a) => !a.some((x) => /^-[a-zA-Z]*o|^--(?:output|compress-program)/.test(x) || longOpt(x, ["output", "compress-program"]))],
|
|
789
|
+
["uniq", (a) => operands(a, /^-[fsw]$/).length <= 1],
|
|
790
|
+
["git", gitReadOnly],
|
|
791
|
+
["find", (a) => !a.some((x) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(x))],
|
|
792
|
+
["sed", sedReadOnly],
|
|
793
|
+
...[...AWKS].map((n) => [n, (a) => awkPrograms(a)?.every(awkPlain) ?? false]),
|
|
794
|
+
["xargs", xargsReadOnly],
|
|
795
|
+
["gh", (a) => !a.slice(0, 2).some((x) => x.startsWith("-")) && GH_READ.test(a.slice(0, 2).join(" ")) && !a.some((x) => /^(?:-O|--output|-D|--dir|--clobber)/.test(x) || longOpt(x, ["output", "dir", "clobber"]))],
|
|
796
|
+
["openspec", (a) => !/^(?:init|update)$/.test(a.find((x) => !x.startsWith("-")) ?? "")],
|
|
797
|
+
]);
|
|
798
|
+
|
|
799
|
+
/** Parses --ttl (minutes) and --fault after the script path. */
|
|
800
|
+
export function parseOptions(argv) {
|
|
801
|
+
const opts = { ttlMin: DEFAULT_TTL_MIN };
|
|
802
|
+
for (let i = 0; i < argv.length; i++) {
|
|
803
|
+
if (argv[i] === "--ttl") {
|
|
804
|
+
const n = Number.parseInt(argv[++i], 10);
|
|
805
|
+
if (Number.isInteger(n) && n > 0) opts.ttlMin = n;
|
|
806
|
+
} else if (argv[i] === "--fault") {
|
|
807
|
+
opts.fault = argv[++i];
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return opts;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Real paths of the temp roots that exist, plus each root as written: a path under an alias
|
|
815
|
+
* such as /tmp is also judged at its real path, so an alias never hides an escape.
|
|
816
|
+
*/
|
|
817
|
+
export function tmpRoots() {
|
|
818
|
+
const out = new Set();
|
|
819
|
+
for (const root of [os.tmpdir(), process.env.TMPDIR, "/tmp", "/private/tmp"]) {
|
|
820
|
+
if (!root) continue;
|
|
821
|
+
try {
|
|
822
|
+
out.add(fs.realpathSync.native(root));
|
|
823
|
+
out.add(path.resolve(root));
|
|
824
|
+
} catch {
|
|
825
|
+
// A missing root holds nothing to exempt.
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return [...out];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Real path of `p` in its on-disk form, or `p` made absolute when it cannot be resolved. */
|
|
832
|
+
function realOr(p) {
|
|
833
|
+
try {
|
|
834
|
+
return fs.realpathSync.native(p);
|
|
835
|
+
} catch {
|
|
836
|
+
return path.resolve(p);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Where a path is judged from; home, project and configDir are real paths.
|
|
842
|
+
* @typedef {object} Env
|
|
843
|
+
* @property {string} home The home directory.
|
|
844
|
+
* @property {string} project CLAUDE_PROJECT_DIR, else the payload cwd.
|
|
845
|
+
* @property {string} cwd The directory a relative operand resolves against.
|
|
846
|
+
* @property {string | null} configDir CLAUDE_CONFIG_DIR when set; every `~/.claude` rule applies to it.
|
|
847
|
+
* @property {string[]} tmpRoots See tmpRoots().
|
|
848
|
+
* @property {{ left: number }} realpathBudget Realpath calls left for this judgment.
|
|
849
|
+
*/
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Judgment env for a payload, resolved so every comparison is between on-disk forms.
|
|
853
|
+
* @returns {Env}
|
|
854
|
+
*/
|
|
855
|
+
export function defaultEnv(payload = {}) {
|
|
856
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
857
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ? realOr(process.env.CLAUDE_CONFIG_DIR) : null;
|
|
858
|
+
return { home: realOr(os.homedir()), project: realOr(process.env.CLAUDE_PROJECT_DIR || cwd), cwd, configDir, tmpRoots: tmpRoots(), realpathBudget: { left: MAX_REALPATHS } };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Lexical and real forms of an operand. The real form follows the kernel: symlinks resolve
|
|
863
|
+
* segment by segment before a `..` applies, the deepest existing ancestor is resolved with the
|
|
864
|
+
* missing segments rejoined, and a broken link is followed from the real path of its directory.
|
|
865
|
+
* @param {string} p The operand, absolute or relative to `env.cwd`.
|
|
866
|
+
* @param {Env} env Each realpath call spends one unit of its budget.
|
|
867
|
+
* @returns {{ paths: string[], legacy: boolean }} The lexical form first, the real form last when
|
|
868
|
+
* distinct; `legacy` when resolution stopped short (budget, loop, unreadable link) and the
|
|
869
|
+
* operand must also pass the name rules.
|
|
870
|
+
*/
|
|
871
|
+
export function resolveOperand(p, env) {
|
|
872
|
+
const written = String(p).replace(/\\/g, "/");
|
|
873
|
+
const paths = [path.resolve(env.cwd, written)];
|
|
874
|
+
let legacy = false;
|
|
875
|
+
const spend = () => {
|
|
876
|
+
if (env.realpathBudget.left <= 0) {
|
|
877
|
+
legacy = true;
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
env.realpathBudget.left--;
|
|
881
|
+
return true;
|
|
882
|
+
};
|
|
883
|
+
// Real path of the deepest existing ancestor of `abs`, which holds no `..`, plus the rest.
|
|
884
|
+
const climb = (abs) => {
|
|
885
|
+
const rest = [];
|
|
886
|
+
for (let dir = abs; ; ) {
|
|
887
|
+
tick();
|
|
888
|
+
if (!spend()) return null;
|
|
889
|
+
try {
|
|
890
|
+
return path.join(fs.realpathSync.native(dir), rest.join("/"));
|
|
891
|
+
} catch (e) {
|
|
892
|
+
if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") {
|
|
893
|
+
legacy = true;
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
let link = false;
|
|
898
|
+
try {
|
|
899
|
+
link = fs.lstatSync(dir).isSymbolicLink();
|
|
900
|
+
} catch {
|
|
901
|
+
// Missing: keep climbing.
|
|
902
|
+
}
|
|
903
|
+
if (link) followBroken(dir, rest);
|
|
904
|
+
const parent = path.dirname(dir);
|
|
905
|
+
if (parent === dir) return null;
|
|
906
|
+
rest.unshift(path.basename(dir));
|
|
907
|
+
dir = parent;
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
// Segments taken from the real directory `base`; a `..` pops the resolved form. Segments join
|
|
911
|
+
// as one string: one argument per segment overflows the stack.
|
|
912
|
+
const walk = (base, segs) => {
|
|
913
|
+
let cur = base;
|
|
914
|
+
let pending = [];
|
|
915
|
+
for (const s of segs) {
|
|
916
|
+
tick();
|
|
917
|
+
if (s === "" || s === ".") continue;
|
|
918
|
+
if (s !== "..") {
|
|
919
|
+
pending.push(s);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (pending.length > 0) {
|
|
923
|
+
cur = climb(path.join(cur, pending.join("/")));
|
|
924
|
+
if (cur === null) return null;
|
|
925
|
+
pending = [];
|
|
926
|
+
}
|
|
927
|
+
cur = path.dirname(cur);
|
|
928
|
+
}
|
|
929
|
+
return pending.length > 0 ? climb(path.join(cur, pending.join("/"))) : cur;
|
|
930
|
+
};
|
|
931
|
+
const followBroken = (link, rest) => {
|
|
932
|
+
if (!spend()) return;
|
|
933
|
+
let dir;
|
|
934
|
+
let target;
|
|
935
|
+
try {
|
|
936
|
+
dir = fs.realpathSync.native(path.dirname(link));
|
|
937
|
+
target = fs.readlinkSync(link);
|
|
938
|
+
} catch {
|
|
939
|
+
legacy = true;
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const real = walk(target.startsWith("/") ? "/" : dir, [...target.split("/"), ...rest]);
|
|
943
|
+
if (real !== null) paths.push(real);
|
|
944
|
+
};
|
|
945
|
+
const real = walk("/", (written.startsWith("/") ? written : `${env.cwd}/${written}`).split("/"));
|
|
946
|
+
if (real !== null) paths.push(real);
|
|
947
|
+
return { paths: [...new Set(paths)], legacy };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Absolute form with `/` separators, folded to NFC and full case as APFS compares names. */
|
|
951
|
+
function norm(p) {
|
|
952
|
+
return path.posix.resolve("/", String(p).replace(/\\/g, "/")).normalize("NFC").toUpperCase().toLowerCase();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Whether `p` is `root` or lies under it (both normalized). */
|
|
956
|
+
function within(p, root) {
|
|
957
|
+
return p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Whether the directory `up` levels above `file` holds `.claude-plugin/plugin.json`. */
|
|
961
|
+
function pluginRoot(file, up) {
|
|
962
|
+
let dir = String(file);
|
|
963
|
+
for (let i = 0; i < up; i++) dir = path.dirname(dir);
|
|
964
|
+
return fs.existsSync(path.join(dir, ".claude-plugin", "plugin.json"));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Whether the normalized path `p` lies in a temp root and outside the home, project and config dirs; with `around`, none of those under `p` either. */
|
|
968
|
+
function tempOnly(p, env, around = false) {
|
|
969
|
+
const kept = [env.home, env.project, ...(env.configDir ? [env.configDir] : [])].map(norm);
|
|
970
|
+
return !kept.some((r) => within(p, r) || (around && within(r, p))) && env.tmpRoots.some((t) => within(p, norm(t)));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Whether a resolved absolute path is live harness configuration by its location (Claude Code loads it, runs it, or removing its directory takes such a file with it); in a temp root only when it also lies under the home, project or config dir or is named like the marker.
|
|
974
|
+
* @param {string} absPath The resolved path.
|
|
975
|
+
* @param {Env} env
|
|
976
|
+
*/
|
|
977
|
+
export function liveTarget(absPath, env) {
|
|
978
|
+
const p = norm(absPath);
|
|
979
|
+
const reach = (root, exact) => (exact ? p === root : within(p, root));
|
|
980
|
+
return liveAt(p.split("/").filter(Boolean), p, env, { m: (seg, name) => seg === name, reach, plugin: (up) => pluginRoot(absPath, up) });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The location rules of liveTarget and patternLive over the target's path segments `s`; `at` is the normalized path the temp-root check judges.
|
|
984
|
+
* @param {(seg: string, name: string) => boolean} o.m Whether segment `seg` matches `name`.
|
|
985
|
+
* @param {(root: string, exact: boolean) => boolean} o.reach Whether `s` is `root` (`exact`) or lies under it.
|
|
986
|
+
* @param {(up: number) => boolean} o.plugin Whether the directory `up` levels above the target can be a plugin root.
|
|
987
|
+
* @param {boolean} [o.around] The temp-root exemption also fails when a home, project or config dir lies under `at`.
|
|
988
|
+
*/
|
|
989
|
+
function liveAt(s, at, env, { m, reach, plugin, around = false }) {
|
|
990
|
+
const last = s[s.length - 1];
|
|
991
|
+
const parent = s[s.length - 2];
|
|
992
|
+
if (m(last, MARKER)) return true;
|
|
993
|
+
if (tempOnly(at, env, around)) return false;
|
|
994
|
+
const home = norm(env.home);
|
|
995
|
+
const configDir = env.configDir ? norm(env.configDir) : null;
|
|
996
|
+
const userDirs = [...new Set([`${home}/.claude`, ...(configDir ? [configDir] : [])])];
|
|
997
|
+
const exact = [`${home}/.claude.json`, ...(configDir ? [`${configDir}/.claude.json`] : [])];
|
|
998
|
+
for (const d of userDirs) exact.push(d, `${d}/claude.md`, `${d}/settings.json`, `${d}/settings.local.json`, `${d}/freeharness`);
|
|
999
|
+
return (
|
|
1000
|
+
s.some((x, i) => m(x, "hooks") && (HOOK_OWNERS.some((o) => m(s[i - 1], o)) || (m(s[i - 1], "freeharness") && m(s[i - 2], ".claude")))) ||
|
|
1001
|
+
userDirs.some((d) => [`${d}/hooks`, `${d}/freeharness/hooks`, `${d}/plugins`].some((h) => reach(h, false))) ||
|
|
1002
|
+
exact.some((e) => reach(e, true)) ||
|
|
1003
|
+
m(last, ".mcp.json") ||
|
|
1004
|
+
(m(parent, ".claude") && (m(last, "settings.json") || m(last, "settings.local.json"))) ||
|
|
1005
|
+
m(last, ".claude") ||
|
|
1006
|
+
m(last, ".freeharness") ||
|
|
1007
|
+
(m(last, "hooks.json") && m(parent, "hooks") && plugin(2)) ||
|
|
1008
|
+
(m(last, "hooks") && plugin(1))
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Whether an Edit or Write of `file` touches live configuration, judged at its written and real
|
|
1014
|
+
* paths. An operand whose resolution stopped short is also judged by the name rules, except past
|
|
1015
|
+
* the realpath budget: a real path went unseen there, so the write is denied.
|
|
1016
|
+
* @param {string} file The file path, absolute or relative to `env.cwd`.
|
|
1017
|
+
* @param {Env} [env] Defaults to the process's home, cwd and temp roots.
|
|
1018
|
+
*/
|
|
1019
|
+
export function isProtectedPath(file, env = defaultEnv()) {
|
|
1020
|
+
const { paths, legacy } = resolveOperand(file, env);
|
|
1021
|
+
if (legacy && env.realpathBudget.left <= 0) return true;
|
|
1022
|
+
return paths.some((p) => liveTarget(p, env)) || (legacy && legacyProtected(paths[0], false));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** `p` with a leading `~` or `~/` made absolute; null for `~user`, `~+` and `~-`. */
|
|
1026
|
+
function expandTilde(p, home) {
|
|
1027
|
+
if (!p.startsWith("~")) return p;
|
|
1028
|
+
return p === "~" || p.startsWith("~/") ? home + p.slice(1) : null;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Whether `dir` is a directory the shell can enter. */
|
|
1032
|
+
function enterable(dir) {
|
|
1033
|
+
try {
|
|
1034
|
+
fs.accessSync(dir, fs.constants.X_OK);
|
|
1035
|
+
return fs.statSync(dir).isDirectory();
|
|
1036
|
+
} catch {
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Where relative paths resolve at a point of a Bash line: every candidate cwd (a cd that may not
|
|
1043
|
+
* run or may fail keeps the old one), `unresolved` once a cd the guard cannot follow ran, and the
|
|
1044
|
+
* pushd stack. Replaced, never mutated.
|
|
1045
|
+
* @typedef {{ cwds: string[], unresolved: boolean, stack: Dir[] }} Dir
|
|
1046
|
+
*/
|
|
1047
|
+
|
|
1048
|
+
/** A dir holding both candidates; `lost` also leaves it unresolved. */
|
|
1049
|
+
function mergeDirs(a, b, lost) {
|
|
1050
|
+
const cwds = [...new Set([...a.cwds, ...b.cwds])];
|
|
1051
|
+
return { cwds: cwds.slice(0, MAX_CWDS), unresolved: lost || a.unresolved || b.unresolved || cwds.length > MAX_CWDS, stack: a.stack === b.stack ? a.stack : [] };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Texts `src` takes with each of its `spans` (read lazily) replaced by every value `valuesOf(name, index)` gives; null once it gives null (the caller's unknown-name policy) or past `max` texts. */
|
|
1055
|
+
function expandNames(src, valuesOf, max, spans = namedSpans(src)) {
|
|
1056
|
+
let out = [""];
|
|
1057
|
+
let last = 0;
|
|
1058
|
+
for (const { index, end, name } of spans) {
|
|
1059
|
+
const values = valuesOf(name, index);
|
|
1060
|
+
if (values === null) return null;
|
|
1061
|
+
const lit = src.slice(last, index);
|
|
1062
|
+
out = out.flatMap((w) => values.map((v) => w + lit + v));
|
|
1063
|
+
if (out.length > max) return null;
|
|
1064
|
+
last = end;
|
|
1065
|
+
}
|
|
1066
|
+
return out.map((w) => w + src.slice(last));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** The `${name}` expansions of `src`. */
|
|
1070
|
+
function* namedSpans(src) {
|
|
1071
|
+
for (const m of src.matchAll(NAMED_EXP)) yield { index: m.index, end: m.index + m[0].length, name: m[1] };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Whether a literal path is live: resolved against each candidate cwd and judged by location,
|
|
1076
|
+
* and by name too when resolution stopped short, or when it is relative and the cwd unresolved.
|
|
1077
|
+
*/
|
|
1078
|
+
function pathLive(p, ctx) {
|
|
1079
|
+
const segs = segments(p);
|
|
1080
|
+
if (p === "" || (segs.length === 1 && segs[0] === "~")) return legacyProtected(p, false);
|
|
1081
|
+
const abs = expandTilde(p, ctx.env.home);
|
|
1082
|
+
if (abs === null) return ownLive(p, false, ctx);
|
|
1083
|
+
const relative = !abs.startsWith("/");
|
|
1084
|
+
if (relative && ctx.dir.unresolved) return ownLive(abs, false, ctx);
|
|
1085
|
+
return (relative ? ctx.dir.cwds : [ctx.env.cwd]).some((cwd) => {
|
|
1086
|
+
const env = { ...ctx.env, cwd };
|
|
1087
|
+
const { paths, legacy } = resolveOperand(abs, env);
|
|
1088
|
+
return paths.some((x) => liveTarget(x, env)) || (legacy && legacyProtected(paths[0], false));
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Whether some expansion of a glob word can be live. Braces expand first; the literal head
|
|
1094
|
+
* (the cwd when there is none) resolves like a path; a tail of bare wildcards is expanded on disk
|
|
1095
|
+
* (wildLive, starLive for a lone `*`), any other is matched against the location rules
|
|
1096
|
+
* (patternLive); a `**` and a `..` after a wildcard keep the name rules.
|
|
1097
|
+
*/
|
|
1098
|
+
function globLive(p, ctx) {
|
|
1099
|
+
if (BRACES.test(p)) return braceVariants(p).some((v) => operandLive(v, ctx));
|
|
1100
|
+
const star = STAR.exec(p);
|
|
1101
|
+
if (star && !hasGlob(star[1] ?? "")) return starLive(star[1] ?? ".", p, ctx);
|
|
1102
|
+
const bare = segments(p);
|
|
1103
|
+
while (bare.length > 0 && PURE_WILD.test(bare[bare.length - 1])) bare.pop();
|
|
1104
|
+
const abs = expandTilde(p, ctx.env.home);
|
|
1105
|
+
const relative = abs !== null && !abs.startsWith("/");
|
|
1106
|
+
if (abs === null || (relative && ctx.dir.unresolved)) return ownLive(p, true, ctx);
|
|
1107
|
+
if ((bare.length === 0 && !relative) || (bare.length === 1 && bare[0] === "~")) return legacyProtected(p, true);
|
|
1108
|
+
const segs = abs.split("/");
|
|
1109
|
+
const first = segs.findIndex(hasGlob);
|
|
1110
|
+
if (first < 0) return pathLive(p, ctx);
|
|
1111
|
+
const tail = segs.slice(first).filter((s) => s !== "" && s !== ".");
|
|
1112
|
+
if (tail.includes("..") || tail.some((s) => s.includes("**"))) return legacyProtected(p, true);
|
|
1113
|
+
// A tail of bare wildcards is expanded on disk; in another tail, trailing ones stand for the
|
|
1114
|
+
// contents of what the rest matches.
|
|
1115
|
+
const bareTail = tail.every((s) => PURE_WILD.test(s));
|
|
1116
|
+
if (!bareTail) while (PURE_WILD.test(tail[tail.length - 1])) tail.pop();
|
|
1117
|
+
const head = segs.slice(0, first).join("/") || (relative ? "." : "/");
|
|
1118
|
+
const folded = tail.map((s) => s.normalize("NFC").toUpperCase().toLowerCase());
|
|
1119
|
+
const dots = ctx.dots;
|
|
1120
|
+
return (relative ? ctx.dir.cwds : [ctx.env.cwd]).some((cwd) => {
|
|
1121
|
+
const env = { ...ctx.env, cwd };
|
|
1122
|
+
const { paths, legacy } = resolveOperand(head, env);
|
|
1123
|
+
// wildLive lists the disk itself, so past the realpath budget only the head takes the name rules.
|
|
1124
|
+
return (legacy && (bareTail ? legacyProtected(head, false) : legacyProtected(p, true))) || paths.some((x) => (bareTail ? wildLive(x, tail, dots, env) : patternLive(x, folded, env, dots)));
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Whether bare-wildcard segments `tail` expand under the directory `dir` to live configuration,
|
|
1130
|
+
* each level listed as the shell would (a dot name only for a dot pattern or under dotglob) and
|
|
1131
|
+
* each match judged at its written and real path; an unreadable directory or more than
|
|
1132
|
+
* MAX_LISTED entries in all counts.
|
|
1133
|
+
*/
|
|
1134
|
+
function wildLive(dir, tail, dots, env, listed = { n: 0 }) {
|
|
1135
|
+
if (tail.length === 0) return liveTarget(dir, env) || liveTarget(realOr(dir), env);
|
|
1136
|
+
const [seg, ...rest] = tail;
|
|
1137
|
+
let handle;
|
|
1138
|
+
try {
|
|
1139
|
+
handle = fs.opendirSync(dir);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
// A pattern that matches nothing reaches the command as written.
|
|
1142
|
+
return e?.code !== "ENOENT" && e?.code !== "ENOTDIR" ? true : liveTarget(path.join(dir, ...tail), env);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
for (;;) {
|
|
1146
|
+
tick();
|
|
1147
|
+
const entry = handle.readSync();
|
|
1148
|
+
if (entry === null) return false;
|
|
1149
|
+
if (++listed.n > MAX_LISTED) return true;
|
|
1150
|
+
const name = entry.name;
|
|
1151
|
+
if ((dots || !name.startsWith(".") || /^[.[]/.test(seg)) && globMatch(seg, name) && wildLive(path.join(dir, name), rest, dots, env, listed)) return true;
|
|
1152
|
+
}
|
|
1153
|
+
} finally {
|
|
1154
|
+
handle.closeSync();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Whether a trailing `*` in `dir` can expand to live configuration: the directory, listed once,
|
|
1160
|
+
* directly holds a live name (a dot name only under a same-line dotglob) or more than
|
|
1161
|
+
* MAX_LISTED entries; an unresolved directory keeps the name rules.
|
|
1162
|
+
*/
|
|
1163
|
+
function starLive(dir, p, ctx) {
|
|
1164
|
+
const abs = expandTilde(dir, ctx.env.home);
|
|
1165
|
+
if (abs === null) return ownLive(p, true, ctx);
|
|
1166
|
+
const relative = !abs.startsWith("/");
|
|
1167
|
+
if (relative && ctx.dir.unresolved) return ownLive(p, true, ctx);
|
|
1168
|
+
const dots = ctx.dots;
|
|
1169
|
+
return (relative ? ctx.dir.cwds : [ctx.env.cwd]).some((cwd) => {
|
|
1170
|
+
const env = { ...ctx.env, cwd };
|
|
1171
|
+
const { paths, legacy } = resolveOperand(abs, env);
|
|
1172
|
+
return (legacy && legacyProtected(p, true)) || paths.some((x) => holdsLive(x, dots, env));
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Whether the directory `dir` directly holds a live entry or more than MAX_LISTED; an unreadable one counts. */
|
|
1177
|
+
function holdsLive(dir, dots, env) {
|
|
1178
|
+
let handle;
|
|
1179
|
+
try {
|
|
1180
|
+
handle = fs.opendirSync(dir);
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
return e?.code !== "ENOENT" && e?.code !== "ENOTDIR";
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
for (let n = 0; ; n++) {
|
|
1186
|
+
const entry = handle.readSync();
|
|
1187
|
+
if (entry === null) return false;
|
|
1188
|
+
if (n >= MAX_LISTED) return true;
|
|
1189
|
+
if ((dots || !entry.name.startsWith(".")) && liveTarget(path.join(dir, entry.name), env)) return true;
|
|
1190
|
+
}
|
|
1191
|
+
} catch {
|
|
1192
|
+
return true;
|
|
1193
|
+
} finally {
|
|
1194
|
+
handle.closeSync();
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** liveTarget's rules for the resolved head `real` plus folded `tail` segments (one a wildcard), each name matched as a glob, a leading wildcard matching a dot name under `dots`; a temp-root head passes unless a home, project or config dir lies on either side of it or the tail can end in the marker name. */
|
|
1199
|
+
function patternLive(real, tail, env, dots) {
|
|
1200
|
+
const headSegs = real.split("/").filter(Boolean);
|
|
1201
|
+
const at = norm(real);
|
|
1202
|
+
const s = [...at.split("/").filter(Boolean), ...tail];
|
|
1203
|
+
const m = (seg, name) => seg !== undefined && (hasGlob(seg) ? globMatch(seg, name) && (dots || !name.startsWith(".") || /^[.[]/.test(seg)) : seg === name);
|
|
1204
|
+
const reach = (root, exact) => {
|
|
1205
|
+
const r = root.split("/").filter(Boolean);
|
|
1206
|
+
return (exact ? s.length === r.length : s.length >= r.length) && r.every((x, i) => m(s[i], x));
|
|
1207
|
+
};
|
|
1208
|
+
// A plugin root inside the literal head is checked on disk; one a wildcard names may hold one.
|
|
1209
|
+
const plugin = (up) => s.length - up > headSegs.length || fs.existsSync(path.join("/", headSegs.slice(0, s.length - up).join("/"), ".claude-plugin", "plugin.json"));
|
|
1210
|
+
return liveAt(s, at, env, { m, reach, plugin, around: true });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Whether a word can expand as a glob (brace expansion included). */
|
|
1214
|
+
function hasGlob(s) {
|
|
1215
|
+
const open = s.indexOf("[");
|
|
1216
|
+
return /[*?]/.test(s) || (open >= 0 && s.lastIndexOf("]") > open) || BRACES.test(s);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Whether a glob segment can match `name`; a class matches any one character. */
|
|
1220
|
+
function globMatch(pattern, name) {
|
|
1221
|
+
const tokens = [];
|
|
1222
|
+
const lastClose = pattern.lastIndexOf("]");
|
|
1223
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1224
|
+
const c = pattern[i];
|
|
1225
|
+
if (c === "*" || c === "?") tokens.push(c);
|
|
1226
|
+
else if (c === "[" && i + 2 <= lastClose) {
|
|
1227
|
+
tokens.push("?");
|
|
1228
|
+
i = pattern.indexOf("]", i + 2);
|
|
1229
|
+
} else tokens.push(c.toLowerCase());
|
|
1230
|
+
}
|
|
1231
|
+
const n = name.toLowerCase();
|
|
1232
|
+
let row = [true, ...Array(n.length).fill(false)];
|
|
1233
|
+
for (const t of tokens) {
|
|
1234
|
+
const next = [t === "*" && row[0]];
|
|
1235
|
+
for (let j = 1; j <= n.length; j++) next[j] = t === "*" ? next[j - 1] || row[j] : row[j - 1] && (t === "?" || t === n[j - 1]);
|
|
1236
|
+
row = next;
|
|
1237
|
+
}
|
|
1238
|
+
return row[n.length];
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Brace expansions of a word; past 64 variants or 4,096 chars the braces become one `*` or `.*`.
|
|
1243
|
+
* With `exact`, only what the shell can produce: a numeric range lists its numbers and a bracket
|
|
1244
|
+
* class its characters; a class it cannot list, or past 64 words, stands for any one character.
|
|
1245
|
+
*/
|
|
1246
|
+
function braceVariants(p, exact = false) {
|
|
1247
|
+
let out = [p];
|
|
1248
|
+
while (out.some((v) => BRACES.test(v))) {
|
|
1249
|
+
const next = [];
|
|
1250
|
+
for (const v of out) {
|
|
1251
|
+
const m = BRACES.exec(v);
|
|
1252
|
+
const range = exact && /^\{(0|-?[1-9]\d{0,5})\.\.(0|-?[1-9]\d{0,5})\}$/.exec(m?.[0] ?? "");
|
|
1253
|
+
const nums = range && Math.abs(range[2] - range[1]) < 64 ? Array.from({ length: Math.abs(range[2] - range[1]) + 1 }, (_, k) => String(+range[1] + (+range[2] < +range[1] ? -k : k))) : ["*"];
|
|
1254
|
+
if (!m) next.push(v);
|
|
1255
|
+
else for (const alt of m[0].includes(",") ? m[0].slice(1, -1).split(",") : nums) next.push(v.slice(0, m.index) + alt + v.slice(m.index + m[0].length));
|
|
1256
|
+
}
|
|
1257
|
+
if (next.length > 64 || next.length * p.length > 4096) {
|
|
1258
|
+
out = ["*", ".*"].map((w) => `${p.slice(0, p.indexOf("{"))}${w}${p.slice(p.lastIndexOf("}") + 1)}`);
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
out = next;
|
|
1262
|
+
}
|
|
1263
|
+
if (!exact) return out;
|
|
1264
|
+
const posix = { alpha: "a-zA-Z", upper: "A-Z", lower: "a-z", digit: "0-9", alnum: "a-zA-Z0-9", xdigit: "0-9a-fA-F", space: " \t\n\r\v\f", blank: " \t", punct: "!-/:-@[-`{-~" };
|
|
1265
|
+
// A class lists its characters (null past 64 words), else stands for one `?`; an unclosed `[` is literal.
|
|
1266
|
+
const classes = (v) => {
|
|
1267
|
+
const n = v.length;
|
|
1268
|
+
// Every class end comes from one right-to-left pass: a scan per `[` would make a word of openers cubic.
|
|
1269
|
+
const sub = Object.fromEntries([":", ".", "="].map((t) => [t, new Int32Array(n + 2).fill(-1)]));
|
|
1270
|
+
const end = new Int32Array(n + 2).fill(-1);
|
|
1271
|
+
for (let q = n - 1; q >= 0; q--) {
|
|
1272
|
+
for (const t of ":.=") sub[t][q] = v[q] === t && v[q + 1] === "]" ? q : sub[t][q + 1];
|
|
1273
|
+
const close = v[q] === "[" && q + 1 < n && ":.=".includes(v[q + 1]) ? sub[v[q + 1]][q + 2] : -1;
|
|
1274
|
+
end[q] = v[q] === "]" ? q : close >= 0 ? end[close + 2] : end[q + 1];
|
|
1275
|
+
}
|
|
1276
|
+
const parts = [];
|
|
1277
|
+
let lit = "";
|
|
1278
|
+
let wild = "";
|
|
1279
|
+
for (let i = 0; i < n; i++) {
|
|
1280
|
+
let k = i + 1 + (v[i + 1] === "!" || v[i + 1] === "^" ? 1 : 0);
|
|
1281
|
+
if (v[k] === "]") k++;
|
|
1282
|
+
const e = v[i] === "[" && k < n ? end[k] : -1;
|
|
1283
|
+
if (e < 0) {
|
|
1284
|
+
lit += v[i];
|
|
1285
|
+
wild += v[i];
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
const raw = v.slice(i + 1, e);
|
|
1289
|
+
const body = raw.replace(/\[:(\w+):\]/g, (_, name) => posix[name] ?? "\0");
|
|
1290
|
+
let chars = !/^[!^]|\[[.=]|\0|\\/.test(raw) && !body.includes("\0") ? new Set() : null;
|
|
1291
|
+
for (let j = 0; chars !== null && j < body.length; j++) {
|
|
1292
|
+
const [a, z] = body[j + 1] === "-" && j + 2 < body.length ? [body.charCodeAt(j), body.charCodeAt((j += 2))] : [body.charCodeAt(j), body.charCodeAt(j)];
|
|
1293
|
+
for (let c = a; c <= z && chars.size <= 64; c++) chars.add(String.fromCharCode(c));
|
|
1294
|
+
if (z < a || chars.size > 64) chars = null;
|
|
1295
|
+
}
|
|
1296
|
+
parts.push(lit, chars);
|
|
1297
|
+
lit = "";
|
|
1298
|
+
wild += "?";
|
|
1299
|
+
i = e;
|
|
1300
|
+
}
|
|
1301
|
+
parts.push(lit);
|
|
1302
|
+
let words = parts.includes(null) ? null : [""];
|
|
1303
|
+
for (const part of words === null ? [] : parts) {
|
|
1304
|
+
words = typeof part === "string" ? words.map((s) => s + part) : words.flatMap((s) => [...part].map((c) => s + c));
|
|
1305
|
+
if (words.length > 64) return { words: null, wild };
|
|
1306
|
+
}
|
|
1307
|
+
return { words, wild };
|
|
1308
|
+
};
|
|
1309
|
+
const listed = out.map(classes);
|
|
1310
|
+
return listed.some((l) => l.words === null) || listed.flatMap((l) => l.words).length > 64 ? listed.map((l) => l.wild) : listed.flatMap((l) => l.words);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** Path segments with empty, `.` and resolvable `..` segments removed. */
|
|
1314
|
+
function segments(p) {
|
|
1315
|
+
const out = [];
|
|
1316
|
+
for (const seg of p.split("/")) {
|
|
1317
|
+
if (seg === "" || seg === ".") continue;
|
|
1318
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
1319
|
+
else out.push(seg);
|
|
1320
|
+
}
|
|
1321
|
+
return out;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Whether a path names a protected file, a hooks directory or one of their parents, by name
|
|
1326
|
+
* alone: the rule for Bash text and for an operand whose location cannot be resolved.
|
|
1327
|
+
* @param {string} p The path.
|
|
1328
|
+
* @param {boolean} glob Treat wildcards as patterns that can expand to a protected path.
|
|
1329
|
+
* @param {boolean} [scripts] Count a hook script name outside a hooks dir.
|
|
1330
|
+
*/
|
|
1331
|
+
function legacyProtected(p, glob, scripts = true) {
|
|
1332
|
+
if (glob && BRACES.test(p)) return braceVariants(p).some((v) => legacyProtected(v, true, scripts));
|
|
1333
|
+
const segs = segments(p);
|
|
1334
|
+
if (segs.some((s) => s.toLowerCase() === "node_modules")) return false;
|
|
1335
|
+
const wild = (s) => glob && hasGlob(s);
|
|
1336
|
+
const is = (s, name) => (wild(s) ? globMatch(s, name) : s.toLowerCase() === name);
|
|
1337
|
+
// A bare wildcard stands for its directory's contents: the project root, or a protected dir.
|
|
1338
|
+
let pure = false;
|
|
1339
|
+
while (segs.length > 0 && wild(segs[segs.length - 1]) && PURE_WILD.test(segs[segs.length - 1])) {
|
|
1340
|
+
segs.pop();
|
|
1341
|
+
pure = true;
|
|
1342
|
+
}
|
|
1343
|
+
const n = segs.length;
|
|
1344
|
+
if (n === 0) return pure;
|
|
1345
|
+
const last = segs[n - 1];
|
|
1346
|
+
const hooksAt = (end) => end >= 2 && is(segs[end - 1], "hooks") && HOOK_PARENTS.some((h) => is(segs[end - 2], h));
|
|
1347
|
+
return (
|
|
1348
|
+
(!pure && (wild(last) ? BASE_SAMPLES.some((b) => (scripts || !SCRIPT.test(b)) && globMatch(last, b)) : BASE.test(last) && (scripts || !SCRIPT.test(last)))) ||
|
|
1349
|
+
(!pure && hooksAt(n - 1)) ||
|
|
1350
|
+
hooksAt(n) ||
|
|
1351
|
+
is(last, ".claude") ||
|
|
1352
|
+
is(last, ".freeharness") ||
|
|
1353
|
+
(n >= 2 && is(segs[n - 2], ".claude") && is(last, "freeharness")) ||
|
|
1354
|
+
segs.some((s, k) => k + 1 < n && is(s, ".claude") && is(segs[k + 1], "plugins")) ||
|
|
1355
|
+
(n === 1 && last === "~")
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Length of the variable name at `i`, or 0. */
|
|
1360
|
+
function nameAt(s, i) {
|
|
1361
|
+
NAME_AT.lastIndex = i;
|
|
1362
|
+
return NAME_AT.exec(s)?.[0].length ?? 0;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* The expansion at `i`: `$name`, `${...}`, `$(...)` or a backtick.
|
|
1367
|
+
* @returns {{end: number, words: string[]} | null} The index after it and the words it can
|
|
1368
|
+
* yield besides its value (an operator's word or replacement); null when none starts here.
|
|
1369
|
+
*/
|
|
1370
|
+
function expansionAt(t, i) {
|
|
1371
|
+
if (t[i] === "`") {
|
|
1372
|
+
const end = quoteEnd(t, i);
|
|
1373
|
+
return end < 0 ? null : { end, words: [] };
|
|
1374
|
+
}
|
|
1375
|
+
if (t[i] !== "$") return null;
|
|
1376
|
+
const open = t[i + 1];
|
|
1377
|
+
if (open === "(" || open === "{") {
|
|
1378
|
+
const end = closeGroup(t, i + 2, open, open === "(" ? ")" : "}");
|
|
1379
|
+
if (end < 0) return null;
|
|
1380
|
+
return { end, words: open === "{" ? expansionWords(t.slice(i + 2, end - 1), true) : [] };
|
|
1381
|
+
}
|
|
1382
|
+
EXP_NAME.lastIndex = i + 1;
|
|
1383
|
+
const m = EXP_NAME.exec(t);
|
|
1384
|
+
return m === null ? null : { end: i + 1 + m[0].length, words: [] };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** Words a `${...}` body can yield after its head (a nested `${...}` head included); `yielded` drops pattern operands. */
|
|
1388
|
+
function expansionWords(body, yielded = false) {
|
|
1389
|
+
let head = EXP_FLAGS.exec(body)[0].length;
|
|
1390
|
+
const open = body[head + 1];
|
|
1391
|
+
if (body[head] === "$" && (open === "{" || open === "(")) head = closeGroup(body, head + 2, open, open === "{" ? "}" : ")");
|
|
1392
|
+
else {
|
|
1393
|
+
EXP_NAME.lastIndex = head;
|
|
1394
|
+
head += EXP_NAME.exec(body)?.[0].length ?? 0;
|
|
1395
|
+
}
|
|
1396
|
+
if (head < 0) return [body];
|
|
1397
|
+
if (body[head] === "[") head = body.indexOf("]", head) + 1 || body.length;
|
|
1398
|
+
const rest = body.slice(head);
|
|
1399
|
+
if (rest === "") return [];
|
|
1400
|
+
const op = EXP_OP.exec(rest)?.[0];
|
|
1401
|
+
if (yielded && (op === undefined || /^(?:##?|%%?|:|\^\^?|,,?|@)$/.test(op))) return [];
|
|
1402
|
+
const after = rest.slice(op?.length ?? 0);
|
|
1403
|
+
if (yielded && op.startsWith("/")) return after.includes("/") ? [after.slice(after.indexOf("/") + 1)] : [];
|
|
1404
|
+
return rest.startsWith("/") && after.includes("/") ? [after, after.slice(after.indexOf("/") + 1)] : [after];
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** Index after the quote closing the one at `i`; -1 if unterminated. */
|
|
1408
|
+
function quoteEnd(s, i) {
|
|
1409
|
+
const q = s[i];
|
|
1410
|
+
let j = i + 1;
|
|
1411
|
+
while (j < s.length && s[j] !== q) j += q !== "'" && s[j] === "\\" ? 2 : 1;
|
|
1412
|
+
return j < s.length ? j + 1 : -1;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Index after the ANSI-C quote opened at `i` (the `'` after `$`); -1 if unterminated. */
|
|
1416
|
+
function ansiEnd(s, i) {
|
|
1417
|
+
let j = i + 1;
|
|
1418
|
+
while (j < s.length && s[j] !== "'") j += s[j] === "\\" ? 2 : 1;
|
|
1419
|
+
return j < s.length ? j + 1 : -1;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const ANSI = { a: "\x07", b: "\b", e: "\x1b", E: "\x1b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
1423
|
+
|
|
1424
|
+
/** Decodes the body of a `$'...'` word the way Bash does. */
|
|
1425
|
+
function decodeAnsi(body) {
|
|
1426
|
+
return body.replace(/\\(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{1,4})|U([0-9a-fA-F]{1,8})|([0-7]{1,3})|c(.)|(.))/gs, (_, x, u, U, o, c, ch) => {
|
|
1427
|
+
if (x || u || U) return String.fromCodePoint(Math.min(Number.parseInt(x || u || U, 16), 0x10ffff));
|
|
1428
|
+
if (o) return String.fromCharCode(Number.parseInt(o, 8) & 0xff);
|
|
1429
|
+
if (c) return String.fromCharCode(c.charCodeAt(0) & 0x1f);
|
|
1430
|
+
return ANSI[ch] ?? ch;
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Index after the closer of a group opened before `j`, skipping quotes; -1 if unterminated.
|
|
1436
|
+
* With `noSingle` a `'` returns -1: zsh reads it literally inside a double-quoted `${...}`.
|
|
1437
|
+
*/
|
|
1438
|
+
function closeGroup(s, j, open, close, noSingle = false) {
|
|
1439
|
+
let depth = 1;
|
|
1440
|
+
while (j < s.length) {
|
|
1441
|
+
const c = s[j];
|
|
1442
|
+
if (c === "\\") j += 2;
|
|
1443
|
+
else if (c === "'" && noSingle) return -1;
|
|
1444
|
+
else if (c === "$" && s[j + 1] === "'") {
|
|
1445
|
+
j = ansiEnd(s, j + 1);
|
|
1446
|
+
if (j < 0) return -1;
|
|
1447
|
+
} else if (c === "'" || c === '"' || c === "`") {
|
|
1448
|
+
j = quoteEnd(s, j);
|
|
1449
|
+
if (j < 0) return -1;
|
|
1450
|
+
} else {
|
|
1451
|
+
if (c === open) depth++;
|
|
1452
|
+
else if (c === close && --depth === 0) return j + 1;
|
|
1453
|
+
j++;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return -1;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* End of a substitution at `i` (`$(`, `${`, backtick; `<(`/`>(` outside double quotes).
|
|
1461
|
+
* @returns {number} `i` when none starts here, the index after it, or -1 when unterminated.
|
|
1462
|
+
*/
|
|
1463
|
+
function substitutionEnd(s, i, inDouble) {
|
|
1464
|
+
const c = s[i];
|
|
1465
|
+
const n = s[i + 1];
|
|
1466
|
+
if (c === "`") return quoteEnd(s, i);
|
|
1467
|
+
if (n === "(" && (c === "$" || (!inDouble && (c === "<" || c === ">")))) return closeGroup(s, i + 2, "(", ")");
|
|
1468
|
+
if (c === "$" && n === "{") return closeGroup(s, i + 2, "{", "}", inDouble);
|
|
1469
|
+
return i;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Splits a Bash command into simple commands of unquoted words. Separators are `;`, `&&`,
|
|
1474
|
+
* `||`, `|`, `&`, newlines and grouping parentheses; redirect operators are words of their
|
|
1475
|
+
* own; substitutions stay verbatim inside their word; a heredoc's operand word is its body;
|
|
1476
|
+
* `$'...'` is decoded and `$"..."` read as a double-quoted string; `$name` becomes `${name}`
|
|
1477
|
+
* (except in a heredoc delimiter), so a quote or escape after a name still ends it. Each
|
|
1478
|
+
* command's place is recorded in PLACES; a `)` inside a `case` never closes a subshell there,
|
|
1479
|
+
* and a case pattern without an expansion is dropped, keeping `case <subject>` as a command.
|
|
1480
|
+
* @param {string} command The command.
|
|
1481
|
+
* @returns {string[][] | null} Simple commands, or null on an unterminated quote, group or case
|
|
1482
|
+
* pattern, a parenthesis after a word, or a `'` inside a double-quoted `${...}`.
|
|
1483
|
+
*/
|
|
1484
|
+
export function splitCommands(command) {
|
|
1485
|
+
const s = String(command);
|
|
1486
|
+
const cmds = [];
|
|
1487
|
+
let words = [];
|
|
1488
|
+
let word = "";
|
|
1489
|
+
let inWord = false;
|
|
1490
|
+
let quoted = false;
|
|
1491
|
+
let pendingDoc = null;
|
|
1492
|
+
const docs = [];
|
|
1493
|
+
const shells = [];
|
|
1494
|
+
let opened = 0;
|
|
1495
|
+
let cases = 0;
|
|
1496
|
+
// Index in `words` of an opening `case` word and of a case pattern's first word; -1 for none.
|
|
1497
|
+
let opening = -1;
|
|
1498
|
+
let arm = -1;
|
|
1499
|
+
let piped = false;
|
|
1500
|
+
let joint = "";
|
|
1501
|
+
let groups = 0;
|
|
1502
|
+
let list = 0;
|
|
1503
|
+
let spans = [];
|
|
1504
|
+
// 1 after a `function` keyword, 2 once its name is dropped (an optional `()` may follow).
|
|
1505
|
+
let fn = 0;
|
|
1506
|
+
const literal = (text) => {
|
|
1507
|
+
if (/[$`]/.test(text)) spans.push([word.length, word.length + text.length]);
|
|
1508
|
+
word += text;
|
|
1509
|
+
};
|
|
1510
|
+
const dropWord = () => {
|
|
1511
|
+
word = "";
|
|
1512
|
+
spans = [];
|
|
1513
|
+
inWord = quoted = false;
|
|
1514
|
+
};
|
|
1515
|
+
const endWord = () => {
|
|
1516
|
+
if (!inWord) return;
|
|
1517
|
+
// A definition header is no command: dropping it leaves the body to split like any group.
|
|
1518
|
+
if (fn === 1 && !pendingDoc) {
|
|
1519
|
+
fn = 2;
|
|
1520
|
+
return dropWord();
|
|
1521
|
+
}
|
|
1522
|
+
fn = 0;
|
|
1523
|
+
if (!quoted && !pendingDoc && word === "function" && words.every((w) => KEYWORDS.has(w))) {
|
|
1524
|
+
fn = 1;
|
|
1525
|
+
return dropWord();
|
|
1526
|
+
}
|
|
1527
|
+
if (pendingDoc) docs.push({ ...pendingDoc, delim: word, quoted, words, index: words.length });
|
|
1528
|
+
pendingDoc = null;
|
|
1529
|
+
const lead = !quoted && words.every((w) => KEYWORDS.has(w));
|
|
1530
|
+
if (lead && arm < 0 && word === "case") {
|
|
1531
|
+
cases++;
|
|
1532
|
+
opening = words.length;
|
|
1533
|
+
}
|
|
1534
|
+
if (lead && word === "esac") {
|
|
1535
|
+
cases = Math.max(0, cases - 1);
|
|
1536
|
+
arm = -1;
|
|
1537
|
+
}
|
|
1538
|
+
if (!quoted && word === "in" && opening >= 0 && words.length === opening + 2) arm = words.length + 1;
|
|
1539
|
+
if (!quoted && words.every((w) => KEYWORDS.has(w))) groups = Math.max(0, groups + (OPENERS.has(word) ? 1 : CLOSERS.has(word) ? -1 : 0));
|
|
1540
|
+
const expanding = (word.match(/[$`]/g)?.length ?? 0) - spans.reduce((n, [a, b]) => n + word.slice(a, b).match(/[$`]/g).length, 0);
|
|
1541
|
+
if (expanding > 0) EXPANDING.add(word);
|
|
1542
|
+
if (spans.length > 0) {
|
|
1543
|
+
if (!LITERALS.has(words)) LITERALS.set(words, new Map());
|
|
1544
|
+
LITERALS.get(words).set(words.length, spans);
|
|
1545
|
+
LITERAL_WORDS.add(word);
|
|
1546
|
+
if (expanding === 0) LITERAL_ONLY.add(word);
|
|
1547
|
+
}
|
|
1548
|
+
words.push(word);
|
|
1549
|
+
word = "";
|
|
1550
|
+
spans = [];
|
|
1551
|
+
inWord = quoted = false;
|
|
1552
|
+
};
|
|
1553
|
+
// `ends` is false where a parenthesis, not a separator, ends the command.
|
|
1554
|
+
const endCommand = (sep = ";", ends = true) => {
|
|
1555
|
+
endWord();
|
|
1556
|
+
if (words.length > 0) {
|
|
1557
|
+
cmds.push(words);
|
|
1558
|
+
PLACES.set(words, { isolated: piped || sep === "|" || sep === "&", piped, joint, next: sep, shells: [...shells] });
|
|
1559
|
+
piped = false;
|
|
1560
|
+
joint = "";
|
|
1561
|
+
}
|
|
1562
|
+
if (sep === "&&" || sep === "||") joint = sep;
|
|
1563
|
+
if (sep === "|") piped = true;
|
|
1564
|
+
// `&` sends the whole and-or list to the background, with every group it closes.
|
|
1565
|
+
if (sep === "&") for (const c of cmds.slice(list)) PLACES.get(c).late = true;
|
|
1566
|
+
if (sep === "&" || (ends && sep === ";" && !joint && !piped && shells.length === 0 && groups === 0)) list = cmds.length;
|
|
1567
|
+
words = [];
|
|
1568
|
+
opening = -1;
|
|
1569
|
+
if (arm > 0) arm = 0;
|
|
1570
|
+
};
|
|
1571
|
+
const readDocs = (pos) => {
|
|
1572
|
+
for (const d of docs.splice(0)) {
|
|
1573
|
+
let body = "";
|
|
1574
|
+
while (pos < s.length) {
|
|
1575
|
+
const nl = s.indexOf("\n", pos);
|
|
1576
|
+
const line = s.slice(pos, nl < 0 ? s.length : nl);
|
|
1577
|
+
pos = nl < 0 ? s.length : nl + 1;
|
|
1578
|
+
if ((d.strip ? line.replace(/^\t+/, "") : line) === d.delim) break;
|
|
1579
|
+
body += `${line}\n`;
|
|
1580
|
+
}
|
|
1581
|
+
d.words[d.index] = body;
|
|
1582
|
+
LITERALS.get(d.words)?.delete(d.index);
|
|
1583
|
+
if (d.quoted) QUOTED_DOCS.set(d.words, new Set([...(QUOTED_DOCS.get(d.words) ?? []), d.index]));
|
|
1584
|
+
}
|
|
1585
|
+
return pos;
|
|
1586
|
+
};
|
|
1587
|
+
let parens = null;
|
|
1588
|
+
// Whether a `(` here opens the `()` of a definition: a plain name after keywords only, or a `function` name.
|
|
1589
|
+
const funcHead = (open) => {
|
|
1590
|
+
if (fn === 2) return true;
|
|
1591
|
+
const name = open ? (quoted ? "" : word) : (words.at(-1) ?? "");
|
|
1592
|
+
return FUNC_NAME.test(name) && !KEYWORDS.has(name) && (open ? words : words.slice(0, -1)).every((w) => KEYWORDS.has(w));
|
|
1593
|
+
};
|
|
1594
|
+
let i = 0;
|
|
1595
|
+
while (i < s.length) {
|
|
1596
|
+
tick();
|
|
1597
|
+
const c = s[i];
|
|
1598
|
+
if (c === "\\") {
|
|
1599
|
+
if (s[i + 1] !== "\n" && i + 1 < s.length) {
|
|
1600
|
+
literal(s[i + 1]);
|
|
1601
|
+
inWord = quoted = true;
|
|
1602
|
+
}
|
|
1603
|
+
i += 2;
|
|
1604
|
+
} else if (c === "'") {
|
|
1605
|
+
const end = quoteEnd(s, i);
|
|
1606
|
+
if (end < 0) return null;
|
|
1607
|
+
literal(s.slice(i + 1, end - 1));
|
|
1608
|
+
inWord = quoted = true;
|
|
1609
|
+
i = end;
|
|
1610
|
+
} else if (c === '"') {
|
|
1611
|
+
let j = i + 1;
|
|
1612
|
+
inWord = quoted = true;
|
|
1613
|
+
while (s[j] !== '"') {
|
|
1614
|
+
if (j >= s.length) return null;
|
|
1615
|
+
const sub = substitutionEnd(s, j, true);
|
|
1616
|
+
if (sub < 0) return null;
|
|
1617
|
+
if (sub > j) {
|
|
1618
|
+
word += s.slice(j, sub);
|
|
1619
|
+
j = sub;
|
|
1620
|
+
} else if (s[j] === "\\" && '$`"\\\n'.includes(s[j + 1])) {
|
|
1621
|
+
if (s[j + 1] !== "\n") literal(s[j + 1]);
|
|
1622
|
+
j += 2;
|
|
1623
|
+
} else if (s[j] === "$" && !pendingDoc && nameAt(s, j + 1) > 0) {
|
|
1624
|
+
const end = j + 1 + nameAt(s, j + 1);
|
|
1625
|
+
word += `\${${s.slice(j + 1, end)}}`;
|
|
1626
|
+
j = end;
|
|
1627
|
+
} else word += s[j++];
|
|
1628
|
+
}
|
|
1629
|
+
i = j + 1;
|
|
1630
|
+
} else {
|
|
1631
|
+
const sub = substitutionEnd(s, i, false);
|
|
1632
|
+
if (sub < 0) return null;
|
|
1633
|
+
if (sub > i) {
|
|
1634
|
+
word += s.slice(i, sub);
|
|
1635
|
+
inWord = true;
|
|
1636
|
+
i = sub;
|
|
1637
|
+
} else if (c === "$" && s[i + 1] === "'") {
|
|
1638
|
+
const end = ansiEnd(s, i + 1);
|
|
1639
|
+
if (end < 0) return null;
|
|
1640
|
+
literal(decodeAnsi(s.slice(i + 2, end - 1)));
|
|
1641
|
+
inWord = quoted = true;
|
|
1642
|
+
i = end;
|
|
1643
|
+
} else if (c === "$" && s[i + 1] === '"') {
|
|
1644
|
+
i++;
|
|
1645
|
+
} else if (c === "$" && !pendingDoc && nameAt(s, i + 1) > 0) {
|
|
1646
|
+
const end = i + 1 + nameAt(s, i + 1);
|
|
1647
|
+
word += `\${${s.slice(i + 1, end)}}`;
|
|
1648
|
+
inWord = true;
|
|
1649
|
+
i = end;
|
|
1650
|
+
} else if (c === "#" && !inWord) {
|
|
1651
|
+
while (i < s.length && s[i] !== "\n") i++;
|
|
1652
|
+
} else if (c === " " || c === "\t" || c === "\r") {
|
|
1653
|
+
endWord();
|
|
1654
|
+
i++;
|
|
1655
|
+
} else if (c === "\n") {
|
|
1656
|
+
endCommand();
|
|
1657
|
+
i = readDocs(i + 1);
|
|
1658
|
+
} else if (arm >= 0 && ((c === "(" && !inWord && words.length === arm) || (c === "|" && s[i + 1] !== "|"))) {
|
|
1659
|
+
// A pattern's optional `(` and its `|` alternatives stay inside the pattern.
|
|
1660
|
+
endWord();
|
|
1661
|
+
i++;
|
|
1662
|
+
} else if (c === "(" && funcHead(inWord) && (parens = FUNC_PARENS.exec(s.slice(i, i + 256))) !== null) {
|
|
1663
|
+
if (fn !== 2 && !inWord) words.pop();
|
|
1664
|
+
dropWord();
|
|
1665
|
+
fn = 0;
|
|
1666
|
+
i += parens[0].length;
|
|
1667
|
+
} else if (c === "(" && (inWord || words.some((w) => !KEYWORDS.has(w) && w !== "time"))) {
|
|
1668
|
+
// A zsh glob (`*.(js|ts)`, `rm (a|b)`) or an array value stays inside its word.
|
|
1669
|
+
const end = closeGroup(s, i + 1, "(", ")");
|
|
1670
|
+
if (end < 0) return null;
|
|
1671
|
+
word += s.slice(i, end);
|
|
1672
|
+
inWord = true;
|
|
1673
|
+
i = end;
|
|
1674
|
+
} else if (c === ";" || c === "(" || c === ")") {
|
|
1675
|
+
// A case pattern is no command; one holding an expansion stays, since its substitution runs.
|
|
1676
|
+
if (c === ")" && arm >= 0) {
|
|
1677
|
+
endWord();
|
|
1678
|
+
if (words.slice(arm).every((w) => !/[$`]/.test(w))) words.splice(arm);
|
|
1679
|
+
arm = -1;
|
|
1680
|
+
}
|
|
1681
|
+
endCommand(";", c === ";");
|
|
1682
|
+
if (c === "(") shells.push(++opened);
|
|
1683
|
+
else if (c === ")" && cases === 0) shells.pop();
|
|
1684
|
+
if (c === ";" && s[i + 1] === ";" && cases > 0) arm = 0;
|
|
1685
|
+
i++;
|
|
1686
|
+
} else if (c === "|" || (c === "&" && s[i + 1] !== ">")) {
|
|
1687
|
+
const two = s.slice(i, i + 2);
|
|
1688
|
+
endCommand(two === "&&" || two === "||" ? two : c === "|" ? "|" : "&");
|
|
1689
|
+
i += s[i + 1] === "|" || s[i + 1] === "&" ? 2 : 1;
|
|
1690
|
+
} else if (c === "&" || c === ">" || c === "<") {
|
|
1691
|
+
const op = (c === "&" ? AMP_OPS : OPS).find((o) => s.startsWith(o, i));
|
|
1692
|
+
const fd = inWord && !quoted && /^\d+$/.test(word) && c !== "&" ? word : "";
|
|
1693
|
+
if (fd) inWord = false;
|
|
1694
|
+
else endWord();
|
|
1695
|
+
word = "";
|
|
1696
|
+
spans = [];
|
|
1697
|
+
(OP_WORDS.get(words) ?? OP_WORDS.set(words, new Set()).get(words)).add(words.length);
|
|
1698
|
+
words.push(fd + op);
|
|
1699
|
+
if (op === "<<" || op === "<<-") pendingDoc = { strip: op === "<<-" };
|
|
1700
|
+
i += op.length;
|
|
1701
|
+
} else {
|
|
1702
|
+
word += c;
|
|
1703
|
+
inWord = true;
|
|
1704
|
+
i++;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
endCommand();
|
|
1709
|
+
if (pendingDoc || arm >= 0) return null;
|
|
1710
|
+
readDocs(s.length);
|
|
1711
|
+
return cmds;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/** Commands nested in a word: `$(...)`, `<(...)`, `>(...)` and backtick bodies; with `arith`, those of a `$((...))` naming no git. */
|
|
1715
|
+
function substitutions(word, arith = false) {
|
|
1716
|
+
const found = [];
|
|
1717
|
+
for (let i = 0; i < word.length; i++) {
|
|
1718
|
+
if (word[i] === "$" && word[i + 1] === "{") continue;
|
|
1719
|
+
const end = substitutionEnd(word, i, false);
|
|
1720
|
+
if (end > i) {
|
|
1721
|
+
const body = word.slice(word[i] === "`" ? i + 1 : i + 2, end - 1);
|
|
1722
|
+
const math = arith && word[i] === "$" && /^\([\s\S]*\)$/.test(body) ? body.slice(1, -1) : null;
|
|
1723
|
+
found.push(...(math === null || GIT_WORD.test(math) ? [body] : substitutions(math, true)));
|
|
1724
|
+
i = end - 1;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return found;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* `text`, each of its words that expands there no longer marked literal: the marks are kept per
|
|
1732
|
+
* word text, so a quoted spelling of the same word on the outer line would hide the expansion.
|
|
1733
|
+
*/
|
|
1734
|
+
function unmark(text) {
|
|
1735
|
+
for (const words of /[$`]/.test(text) ? (splitCommands(text) ?? []) : []) {
|
|
1736
|
+
for (const [i, w] of words.entries()) {
|
|
1737
|
+
if (!/[$`]/.test(w) || LITERALS.get(words)?.has(i)) continue;
|
|
1738
|
+
LITERAL_WORDS.delete(w);
|
|
1739
|
+
LITERAL_ONLY.delete(w);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return text;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Code with each string and regex literal reduced to a stand-in, the code of every f-string or
|
|
1747
|
+
* template field appended, so a name test sees all code and no literal text.
|
|
1748
|
+
*/
|
|
1749
|
+
function maskLiterals(code, toks) {
|
|
1750
|
+
let masked = "";
|
|
1751
|
+
let from = 0;
|
|
1752
|
+
const fields = [];
|
|
1753
|
+
for (const x of toks.filter((y) => y.t === "s" || y.t === "r")) {
|
|
1754
|
+
masked += `${code.slice(from, x.s)}${x.t === "s" ? "'x'" : "0"}`;
|
|
1755
|
+
from = x.e;
|
|
1756
|
+
for (const [s, e] of x.fields ?? []) fields.push(code.slice(s, e));
|
|
1757
|
+
}
|
|
1758
|
+
return [masked + code.slice(from), ...fields].join("\n");
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const SED_FLAGS = /[gpiImMe0-9]*/y;
|
|
1762
|
+
|
|
1763
|
+
/** File names a sed script writes with `w`/`W` or `s///w`; null when it executes or parses unclearly, `files` keeping those before it. */
|
|
1764
|
+
function sedWrites(s, files = []) {
|
|
1765
|
+
let i = 0;
|
|
1766
|
+
const eol = () => (s.indexOf("\n", i) < 0 ? s.length : s.indexOf("\n", i));
|
|
1767
|
+
const blank = () => {
|
|
1768
|
+
while (s[i] === " " || s[i] === "\t") i++;
|
|
1769
|
+
};
|
|
1770
|
+
const part = (d, regex) => {
|
|
1771
|
+
const end = regex ? regexEnd(s, i, d) : partEnd(s, i, d, false);
|
|
1772
|
+
i = end;
|
|
1773
|
+
return end >= 0;
|
|
1774
|
+
};
|
|
1775
|
+
const address = () => {
|
|
1776
|
+
if (/\d/.test(s[i] ?? "")) while (/[\d~]/.test(s[i] ?? "")) i++;
|
|
1777
|
+
else if (s[i] === "$") i++;
|
|
1778
|
+
else if (s[i] === "/" || s[i] === "\\") {
|
|
1779
|
+
const d = s[i] === "\\" ? s[++i] : "/";
|
|
1780
|
+
i++;
|
|
1781
|
+
if (!d || d === "\n" || !part(d, true)) return null;
|
|
1782
|
+
while (s[i] === "I" || s[i] === "M") i++;
|
|
1783
|
+
} else return false;
|
|
1784
|
+
return true;
|
|
1785
|
+
};
|
|
1786
|
+
while (i < s.length) {
|
|
1787
|
+
if (/[\s;}]/.test(s[i])) {
|
|
1788
|
+
i++;
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1791
|
+
if (s[i] === "#") {
|
|
1792
|
+
i = eol();
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
const first = address();
|
|
1796
|
+
if (first === null) return null;
|
|
1797
|
+
blank();
|
|
1798
|
+
if (first && s[i] === ",") {
|
|
1799
|
+
i++;
|
|
1800
|
+
blank();
|
|
1801
|
+
if (s[i] === "+" || s[i] === "~") for (i++; /\d/.test(s[i] ?? ""); ) i++;
|
|
1802
|
+
else if (address() !== true) return null;
|
|
1803
|
+
}
|
|
1804
|
+
blank();
|
|
1805
|
+
while (s[i] === "!") {
|
|
1806
|
+
i++;
|
|
1807
|
+
blank();
|
|
1808
|
+
}
|
|
1809
|
+
const cmd = s[i++] ?? "";
|
|
1810
|
+
if (cmd === "") return null;
|
|
1811
|
+
if (cmd === "{" || "=dDgGhHnNpPxzF".includes(cmd)) continue;
|
|
1812
|
+
if ("lLqQ".includes(cmd)) {
|
|
1813
|
+
blank();
|
|
1814
|
+
while (/\d/.test(s[i] ?? "")) i++;
|
|
1815
|
+
} else if ("btT:v".includes(cmd)) {
|
|
1816
|
+
blank();
|
|
1817
|
+
while (i < s.length && !/[\s;]/.test(s[i])) i++;
|
|
1818
|
+
blank();
|
|
1819
|
+
if (i < s.length && !/[;\n}#]/.test(s[i])) return null;
|
|
1820
|
+
} else if (cmd === "r" || cmd === "R") i = eol();
|
|
1821
|
+
else if ("aic".includes(cmd)) {
|
|
1822
|
+
for (i = eol(); i < s.length; i = eol()) {
|
|
1823
|
+
let k = i;
|
|
1824
|
+
while (s[k - 1] === "\\") k--;
|
|
1825
|
+
if ((i - k) % 2 === 0) break;
|
|
1826
|
+
i++;
|
|
1827
|
+
}
|
|
1828
|
+
} else if (cmd === "w" || cmd === "W") {
|
|
1829
|
+
files.push(s.slice(i, eol()).trim());
|
|
1830
|
+
i = eol();
|
|
1831
|
+
} else if (cmd === "s" || cmd === "y") {
|
|
1832
|
+
const d = s[i++];
|
|
1833
|
+
if (!d || d === "\n" || d === "\\" || !part(d, cmd === "s") || !part(d, false)) return null;
|
|
1834
|
+
if (cmd === "y") continue;
|
|
1835
|
+
SED_FLAGS.lastIndex = i;
|
|
1836
|
+
const flags = SED_FLAGS.exec(s)?.[0] ?? "";
|
|
1837
|
+
i += flags.length;
|
|
1838
|
+
if (flags.includes("e")) return null;
|
|
1839
|
+
if (s[i] === "w") {
|
|
1840
|
+
files.push(s.slice(i + 1, eol()).trim());
|
|
1841
|
+
i = eol();
|
|
1842
|
+
}
|
|
1843
|
+
} else return null;
|
|
1844
|
+
}
|
|
1845
|
+
return files;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/** Index past the part of `s` from `i` that an unescaped `d` ends on the same line, or -1. */
|
|
1849
|
+
function partEnd(s, i, d, brackets) {
|
|
1850
|
+
for (let j = i; j < s.length && s[j] !== "\n"; j++) {
|
|
1851
|
+
if (s[j] === "\\") j++;
|
|
1852
|
+
else if (s[j] === d) return j + 1;
|
|
1853
|
+
else if (brackets && s[j] === "[") {
|
|
1854
|
+
j = bracketEnd(s, j) - 1;
|
|
1855
|
+
if (j < 0) return -1;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
return -1;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/** `partEnd` of a regex, or -1 where a tool that skips bracket expressions would end it elsewhere. */
|
|
1862
|
+
function regexEnd(s, i, d) {
|
|
1863
|
+
const end = partEnd(s, i, d, false);
|
|
1864
|
+
return end >= 0 && (d === "[" || end === partEnd(s, i, d, true)) ? end : -1;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** Index past the bracket expression opening at `j`, or 0 when it does not close on its line. */
|
|
1868
|
+
function bracketEnd(s, j) {
|
|
1869
|
+
let k = j + 1;
|
|
1870
|
+
if (s[k] === "^") k++;
|
|
1871
|
+
if (s[k] === "]") k++;
|
|
1872
|
+
for (; k < s.length && s[k] !== "\n"; k++) {
|
|
1873
|
+
if (s[k] === "]") return k + 1;
|
|
1874
|
+
if (s[k] === "[" && ".:=".includes(s[k + 1] || "\n")) {
|
|
1875
|
+
const close = s.indexOf(`${s[k + 1]}]`, k + 2);
|
|
1876
|
+
if (close < 0) return 0;
|
|
1877
|
+
k = close + 1;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return 0;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** awk words after which a `/` opens a regex rather than divides. */
|
|
1884
|
+
const AWK_STATEMENT = new Set(["print", "printf", "return", "case", "else", "do", "in"]);
|
|
1885
|
+
|
|
1886
|
+
/** Whether awk code has no print redirect, pipe, `system`, `@` or shell expansion, all literals closed. */
|
|
1887
|
+
function awkPlain(code) {
|
|
1888
|
+
if (/\$[{(@*]|`/.test(code)) return false;
|
|
1889
|
+
const conds = [];
|
|
1890
|
+
const token = /[A-Za-z_]\w*|[\d.]+/y;
|
|
1891
|
+
let depth = 0;
|
|
1892
|
+
let print = -1;
|
|
1893
|
+
let operand = false;
|
|
1894
|
+
let cond = false;
|
|
1895
|
+
for (let i = 0; i < code.length; ) {
|
|
1896
|
+
const c = code[i];
|
|
1897
|
+
token.lastIndex = i;
|
|
1898
|
+
const w = token.exec(code)?.[0];
|
|
1899
|
+
if (w !== undefined) {
|
|
1900
|
+
if (w === "system") return false;
|
|
1901
|
+
if (w === "print" || w === "printf") print = depth;
|
|
1902
|
+
cond = /^(?:if|while|for)$/.test(w);
|
|
1903
|
+
operand = !AWK_STATEMENT.has(w) && !cond;
|
|
1904
|
+
i += w.length;
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
if (c === '"' || (c === "/" && !operand)) {
|
|
1908
|
+
i = c === '"' ? partEnd(code, i + 1, c, false) : regexEnd(code, i + 1, c);
|
|
1909
|
+
if (i < 0) return false;
|
|
1910
|
+
operand = true;
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
if (c === "|" && code[i + 1] !== "|") return false;
|
|
1914
|
+
if (c === "@" || (c === ">" && print >= 0 && depth <= print) || (c === "\\" && code[i + 1] !== "\n")) return false;
|
|
1915
|
+
if (c === "#") {
|
|
1916
|
+
while (i < code.length && code[i] !== "\n") i++;
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (c === "(") {
|
|
1920
|
+
if (cond) conds.push(depth);
|
|
1921
|
+
depth++;
|
|
1922
|
+
} else if (c === ")") {
|
|
1923
|
+
if (--depth < 0) return false;
|
|
1924
|
+
operand = conds.at(-1) !== depth;
|
|
1925
|
+
if (!operand) conds.pop();
|
|
1926
|
+
} else if (c === "{" || c === "}" || c === ";") print = -1;
|
|
1927
|
+
if ((c === "+" || c === "-") && code[i + 1] === c) i += 2;
|
|
1928
|
+
else {
|
|
1929
|
+
if (c === "]") operand = true;
|
|
1930
|
+
else if (!/[ \t)\\]/.test(c)) operand = false;
|
|
1931
|
+
cond &&= c === " " || c === "\t";
|
|
1932
|
+
i += c === "|" || c === "\\" ? 2 : 1;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return true;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Protected paths a Bash command writes through a literal writer, judged per simple command.
|
|
1940
|
+
* Input past MAX_CHARS is not judged: the guard is a second layer, so it allows.
|
|
1941
|
+
* @param {string} command The command.
|
|
1942
|
+
* @param {Env} [env] Defaults to the process's home, cwd and temp roots.
|
|
1943
|
+
* @returns {string[]} Distinct protected targets as written; empty when it only reads or
|
|
1944
|
+
* mentions them, or is too long to judge.
|
|
1945
|
+
*/
|
|
1946
|
+
export function judgeBash(command, env = defaultEnv()) {
|
|
1947
|
+
const text = String(command);
|
|
1948
|
+
if (text.length > MAX_CHARS) return [];
|
|
1949
|
+
for (const words of [LITERAL_WORDS, LITERAL_ONLY, EXPANDING]) words.clear();
|
|
1950
|
+
const ctx = { env, dir: { cwds: [env.cwd], unresolved: false, stack: [] }, alt: null, dots: DOTGLOB.test(text), depth: 0 };
|
|
1951
|
+
return [...new Set(judgeLine(text, ctx))];
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Protected targets the simple commands of `text` write. A subshell, a pipeline stage and a
|
|
1956
|
+
* substitution run in a child shell, so a `cd` there never moves the cwd of later commands.
|
|
1957
|
+
*/
|
|
1958
|
+
function judgeLine(text, ctx) {
|
|
1959
|
+
const cmds = ctx.depth <= MAX_DEPTH ? splitCommands(text) : null;
|
|
1960
|
+
const out = [];
|
|
1961
|
+
const saved = [];
|
|
1962
|
+
for (const words of cmds ?? []) {
|
|
1963
|
+
tick();
|
|
1964
|
+
const place = PLACES.get(words);
|
|
1965
|
+
while (saved.length > 0 && !place.shells.includes(saved[saved.length - 1].id)) ctx.dir = saved.pop().dir;
|
|
1966
|
+
// A cd that a failed `&&` chain may have skipped leaves both cwds to the commands after the chain.
|
|
1967
|
+
if (ctx.alt && place.joint !== "&&") [ctx.dir, ctx.alt] = [mergeDirs(ctx.dir, ctx.alt, false), null];
|
|
1968
|
+
for (const id of place.shells) if (!saved.some((s) => s.id === id)) saved.push({ id, dir: ctx.dir });
|
|
1969
|
+
out.push(...judgeWords(words, place, ctx));
|
|
1970
|
+
}
|
|
1971
|
+
return out;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/** Whether a word holds an expansion the guard does not resolve: its value is unknown, so it allows. */
|
|
1975
|
+
const unknown = (w) => w === undefined || EXPANDING.has(w);
|
|
1976
|
+
|
|
1977
|
+
/** Protected targets one simple command writes through a literal writer (see the header). */
|
|
1978
|
+
function judgeWords(words, place, ctx) {
|
|
1979
|
+
const ops = OP_WORDS.get(words) ?? new Set();
|
|
1980
|
+
const out = [];
|
|
1981
|
+
const args = [];
|
|
1982
|
+
const live = (w) => !unknown(w) && operandLive(w, ctx);
|
|
1983
|
+
for (let i = 0; i < words.length; i++) {
|
|
1984
|
+
const w = words[i];
|
|
1985
|
+
if (ops.has(i)) {
|
|
1986
|
+
const op = REDIRECT.exec(w)?.[1];
|
|
1987
|
+
const target = words[++i];
|
|
1988
|
+
// A heredoc body and a here-string are data; `>&2` and `>&-` duplicate or close a descriptor.
|
|
1989
|
+
if (OUTPUT_OPS.has(op) && !(/&/.test(op) && /^(?:\d+|-)$/.test(target ?? "")) && live(target)) out.push(target);
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
for (const body of substitutions(literalBlanked(words, i))) out.push(...judgeLine(body, { ...ctx, depth: ctx.depth + 1 }));
|
|
1993
|
+
args.push(w);
|
|
1994
|
+
}
|
|
1995
|
+
let k = 0;
|
|
1996
|
+
while (k < args.length && (ASSIGN.test(args[k]) || KEYWORDS.has(args[k]))) k++;
|
|
1997
|
+
if (k >= args.length || unknown(args[k])) return out;
|
|
1998
|
+
const name = path.posix.basename(args[k]);
|
|
1999
|
+
const rest = args.slice(k + 1);
|
|
2000
|
+
if (MOVERS.has(name)) {
|
|
2001
|
+
const maybe = place.joint === "||" || place.next === "||" || args.slice(0, k).some((w) => KEYWORDS.has(w) && w !== "{" && w !== "!");
|
|
2002
|
+
if (!place.isolated) moveCwd(name, rest, place, maybe, ctx);
|
|
2003
|
+
return out;
|
|
2004
|
+
}
|
|
2005
|
+
const writes = (operands) => operands.filter(live);
|
|
2006
|
+
if (name === "tee" || name === "rm" || name === "touch") out.push(...writes(plainOperands(rest, name === "touch" ? /^-[rtd]$/ : null)));
|
|
2007
|
+
else if (name === "sed") out.push(...writes(sedInPlace(rest)));
|
|
2008
|
+
else if (name === "cp" || name === "install" || name === "mv") out.push(...copyTargets(name, rest, live, ctx));
|
|
2009
|
+
else if (SHELLS.test(name)) {
|
|
2010
|
+
const code = inlineCode(rest, /^-[a-zA-Z]*c$/, /^[-+][oO]$|^--(?:rcfile|init-file)$/);
|
|
2011
|
+
if (code !== null && !unknown(code)) out.push(...judgeLine(code, { ...ctx, depth: ctx.depth + 1 }));
|
|
2012
|
+
} else {
|
|
2013
|
+
if (name === "perl") out.push(...writes(perlInPlace(rest)));
|
|
2014
|
+
const lang = /^python[\d.]*$/.test(name) ? "python" : /^(?:node|perl|ruby)$/.test(name) ? name : null;
|
|
2015
|
+
const code = lang === null ? null : interpreterCode(lang, rest);
|
|
2016
|
+
if (code !== null && !unknown(code)) out.push(...codeTargets(code, lang, ctx));
|
|
2017
|
+
}
|
|
2018
|
+
return out;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** Word `i` of `words` with its quoted `$` and backtick text blanked, so only a substitution that runs is found. */
|
|
2022
|
+
function literalBlanked(words, i) {
|
|
2023
|
+
return (LITERALS.get(words)?.get(i) ?? []).reduce((t, [a, b]) => t.slice(0, a) + " ".repeat(b - a) + t.slice(b), words[i]);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/** Operands past the options (`--` ends them); `valued` options take the next word. */
|
|
2027
|
+
function plainOperands(args, valued = null) {
|
|
2028
|
+
const out = [];
|
|
2029
|
+
let ended = false;
|
|
2030
|
+
for (let j = 0; j < args.length; j++) {
|
|
2031
|
+
const a = args[j];
|
|
2032
|
+
if (!ended && a === "--") ended = true;
|
|
2033
|
+
else if (!ended && /^-./.test(a)) j += valued?.test(a) ? 1 : 0;
|
|
2034
|
+
else out.push(a);
|
|
2035
|
+
}
|
|
2036
|
+
return out;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* The file operands of a `perl -i` (`-pi`, `-i.bak`); none without one. The rest of a bundle after
|
|
2041
|
+
* `i` is the backup suffix; the script is the first operand unless -e or -E gives it.
|
|
2042
|
+
*/
|
|
2043
|
+
function perlInPlace(args) {
|
|
2044
|
+
let inPlace = false;
|
|
2045
|
+
let script = false;
|
|
2046
|
+
const files = [];
|
|
2047
|
+
for (let j = 0; j < args.length; j++) {
|
|
2048
|
+
const a = args[j];
|
|
2049
|
+
if (a === "--") {
|
|
2050
|
+
files.push(...args.slice(j + 1 + (script ? 0 : 1)));
|
|
2051
|
+
break;
|
|
2052
|
+
}
|
|
2053
|
+
if (/^-[^-]/.test(a)) {
|
|
2054
|
+
for (let p = 1; p < a.length; p++) {
|
|
2055
|
+
const c = a[p];
|
|
2056
|
+
if (c === "i") {
|
|
2057
|
+
inPlace = true;
|
|
2058
|
+
break;
|
|
2059
|
+
}
|
|
2060
|
+
if ("eEIMm".includes(c)) {
|
|
2061
|
+
script ||= c === "e" || c === "E";
|
|
2062
|
+
if (p === a.length - 1) j++;
|
|
2063
|
+
break;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
} else if (!script) script = true;
|
|
2067
|
+
else files.push(a);
|
|
2068
|
+
}
|
|
2069
|
+
return inPlace ? files : [];
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** The file operands of a `sed -i` or `sed --in-place`; none without one. The script is the first operand unless -e or -f gives it. */
|
|
2073
|
+
function sedInPlace(args) {
|
|
2074
|
+
let inPlace = false;
|
|
2075
|
+
let script = false;
|
|
2076
|
+
const files = [];
|
|
2077
|
+
for (let j = 0; j < args.length; j++) {
|
|
2078
|
+
const a = args[j];
|
|
2079
|
+
if (a === "--") {
|
|
2080
|
+
files.push(...args.slice(j + 1 + (script ? 0 : 1)));
|
|
2081
|
+
break;
|
|
2082
|
+
}
|
|
2083
|
+
if (/^--in-place(?:=|$)/.test(a)) inPlace = true;
|
|
2084
|
+
else if (/^--(?:expression|file)=/.test(a)) script = true;
|
|
2085
|
+
else if (/^--(?:expression|file|line-length)$/.test(a)) {
|
|
2086
|
+
script ||= a !== "--line-length";
|
|
2087
|
+
j++;
|
|
2088
|
+
} else if (/^-[^-]/.test(a)) {
|
|
2089
|
+
for (let p = 1; p < a.length; p++) {
|
|
2090
|
+
const c = a[p];
|
|
2091
|
+
if (c === "i") {
|
|
2092
|
+
inPlace = true;
|
|
2093
|
+
// BSD sed takes the backup suffix as the next word: `-i ''`, `-i .bak`.
|
|
2094
|
+
if (a.length === 2 && /^(?:|\.[\w-]+)$/.test(args[j + 1] ?? "x")) j++;
|
|
2095
|
+
break;
|
|
2096
|
+
}
|
|
2097
|
+
if ("efl".includes(c)) {
|
|
2098
|
+
script ||= c !== "l";
|
|
2099
|
+
if (p === a.length - 1) j++;
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
} else if (!script) script = true;
|
|
2104
|
+
else files.push(a);
|
|
2105
|
+
}
|
|
2106
|
+
return inPlace ? files : [];
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* The paths a cp, install or mv writes: the destination (`-t` directory or last operand), and for
|
|
2111
|
+
* mv every source too. A destination that is a directory receives each source by its base name;
|
|
2112
|
+
* `install -d` creates every operand.
|
|
2113
|
+
*/
|
|
2114
|
+
function copyTargets(name, args, live, ctx) {
|
|
2115
|
+
const valued = name === "install" ? /^-[BfghlMmNoSt]$|^--(?:suffix|mode|owner|group|strip-program|target-directory)$/ : /^-[St]$|^--(?:suffix|target-directory)$/;
|
|
2116
|
+
let dir = null;
|
|
2117
|
+
let noDir = false;
|
|
2118
|
+
let dirs = false;
|
|
2119
|
+
const ops = [];
|
|
2120
|
+
let ended = false;
|
|
2121
|
+
for (let j = 0; j < args.length; j++) {
|
|
2122
|
+
const a = args[j];
|
|
2123
|
+
if (!ended && a === "--") ended = true;
|
|
2124
|
+
else if (!ended && /^-./.test(a)) {
|
|
2125
|
+
const t = /^--target-directory(?:=(.*))?$/.exec(a) ?? /^-[A-Za-z]*?t(.*)$/.exec(a);
|
|
2126
|
+
if (t) dir = t[1] || (args[++j] ?? null);
|
|
2127
|
+
else if (valued.test(a)) j++;
|
|
2128
|
+
if (/^-[A-Za-z]*T|^--no-target-directory$/.test(a)) noDir = true;
|
|
2129
|
+
if (name === "install" && /^-[A-Za-z]*d|^--directory$/.test(a)) dirs = true;
|
|
2130
|
+
} else ops.push(a);
|
|
2131
|
+
}
|
|
2132
|
+
if (dirs) return ops.filter(live);
|
|
2133
|
+
if (dir === null && ops.length < 2) return name === "mv" ? ops.filter(live) : [];
|
|
2134
|
+
const sources = dir === null ? ops.slice(0, -1) : ops;
|
|
2135
|
+
const dest = dir ?? ops[ops.length - 1];
|
|
2136
|
+
const moved = name === "mv" ? sources.filter(live) : [];
|
|
2137
|
+
if (unknown(dest)) return moved;
|
|
2138
|
+
if (live(dest)) return [...moved, dest];
|
|
2139
|
+
const into = dir !== null || (!noDir && (dest.endsWith("/") || isDir(dest, ctx)));
|
|
2140
|
+
const landed = into ? sources.filter((s) => !unknown(s)).map((s) => `${dest.replace(/\/+$/, "")}/${path.posix.basename(s.replace(/\/+$/, ""))}`) : [];
|
|
2141
|
+
return [...moved, ...landed.filter(live)];
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/** Whether a literal path is a directory from some candidate cwd. */
|
|
2145
|
+
function isDir(p, ctx) {
|
|
2146
|
+
const abs = expandTilde(p, ctx.env.home);
|
|
2147
|
+
if (abs === null) return false;
|
|
2148
|
+
return (abs.startsWith("/") ? [ctx.env.cwd] : ctx.dir.cwds).some((cwd) => {
|
|
2149
|
+
try {
|
|
2150
|
+
return fs.statSync(path.resolve(cwd, abs)).isDirectory();
|
|
2151
|
+
} catch {
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** The word after the first option matching `flag` (a cluster ending in it), or null; `valued` options take the next word, an operand ends the scan. */
|
|
2158
|
+
function inlineCode(args, flag, valued) {
|
|
2159
|
+
for (let j = 0; j < args.length && /^[-+]/.test(args[j]) && args[j] !== "--"; j++) {
|
|
2160
|
+
if (flag.test(args[j])) return args[j + 1] ?? null;
|
|
2161
|
+
if (valued.test(args[j])) j++;
|
|
2162
|
+
}
|
|
2163
|
+
return null;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/** Inline code of a python `-c`, node `-e`/`-p`/`--eval`/`--print`, or perl and ruby `-e`; null for a script, a module or stdin. */
|
|
2167
|
+
function interpreterCode(lang, args) {
|
|
2168
|
+
for (let j = 0; j < args.length; j++) {
|
|
2169
|
+
const a = args[j];
|
|
2170
|
+
let m;
|
|
2171
|
+
if (a === "--" || a === "-" || !a.startsWith("-")) return null;
|
|
2172
|
+
if (lang === "python") {
|
|
2173
|
+
if (/^-[WX]$/.test(a)) j++;
|
|
2174
|
+
else if ((m = /^-[A-Za-z]*?([cm])(.*)$/.exec(a))) return m[1] === "m" ? null : m[2] || (args[j + 1] ?? null);
|
|
2175
|
+
} else if (lang === "node") {
|
|
2176
|
+
if ((m = /^(?:-[pe]{1,2}|--eval|--print)(?:=(.*))?$/.exec(a))) return m[1] ?? args[j + 1] ?? null;
|
|
2177
|
+
if (/^(?:-r|--require|--import|--loader|--experimental-loader|-C|--conditions)$/.test(a)) j++;
|
|
2178
|
+
} else if ((m = /^-[A-Za-z]*?[eE](.*)$/.exec(a))) return m[1] || (args[j + 1] ?? null);
|
|
2179
|
+
else if (/^-[A-Za-z]*[IMmx]$/.test(a)) j++;
|
|
2180
|
+
}
|
|
2181
|
+
return null;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/** Callees that write, move or remove every path argument they get, by method name alone. */
|
|
2185
|
+
const CODE_WRITE = /^(?:writeFile|writeFileSync|appendFile|appendFileSync|unlinkSync|rmSync|rmdirSync|renameSync|mkdirSync|truncateSync|chmodSync|createWriteStream|outputFile|outputFileSync|write_text|write_bytes|rmtree|removedirs|makedirs|unlink|copyFileSync|cpSync|symlinkSync|linkSync)$/;
|
|
2186
|
+
/** Method names that write only on a file-system module (`os.remove`, not `list.remove`). */
|
|
2187
|
+
const MODULE_WRITE = /^(?:remove|rename|replace|rmdir|mkdir|truncate|chmod|chown|utime|move|rm|delete|write|mv|touch|copy|copy2|copyfile|copytree|copyFile|cp|symlink|link|ln|ln_s)$/;
|
|
2188
|
+
const FS_MODULE = /^(?:os|shutil|fs|fsp|promises|FileUtils|File|Dir|pathlib)$/;
|
|
2189
|
+
/** Copy and link calls write their second argument only: the first is read or pointed at. */
|
|
2190
|
+
const CODE_COPY = /^(?:copy|copy2|copyfile|copytree|copyFile|copyFileSync|cp|cpSync|symlink|symlinkSync|link|linkSync|ln|ln_s)$/;
|
|
2191
|
+
/** Methods that write the path object they are called on. */
|
|
2192
|
+
const RECEIVER_WRITE = /^(?:write_text|write_bytes|unlink|rename|replace|touch|symlink_to|hardlink_to|rmdir|mkdir|chmod)$/;
|
|
2193
|
+
const PATH_CALL = /^(?:(?:pathlib\.)?(?:Path|PosixPath|PurePath)|path\.resolve|os\.path\.(?:abspath|expanduser|realpath))$/;
|
|
2194
|
+
/** A file-open flag that writes: a literal mode, or os.open flags. */
|
|
2195
|
+
const WRITE_MODE = /[wax+]|O_(?:WRONLY|RDWR|CREAT|APPEND|TRUNC)/;
|
|
2196
|
+
const PERL_WRITERS = /\b(?:unlink|rename|link|symlink|truncate|chmod|chown|rmdir|mkdir|utime)\b/;
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* Protected paths inline interpreter code writes: a path literal, directly or through a name the
|
|
2200
|
+
* code binds it to, passed to an open with a write or non-literal mode or to a module write, move,
|
|
2201
|
+
* copy, link or remove call. A read, a mention in data and a path the code takes from argv pass.
|
|
2202
|
+
*/
|
|
2203
|
+
function codeTargets(code, lang, ctx) {
|
|
2204
|
+
const { strings, calls } = codeShape(code);
|
|
2205
|
+
const out = [];
|
|
2206
|
+
for (const s of strings) {
|
|
2207
|
+
tick();
|
|
2208
|
+
let value = s.value;
|
|
2209
|
+
let perlWrite = false;
|
|
2210
|
+
if (lang === "perl") {
|
|
2211
|
+
const m = /^\s*(\+?>{1,2}|\+<)\s*(.*)$/.exec(value);
|
|
2212
|
+
if (m) [perlWrite, value] = [true, m[2]];
|
|
2213
|
+
}
|
|
2214
|
+
if (!/^[^\s'"`]{1,4096}$/.test(value) || hasGlob(value) || !pathLive(value, ctx)) continue;
|
|
2215
|
+
if (lang === "perl") {
|
|
2216
|
+
const stmt = code.slice(Math.max(code.lastIndexOf(";", s.start), code.lastIndexOf("{", s.start)) + 1, s.start);
|
|
2217
|
+
if (perlWrite || PERL_WRITERS.test(stmt) || /['"]\+?>{1,2}['"]\s*,\s*$/.test(stmt)) out.push(value);
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
// A call such as Path('x') stands for its path: what receives the call is judged.
|
|
2221
|
+
let span = { start: s.start, end: s.end };
|
|
2222
|
+
const wrap = innermost(calls, span.start);
|
|
2223
|
+
if (wrap && PATH_CALL.test(wrap.callee)) span = { start: wrap.start, end: wrap.close + 1 };
|
|
2224
|
+
const sites = [span];
|
|
2225
|
+
const bound = /(?:^|[^\w$.=!<>])(?:(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)\s*=\s*$/.exec(code.slice(Math.max(0, span.start - 128), span.start));
|
|
2226
|
+
if (bound) {
|
|
2227
|
+
for (const m of code.matchAll(new RegExp(String.raw`(?<![\w$.])${bound[1].replace(/\$/g, "\\$")}(?![\w$])(?!\s*=[^=])`, "g"))) sites.push({ start: m.index, end: m.index + m[0].length });
|
|
2228
|
+
}
|
|
2229
|
+
if (sites.some((site) => writtenAt(code, calls, site))) out.push(s.value);
|
|
2230
|
+
}
|
|
2231
|
+
return out;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/** Whether the value at `site` is written: a write-call argument, an open with a write mode, or the receiver of a write method. */
|
|
2235
|
+
function writtenAt(code, calls, site) {
|
|
2236
|
+
const method = /^\s*\.\s*([A-Za-z_]\w*)\s*\(/.exec(code.slice(site.end, site.end + 64));
|
|
2237
|
+
if (method && RECEIVER_WRITE.test(method[1])) return true;
|
|
2238
|
+
if (method?.[1] === "open") {
|
|
2239
|
+
const call = calls.find((c) => c.open === site.end + method[0].length - 1);
|
|
2240
|
+
return call !== undefined && writeMode(code, call, 0);
|
|
2241
|
+
}
|
|
2242
|
+
const call = innermost(calls, site.start);
|
|
2243
|
+
if (!call) return false;
|
|
2244
|
+
const arg = call.commas.filter((c) => c < site.start).length;
|
|
2245
|
+
const parts = call.callee.split(".");
|
|
2246
|
+
const last = parts[parts.length - 1];
|
|
2247
|
+
const receiver = parts[parts.length - 2] ?? "";
|
|
2248
|
+
if (/^(?:open|openSync|fopen|sysopen)$/.test(last) && (parts.length === 1 || /^(?:io|codecs|os|fs|File|builtins|promises)$/.test(receiver))) return arg === 0 && writeMode(code, call, 1);
|
|
2249
|
+
// A node read call opens for write under a write flag option.
|
|
2250
|
+
if (/^(?:readFileSync|readFile|createReadStream)$/.test(last)) return arg === 0 && writeMode(code, call, -1);
|
|
2251
|
+
const writer = CODE_WRITE.test(last) || (MODULE_WRITE.test(last) && FS_MODULE.test(receiver));
|
|
2252
|
+
return writer && (!CODE_COPY.test(last) || arg >= 1);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/** Whether an open call's mode (argument `index`, `mode=`, or a `flag:` option) writes; no mode reads, a non-literal one may write. */
|
|
2256
|
+
function writeMode(code, call, index) {
|
|
2257
|
+
const ends = [call.open, ...call.commas, call.close];
|
|
2258
|
+
const args = ends.slice(0, -1).map((a, k) => code.slice(a + 1, ends[k + 1]).trim());
|
|
2259
|
+
const named = args.map((a) => /^(?:mode|flags?)\s*[=:]\s*([\s\S]+)$/.exec(a)?.[1]).find((v) => v !== undefined);
|
|
2260
|
+
const option = args.map((a) => /\bflags?\s*:\s*([^,}]+)/.exec(a)?.[1]).find((v) => v !== undefined);
|
|
2261
|
+
const mode = (named ?? (args[index] && !/^\w+\s*=/.test(args[index]) ? args[index] : option))?.trim();
|
|
2262
|
+
if (mode === undefined || mode === "") return false;
|
|
2263
|
+
const lit = /^[rbuf]*(['"])([^'"]*)\1$/.exec(mode);
|
|
2264
|
+
if (lit) return WRITE_MODE.test(lit[2]);
|
|
2265
|
+
if (/^\{/.test(mode)) return option !== undefined && writeModeText(option);
|
|
2266
|
+
return /^\d+$/.test(mode) ? mode !== "0" : !/^(?:os\.)?O_RDONLY$/.test(mode);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** Whether an option value text names a writing flag. */
|
|
2270
|
+
function writeModeText(text) {
|
|
2271
|
+
const lit = /^\s*(['"])([^'"]*)\1\s*$/.exec(text);
|
|
2272
|
+
return lit ? WRITE_MODE.test(lit[2]) : true;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** The innermost call whose parentheses hold `at`, or null. */
|
|
2276
|
+
function innermost(calls, at) {
|
|
2277
|
+
let best = null;
|
|
2278
|
+
for (const c of calls) if (c.open < at && at < c.close && (best === null || c.open > best.open)) best = c;
|
|
2279
|
+
return best;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* String literals of interpreter code (start, end and value) and its calls: the callee's dotted
|
|
2284
|
+
* name, where the callee starts, the open and close parentheses and the commas at the call's top
|
|
2285
|
+
* level. An unterminated literal or group ends the scan.
|
|
2286
|
+
*/
|
|
2287
|
+
function codeShape(code) {
|
|
2288
|
+
const strings = [];
|
|
2289
|
+
const calls = [];
|
|
2290
|
+
const stack = [];
|
|
2291
|
+
for (let i = 0; i < code.length; i++) {
|
|
2292
|
+
const c = code[i];
|
|
2293
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
2294
|
+
const triple = c !== "`" && code.startsWith(c.repeat(3), i);
|
|
2295
|
+
const q = triple ? c.repeat(3) : c;
|
|
2296
|
+
let j = i + q.length;
|
|
2297
|
+
while (j < code.length && !code.startsWith(q, j)) j += code[j] === "\\" ? 2 : 1;
|
|
2298
|
+
if (j >= code.length) break;
|
|
2299
|
+
strings.push({ start: i, end: j + q.length, value: code.slice(i + q.length, j) });
|
|
2300
|
+
i = j + q.length - 1;
|
|
2301
|
+
} else if (c === "(" || c === "[" || c === "{") {
|
|
2302
|
+
const callee = c === "(" ? (/((?:[A-Za-z_$][\w$]*\s*\.\s*)*[A-Za-z_$][\w$]*)\s*$/.exec(code.slice(Math.max(0, i - 128), i)) ?? null) : null;
|
|
2303
|
+
stack.push({ kind: c, open: i, commas: [], callee: callee ? callee[1].replace(/\s+/g, "") : "", start: callee ? i - callee[0].length : i });
|
|
2304
|
+
} else if (c === ")" || c === "]" || c === "}") {
|
|
2305
|
+
const frame = stack.pop();
|
|
2306
|
+
if (frame?.kind === "(") calls.push({ ...frame, close: i });
|
|
2307
|
+
} else if (c === "," && stack.length > 0) stack[stack.length - 1].commas.push(i);
|
|
2308
|
+
}
|
|
2309
|
+
return { strings, calls };
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** Whether a literal Bash operand names live configuration: a glob, brace or bracket word by any expansion it can take. */
|
|
2313
|
+
function operandLive(p, ctx) {
|
|
2314
|
+
if (!hasGlob(p)) return pathLive(p, ctx);
|
|
2315
|
+
// Braces and classes expand to the words the shell can produce; a wildcard left is matched on disk.
|
|
2316
|
+
return (/[[{]/.test(p) ? braceVariants(p, true) : [p]).some((w) => (hasGlob(w) ? globLive(w, ctx) : pathLive(w, ctx)));
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Applies a `cd`, `pushd` or `popd` to the cwd. A literal target replaces it (both are kept when
|
|
2321
|
+
* the command may not run, or the target may not be a directory); any other target leaves the cwd
|
|
2322
|
+
* unresolved, so later relative operands are judged by their own segments.
|
|
2323
|
+
*/
|
|
2324
|
+
function moveCwd(name, args, place, maybe, ctx) {
|
|
2325
|
+
const from = ctx.dir;
|
|
2326
|
+
const merge = (a, b) => mergeDirs(a, b, false);
|
|
2327
|
+
let to;
|
|
2328
|
+
if (name === "popd") to = args.length === 0 && from.stack.length > 0 ? from.stack[from.stack.length - 1] : { ...from, unresolved: true };
|
|
2329
|
+
else {
|
|
2330
|
+
const literal = args.length === 1 && !unknown(args[0]) && !/^[-+]/.test(args[0]) && !hasGlob(args[0]);
|
|
2331
|
+
const target = args.length === 0 ? (name === "cd" ? ctx.env.home : null) : literal ? expandTilde(args[0], ctx.env.home) : null;
|
|
2332
|
+
if (target === null) to = { ...from, unresolved: true };
|
|
2333
|
+
else {
|
|
2334
|
+
const abs = target.startsWith("/");
|
|
2335
|
+
let sure = true;
|
|
2336
|
+
const cwds = [];
|
|
2337
|
+
for (const cwd of abs ? [ctx.env.cwd] : from.cwds) {
|
|
2338
|
+
const { paths, legacy } = resolveOperand(target, { ...ctx.env, cwd });
|
|
2339
|
+
if (legacy || !enterable(paths[paths.length - 1])) sure = false;
|
|
2340
|
+
cwds.push(...paths);
|
|
2341
|
+
}
|
|
2342
|
+
to = { cwds: [...new Set(cwds)], unresolved: !abs && from.unresolved, stack: from.stack };
|
|
2343
|
+
if (!sure) to = merge(from, to);
|
|
2344
|
+
}
|
|
2345
|
+
if (name === "pushd") to = { ...to, stack: [...from.stack, from] };
|
|
2346
|
+
}
|
|
2347
|
+
if (place.joint === "&&" && !maybe) ctx.alt = ctx.alt ? merge(ctx.alt, from) : from;
|
|
2348
|
+
ctx.dir = maybe ? merge(from, to) : to;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** Whether relative `p` is live by its own segments, read from an unresolved cwd. */
|
|
2352
|
+
function ownLive(p, glob, ctx) {
|
|
2353
|
+
const s = segments(p).map((x) => x.normalize("NFC").toUpperCase().toLowerCase());
|
|
2354
|
+
if (s.length === 0) return false;
|
|
2355
|
+
const m = (seg, name) => seg !== undefined && (glob && hasGlob(seg) ? globMatch(seg, name) && (ctx.dots || !name.startsWith(".") || /^[.[]/.test(seg)) : seg === name);
|
|
2356
|
+
return liveAt(s, `/${s.join("/")}`, ctx.env, { m, reach: () => false, plugin: () => false });
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/** Whether `arg` is `--<name>` or an abbreviation of it at least `min` characters long. */
|
|
2360
|
+
function gitLong(arg, name, min = 1) {
|
|
2361
|
+
const m = /^--([^=]+)/.exec(arg);
|
|
2362
|
+
return m !== null && m[1].length >= min && name.startsWith(m[1]);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/** Bundled short flags of `arg`, up to and including the first flag in `valued`. */
|
|
2366
|
+
function shortFlags(arg, valued = "") {
|
|
2367
|
+
if (!/^-[^-]/.test(arg)) return "";
|
|
2368
|
+
let out = "";
|
|
2369
|
+
for (const c of arg.slice(1)) {
|
|
2370
|
+
out += c;
|
|
2371
|
+
if (valued.includes(c)) break;
|
|
2372
|
+
}
|
|
2373
|
+
return out;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/** Whether a git subcommand with its arguments destroys commits, refs or uncommitted work; a forced worktree removal is judged from `ctx.loc`. */
|
|
2377
|
+
function destructive(sub, args, ctx) {
|
|
2378
|
+
const end = args.indexOf("--");
|
|
2379
|
+
const opts = end < 0 ? args : args.slice(0, end);
|
|
2380
|
+
const flags = (valued) => opts.map((a) => shortFlags(a, valued)).join("");
|
|
2381
|
+
const long = (name, min) => opts.some((a) => gitLong(a, name, min));
|
|
2382
|
+
const op = opts.find((a) => !a.startsWith("-"));
|
|
2383
|
+
switch (sub) {
|
|
2384
|
+
case "reset":
|
|
2385
|
+
return long("hard") || long("keep") || long("merge");
|
|
2386
|
+
case "push":
|
|
2387
|
+
return pushDestroys(opts, args);
|
|
2388
|
+
case "branch":
|
|
2389
|
+
return /[DMCf]/.test(flags("u")) || long("force", 2);
|
|
2390
|
+
case "clean":
|
|
2391
|
+
return flags("e").includes("f") || long("force");
|
|
2392
|
+
case "checkout": {
|
|
2393
|
+
const ops = operands(opts, /^(?:-[bB]|--orphan|--conflict)$/);
|
|
2394
|
+
const paths = ops.length > 1 || ops.some((o) => o !== "-" && NOT_REF.test(o)) || (end >= 0 && end < args.length - 1);
|
|
2395
|
+
return paths || /[fpB]/.test(flags("bB")) || long("force") || long("patch", 2) || long("ours", 2) || long("theirs", 2) || long("pathspec-from-file", 2);
|
|
2396
|
+
}
|
|
2397
|
+
case "switch":
|
|
2398
|
+
return /[fC]/.test(flags("cC")) || long("force") || long("force-create", 7) || long("discard-changes", 2);
|
|
2399
|
+
case "config":
|
|
2400
|
+
return configSets(args);
|
|
2401
|
+
case "restore":
|
|
2402
|
+
// `--s` is ambiguous with `--source`, so it selects no target and the default worktree stays.
|
|
2403
|
+
return !(flags("s").includes("S") || long("staged", 2)) || flags("s").includes("W") || long("worktree");
|
|
2404
|
+
case "stash":
|
|
2405
|
+
return op === "drop" || op === "clear";
|
|
2406
|
+
case "reflog":
|
|
2407
|
+
return op === "expire";
|
|
2408
|
+
case "filter-branch":
|
|
2409
|
+
return true;
|
|
2410
|
+
case "worktree": {
|
|
2411
|
+
// `-B` resets an existing branch and `--force` overrides the checks that keep a branch or path in use.
|
|
2412
|
+
if (op === "add") return /[Bf]/.test(flags("bB")) || long("force");
|
|
2413
|
+
if (op !== "remove" || !(flags().includes("f") || long("force"))) return false;
|
|
2414
|
+
const loc = (ctx.loc ??= defaultEnv());
|
|
2415
|
+
const scratch = (p) => {
|
|
2416
|
+
const r = p.startsWith("/") && !/[$`*?[{\\]/.test(p) ? resolveOperand(p, loc) : { paths: [], legacy: true };
|
|
2417
|
+
return !r.legacy && r.paths.every((q) => tempOnly(norm(q), loc));
|
|
2418
|
+
};
|
|
2419
|
+
return !operands(args, /(?!)/).slice(1).every(scratch);
|
|
2420
|
+
}
|
|
2421
|
+
default:
|
|
2422
|
+
return false;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const refName = (r) => r.replace(/^refs\/heads\//, "");
|
|
2427
|
+
|
|
2428
|
+
/** Whether `git push` mirrors, deletes, or forces a ref no lease covers. */
|
|
2429
|
+
function pushDestroys(opts, args) {
|
|
2430
|
+
let force = false;
|
|
2431
|
+
let lease = false;
|
|
2432
|
+
const leased = [];
|
|
2433
|
+
for (const a of opts) {
|
|
2434
|
+
if (gitLong(a, "force-with-lease", 7)) {
|
|
2435
|
+
if (a.includes("=")) leased.push(refName(a.slice(a.indexOf("=") + 1).split(":")[0]));
|
|
2436
|
+
else lease = true;
|
|
2437
|
+
} else if (gitLong(a, "no-force-with-lease", 4)) {
|
|
2438
|
+
lease = false;
|
|
2439
|
+
leased.length = 0;
|
|
2440
|
+
} else if (gitLong(a, "mirror") || gitLong(a, "delete", 2) || gitLong(a, "prune", 3) || shortFlags(a, "o").includes("d")) return true;
|
|
2441
|
+
else if (gitLong(a, "force") || shortFlags(a, "o").includes("f")) force = true;
|
|
2442
|
+
}
|
|
2443
|
+
const refs = operands(args, /^(?:-o|--push-option|--repo|--receive-pack|--exec)$/);
|
|
2444
|
+
if (refs.some((r) => /^\+?:/.test(r)) || (force && !lease)) return true;
|
|
2445
|
+
return !lease && refs.some((r) => r.startsWith("+") && !leased.includes(refName(r.slice(1).split(":").pop())));
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* Whether a config key (lowercase) with `value` (null when unknown) cannot add an alias or code.
|
|
2450
|
+
* A bare fsmonitor key reads as true, and any value but an explicit false may name a hook program.
|
|
2451
|
+
*/
|
|
2452
|
+
function safeKey(key, value) {
|
|
2453
|
+
if (/^core\.(?:fsmonitor|untrackedcache)$/.test(key)) return value !== null && /^(?:false|no|off|0)$/i.test(value);
|
|
2454
|
+
if (key.endsWith(".renamelimit") && !(value !== null && /^\d+$/.test(value))) return false;
|
|
2455
|
+
return GIT_SAFE_KEY.test(key) || (GIT_PROGRAM_KEY.test(key) && value !== null && SAFE_PROGRAM.test(value));
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/** Whether `git config` arguments edit the file, rename a section, or set an unsafe key. */
|
|
2459
|
+
function configSets(args) {
|
|
2460
|
+
const ops = operands(args, /^(?:-f|--file|--blob|--type|--default|--comment|--value)$/);
|
|
2461
|
+
const verb = /^(?:set|get|list|unset|edit|rename-section|remove-section)$/.test(ops[0] ?? "") ? ops.shift() : null;
|
|
2462
|
+
if (verb === "edit" || verb === "rename-section" || args.some((a) => shortFlags(a, "f").includes("e") || gitLong(a, "edit", 2) || gitLong(a, "rename-section", 3))) return true;
|
|
2463
|
+
if ((verb !== null && verb !== "set") || args.some((a) => /^--(?:get|list|unset|remove-section|show-)/.test(a) || shortFlags(a, "f").includes("l"))) return false;
|
|
2464
|
+
return ops.length >= 2 && !safeKey(ops[0].toLowerCase(), ops[1]);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/** Whether a line sets git config or a program git runs through the environment. */
|
|
2468
|
+
function gitEnvTainted(text) {
|
|
2469
|
+
for (const [, name, quoted] of String(text).matchAll(GIT_ENV)) {
|
|
2470
|
+
const value = quoted.replace(/['"\\]/g, "");
|
|
2471
|
+
const safe = name.startsWith("GIT_CONFIG_KEY_") ? safeKey(value.toLowerCase(), null) : /EDITOR|PAGER/.test(name) && SAFE_PROGRAM.test(value);
|
|
2472
|
+
if (!safe) return true;
|
|
2473
|
+
}
|
|
2474
|
+
return false;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** Whether a known text can become an option, a `+`/`:` refspec or a glob; `inside` for a slice, `lead` at a word start. */
|
|
2478
|
+
function yieldsText(text, inside, lead = true) {
|
|
2479
|
+
if (/[$`]/.test(text)) return true;
|
|
2480
|
+
const s = text.replace(/['"\\]/g, "");
|
|
2481
|
+
const [first, ...rest] = s.split(/\s+/);
|
|
2482
|
+
return /[*?[]/.test(s) || rest.some((t) => /^[-+:]/.test(t)) || (lead && (inside ? /[-+:]/.test(s) : /^[-+:]/.test(first)));
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/** Whether a substitution, bare or double-quoted, runs a GIT_QUERY query whose arguments are literal or such substitutions. */
|
|
2486
|
+
function gitQuery(text) {
|
|
2487
|
+
const t = /^"[\s\S]*"$/.test(text) ? text.slice(1, -1) : text;
|
|
2488
|
+
const end = t[0] === "`" ? quoteEnd(t, 0) : t.startsWith("$(") && t[2] !== "(" ? closeGroup(t, 2, "(", ")") : -1;
|
|
2489
|
+
const cmds = end === t.length ? splitCommands(t.slice(t[0] === "`" ? 1 : 2, -1)) : null;
|
|
2490
|
+
if (cmds === null || cmds.length !== 1 || cmds[0][0] !== "git") return false;
|
|
2491
|
+
const args = cmds[0].slice(1);
|
|
2492
|
+
while (/^(?:--no-pager|-P|-C)$/.test(args[0] ?? "")) args.splice(0, args[0] === "-C" ? 2 : 1);
|
|
2493
|
+
const [sub, ...rest] = args;
|
|
2494
|
+
const flags = GIT_QUERY.get(sub);
|
|
2495
|
+
if (flags === undefined || args.some((a) => /[$`]/.test(a) && !gitQuery(a))) return false;
|
|
2496
|
+
return sub === "branch" ? rest.length === 1 && flags.test(rest[0]) : rest.every((a) => (a.startsWith("-") ? flags.test(a) : !/^[+:]/.test(a)));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** Raw words of `s` from `i` to a separator (only the first with `one`); null on an open quote. */
|
|
2500
|
+
function rawWords(s, i, one) {
|
|
2501
|
+
const out = [];
|
|
2502
|
+
let word = "";
|
|
2503
|
+
for (;;) {
|
|
2504
|
+
const c = s[i];
|
|
2505
|
+
if (c === undefined || /[\s;&|()<>]/.test(c)) {
|
|
2506
|
+
if (one) return [word];
|
|
2507
|
+
if (word !== "") out.push(word);
|
|
2508
|
+
if (c === undefined || /[\n;&|()<>]/.test(c)) return out;
|
|
2509
|
+
word = "";
|
|
2510
|
+
i++;
|
|
2511
|
+
continue;
|
|
2512
|
+
}
|
|
2513
|
+
const sub = substitutionEnd(s, i, false);
|
|
2514
|
+
const end = c === "\\" ? i + 2 : c === "'" || c === '"' ? quoteEnd(s, i) : sub === i ? i + 1 : sub;
|
|
2515
|
+
if (end < 0) return null;
|
|
2516
|
+
word += s.slice(i, end);
|
|
2517
|
+
i = end;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// Names are found with fixed patterns and compared as strings: command text is never compiled.
|
|
2522
|
+
const NAME_USE = /\$\{?[#!]?([A-Za-z_]\w*)/g;
|
|
2523
|
+
const WORD_NAME = /(?<=^|[\s;&|(){}\x60'"])(?<!\$\{)[A-Za-z_]\w*/g;
|
|
2524
|
+
const EXPANSION_BODY = /\$\{([^}]*)/g;
|
|
2525
|
+
const ASSIGNED_NAME = /\b([A-Za-z_]\w*)(?:\[[^\]]*\])?:*=/g;
|
|
2526
|
+
|
|
2527
|
+
/** Index of the first `$name`, `${name`, `${#name` or `${!name` in `text`; -1 when none. */
|
|
2528
|
+
function useIndex(text, name) {
|
|
2529
|
+
for (const m of text.matchAll(NAME_USE)) if (m[1] === name) return m.index;
|
|
2530
|
+
return -1;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** Whether an expansion in `line` assigns `name` (`${name=v}`, `${x:-${name:=v}}`). */
|
|
2534
|
+
function assignsInExpansion(line, name) {
|
|
2535
|
+
for (const body of line.matchAll(EXPANSION_BODY)) for (const m of body[1].matchAll(ASSIGNED_NAME)) if (m[1] === name) return true;
|
|
2536
|
+
return false;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* Binding counts per name for standalone assignments and for-in lists that run in the line's own
|
|
2541
|
+
* shell, an assignment unconditionally or only for the uses its `&&` chain gates; `lost` names
|
|
2542
|
+
* are bound anywhere else or by `+=`.
|
|
2543
|
+
*/
|
|
2544
|
+
function lineScope(line) {
|
|
2545
|
+
const kept = new Map();
|
|
2546
|
+
const lost = new Set();
|
|
2547
|
+
const found = [];
|
|
2548
|
+
const frames = [];
|
|
2549
|
+
const cmds = splitCommands(line) ?? [];
|
|
2550
|
+
const gated = (i, name) => {
|
|
2551
|
+
const end = cmds.findIndex((c, k) => k > i && PLACES.get(c)?.joint !== "&&");
|
|
2552
|
+
return end < 0 || !cmds.slice(end).some((c) => useIndex(c.join(" "), name) >= 0);
|
|
2553
|
+
};
|
|
2554
|
+
for (const [i, words] of cmds.entries()) {
|
|
2555
|
+
const place = PLACES.get(words);
|
|
2556
|
+
const away = place === undefined || place.isolated || place.late === true || place.shells.length > 0;
|
|
2557
|
+
let j = 0;
|
|
2558
|
+
for (; KEYWORDS.has(words[j]); j++) {
|
|
2559
|
+
if (OPENERS.has(words[j])) frames.push({ kind: words[j], away });
|
|
2560
|
+
else if (CLOSERS.has(words[j]) && frames.length > 0) frames.pop().away ||= away;
|
|
2561
|
+
}
|
|
2562
|
+
const rest = words.slice(j);
|
|
2563
|
+
const outer = [...frames];
|
|
2564
|
+
for (const w of rest.filter((x) => ASSIGN.test(x))) {
|
|
2565
|
+
const name = /^\w+/.exec(w)[0];
|
|
2566
|
+
const ok = !away && (!place.joint || (place.joint === "&&" && gated(i, name))) && rest.every((x) => ASSIGN.test(x)) && !/^\w+(?:\[|\+=)/.test(w) && outer.every((f) => f.kind === "{");
|
|
2567
|
+
found.push({ name, ok, outer });
|
|
2568
|
+
}
|
|
2569
|
+
if (OPENERS.has(rest[0])) frames.push({ kind: rest[0], away });
|
|
2570
|
+
if (rest[0] === "for" && rest[2] === "in") found.push({ name: rest[1], ok: !away && frames.every((f) => f.kind === "{" || f.kind === "for"), outer: [...frames] });
|
|
2571
|
+
}
|
|
2572
|
+
for (const { name, ok, outer } of found) {
|
|
2573
|
+
if (ok && !outer.some((f) => f.away)) kept.set(name, (kept.get(name) ?? 0) + 1);
|
|
2574
|
+
else lost.add(name);
|
|
2575
|
+
}
|
|
2576
|
+
return { kept, lost };
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** Values `name` is assigned or for-in listed on the line, or null when something else can bind it or a use comes first. */
|
|
2580
|
+
function lineValues(name, ctx) {
|
|
2581
|
+
if (name === XARGS_NAME) return [];
|
|
2582
|
+
if (!ctx.names.has(name)) {
|
|
2583
|
+
const line = ctx.line;
|
|
2584
|
+
// A bare name at a word start not followed by `=` or a for-in list is bound by read, printf -v or a like form.
|
|
2585
|
+
let values = assignsInExpansion(line, name) ? null : [];
|
|
2586
|
+
let first = line.length;
|
|
2587
|
+
let count = 0;
|
|
2588
|
+
for (const m of values === null ? [] : line.matchAll(WORD_NAME)) {
|
|
2589
|
+
if (m[0] !== name) continue;
|
|
2590
|
+
const at = m.index + name.length;
|
|
2591
|
+
const list = /^[ \t]+in[ \t]/.exec(line.slice(at, at + 8));
|
|
2592
|
+
const forIn = list !== null && /(?:^|[\s;&|(){}])for[ \t]+$/.test(line.slice(Math.max(0, m.index - 64), m.index));
|
|
2593
|
+
const bound = /^\+?=/.test(line.slice(at, at + 2)) ? rawWords(line, line.indexOf("=", at) + 1, true) : forIn ? rawWords(line, at + list[0].length, false) : null;
|
|
2594
|
+
if (bound === null) {
|
|
2595
|
+
values = null;
|
|
2596
|
+
break;
|
|
2597
|
+
}
|
|
2598
|
+
values.push(...bound.flatMap((w) => (forIn && BRACES.test(w) ? braceVariants(w) : [w])));
|
|
2599
|
+
first = Math.min(first, m.index);
|
|
2600
|
+
count++;
|
|
2601
|
+
}
|
|
2602
|
+
ctx.scope ??= lineScope(line);
|
|
2603
|
+
if (values !== null && (ctx.scope.lost.has(name) || (ctx.scope.kept.get(name) ?? 0) !== count)) values = null;
|
|
2604
|
+
const use = useIndex(line, name);
|
|
2605
|
+
if (values?.length && (use < 0 ? first : use) < first) values = null;
|
|
2606
|
+
ctx.names.set(name, values);
|
|
2607
|
+
}
|
|
2608
|
+
return ctx.names.get(name);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* What one expansion of a split word can be: "yields" (an option, a `+`/`:` refspec or a glob),
|
|
2613
|
+
* "unknown" (a value from outside the line) or "safe"; `lead` when it starts the word.
|
|
2614
|
+
*/
|
|
2615
|
+
function expansionKind(text, ctx, lead = true) {
|
|
2616
|
+
if (text[0] === "`" || text[1] === "(") return gitQuery(text) ? "safe" : "yields";
|
|
2617
|
+
const body = text[1] === "{" ? text.slice(2, -1) : text.slice(1);
|
|
2618
|
+
const m = /^([A-Za-z_]\w*)([\s\S]*)$/.exec(body);
|
|
2619
|
+
// Flags, indirection, subscripts, nesting and special parameters count as yielding.
|
|
2620
|
+
if (m === null || SHELL_SET.test(m[1]) || (m[2] !== "" && !EXP_OP.test(m[2]))) return "yields";
|
|
2621
|
+
const slice = m[2] !== "" && !/^:?[-=?+]/.test(m[2]);
|
|
2622
|
+
if (expansionWords(body).some((w) => yieldsText(w, slice, lead))) return "yields";
|
|
2623
|
+
const values = lineValues(m[1], ctx);
|
|
2624
|
+
if (values === null || values.some((v) => !(gitQuery(v) && (!slice || !lead)) && yieldsText(v, slice, lead))) return "yields";
|
|
2625
|
+
return values.length === 0 ? "unknown" : "safe";
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** Whether the git line is short, splits, and binds no name in its unquoted text (see BINDERS). */
|
|
2629
|
+
function plainLine(ctx) {
|
|
2630
|
+
ctx.plain ??= ctx.line.length <= MAX_CHARS && splitCommands(ctx.line) !== null && !BINDERS.test(unquotedText(ctx.line));
|
|
2631
|
+
return ctx.plain;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/** A heredoc operator and its delimiter: quoted (groups 2-4) or bare (group 5). */
|
|
2635
|
+
const DOC_OP = /<<(-?)[ \t]*(?:(['"])(\w+)\2|\\(\w+)|(\w+))/y;
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* `line` without single-quoted and ANSI-C text and quoted heredoc bodies; the whole line when a
|
|
2639
|
+
* quote, body or heredoc operator cannot be read, so no unquoted text is ever dropped.
|
|
2640
|
+
*/
|
|
2641
|
+
function unquotedText(line) {
|
|
2642
|
+
let out = "";
|
|
2643
|
+
const docs = [];
|
|
2644
|
+
let i = 0;
|
|
2645
|
+
while (i < line.length) {
|
|
2646
|
+
tick();
|
|
2647
|
+
const c = line[i];
|
|
2648
|
+
const sub = substitutionEnd(line, i, false);
|
|
2649
|
+
if (sub < 0) return line;
|
|
2650
|
+
let next = i + 1;
|
|
2651
|
+
if (sub > i) next = sub;
|
|
2652
|
+
else if (line.startsWith("<<<", i)) next = i + 3;
|
|
2653
|
+
else if (line.startsWith("<<", i)) {
|
|
2654
|
+
DOC_OP.lastIndex = i;
|
|
2655
|
+
const doc = DOC_OP.exec(line);
|
|
2656
|
+
if (doc === null) return line;
|
|
2657
|
+
docs.push({ strip: doc[1] === "-", delim: doc[3] ?? doc[4] ?? doc[5], quoted: doc[5] === undefined });
|
|
2658
|
+
next = DOC_OP.lastIndex;
|
|
2659
|
+
} else if (c === "\\") next = i + 2;
|
|
2660
|
+
else if (c === "#" && /^$|[\s;&|()<>]/.test(line[i - 1] ?? "")) next = line.includes("\n", i) ? line.indexOf("\n", i) : line.length;
|
|
2661
|
+
else if (c === "'" || (c === "$" && line[i + 1] === "'")) {
|
|
2662
|
+
next = c === "'" ? quoteEnd(line, i) : ansiEnd(line, i + 1);
|
|
2663
|
+
if (next < 0) return line;
|
|
2664
|
+
out += "''";
|
|
2665
|
+
i = next;
|
|
2666
|
+
continue;
|
|
2667
|
+
} else if (c === '"') {
|
|
2668
|
+
let j = i + 1;
|
|
2669
|
+
while (j < line.length && line[j] !== '"') {
|
|
2670
|
+
const inner = substitutionEnd(line, j, true);
|
|
2671
|
+
if (inner < 0) return line;
|
|
2672
|
+
j = inner > j ? inner : line[j] === "\\" ? j + 2 : j + 1;
|
|
2673
|
+
}
|
|
2674
|
+
if (j >= line.length) return line;
|
|
2675
|
+
next = j + 1;
|
|
2676
|
+
} else if (c === "\n" && docs.length > 0) {
|
|
2677
|
+
out += "\n";
|
|
2678
|
+
i++;
|
|
2679
|
+
for (const d of docs.splice(0)) {
|
|
2680
|
+
for (let done = false; !done; ) {
|
|
2681
|
+
if (i >= line.length) return line;
|
|
2682
|
+
const end = line.includes("\n", i) ? line.indexOf("\n", i) : line.length;
|
|
2683
|
+
const text = line.slice(i, end);
|
|
2684
|
+
i = end + 1;
|
|
2685
|
+
done = (d.strip ? text.replace(/^\t+/, "") : text) === d.delim;
|
|
2686
|
+
if (!done && !d.quoted) out += `${text}\n`;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
continue;
|
|
2690
|
+
}
|
|
2691
|
+
out += line.slice(i, next);
|
|
2692
|
+
i = next;
|
|
2693
|
+
}
|
|
2694
|
+
return docs.length > 0 ? line : out;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/** Raw word `text` without the backslash-newlines the shell drops outside single quotes. */
|
|
2698
|
+
function joinLines(text) {
|
|
2699
|
+
if (!text.includes("\\\n")) return text;
|
|
2700
|
+
let out = "";
|
|
2701
|
+
let from = 0;
|
|
2702
|
+
let dq = false;
|
|
2703
|
+
for (let i = 0; i < text.length; i++) {
|
|
2704
|
+
const c = text[i];
|
|
2705
|
+
const end = c === "'" && !dq ? text.indexOf("'", i + 1) : -1;
|
|
2706
|
+
if (c === "\\" && text[i + 1] === "\n") {
|
|
2707
|
+
out += text.slice(from, i);
|
|
2708
|
+
from = i + 2;
|
|
2709
|
+
} else if (end <= i) dq = c === '"' ? !dq : dq;
|
|
2710
|
+
i = c === "\\" ? i + 1 : Math.max(i, end);
|
|
2711
|
+
}
|
|
2712
|
+
return out + text.slice(from);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** `text` joined at every backslash-newline, as code parsed again reads it. */
|
|
2716
|
+
function joinAll(text) {
|
|
2717
|
+
return text.replaceAll("\\\n", "");
|
|
2718
|
+
}
|
|
31
2719
|
|
|
32
2720
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
2721
|
+
* Word lists `words` can split into with each `$name` filled by its line values, split and joined
|
|
2722
|
+
* at a backslash-newline (in quotes too under `code`); a word with an unknown value stays as
|
|
2723
|
+
* written, and past MAX_VARIANTS lists the result is null.
|
|
36
2724
|
*/
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
2725
|
+
function boundVariants(words, ctx) {
|
|
2726
|
+
const texts = (v) => [...new Set([v, joinLines(v), ctx.code ? joinAll(v) : v])].map((t) => t.replace(/['"\\]/g, ""));
|
|
2727
|
+
const valuesOf = (name) => {
|
|
2728
|
+
const values = name === null || SHELL_SET.test(name) ? null : lineValues(name, ctx);
|
|
2729
|
+
return values?.length && !values.some((v) => /[$`]/.test(v)) ? values.flatMap(texts) : null;
|
|
2730
|
+
};
|
|
2731
|
+
let out = [[]];
|
|
2732
|
+
for (const w of words) {
|
|
2733
|
+
const alts = /[$`]/.test(w) ? expandNames(w, valuesOf, Infinity, shellSpans(w)) : null;
|
|
2734
|
+
const next = alts === null ? [[w]] : alts.map((a) => a.split(/\s+/).filter(Boolean));
|
|
2735
|
+
out = out.flatMap((o) => next.map((n) => [...o, ...n]));
|
|
2736
|
+
if (out.length > MAX_VARIANTS) return null;
|
|
2737
|
+
}
|
|
2738
|
+
return out;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
/** Each `$` or backtick expansion of `w` in order; the name is null but for `$name` and `${name}`, and one that does not parse ends the scan. */
|
|
2742
|
+
function* shellSpans(w) {
|
|
2743
|
+
for (let i = 0; i < w.length; i++) {
|
|
2744
|
+
if (w[i] !== "$" && w[i] !== "`") continue;
|
|
2745
|
+
const e = expansionAt(w, i);
|
|
2746
|
+
yield { index: i, end: e?.end ?? i, name: e === null ? null : (/^\$\{?([A-Za-z_]\w*)\}?$/.exec(w.slice(i, e.end))?.[1] ?? null) };
|
|
2747
|
+
if (e === null) return;
|
|
2748
|
+
i = e.end - 1;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
/**
|
|
2753
|
+
* Whether an expansion argument of a listed subcommand can complete a listed form: known text
|
|
2754
|
+
* yields an option or a `+`/`:` refspec, or an unknown value stands in a push refspec, a checkout
|
|
2755
|
+
* operand, the stash, reflog or worktree verb, or the start of a worktree add word (it may be `-B`
|
|
2756
|
+
* or `--force`). A line with a binder denies every one, but for the read-only stash and worktree
|
|
2757
|
+
* forms, which move no ref, and a worktree add, read through its same-line literal bindings.
|
|
2758
|
+
*/
|
|
2759
|
+
function expansionDenies(sub, args, ctx) {
|
|
2760
|
+
if (!args.some((a) => /[$`]/.test(a))) return false;
|
|
2761
|
+
if (sub === "stash" ? /^(?:show|list)$/.test(args[0]) : sub === "worktree" && /^(?:list|prune)$/.test(args[0])) return false;
|
|
2762
|
+
const adds = sub === "worktree" && args[0] === "add";
|
|
2763
|
+
const plain = plainLine(ctx);
|
|
2764
|
+
if (!plain && !adds) return true;
|
|
2765
|
+
const bound = (text, lead) => {
|
|
2766
|
+
const m = /^\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))$/.exec(text);
|
|
2767
|
+
if (m === null) return (text[0] === "`" || text[1] === "(") && gitQuery(text) ? "safe" : "yields";
|
|
2768
|
+
const value = literalBinding(m[1] ?? m[2], ctx);
|
|
2769
|
+
return value === null ? "unknown" : yieldsText(value, false, lead) ? "yields" : "safe";
|
|
2770
|
+
};
|
|
2771
|
+
const [short, long] = GIT_SUB_VALUED.get(sub) ?? ["", []];
|
|
2772
|
+
const kinds = (w) => {
|
|
2773
|
+
const out = [];
|
|
2774
|
+
for (let i = 0; i < w.length; ) {
|
|
2775
|
+
const e = w[i] === "$" || w[i] === "`" ? expansionAt(w, i) : null;
|
|
2776
|
+
if (e === null && (w[i] === "$" || w[i] === "`")) out.push([i, "yields"]);
|
|
2777
|
+
if (e !== null) out.push([i, plain ? expansionKind(w.slice(i, e.end), ctx, i === 0) : bound(w.slice(i, e.end), i === 0)]);
|
|
2778
|
+
i = e === null ? i + 1 : e.end;
|
|
2779
|
+
}
|
|
2780
|
+
return out;
|
|
2781
|
+
};
|
|
2782
|
+
const yields = (w) => w !== undefined && kinds(w).some(([, k]) => k === "yields");
|
|
2783
|
+
let pos = 0;
|
|
2784
|
+
let ended = false;
|
|
2785
|
+
for (let i = 0; i < args.length; i++) {
|
|
2786
|
+
const a = args[i];
|
|
2787
|
+
if (!ended && a === "--") ended = true;
|
|
2788
|
+
else if (!ended && /^-[^-]/.test(a)) {
|
|
2789
|
+
let j = 1;
|
|
2790
|
+
while (j < a.length && !short.includes(a[j])) if ("$`".includes(a[j++])) return true;
|
|
2791
|
+
if (j < a.length && yields(j + 1 < a.length ? a.slice(j + 1) : args[++i])) return true;
|
|
2792
|
+
} else if (!ended && a.startsWith("--")) {
|
|
2793
|
+
const eq = a.includes("=") ? a.indexOf("=") : a.length;
|
|
2794
|
+
if (/[$`]/.test(a.slice(0, eq)) || yields(a.slice(eq + 1))) return true;
|
|
2795
|
+
if (eq === a.length && long.some((n) => gitLong(a, n, 3)) && yields(args[++i])) return true;
|
|
2796
|
+
} else {
|
|
2797
|
+
const found = kinds(a);
|
|
2798
|
+
const unknown = found.filter(([, k]) => k === "unknown");
|
|
2799
|
+
if (found.some(([, k]) => k === "yields")) return true;
|
|
2800
|
+
if (((sub === "push" && pos > 0) || (adds && !ended)) && unknown.some(([at]) => at === 0)) return true;
|
|
2801
|
+
if (unknown.length > 0 && (sub === "checkout" || (pos === 0 && /^(?:stash|reflog|worktree)$/.test(sub)))) return true;
|
|
2802
|
+
pos++;
|
|
43
2803
|
}
|
|
44
2804
|
}
|
|
45
|
-
return
|
|
2805
|
+
return false;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/**
|
|
2809
|
+
* Whether `argv` (from the `git` word on) destroys work or sets config that can add an alias or
|
|
2810
|
+
* run code; `-c alias.*` is resolved. A listed subcommand denies on `expansionDenies` or a
|
|
2811
|
+
* destructive form of its literal arguments; alias resolution past MAX_DEPTH hops allows.
|
|
2812
|
+
* @param {boolean} [fed] argv carries the input xargs or find hands a literal git child as the
|
|
2813
|
+
* opaque XARGS_INPUT operand; a subcommand the input spells is unknown and allows.
|
|
2814
|
+
*/
|
|
2815
|
+
function gitRun(argv, depth, ctx, fed = false) {
|
|
2816
|
+
const aliases = new Map();
|
|
2817
|
+
let words = argv.slice(1);
|
|
2818
|
+
for (let hops = 0; ; hops++) {
|
|
2819
|
+
let j = 0;
|
|
2820
|
+
for (; j < words.length && words[j].startsWith("-"); j++) {
|
|
2821
|
+
const a = words[j];
|
|
2822
|
+
const env = a === "--config-env" || a.startsWith("--config-env=");
|
|
2823
|
+
if (a.startsWith("--exec-path=")) return true;
|
|
2824
|
+
if (a !== "-c" && !env) {
|
|
2825
|
+
if (GIT_VALUED.test(a)) j++;
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
const entry = env && a.includes("=") ? a.slice(a.indexOf("=") + 1) : (words[++j] ?? "");
|
|
2829
|
+
const eq = entry.indexOf("=");
|
|
2830
|
+
const key = (eq < 0 ? entry : entry.slice(0, eq)).toLowerCase();
|
|
2831
|
+
// --config-env takes the value from a variable, so an alias it defines is unknown.
|
|
2832
|
+
if (key.startsWith("alias.") && !env && eq > 0) aliases.set(key.slice(6), entry.slice(eq + 1));
|
|
2833
|
+
else if ((CONFIG_KEY.test(key) || /[$`]/.test(key)) && !safeKey(key, env ? null : entry.slice(eq + 1 || entry.length))) return true;
|
|
2834
|
+
}
|
|
2835
|
+
const [sub, ...braced] = words.slice(j);
|
|
2836
|
+
if (sub === undefined || (fed && sub.includes(XARGS_INPUT))) return false;
|
|
2837
|
+
if (/[$`]/.test(sub) && !hasGlob(sub) && plainLine(ctx)) {
|
|
2838
|
+
const bound = boundVariants([sub], ctx);
|
|
2839
|
+
if (bound !== null && !bound.some((v) => v[0] === sub)) return bound.some((v) => gitRun([argv[0], ...words.slice(0, j), ...v, ...braced], depth, ctx, fed));
|
|
2840
|
+
}
|
|
2841
|
+
if (/[$`]/.test(sub) || hasGlob(sub)) return true;
|
|
2842
|
+
// Brace expansion turns `{--hard,}` into `--hard`.
|
|
2843
|
+
const rest = braced.flatMap((a) => (BRACES.test(a) && !/[$`]/.test(a) ? braceVariants(a) : [a]));
|
|
2844
|
+
const value = aliases.get(sub.toLowerCase());
|
|
2845
|
+
if (value === undefined) return GIT_LISTED.has(sub) && (expansionDenies(sub, rest, ctx) || (boundVariants(rest, ctx)?.some((v) => destructive(sub, v, ctx)) ?? false));
|
|
2846
|
+
if (hops >= MAX_DEPTH) return false;
|
|
2847
|
+
if (/[$`]/.test(value)) return true;
|
|
2848
|
+
if (value.startsWith("!")) return gitCheck([value.slice(1), ...rest].join(" "), depth + 1, ctx) !== null;
|
|
2849
|
+
words = [...(splitCommands(value)?.[0] ?? value.split(/\s+/)), ...rest];
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/** Deny reason for a git command, echoed through `visible`. */
|
|
2854
|
+
function gitReason(words) {
|
|
2855
|
+
return (
|
|
2856
|
+
`FreeHarness git guard: \`${visible(words.join(" "))}\` can destroy commits, refs or uncommitted work. ` +
|
|
2857
|
+
"If it is intended, ask the user to run it with the `!` prompt prefix; no marker opens this guard. " +
|
|
2858
|
+
"Do not route the command through another tool."
|
|
2859
|
+
);
|
|
46
2860
|
}
|
|
47
2861
|
|
|
48
2862
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
2863
|
+
* Git guard state of a line: text, aliases, env taint, binder memos (`plain`, `names`, `scope`),
|
|
2864
|
+
* `feed` (the simple command piping into the one judged, for shellBody), `code` (inside code
|
|
2865
|
+
* parsed again) and the judgment env (`loc`).
|
|
52
2866
|
*/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (/\/node_modules\//.test(p)) return false;
|
|
56
|
-
const base = p.split("/").pop() || "";
|
|
57
|
-
return NAMES.test(base) || DIRS.test(p);
|
|
2867
|
+
function gitLine(text, loc = undefined) {
|
|
2868
|
+
return { line: text, aliases: new Map(), env: gitEnvTainted(text), plain: undefined, names: new Map(), scope: undefined, feed: null, code: false, loc };
|
|
58
2869
|
}
|
|
59
2870
|
|
|
60
2871
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
2872
|
+
* Value of `name` from its one same-line binding, read even on a line with a binder word: a
|
|
2873
|
+
* standalone top-level assignment of literal text, unconditional and ahead of every use, with no
|
|
2874
|
+
* other mention of the name and no command that can bind a name the text does not show (eval,
|
|
2875
|
+
* source, a trap, a nameref, a binder given an expanded name) or remap a command (hash); null
|
|
2876
|
+
* otherwise.
|
|
2877
|
+
*/
|
|
2878
|
+
function literalBinding(name, ctx) {
|
|
2879
|
+
// Code parsed again runs in another shell, where the line's binding may not hold.
|
|
2880
|
+
if (ctx.code) return null;
|
|
2881
|
+
ctx.literals ??= new Map();
|
|
2882
|
+
if (ctx.literals.has(name)) return ctx.literals.get(name);
|
|
2883
|
+
ctx.literals.set(name, null);
|
|
2884
|
+
const line = ctx.line;
|
|
2885
|
+
const opaque = SHELL_SET.test(name) || line.length > MAX_CHARS || /\bIFS\b|\$\{[(!][^}]*=/.test(line) || assignsInExpansion(line, name);
|
|
2886
|
+
const cmds = opaque ? null : splitCommands(line);
|
|
2887
|
+
if (cmds === null) return null;
|
|
2888
|
+
const opener = /^(?:eval|source|\.|fc|trap|emulate|enable|zmodload|autoload|zparseopts|hash)$/;
|
|
2889
|
+
// Builtins that can assign a name an operand spells, or run one that does.
|
|
2890
|
+
const binder = /^(?:declare|typeset|local|export|readonly|integer|float|let|private|set|read|getln|mapfile|readarray|getopts|vared|sysread|zstat|zformat|strftime|zstyle|zle|zregexparse|pcre_match|zselect|zsocket|ztcp|zpty|zsystem|sysopen|sched|compadd|compgen|zcurses|unset|select|coproc|print|printf|wait|builtin|command|exec|noglob|nocorrect|-|time)$/;
|
|
2891
|
+
const heads = [];
|
|
2892
|
+
let value = null;
|
|
2893
|
+
let at = -1;
|
|
2894
|
+
let frames = 0;
|
|
2895
|
+
for (const [i, c] of cmds.entries()) {
|
|
2896
|
+
const place = PLACES.get(c);
|
|
2897
|
+
let j = 0;
|
|
2898
|
+
for (; KEYWORDS.has(c[j]); j++) frames += OPENERS.has(c[j]) ? 1 : CLOSERS.has(c[j]) ? -1 : 0;
|
|
2899
|
+
let k = j;
|
|
2900
|
+
while (k < c.length && (ASSIGN.test(c[k]) || PRECOMMANDS.has(c[k]) || /^(?:command|builtin|time)$/.test(c[k]))) for (k++; /^-\w*$/.test(c[k] ?? ""); k++);
|
|
2901
|
+
const head = c[k] ?? "";
|
|
2902
|
+
const tail = c.slice(k + 1);
|
|
2903
|
+
const expanded = tail.some((w) => /[$`]/.test(w));
|
|
2904
|
+
if (opener.test(head)) return null;
|
|
2905
|
+
// A declaration builtin takes an operand as one assignment, so only an expanded name can bind another name.
|
|
2906
|
+
const declares = /^(?:declare|typeset|local|export|readonly|integer|float|let|private)$/.test(head);
|
|
2907
|
+
if (declares && tail.some((w) => /^[-+]\w*n/.test(w) || /[$`]/.test(w.split("=")[0]))) return null;
|
|
2908
|
+
const names = /^(?:printf|print)$/.test(head) ? tail.some((w) => /^-\w*v/.test(w)) : head === "wait" ? tail.some((w) => /^-\w*p/.test(w)) : !declares && binder.test(head);
|
|
2909
|
+
if (names && expanded) return null;
|
|
2910
|
+
if (expanded && /[$`]/.test(head)) {
|
|
2911
|
+
// An expanded command word may itself be a binder; it is judged once its own binding is read.
|
|
2912
|
+
const ref = /^("?)\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))([^$`"\\]*)\1$/.exec(head);
|
|
2913
|
+
if (ref === null) return null;
|
|
2914
|
+
heads.push([ref[2] ?? ref[3], ref[4]]);
|
|
2915
|
+
}
|
|
2916
|
+
for (const [p, w] of c.entries()) {
|
|
2917
|
+
if (QUOTED_DOCS.get(c)?.has(p)) continue;
|
|
2918
|
+
if (useIndex(w, name) >= 0 && at < 0) return null;
|
|
2919
|
+
for (const m of w.matchAll(WORD_NAME)) {
|
|
2920
|
+
if (m[0] !== name) continue;
|
|
2921
|
+
const v = w.slice(name.length + 1);
|
|
2922
|
+
const plain = m.index === 0 && w[name.length] === "=" && at < 0 && frames === 0 && j === 0 && c.every((x) => ASSIGN.test(x));
|
|
2923
|
+
const settled = place !== undefined && !place.isolated && !place.late && place.shells.length === 0 && !place.joint;
|
|
2924
|
+
if (!plain || !settled || v === "" || /[\s$`\\*?[]/.test(v)) return null;
|
|
2925
|
+
value = v;
|
|
2926
|
+
at = i;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (OPENERS.has(c[j])) frames++;
|
|
2930
|
+
}
|
|
2931
|
+
ctx.literals.set(name, value);
|
|
2932
|
+
for (const [h, suffix] of heads) {
|
|
2933
|
+
const v = h === name ? value : literalBinding(h, ctx);
|
|
2934
|
+
const base = v === null ? "" : path.posix.basename(v + suffix);
|
|
2935
|
+
if (v === null || opener.test(base) || binder.test(base)) {
|
|
2936
|
+
ctx.literals.set(name, null);
|
|
2937
|
+
return null;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
return value;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* Git guard reason for one simple command from `splitCommands`, or null; nested code is judged
|
|
2945
|
+
* too, and recursion past MAX_DEPTH allows.
|
|
2946
|
+
* @param {boolean} [appended] xargs, find or parallel adds arguments.
|
|
2947
|
+
* @param {boolean} [unaliased] the command word came from an expansion, which a shell never alias-expands.
|
|
2948
|
+
*/
|
|
2949
|
+
export function gitDenial(words, depth = 0, ctx = gitLine(words.join(" ")), appended = false, unaliased = false) {
|
|
2950
|
+
if (depth > MAX_DEPTH) return null;
|
|
2951
|
+
if (words.length === 1 && (words[0] === "cat" || words[0] === "tee")) PASSED.set(words, ctx.feed);
|
|
2952
|
+
const argv = [];
|
|
2953
|
+
const from = [];
|
|
2954
|
+
const inputs = [];
|
|
2955
|
+
let stdin = null;
|
|
2956
|
+
let quotedDoc = false;
|
|
2957
|
+
let herestring = false;
|
|
2958
|
+
for (let i = 0; i < words.length; i++) {
|
|
2959
|
+
const op = REDIRECT.exec(words[i]);
|
|
2960
|
+
if (!op || i + 1 >= words.length) {
|
|
2961
|
+
argv.push(words[i]);
|
|
2962
|
+
from.push(i);
|
|
2963
|
+
} else if (op[1].startsWith("<<")) {
|
|
2964
|
+
herestring = op[1] === "<<<";
|
|
2965
|
+
stdin = words[++i];
|
|
2966
|
+
quotedDoc = op[1] !== "<<<" && (QUOTED_DOCS.get(words)?.has(i) ?? false);
|
|
2967
|
+
} else if (!OUTPUT_OPS.has(op[1])) inputs.push(words[++i]);
|
|
2968
|
+
else i++;
|
|
2969
|
+
}
|
|
2970
|
+
const io = { stdin, quoted: quotedDoc, herestring, inputs, feed: ctx.feed, source: words };
|
|
2971
|
+
const code = (text) => codeDenial(text, depth + 1, ctx, appended);
|
|
2972
|
+
let k = 0;
|
|
2973
|
+
let forked = false;
|
|
2974
|
+
let input;
|
|
2975
|
+
let fed = false;
|
|
2976
|
+
while (k < argv.length) {
|
|
2977
|
+
const w = argv[k];
|
|
2978
|
+
const name = path.posix.basename(w);
|
|
2979
|
+
const valued = name === "xargs" ? XARGS_VALUED : WRAPPERS.get(name)?.[0];
|
|
2980
|
+
if (ASSIGN.test(w) || KEYWORDS.has(w) || PRECOMMANDS.has(w)) k++;
|
|
2981
|
+
else if (w === "function" || w === "repeat") k += 2;
|
|
2982
|
+
else if ((w === "for" || w === "select") && argv[k + 2] === "do") k += 3;
|
|
2983
|
+
else if (valued !== undefined) {
|
|
2984
|
+
if (name === "xargs") {
|
|
2985
|
+
const opts = input === undefined && !appended ? xargsChild(argv, k + 1) : false;
|
|
2986
|
+
input = opts === false ? false : opts?.mark;
|
|
2987
|
+
}
|
|
2988
|
+
appended ||= name === "xargs";
|
|
2989
|
+
fed ||= name === "xargs";
|
|
2990
|
+
for (k++; k < argv.length && argv[k].startsWith("-"); k++) {
|
|
2991
|
+
const a = argv[k];
|
|
2992
|
+
if (a === "--") {
|
|
2993
|
+
k++;
|
|
2994
|
+
break;
|
|
2995
|
+
}
|
|
2996
|
+
if (name === "command" && /^-[vV]$/.test(a)) return null;
|
|
2997
|
+
if (name === "env" && ENV_SPLIT.test(a)) {
|
|
2998
|
+
const str = a.replace(ENV_SPLIT, "") || argv[++k];
|
|
2999
|
+
return code([str ?? "", ...argv.slice(k + 1)].join(" "));
|
|
3000
|
+
}
|
|
3001
|
+
if (valued?.test(a)) k++;
|
|
3002
|
+
else if (name !== "xargs" && valuedCluster(name, a)) {
|
|
3003
|
+
// The value word may be read as the command word too; forked once per call.
|
|
3004
|
+
const found = forked ? null : gitDenial([...argv.slice(k + 1), ...(stdin === null ? [] : ["<<<", stdin])], depth + 1, ctx, appended, unaliased);
|
|
3005
|
+
if (found !== null) return found;
|
|
3006
|
+
forked = true;
|
|
3007
|
+
k++;
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
if (name === "timeout") k++;
|
|
3011
|
+
} else break;
|
|
3012
|
+
}
|
|
3013
|
+
if (k >= argv.length) return null;
|
|
3014
|
+
const word = argv[k];
|
|
3015
|
+
const name = path.posix.basename(word);
|
|
3016
|
+
const args = argv.slice(k + 1);
|
|
3017
|
+
if (!unaliased && ctx.aliases.has(word)) return code([ctx.aliases.get(word), ...args].join(" "));
|
|
3018
|
+
if (APPLETS.test(name) && args.length > 0) {
|
|
3019
|
+
const applet = words.filter((_, i) => i !== from[k]).map((w, i) => (i === from[k] ? appletName(w) : w));
|
|
3020
|
+
if (QUOTED_DOCS.has(words)) QUOTED_DOCS.set(applet, new Set([...QUOTED_DOCS.get(words)].map((i) => (i > from[k] ? i - 1 : i))));
|
|
3021
|
+
return gitDenial(applet, depth + 1, ctx, appended, unaliased);
|
|
3022
|
+
}
|
|
3023
|
+
// Brace expansion or a glob can turn the command word into git.
|
|
3024
|
+
const [head, ...spread] = hasGlob(word) && !/[$`]/.test(word) ? braceVariants(word) : [word];
|
|
3025
|
+
// An entry that runs code (an alias to a shell command) denies whatever the verb runs.
|
|
3026
|
+
if (globMatch(path.posix.basename(head), "git")) {
|
|
3027
|
+
const given = [...spread, ...args];
|
|
3028
|
+
// The git words of an xargs child are literal and only its operand is input, so the child is judged with that operand opaque.
|
|
3029
|
+
const operand = typeof input === "string" && input !== "" ? given.map((a) => a.replaceAll(input, XARGS_INPUT)) : [...given, XARGS_INPUT];
|
|
3030
|
+
return ctx.env || gitOptions(given).code || gitRun(["git", ...given], depth, ctx) || (fed && gitRun(["git", ...operand], depth, ctx, true)) ? gitReason(words) : null;
|
|
3031
|
+
}
|
|
3032
|
+
// A command word an expansion or a path spells may be a shell: stdin fed to it is code.
|
|
3033
|
+
if (!appended && (/[$`]/.test(word) || (word.includes("/") && !SYSTEM_BIN.test(word))) && fedStdin(io)) {
|
|
3034
|
+
const found = bodyDenial(stdinBody(io, ctx), (t) => codeDenial(t, depth + 1, ctx));
|
|
3035
|
+
if (found !== null) return found;
|
|
3036
|
+
}
|
|
3037
|
+
// An expansion command word may be git, a variable's code or a substitution's output.
|
|
3038
|
+
const bound = /[$`]/.test(word) && plainLine(ctx) ? boundVariants([word], ctx) : null;
|
|
3039
|
+
if (bound !== null && !bound.some((v) => v[0] === word)) {
|
|
3040
|
+
for (const v of bound) {
|
|
3041
|
+
const found = gitDenial([...v, ...args, ...(stdin === null ? [] : ["<<<", stdin])], depth + 1, ctx, appended);
|
|
3042
|
+
if (found !== null) return found;
|
|
3043
|
+
}
|
|
3044
|
+
return null;
|
|
3045
|
+
}
|
|
3046
|
+
const ref = /[$`]/.test(word) && !LITERAL_WORDS.has(word) ? /^\$\{([A-Za-z_]\w*)\}([^$`]*)$/.exec(word) : null;
|
|
3047
|
+
const value = ref === null ? null : literalBinding(ref[1], ctx);
|
|
3048
|
+
if (value !== null) {
|
|
3049
|
+
const swapped = [...words];
|
|
3050
|
+
swapped[from[k]] = value + ref[2];
|
|
3051
|
+
if (QUOTED_DOCS.has(words)) QUOTED_DOCS.set(swapped, QUOTED_DOCS.get(words));
|
|
3052
|
+
return gitDenial(swapped, depth + 1, ctx, appended, true);
|
|
3053
|
+
}
|
|
3054
|
+
// A command word whose value the line does not show is not judged as git.
|
|
3055
|
+
if (/[$`]/.test(word)) return null;
|
|
3056
|
+
if (name === "alias") {
|
|
3057
|
+
for (const a of args.filter((x) => x.indexOf("=") > 0)) ctx.aliases.set(a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1));
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
if (name === "eval") {
|
|
3061
|
+
return bodyDenial(shellBody(argv.slice(k), io, ctx), code) ?? code(unmark((args[0] === "--" ? args.slice(1) : args).join(" ")));
|
|
3062
|
+
}
|
|
3063
|
+
if (name === "find") {
|
|
3064
|
+
for (let j = 0; j < args.length; j++) {
|
|
3065
|
+
if (!/^-(?:exec|execdir|ok|okdir)$/.test(args[j])) continue;
|
|
3066
|
+
let end = j + 1;
|
|
3067
|
+
while (end < args.length && args[end] !== ";" && args[end] !== "+") end++;
|
|
3068
|
+
const child = args.slice(j + 1, end);
|
|
3069
|
+
const found = syntaxOnly(child, args) ? null : gitDenial(child, depth + 1, ctx, true);
|
|
3070
|
+
if (found !== null) return found;
|
|
3071
|
+
// find hands a literal git child each path as its `{}` operand, judged opaque.
|
|
3072
|
+
if (path.posix.basename(child[0] ?? "") === "git" && gitRun(["git", ...child.slice(1).map((a) => a.replaceAll("{}", XARGS_INPUT))], depth + 1, ctx, true)) return gitReason(child);
|
|
3073
|
+
j = end;
|
|
3074
|
+
}
|
|
3075
|
+
return null;
|
|
3076
|
+
}
|
|
3077
|
+
if (SHELLS.test(name) || name === "source" || name === ".") return shellDenial(name, args, io, words, depth, ctx, appended, input);
|
|
3078
|
+
if (name === "sed" || AWKS.has(name)) {
|
|
3079
|
+
const scripts = (name === "sed" ? null : awkPrograms(args)) ?? args;
|
|
3080
|
+
return scripts.some((s) => [s, joinAll(s)].some((t) => GIT_WORD.test(t)) && (name === "sed" ? sedWrites(s) === null : !awkPlain(s))) ? gitReason(words) : null;
|
|
3081
|
+
}
|
|
3082
|
+
if (READ_ONLY.has(name)) return null;
|
|
3083
|
+
const interpreted = INTERPRETERS.test(name) || name === "ruby";
|
|
3084
|
+
if (!appended && (name === "ssh" || STDIN_CODE.test(name))) {
|
|
3085
|
+
// Interpreter heredocs keep the spawn rules below; past an option, module or script a body may reach a shell too.
|
|
3086
|
+
const own = (t) => (interpreted && stdin !== null ? null : readerDenial(name, words, t, depth, ctx, appended, unaliased));
|
|
3087
|
+
const shell = name !== "ssh" && !plainStdin(args);
|
|
3088
|
+
const found = bodyDenial(shellBody(argv.slice(k), io, ctx), (t) => own(t) ?? (shell ? codeDenial(t, depth + 1, ctx) : null));
|
|
3089
|
+
if (found !== null) return found;
|
|
3090
|
+
}
|
|
3091
|
+
const text = interpreted ? [...args, stdin ?? ""].join("\n") : "";
|
|
3092
|
+
const spawn = /^(?:perl|ruby)$/.test(name) ? SPAWN_SCRIPT : SPAWN;
|
|
3093
|
+
const py = /^python/.test(name) ? true : name === "node" ? false : null;
|
|
3094
|
+
// A shell expansion can rewrite the code after the guard reads it; a line that expands nothing
|
|
3095
|
+
// outside single quotes hands its `$` and backticks to the code as they are.
|
|
3096
|
+
const expands = ctx.code || /\$[\w{(@*#?$!-]|`/.test(unquotedText(ctx.line));
|
|
3097
|
+
const opaque = expands && (/\$\S|`/.test(args.join("\n")) || (stdin !== null && !quotedDoc && /\$\S|`/.test(stdin)));
|
|
3098
|
+
/**
|
|
3099
|
+
* Whether the code the interpreter reads (a code option's operand, or stdin when no script
|
|
3100
|
+
* operand leads) holds a shell expansion its text does not show. The value may be a git word,
|
|
3101
|
+
* so with a spawn call it denies; outside a plain string literal it may be the spawn call too.
|
|
3102
|
+
*/
|
|
3103
|
+
const codeExpands = () => {
|
|
3104
|
+
// A split word no longer shows its quotes, but a line with no expansion outside single quotes has none to hide.
|
|
3105
|
+
if (!expands) return false;
|
|
3106
|
+
const pieces = [];
|
|
3107
|
+
for (let j = 0; j < args.length; j++) {
|
|
3108
|
+
if (/^--(?:eval|print)=|^-[A-Za-z]*[ceEp]./.test(args[j])) pieces.push([args[j], false]);
|
|
3109
|
+
else if (/^(?:--(?:eval|print)|-[A-Za-z]*[ceEp])$/.test(args[j]) && j + 1 < args.length) pieces.push([args[++j], false]);
|
|
3110
|
+
}
|
|
3111
|
+
if (stdin !== null && !quotedDoc && pieces.length === 0 && (args.length === 0 || args[0].startsWith("-"))) pieces.push([stdin, !herestring]);
|
|
3112
|
+
const mark = "fh_shell_value";
|
|
3113
|
+
const dynamic = [];
|
|
3114
|
+
let unknown = false;
|
|
3115
|
+
for (const [w, doc] of pieces) {
|
|
3116
|
+
let out = "";
|
|
3117
|
+
const marks = [];
|
|
3118
|
+
for (let i = 0; i < w.length; ) {
|
|
3119
|
+
const c = w[i];
|
|
3120
|
+
const e = c === "$" || c === "`" ? expansionAt(w, i) : null;
|
|
3121
|
+
// A split word spells an expanded name `${name}`, so a bare one was quoted; a heredoc body stays raw.
|
|
3122
|
+
if (e === null || (!doc && c === "$" && /[A-Za-z_]/.test(w[i + 1] ?? ""))) {
|
|
3123
|
+
if (c === "`" && e === null) return true;
|
|
3124
|
+
const step = doc && c === "\\" ? 2 : 1;
|
|
3125
|
+
out += w.slice(i, i + step);
|
|
3126
|
+
i += step;
|
|
3127
|
+
} else {
|
|
3128
|
+
const ref = /^\$\{([A-Za-z_]\w*)\}$/.exec(w.slice(i, e.end));
|
|
3129
|
+
unknown ||= !/^[\w./:@%+,=~-]*$/.test((ref === null ? null : literalBinding(ref[1], ctx)) ?? "$");
|
|
3130
|
+
marks.push(out.length);
|
|
3131
|
+
out += mark;
|
|
3132
|
+
i = e.end;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
if (marks.length > 0) dynamic.push({ text: out, marks });
|
|
3136
|
+
}
|
|
3137
|
+
if (dynamic.length === 0) return false;
|
|
3138
|
+
// Behind a bound command word no string literal vouches for a value the line does not show.
|
|
3139
|
+
if ((unaliased && unknown) || [text, joinAll(text)].some((t) => spawn.test(t))) return true;
|
|
3140
|
+
return dynamic.some(({ text: t, marks }) => {
|
|
3141
|
+
const strings = py === null ? null : (codeTokens(t, py)?.code.filter((x) => x.t === "s" && !(py ? /^[rRbBuU]*[fF]/.test(t.slice(x.s)) : t[x.s] === "`")) ?? null);
|
|
3142
|
+
return strings === null || marks.some((m) => !strings.some((x) => x.s < m && m + mark.length < x.e));
|
|
3143
|
+
});
|
|
3144
|
+
};
|
|
3145
|
+
if (interpreted && codeExpands()) return gitReason(words);
|
|
3146
|
+
// A git variable set anywhere on the line reaches every git the code spawns.
|
|
3147
|
+
const gitEnv = ctx.env || /\bGIT_\w+=/.test(ctx.line);
|
|
3148
|
+
const readOnly = (t) => (name === "perl" ? perlInert(perlCode(args, stdin)) : spawnsReadOnly(t, py ?? name, gitEnv));
|
|
3149
|
+
if ([text, joinAll(text)].some((t) => spawn.test(t) && GIT_WORD.test(t) && (opaque || !readOnly(t)))) return gitReason(words);
|
|
3150
|
+
// An unlisted command may run its arguments (caffeinate, xcrun, watch, flock, ssh, parallel).
|
|
3151
|
+
const hit = args.findIndex((a) => ["git", "eval", "xargs"].includes(path.posix.basename(a)) || SHELLS.test(path.posix.basename(a)) || WRAPPERS.has(path.posix.basename(a)));
|
|
3152
|
+
for (const a of (hit < 0 ? args : args.slice(0, hit)).filter((x) => !interpreted && /\s/.test(x))) {
|
|
3153
|
+
const found = code(a);
|
|
3154
|
+
if (found !== null) return found;
|
|
3155
|
+
}
|
|
3156
|
+
return hit >= 0 ? gitDenial(args.slice(hit), depth + 1, ctx, appended) : null;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
/**
|
|
3160
|
+
* Whether every call code spawns is a literal read-only git argv: python (`lang` true), node
|
|
3161
|
+
* (false) or perl. Every spawn name must be an import, a constant or a call read literally, and
|
|
3162
|
+
* every git word outside a comment must lie in an accepted argv, so a call the extractor misses
|
|
3163
|
+
* still denies. Code that spawns may not alter the environment, directory or files git reads
|
|
3164
|
+
* either, nor run under a git variable the line sets (`gitEnv`), as git config and variables name
|
|
3165
|
+
* programs git runs. Code with no spawn call at all and no way to run a string spawns no git, so
|
|
3166
|
+
* its git words are data; the code of an f-string or template field is code, never data. Perl is
|
|
3167
|
+
* judged by `perlInert`, ruby never.
|
|
3168
|
+
*/
|
|
3169
|
+
function spawnsReadOnly(text, lang, gitEnv) {
|
|
3170
|
+
if (typeof lang !== "boolean") return false;
|
|
3171
|
+
const py = lang;
|
|
3172
|
+
// `__import__('subprocess')` is that module; any other `__import__` stays dynamic.
|
|
3173
|
+
const src = py ? text.replace(/__import__\s*\(\s*(['"])(subprocess|os)\1\s*\)/g, (all, q, mod) => mod.padEnd(all.length)) : text;
|
|
3174
|
+
const lexed = codeTokens(src, py);
|
|
3175
|
+
if (lexed === null) return false;
|
|
3176
|
+
const { code, comments } = lexed;
|
|
3177
|
+
// String bodies are data; comments stay, as a lexer slip could hide code in one.
|
|
3178
|
+
const masked = maskLiterals(src, code);
|
|
3179
|
+
if ((py ? PY_DYNAMIC : JS_DYNAMIC).test(masked)) return false;
|
|
3180
|
+
/** Whether `pos` lies in string text outside any replacement field. */
|
|
3181
|
+
const inText = (pos) => {
|
|
3182
|
+
const k = tokenAt(code, pos);
|
|
3183
|
+
return k >= 0 && code[k].t === "s" && !(code[k].fields ?? []).some(([s, e]) => s <= pos && pos < e);
|
|
3184
|
+
};
|
|
3185
|
+
const seen = [];
|
|
3186
|
+
let sites = 0;
|
|
3187
|
+
for (const m of src.matchAll(new RegExp(SPAWN.source, "g"))) {
|
|
3188
|
+
const k = tokenAt(code, m.index);
|
|
3189
|
+
// Prose in a string names no module or attribute a lookup by name could reach.
|
|
3190
|
+
if (inText(m.index) && /\s/.test(src.slice(code[k].s, code[k].e))) continue;
|
|
3191
|
+
sites++;
|
|
3192
|
+
if (k < 0 || !spawnSite(code, k, py, seen)) return false;
|
|
3193
|
+
}
|
|
3194
|
+
if (sites > 0 && (gitEnv || (py ? pyAltersRun(code, masked, src) : jsAltersRun(code, masked)))) return false;
|
|
3195
|
+
const gitWords = [...src.matchAll(new RegExp(GIT_WORD.source, "g"))];
|
|
3196
|
+
if (sites === 0 && !(py ? STRING_RUNNERS.python.test(masked) : STRING_RUNNERS.node.test(src)) && gitWords.every((m) => tokenAt(comments, m.index) >= 0 || inText(m.index))) return true;
|
|
3197
|
+
return gitWords.every((m) => tokenAt(comments, m.index) >= 0 || seen.some(([s, e]) => s <= m.index && m.index < e));
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
/**
|
|
3201
|
+
* Whether python code may alter what a git call runs under. Only plain modules may load, `os`
|
|
3202
|
+
* under its own name and through read-only members alone, and `open` only with a literal read
|
|
3203
|
+
* mode, as every other module or member may write a file, the environment or the directory.
|
|
3204
|
+
*/
|
|
3205
|
+
function pyAltersRun(code, masked, src) {
|
|
3206
|
+
if (PY_ALTERS.test(masked) || STRING_RUNNERS.python.test(masked)) return true;
|
|
3207
|
+
if (code.some((t) => (t.fields ?? []).some(([s, e]) => /\b(?:os|open|import)\b/.test(src.slice(s, e))))) return true;
|
|
3208
|
+
const imported = new Set();
|
|
3209
|
+
const dotted = (j) => {
|
|
3210
|
+
if (code[j]?.t !== "w") return null;
|
|
3211
|
+
let name = code[j].v;
|
|
3212
|
+
imported.add(j);
|
|
3213
|
+
for (j++; isP(code[j], ".") && code[j + 1]?.t === "w"; j += 2) name += `.${code[j + 1].v}`;
|
|
3214
|
+
return PY_PLAIN_MODULES.has(name.split(".")[0]) ? { name, end: j } : null;
|
|
3215
|
+
};
|
|
3216
|
+
for (let k = 0; k < code.length; k++) {
|
|
3217
|
+
const t = code[k];
|
|
3218
|
+
if (t.t !== "w") continue;
|
|
3219
|
+
const head = k === 0 || t.nl || isP(code[k - 1], ";") || isP(code[k - 1], ":");
|
|
3220
|
+
if (t.v === "from" && head) {
|
|
3221
|
+
const mod = dotted(k + 1);
|
|
3222
|
+
if (mod === null || mod.name === "os" || code[mod.end]?.v !== "import") return true;
|
|
3223
|
+
k = mod.end;
|
|
3224
|
+
} else if (t.v === "import") {
|
|
3225
|
+
if (!head) return true;
|
|
3226
|
+
for (let j = k + 1; ; ) {
|
|
3227
|
+
const mod = dotted(j);
|
|
3228
|
+
if (mod === null) return true;
|
|
3229
|
+
j = mod.end;
|
|
3230
|
+
if (code[j]?.v === "as") {
|
|
3231
|
+
if (mod.name === "os") return true;
|
|
3232
|
+
j += 2;
|
|
3233
|
+
}
|
|
3234
|
+
if (!isP(code[j], ",")) {
|
|
3235
|
+
k = j - 1;
|
|
3236
|
+
break;
|
|
3237
|
+
}
|
|
3238
|
+
j += isP(code[j + 1], "\\") ? 2 : 1;
|
|
3239
|
+
}
|
|
3240
|
+
} else if (t.v === "os" && !imported.has(k) && !isP(code[k - 1], ".") && !(isP(code[k + 1], ".") && PY_OS_READ.has(code[k + 2]?.v))) return true;
|
|
3241
|
+
else if (t.v === "open" && !pyReadOpen(code, k)) return true;
|
|
3242
|
+
}
|
|
3243
|
+
return false;
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
/** Whether the `open` at code token `k` is the builtin called with a literal read mode and plain keywords. */
|
|
3247
|
+
function pyReadOpen(code, k) {
|
|
3248
|
+
const args = isP(code[k - 1], ".") ? null : spawnArgs(code, k + 1);
|
|
3249
|
+
if (args === null) return false;
|
|
3250
|
+
let pos = 0;
|
|
3251
|
+
return args.every(([s, e]) => {
|
|
3252
|
+
const key = code[s].t === "w" && isP(code[s + 1], "=") && !isP(code[s + 2], "=") ? code[s].v : null;
|
|
3253
|
+
if (isP(code[s], "*") || (key !== null && !PY_OPEN_KW.has(key))) return false;
|
|
3254
|
+
const at = key === null ? pos++ : key === "mode" ? 1 : -1;
|
|
3255
|
+
if (at !== 1) return at < 2;
|
|
3256
|
+
const mode = strAt(code, key === null ? [s, e] : [s + 2, e]);
|
|
3257
|
+
return mode !== null && /^[rbt]+$/.test(mode);
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
/**
|
|
3262
|
+
* Whether node code may alter what a git call runs under: an environment, directory, file or
|
|
3263
|
+
* code-loading name (every builtin module is a global under `node -e`), a `process` member
|
|
3264
|
+
* outside the read-only ones, or a module other than the plain ones.
|
|
3265
|
+
*/
|
|
3266
|
+
function jsAltersRun(code, masked) {
|
|
3267
|
+
if (JS_ALTERS.test(masked)) return true;
|
|
3268
|
+
return code.some((t, k) => t.t === "s" && ((isP(code[k - 1], "(") && code[k - 2]?.v === "require") || (code[k - 1]?.t === "w" && /^(?:from|import)$/.test(code[k - 1].v))) && !JS_PLAIN_MODULES.test(t.v ?? ""));
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
/**
|
|
3272
|
+
* Modules and names that run a string as code or a process beyond the dynamic forms: python
|
|
3273
|
+
* names (read outside strings), node module names (read inside the literal a `require` takes).
|
|
3274
|
+
*/
|
|
3275
|
+
const STRING_RUNNERS = {
|
|
3276
|
+
python: /\b(?:modules|pickle|cPickle|marshal|shelve|dill|ctypes|cffi|runpy|code|codeop|pty|timeit|doctest|pdb|bdb|cProfile|profile|trace|compile|setattr)\b/,
|
|
3277
|
+
node: /(?:\brequire\s*\(\s*|\bfrom\s*)["'`](?:node:)?(?:vm|worker_threads|inspector|repl|wasi)["'`]|\bWorker\b/,
|
|
3278
|
+
};
|
|
3279
|
+
|
|
3280
|
+
/**
|
|
3281
|
+
* The code perl runs from its arguments: the `-e` and `-E` operands joined, or stdin when no
|
|
3282
|
+
* script operand leads. Null for a script file, or for an option that loads or runs other code
|
|
3283
|
+
* or that this reader does not know (`-M`, `-m`, `-I`, `-F`, `-d`, `-x`, `-C`).
|
|
3284
|
+
*/
|
|
3285
|
+
function perlCode(args, stdin) {
|
|
3286
|
+
const codes = [];
|
|
3287
|
+
let j = 0;
|
|
3288
|
+
for (; j < args.length; j++) {
|
|
3289
|
+
const a = args[j];
|
|
3290
|
+
if (a === "--") {
|
|
3291
|
+
j++;
|
|
3292
|
+
break;
|
|
3293
|
+
}
|
|
3294
|
+
if (a === "-" || !a.startsWith("-")) break;
|
|
3295
|
+
for (let p = 1; p < a.length; p++) {
|
|
3296
|
+
const c = a[p];
|
|
3297
|
+
if (c === "e" || c === "E") {
|
|
3298
|
+
const v = p + 1 < a.length ? a.slice(p + 1) : args[++j];
|
|
3299
|
+
if (v === undefined) return null;
|
|
3300
|
+
codes.push(v);
|
|
3301
|
+
break;
|
|
3302
|
+
}
|
|
3303
|
+
// `-i` takes the rest of its word as a backup suffix; `-0` and `-l` take digits.
|
|
3304
|
+
if (c === "i") break;
|
|
3305
|
+
if (c === "0" || c === "l") p += /^(?:x[\da-fA-F]+|[0-7]*)/.exec(a.slice(p + 1))[0].length;
|
|
3306
|
+
else if (!"naplswWXtTUc".includes(c)) return null;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
if (codes.length > 0) return codes.join("\n");
|
|
3310
|
+
return j < args.length && args[j] !== "-" ? null : stdin;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
/** Perl quote-like operators; `qx` runs a command. */
|
|
3314
|
+
const PERL_QUOTES = new Set(["q", "qq", "qw", "qx", "m", "qr", "s", "tr", "y"]);
|
|
3315
|
+
/** Perl words after which a `/` opens a match and a `<` a readline, not a division or comparison. */
|
|
3316
|
+
const PERL_TERM_AFTER = new Set("if unless while until and or not xor return split grep map join push unshift print printf say die warn x lt gt le ge eq ne cmp keys values each delete exists defined scalar ref length lc uc lcfirst ucfirst chomp chop sprintf sort reverse when".split(" "));
|
|
3317
|
+
const PERL_NAME = /[A-Za-z_]\w*(?:(?:::|')[A-Za-z_]\w*)*/y;
|
|
3318
|
+
const PERL_NUMBER = /0[xXbB][\da-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/y;
|
|
3319
|
+
const PERL_READLINE = /<\$?\w*>/y;
|
|
3320
|
+
/** Perl words after which the next word is a declared name. */
|
|
3321
|
+
const PERL_DECLARES = new Set(["sub", "package", "use", "no"]);
|
|
3322
|
+
/** What follows a `&` or `*` sigil that calls or names code perl reads by name. */
|
|
3323
|
+
const PERL_SIGIL_NAME = /^\s*(?:[${':]|(?:q|qq|qw|qx|m|qr|s|tr|y)\b)/;
|
|
3324
|
+
|
|
3325
|
+
/** Whether a perl body interpolates code or any variable but a capture group (`$1`). */
|
|
3326
|
+
function perlInterpolates(body) {
|
|
3327
|
+
for (let k = 0; k < body.length; k++) {
|
|
3328
|
+
if (body[k] === "\\") k++;
|
|
3329
|
+
else if (body[k] === "@") return true;
|
|
3330
|
+
else if (body[k] === "$") {
|
|
3331
|
+
const d = /^\d+/.exec(body.slice(k + 1));
|
|
3332
|
+
if (d === null || /[[{:'$@-]/.test(body[k + 1 + d[0].length] ?? "")) return true;
|
|
3333
|
+
k += d[0].length;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
return false;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
/**
|
|
3340
|
+
* The end of the perl quote-like `op` whose delimiter is at `j`, or null when it can run code:
|
|
3341
|
+
* `qx`, an `s///e`, a regex code block, an interpolating body holding any `$` or `@` but a
|
|
3342
|
+
* capture group, or a second part this reader cannot place.
|
|
3343
|
+
*/
|
|
3344
|
+
function perlQuote(code, j, op) {
|
|
3345
|
+
const parts = ["s", "tr", "y"].includes(op) ? 2 : 1;
|
|
3346
|
+
const first = code[j];
|
|
3347
|
+
const bodies = [];
|
|
3348
|
+
let open = first;
|
|
3349
|
+
for (let p = 0; p < parts; p++) {
|
|
3350
|
+
const close = { "(": ")", "[": "]", "{": "}", "<": ">" }[open] ?? open;
|
|
3351
|
+
let depth = 0;
|
|
3352
|
+
let k = j + 1;
|
|
3353
|
+
for (; k < code.length; k++) {
|
|
3354
|
+
if (code[k] === "\\") k++;
|
|
3355
|
+
else if (code[k] === open && close !== open) depth++;
|
|
3356
|
+
else if (code[k] === close && depth-- === 0) break;
|
|
3357
|
+
}
|
|
3358
|
+
if (k >= code.length) return null;
|
|
3359
|
+
bodies.push(code.slice(j + 1, k));
|
|
3360
|
+
if (p + 1 < parts && close === open) j = k;
|
|
3361
|
+
else {
|
|
3362
|
+
j = k + 1;
|
|
3363
|
+
if (p + 1 === parts) break;
|
|
3364
|
+
while (/\s/.test(code[j] ?? "")) j++;
|
|
3365
|
+
open = code[j];
|
|
3366
|
+
if (open === undefined || /[\w#)\]}]/.test(open)) return null;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
const flags = ["m", "qr", "s", "tr", "y"].includes(op) ? /^[a-zA-Z]*/.exec(code.slice(j))[0] : "";
|
|
3370
|
+
if (op === "qx" || (op === "s" && flags.includes("e"))) return null;
|
|
3371
|
+
if (["qq", "m", "qr", "s"].includes(op) && first !== "'" && bodies.some(perlInterpolates)) return null;
|
|
3372
|
+
if (["m", "qr", "s"].includes(op) && /\(\?\??\{|\(\*\{/.test(bodies[0])) return null;
|
|
3373
|
+
return j + flags.length;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
/**
|
|
3377
|
+
* Whether perl code holds no spawn form once its quoted texts are blanked. A lexer tracks whether
|
|
3378
|
+
* a term or an operator comes next, so a `/`, `<`, `%` or quote is read as perl reads it; any
|
|
3379
|
+
* construct it cannot place (a `=>`, a quote-like word after `-`, before a space or a closing
|
|
3380
|
+
* bracket or where perl reads a name, a `/` after `}` or an unknown word, a string called through
|
|
3381
|
+
* `->(` or `&`, an `__END__` or `__DATA__` that may be a hash key) refuses. Comments
|
|
3382
|
+
* stay; heredocs, POD, backticks, an `open`, a `%SIG` handler and code that loads or evaluates
|
|
3383
|
+
* other code are never inert.
|
|
3384
|
+
*/
|
|
3385
|
+
function perlInert(code) {
|
|
3386
|
+
if (code == null || /<<\s*[~"'\w]|^=[a-zA-Z]|=>/m.test(code)) return false;
|
|
3387
|
+
let out = "";
|
|
3388
|
+
let state = "term";
|
|
3389
|
+
let word = "";
|
|
3390
|
+
let minus = false;
|
|
3391
|
+
for (let i = 0; i < code.length; ) {
|
|
3392
|
+
tick();
|
|
3393
|
+
const c = code[i];
|
|
3394
|
+
if (/\s/.test(c)) {
|
|
3395
|
+
out += c;
|
|
3396
|
+
i++;
|
|
3397
|
+
continue;
|
|
3398
|
+
}
|
|
3399
|
+
if (c === "#") {
|
|
3400
|
+
const nl = code.indexOf("\n", i);
|
|
3401
|
+
const stop = nl < 0 ? code.length : nl;
|
|
3402
|
+
out += code.slice(i, stop);
|
|
3403
|
+
i = stop;
|
|
3404
|
+
continue;
|
|
3405
|
+
}
|
|
3406
|
+
const term = state === "term" || (state === "word" && PERL_TERM_AFTER.has(word));
|
|
3407
|
+
const afterMinus = minus;
|
|
3408
|
+
minus = false;
|
|
3409
|
+
let end = -1;
|
|
3410
|
+
let next = "operator";
|
|
3411
|
+
if (c === "$" || c === "@" || (c === "%" && term)) {
|
|
3412
|
+
end = i + 1;
|
|
3413
|
+
if (c === "$" && code[end] === "#") end++;
|
|
3414
|
+
if (/[A-Za-z_]/.test(code[end] ?? "")) {
|
|
3415
|
+
PERL_NAME.lastIndex = end;
|
|
3416
|
+
PERL_NAME.test(code);
|
|
3417
|
+
if (code.slice(end, PERL_NAME.lastIndex).includes("'")) return false;
|
|
3418
|
+
end = PERL_NAME.lastIndex;
|
|
3419
|
+
} else if (code[end] === "^") end += 2;
|
|
3420
|
+
else if (/\d/.test(code[end] ?? "")) while (/\d/.test(code[end] ?? "")) end++;
|
|
3421
|
+
else if (code[end] === "$" && /['"]/.test(code[end + 1] ?? "")) return false;
|
|
3422
|
+
// `$'q` and `$::q` name a package variable, so the quote-like word after them is no quote.
|
|
3423
|
+
else if (/^(?:'|::)\w/.test(code.slice(end, end + 3))) return false;
|
|
3424
|
+
else if (code[end] !== "{" && code[end] !== "$" && end < code.length && !/\s/.test(code[end])) end++;
|
|
3425
|
+
out += code.slice(i, end);
|
|
3426
|
+
} else if (/\d/.test(c)) {
|
|
3427
|
+
PERL_NUMBER.lastIndex = i;
|
|
3428
|
+
PERL_NUMBER.test(code);
|
|
3429
|
+
end = PERL_NUMBER.lastIndex;
|
|
3430
|
+
out += code.slice(i, end);
|
|
3431
|
+
} else if (/[A-Za-z_]/.test(c)) {
|
|
3432
|
+
PERL_NAME.lastIndex = i;
|
|
3433
|
+
PERL_NAME.test(code);
|
|
3434
|
+
const w = code.slice(i, PERL_NAME.lastIndex);
|
|
3435
|
+
if (w.includes("'")) return false;
|
|
3436
|
+
// Perl ends the program at such a word only at a statement start; a hash key keeps running.
|
|
3437
|
+
if (w === "__END__" || w === "__DATA__") return false;
|
|
3438
|
+
// A quote-like word perl reads as a name: package-qualified, or after `sub`, `package`, `use`, `no`.
|
|
3439
|
+
if (PERL_QUOTES.has(w.slice(w.lastIndexOf(":") + 1)) && (w.includes("::") || code.slice(i - 2, i) === "::" || (state === "word" && PERL_DECLARES.has(word)))) return false;
|
|
3440
|
+
if (state === "arrow" || !PERL_QUOTES.has(w)) {
|
|
3441
|
+
end = i + w.length;
|
|
3442
|
+
out += w;
|
|
3443
|
+
if (state !== "arrow") [next, word] = ["word", w];
|
|
3444
|
+
} else {
|
|
3445
|
+
const d = code[i + w.length];
|
|
3446
|
+
if (afterMinus || d === undefined || /[\s)\]}]/.test(d)) return false;
|
|
3447
|
+
end = perlQuote(code, i + w.length, w);
|
|
3448
|
+
if (end === null) return false;
|
|
3449
|
+
out += " ".repeat(end - i);
|
|
3450
|
+
}
|
|
3451
|
+
} else if (c === "'" || c === '"' || (c === "/" && state !== "operator")) {
|
|
3452
|
+
if (c === "/" && !term) return false;
|
|
3453
|
+
end = perlQuote(code, i, c === "'" ? "q" : c === '"' ? "qq" : "m");
|
|
3454
|
+
if (end === null) return false;
|
|
3455
|
+
out += " ".repeat(end - i);
|
|
3456
|
+
} else if (c === "<" && state !== "operator") {
|
|
3457
|
+
PERL_READLINE.lastIndex = i;
|
|
3458
|
+
if (!term || !PERL_READLINE.test(code)) return false;
|
|
3459
|
+
end = PERL_READLINE.lastIndex;
|
|
3460
|
+
out += code.slice(i, end);
|
|
3461
|
+
} else if (c === "`") return false;
|
|
3462
|
+
else if (c === "%" && state !== "operator" && /[\w{$]/.test(code[i + 1] ?? "")) return false;
|
|
3463
|
+
else if (c === "-" && code[i + 1] === ">") {
|
|
3464
|
+
end = i + 2;
|
|
3465
|
+
// A method looked up by name or a string called as code.
|
|
3466
|
+
if (/^\s*[($&]/.test(code.slice(end))) return false;
|
|
3467
|
+
out += "->";
|
|
3468
|
+
next = "arrow";
|
|
3469
|
+
} else {
|
|
3470
|
+
// A sub or glob sigil names code by a string, a reference or a word read as a name, never a quote.
|
|
3471
|
+
if ((c === "&" || c === "*") && state !== "operator" && PERL_SIGIL_NAME.test(code.slice(i + 1, i + 64))) return false;
|
|
3472
|
+
end = i + (c === "/" && code[i + 1] === "/" ? 2 : 1);
|
|
3473
|
+
out += code.slice(i, end);
|
|
3474
|
+
next = c === ")" || c === "]" ? "operator" : c === "}" ? "brace" : "term";
|
|
3475
|
+
minus = c === "-";
|
|
3476
|
+
}
|
|
3477
|
+
i = end;
|
|
3478
|
+
state = next;
|
|
3479
|
+
}
|
|
3480
|
+
// `open` and `pipe` take their command inside a string, so no string of such code is data.
|
|
3481
|
+
return !SPAWN_SCRIPT.test(out) && !REBIND.perl.test(out) && !/\b(?:open|sysopen|pipe|syscall|format|SIG)\b/.test(out);
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
/**
|
|
3485
|
+
* Code and comment tokens; a string's `v` is null when an escape, an interpolation or a shell
|
|
3486
|
+
* expansion makes its value unknown, and `pre` keeps the literal text before any interpolation.
|
|
3487
|
+
* An f-string or template token lists its replacement fields as `fields`, and a node regular
|
|
3488
|
+
* expression literal is an `r` token. Null when a string, field, regular expression or block
|
|
3489
|
+
* comment never ends or cannot be placed: a `/` after `}` or `++`, a hashbang or an HTML-like
|
|
3490
|
+
* comment node honors.
|
|
3491
|
+
*/
|
|
3492
|
+
function codeTokens(text, py) {
|
|
3493
|
+
const code = [];
|
|
3494
|
+
const comments = [];
|
|
3495
|
+
const word = /[A-Za-z_$][\w$]*|\d[\w.]*/y;
|
|
3496
|
+
const opens = [];
|
|
3497
|
+
if (!py && text.startsWith("#!")) return null;
|
|
3498
|
+
let nl = true;
|
|
3499
|
+
for (let i = 0; i < text.length; ) {
|
|
3500
|
+
tick();
|
|
3501
|
+
const c = text[i];
|
|
3502
|
+
if (/\s/.test(c)) {
|
|
3503
|
+
nl ||= c === "\n";
|
|
3504
|
+
i++;
|
|
3505
|
+
continue;
|
|
3506
|
+
}
|
|
3507
|
+
if (!py && (text.startsWith("<!--", i) || (nl && text.startsWith("-->", i)))) return null;
|
|
3508
|
+
const close = py ? (c === "#" ? "\n" : null) : text.startsWith("//", i) ? "\n" : text.startsWith("/*", i) ? "*/" : null;
|
|
3509
|
+
if (close !== null) {
|
|
3510
|
+
const end = text.indexOf(close, i + 1);
|
|
3511
|
+
if (end < 0 && close === "*/") return null;
|
|
3512
|
+
const e = end < 0 ? text.length : close === "*/" ? end + 2 : end;
|
|
3513
|
+
comments.push({ s: i, e });
|
|
3514
|
+
i = e;
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
const regex = !py && c === "/" ? regexStarts(code) : false;
|
|
3518
|
+
if (regex === null) return null;
|
|
3519
|
+
if (regex) {
|
|
3520
|
+
const e = jsRegexEnd(text, i);
|
|
3521
|
+
if (e < 0) return null;
|
|
3522
|
+
code.push({ t: "r", s: i, e, v: null, nl });
|
|
3523
|
+
i = e;
|
|
3524
|
+
nl = false;
|
|
3525
|
+
continue;
|
|
3526
|
+
}
|
|
3527
|
+
const str = scanString(text, i, py);
|
|
3528
|
+
if (str === null) return null;
|
|
3529
|
+
if (str !== undefined) {
|
|
3530
|
+
const body = text.slice(str.body[0], str.body[1]);
|
|
3531
|
+
const known = !/[\\$`]/.test(body) && !(str.fmt && /[{}]/.test(body));
|
|
3532
|
+
const cut = str.fmt ? body.search(py ? /\{/ : /\$/) : -1;
|
|
3533
|
+
const head = cut < 0 ? body : body.slice(0, cut);
|
|
3534
|
+
const pre = known ? body : str.fmt && !/[\\$`]/.test(head) ? head : null;
|
|
3535
|
+
code.push({ t: "s", s: i, e: str.end, v: known ? body : null, pre, nl, fields: str.fields });
|
|
3536
|
+
i = str.end;
|
|
3537
|
+
} else {
|
|
3538
|
+
word.lastIndex = i;
|
|
3539
|
+
const w = word.exec(text);
|
|
3540
|
+
const tok = w ? { t: /^\d/.test(w[0]) ? "n" : "w", s: i, e: word.lastIndex, v: w[0], nl } : { t: "p", s: i, e: i + 1, v: c, nl };
|
|
3541
|
+
if (c === "(") opens.push(code.length);
|
|
3542
|
+
else if (c === ")") tok.open = opens.pop();
|
|
3543
|
+
code.push(tok);
|
|
3544
|
+
i = w ? word.lastIndex : i + 1;
|
|
3545
|
+
}
|
|
3546
|
+
nl = false;
|
|
3547
|
+
}
|
|
3548
|
+
return { code, comments };
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
const PY_PREFIX = /[rRbBuUfF]{1,2}(?=['"])/y;
|
|
3552
|
+
/** Node words after which a `/` opens a regular expression rather than dividing. */
|
|
3553
|
+
const JS_REGEX_AFTER = new Set("return typeof instanceof in of new delete void throw case do else yield await".split(" "));
|
|
3554
|
+
|
|
3555
|
+
/** Whether a node `/` after `code` opens a regular expression; null when only the parse tree could tell. */
|
|
3556
|
+
function regexStarts(code) {
|
|
3557
|
+
const prev = code[code.length - 1];
|
|
3558
|
+
if (prev === undefined) return true;
|
|
3559
|
+
if (prev.t !== "p") return prev.t === "w" && JS_REGEX_AFTER.has(prev.v);
|
|
3560
|
+
if (prev.v === "]") return false;
|
|
3561
|
+
if (prev.v === ")") {
|
|
3562
|
+
const kw = code[prev.open - 1];
|
|
3563
|
+
return kw?.t === "w" && ["if", "while", "for", "with"].includes(kw.v);
|
|
3564
|
+
}
|
|
3565
|
+
const before = code[code.length - 2];
|
|
3566
|
+
if (prev.v === "}" || ((prev.v === "+" || prev.v === "-") && before?.t === "p" && before.v === prev.v && before.e === prev.s)) return null;
|
|
3567
|
+
return true;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
/** End of the node regular expression literal at `i`, flags included, or -1. */
|
|
3571
|
+
function jsRegexEnd(text, i) {
|
|
3572
|
+
let cls = false;
|
|
3573
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
3574
|
+
const c = text[j];
|
|
3575
|
+
if (c === "\\") j++;
|
|
3576
|
+
else if (c === "\n") return -1;
|
|
3577
|
+
else if (c === "[") cls = true;
|
|
3578
|
+
else if (c === "]") cls = false;
|
|
3579
|
+
else if (c === "/" && !cls) {
|
|
3580
|
+
let e = j + 1;
|
|
3581
|
+
while (/[a-z]/i.test(text[e] ?? "")) e++;
|
|
3582
|
+
return e;
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
return -1;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
/**
|
|
3589
|
+
* The string literal whose prefix or quote is at `i`: its end, body span and the spans of its
|
|
3590
|
+
* replacement fields (python f-string `{...}`, node template `${...}`), each scanned as code so a
|
|
3591
|
+
* nested string never ends the literal early. Undefined when no string starts at `i`; null when
|
|
3592
|
+
* it never ends or a field cannot be read.
|
|
64
3593
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
3594
|
+
function scanString(text, i, py) {
|
|
3595
|
+
let p = i;
|
|
3596
|
+
if (py) {
|
|
3597
|
+
PY_PREFIX.lastIndex = i;
|
|
3598
|
+
if (PY_PREFIX.test(text)) p = PY_PREFIX.lastIndex;
|
|
3599
|
+
}
|
|
3600
|
+
const q = text[p];
|
|
3601
|
+
if (!(q === "'" || q === '"' || (!py && q === "`"))) return undefined;
|
|
3602
|
+
const flags = text.slice(i, p);
|
|
3603
|
+
const fmt = py ? /f/i.test(flags) : q === "`";
|
|
3604
|
+
const raw = /r/i.test(flags);
|
|
3605
|
+
const delim = py && text.startsWith(q.repeat(3), p) ? q.repeat(3) : q;
|
|
3606
|
+
const fields = [];
|
|
3607
|
+
for (let j = p + delim.length; j < text.length; j++) {
|
|
3608
|
+
tick();
|
|
3609
|
+
if (text.startsWith(delim, j)) return { end: j + delim.length, body: [p + delim.length, j], fmt, fields };
|
|
3610
|
+
const c = text[j];
|
|
3611
|
+
if (c === "\n" && delim === q && q !== "`") return null;
|
|
3612
|
+
if (c === "\\") {
|
|
3613
|
+
// In an f-string `\{` leaves the brace a field start, and `\N{...}` names a character.
|
|
3614
|
+
if (fmt && py && text[j + 1] === "{") continue;
|
|
3615
|
+
if (fmt && py && !raw && text.startsWith("N{", j + 1)) {
|
|
3616
|
+
j = text.indexOf("}", j);
|
|
3617
|
+
if (j < 0) return null;
|
|
3618
|
+
} else j++;
|
|
3619
|
+
continue;
|
|
3620
|
+
}
|
|
3621
|
+
if (!fmt || !(py ? c === "{" : c === "$" && text[j + 1] === "{")) continue;
|
|
3622
|
+
if (py && text[j + 1] === "{") {
|
|
3623
|
+
j++;
|
|
3624
|
+
continue;
|
|
3625
|
+
}
|
|
3626
|
+
const s = py ? j + 1 : j + 2;
|
|
3627
|
+
const e = fieldEnd(text, s, py);
|
|
3628
|
+
if (e < 0) return null;
|
|
3629
|
+
fields.push([s, e]);
|
|
3630
|
+
j = e;
|
|
3631
|
+
}
|
|
3632
|
+
return null;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/**
|
|
3636
|
+
* The `}` closing the replacement field whose code starts at `s`, past nested strings, brackets
|
|
3637
|
+
* and a python format spec; -1 when the field holds a comment, a node `/` or backslash, or never
|
|
3638
|
+
* closes.
|
|
3639
|
+
*/
|
|
3640
|
+
function fieldEnd(text, s, py) {
|
|
3641
|
+
let depth = 0;
|
|
3642
|
+
for (let j = s; j < text.length; j++) {
|
|
3643
|
+
tick();
|
|
3644
|
+
const c = text[j];
|
|
3645
|
+
if ((py && c === "#") || (!py && (c === "/" || c === "\\"))) return -1;
|
|
3646
|
+
// A prefix letter inside a longer name opens no string.
|
|
3647
|
+
const str = /[A-Za-z]/.test(c) && /\w/.test(text[j - 1] ?? "") ? undefined : scanString(text, j, py);
|
|
3648
|
+
if (str === null) return -1;
|
|
3649
|
+
if (str !== undefined) {
|
|
3650
|
+
j = str.end - 1;
|
|
3651
|
+
continue;
|
|
3652
|
+
}
|
|
3653
|
+
if (py && depth === 0 && c === ":") {
|
|
3654
|
+
for (let k = j + 1; k < text.length; k++) {
|
|
3655
|
+
if (text[k] === "}") return k;
|
|
3656
|
+
if (text[k] !== "{") continue;
|
|
3657
|
+
k = fieldEnd(text, k + 1, py);
|
|
3658
|
+
if (k < 0) return -1;
|
|
3659
|
+
}
|
|
3660
|
+
return -1;
|
|
3661
|
+
}
|
|
3662
|
+
if ("([{".includes(c)) depth++;
|
|
3663
|
+
else if (c === ")" || c === "]") depth--;
|
|
3664
|
+
else if (c === "}" && depth-- === 0) return j;
|
|
3665
|
+
}
|
|
3666
|
+
return -1;
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
/** Index of the token covering `pos`, or -1. */
|
|
3670
|
+
function tokenAt(toks, pos) {
|
|
3671
|
+
let lo = 0;
|
|
3672
|
+
let hi = toks.length - 1;
|
|
3673
|
+
while (lo <= hi) {
|
|
3674
|
+
const mid = (lo + hi) >> 1;
|
|
3675
|
+
if (toks[mid].e <= pos) lo = mid + 1;
|
|
3676
|
+
else if (toks[mid].s > pos) hi = mid - 1;
|
|
3677
|
+
else return mid;
|
|
3678
|
+
}
|
|
3679
|
+
return -1;
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
function isP(tok, ch) {
|
|
3683
|
+
return tok?.t === "p" && tok.v === ch;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
/** Index of the bracket closing the one at `open`, or -1 when brackets mismatch or never close. */
|
|
3687
|
+
function closeOf(code, open) {
|
|
3688
|
+
const want = [];
|
|
3689
|
+
for (let k = open; k < code.length; k++) {
|
|
3690
|
+
if (code[k].t !== "p") continue;
|
|
3691
|
+
const i = "([{".indexOf(code[k].v);
|
|
3692
|
+
if (i >= 0) want.push(")]}"[i]);
|
|
3693
|
+
else if (")]}".includes(code[k].v)) {
|
|
3694
|
+
if (want.pop() !== code[k].v) return -1;
|
|
3695
|
+
if (want.length === 0) return k;
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
return -1;
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
/** Token ranges [s, e) between top-level commas of `code[s..e)`. */
|
|
3702
|
+
function splitTop(code, s, e) {
|
|
3703
|
+
const out = [];
|
|
3704
|
+
let depth = 0;
|
|
3705
|
+
let from = s;
|
|
3706
|
+
for (let k = s; k < e; k++) {
|
|
3707
|
+
const v = code[k].t === "p" ? code[k].v : "";
|
|
3708
|
+
if (v !== "" && "([{".includes(v)) depth++;
|
|
3709
|
+
else if (v !== "" && ")]}".includes(v)) depth--;
|
|
3710
|
+
else if (v === "," && depth === 0) {
|
|
3711
|
+
if (k > from) out.push([from, k]);
|
|
3712
|
+
from = k + 1;
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
if (e > from) out.push([from, e]);
|
|
3716
|
+
return out;
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
function spawnArgs(code, open) {
|
|
3720
|
+
const close = isP(code[open], "(") ? closeOf(code, open) : -1;
|
|
3721
|
+
return close < 0 ? null : splitTop(code, open + 1, close);
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
function strAt(code, [s, e]) {
|
|
3725
|
+
return e - s === 1 && code[s].t === "s" ? code[s].v : null;
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
/**
|
|
3729
|
+
* Elements of a list, tuple or array (a known string, or `{ prefix }` when built at run time) and
|
|
3730
|
+
* the source spans the argv vouches for, so a git word inside a run-time term stays unexplained.
|
|
3731
|
+
*/
|
|
3732
|
+
function listAt(code, [s, e]) {
|
|
3733
|
+
if (!(isP(code[s], "[") || isP(code[s], "(")) || closeOf(code, s) !== e - 1) return null;
|
|
3734
|
+
const argv = [];
|
|
3735
|
+
const spans = [];
|
|
3736
|
+
let at = s + 1;
|
|
3737
|
+
for (const r of splitTop(code, s + 1, e - 1)) {
|
|
3738
|
+
const item = r[0] === at ? elemAt(code, r) : null;
|
|
3739
|
+
if (item === null) return null;
|
|
3740
|
+
argv.push(item);
|
|
3741
|
+
spans.push([code[r[0]].s, code[typeof item === "string" ? r[1] - 1 : r[0]].e]);
|
|
3742
|
+
at = r[1] + 1;
|
|
3743
|
+
}
|
|
3744
|
+
return { argv, spans };
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
/** A known string, or `{ prefix }` for a literal joined by `+` to primaries (never `?:`, `or`, `if`) or interpolated. */
|
|
3748
|
+
function elemAt(code, [s, e]) {
|
|
3749
|
+
const t = code[s];
|
|
3750
|
+
if (t.t !== "s") return null;
|
|
3751
|
+
if (e - s === 1 && t.v !== null) return t.v;
|
|
3752
|
+
return t.pre && (e - s === 1 || (isP(code[s + 1], "+") && termsAt(code, s + 2, e))) ? { prefix: t.pre } : null;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
/** Whether `code[k..e)` is primaries (a name, string, number or group with member, call or index suffixes) joined by `+`. */
|
|
3756
|
+
function termsAt(code, k, e) {
|
|
3757
|
+
const group = (j) => (isP(code[j], "(") || isP(code[j], "[") ? closeOf(code, j) : -1);
|
|
3758
|
+
for (;;) {
|
|
3759
|
+
if (k >= e) return false;
|
|
3760
|
+
if (["w", "s", "n"].includes(code[k].t)) k++;
|
|
3761
|
+
else if (group(k) > k && group(k) < e) k = group(k) + 1;
|
|
3762
|
+
else return false;
|
|
3763
|
+
for (;;) {
|
|
3764
|
+
if (k + 1 < e && isP(code[k], ".") && code[k + 1].t === "w") k += 2;
|
|
3765
|
+
else if (group(k) > k && group(k) < e) k = group(k) + 1;
|
|
3766
|
+
else break;
|
|
3767
|
+
}
|
|
3768
|
+
if (k === e) return true;
|
|
3769
|
+
if (!isP(code[k], "+")) return false;
|
|
3770
|
+
k++;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
/**
|
|
3775
|
+
* Whether the spawn name at code token `k` is an import or a constant, or opens a read-only git
|
|
3776
|
+
* spawn call; a shell string is never one, as a shell may run more than its text shows.
|
|
3777
|
+
*/
|
|
3778
|
+
function spawnSite(code, k, py, seen) {
|
|
3779
|
+
const tok = code[k];
|
|
3780
|
+
const v = tok.t === "w" ? tok.v : null;
|
|
3781
|
+
const member = (j) => (isP(code[j + 1], ".") && code[j + 2]?.t === "w" ? code[j + 2].v : null);
|
|
3782
|
+
if (py) {
|
|
3783
|
+
if (v !== "subprocess") return false;
|
|
3784
|
+
const name = member(k);
|
|
3785
|
+
if (name === null) return pyImport(code, k);
|
|
3786
|
+
return PY_CONSTS.has(name) || (PY_ARGV.has(name) && pyCall(code, k + 3, seen));
|
|
3787
|
+
}
|
|
3788
|
+
if (tok.t === "s") return /^(?:node:)?child_process$/.test(tok.v ?? "") && jsModule(code, k, seen);
|
|
3789
|
+
const recv = v === "child_process";
|
|
3790
|
+
const name = recv ? member(k) : v;
|
|
3791
|
+
if (recv && name === null) return /^(?:const|let|var)$/.test(code[k - 1]?.v ?? "") && isP(code[k + 1], "=");
|
|
3792
|
+
if (!JS_ARGV.has(name) && !JS_SHELL.has(name)) return false;
|
|
3793
|
+
const at = recv ? k + 2 : k;
|
|
3794
|
+
return isP(code[at + 1], "(") ? jsCall(code, at + 1, name, seen) : !recv && jsDestructured(code, k);
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
/** Whether `subprocess` at `k` is a plain import, or `from subprocess import` of constants only. */
|
|
3798
|
+
function pyImport(code, k) {
|
|
3799
|
+
if (code[k - 1]?.v === "from" && code[k + 1]?.v === "import") {
|
|
3800
|
+
for (let j = isP(code[k + 2], "(") ? k + 3 : k + 2; ; j += 2) {
|
|
3801
|
+
if (code[j]?.t !== "w" || !PY_CONSTS.has(code[j].v)) return false;
|
|
3802
|
+
if (!isP(code[j + 1], ",")) return true;
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
let j = k;
|
|
3806
|
+
while (isP(code[j - 1], ",") && code[j - 2]?.t === "w") j -= 2;
|
|
3807
|
+
return code[j - 1]?.v === "import" && (code[j - 1].nl || isP(code[j - 2], ";")) && code[k + 1]?.v !== "as";
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
/** Whether the module string at `k` is bound only to spawn names, or to an alias whose every use is a judged spawn call. */
|
|
3811
|
+
function jsModule(code, k, seen) {
|
|
3812
|
+
const required = isP(code[k - 1], "(") && code[k - 2]?.v === "require" && isP(code[k + 1], ")");
|
|
3813
|
+
if (!required && !(code[k - 1]?.t === "w" && code[k - 1].v === "from")) return false;
|
|
3814
|
+
const spawnAt = (j) => (JS_ARGV.has(code[j + 1]?.v) || JS_SHELL.has(code[j + 1]?.v)) && isP(code[j], ".") && isP(code[j + 2], "(") && jsCall(code, j + 2, code[j + 1].v, seen);
|
|
3815
|
+
if (required && isP(code[k + 2], ".")) return spawnAt(k + 2);
|
|
3816
|
+
if (required && !isP(code[k - 3], "=")) return false;
|
|
3817
|
+
const target = required ? k - 4 : k - 2;
|
|
3818
|
+
if (isP(code[target], "}")) {
|
|
3819
|
+
let j = target - 1;
|
|
3820
|
+
for (; code[j]?.t === "w" || isP(code[j], ","); j--) if (code[j].t === "w" && !JS_ARGV.has(code[j].v) && !JS_SHELL.has(code[j].v)) return false;
|
|
3821
|
+
return isP(code[j], "{");
|
|
3822
|
+
}
|
|
3823
|
+
const alias = code[target]?.t === "w" ? code[target].v : null;
|
|
3824
|
+
return alias !== null && code.every((t, j) => j === target || t.t !== "w" || t.v !== alias || isP(code[j - 1], ".") || spawnAt(j + 1));
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
/** Whether a destructuring of a spawn name from `require(...)` or an `import {...} from` holds `k`. */
|
|
3828
|
+
function jsDestructured(code, k) {
|
|
3829
|
+
let a = k;
|
|
3830
|
+
let b = k;
|
|
3831
|
+
while (isP(code[a - 1], ",") && code[a - 2]?.t === "w") a -= 2;
|
|
3832
|
+
while (isP(code[b + 1], ",") && code[b + 2]?.t === "w") b += 2;
|
|
3833
|
+
if (isP(code[b + 1], ",")) b++;
|
|
3834
|
+
if (!isP(code[a - 1], "{") || !isP(code[b + 1], "}")) return false;
|
|
3835
|
+
const next = code[b + 2];
|
|
3836
|
+
return (isP(next, "=") && code[b + 3]?.v === "require") || (next?.t === "w" && next.v === "from");
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
/** Python spawn call: one argv list, and only keywords that pick neither the program, a shell, the environment nor the directory. */
|
|
3840
|
+
function pyCall(code, open, seen) {
|
|
3841
|
+
const args = spawnArgs(code, open);
|
|
3842
|
+
if (args === null) return false;
|
|
3843
|
+
const pos = [];
|
|
3844
|
+
for (const r of args) {
|
|
3845
|
+
const [s, e] = r;
|
|
3846
|
+
const key = code[s].t === "w" && isP(code[s + 1], "=") && !isP(code[s + 2], "=") ? code[s].v : null;
|
|
3847
|
+
if (key === null || key === "args") pos.push(key === null ? r : [s + 2, e]);
|
|
3848
|
+
else if (key === "shell" ? !(e - s === 3 && code[s + 2].v === "False") : !PY_KW.has(key)) return false;
|
|
3849
|
+
}
|
|
3850
|
+
return pos.length === 1 && argvRead(code, pos[0], seen);
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/** Node spawn call: program, argv array and an options object with no env, shell or cwd; a callback last. */
|
|
3854
|
+
function jsCall(code, open, name, seen) {
|
|
3855
|
+
const args = JS_SHELL.has(name) ? null : spawnArgs(code, open);
|
|
3856
|
+
if (args === null || args.length === 0) return false;
|
|
3857
|
+
const list = args[1] !== undefined ? listAt(code, args[1]) : null;
|
|
3858
|
+
let opts = args.slice(list ? 2 : 1);
|
|
3859
|
+
// A callback's own spawns and git words are judged where they stand.
|
|
3860
|
+
if (name === "execFile" && opts.length > 0 && !isP(code[opts.at(-1)[0]], "{")) opts = opts.slice(0, -1);
|
|
3861
|
+
if (opts.length > 1 || (opts.length === 1 && !jsOptions(code, opts[0]))) return false;
|
|
3862
|
+
const program = strAt(code, args[0]);
|
|
3863
|
+
if (program === null || !gitArgvReadOnly([program, ...(list?.argv ?? [])])) return false;
|
|
3864
|
+
seen.push([code[args[0][0]].s, code[args[0][1] - 1].e], ...(list?.spans ?? []));
|
|
3865
|
+
return true;
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
function jsOptions(code, [s, e]) {
|
|
3869
|
+
if (!isP(code[s], "{") || !isP(code[e - 1], "}")) return false;
|
|
3870
|
+
return splitTop(code, s + 1, e - 1).every(([a, b]) => {
|
|
3871
|
+
const key = code[a].t === "w" || code[a].t === "s" ? code[a].v : null;
|
|
3872
|
+
if (key === null || !isP(code[a + 1], ":")) return false;
|
|
3873
|
+
if (key === "shell") return b - a === 3 && code[a + 2].v === "false";
|
|
3874
|
+
return JS_KW.has(key);
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
/** Whether the argv list at range `r` is a read-only git call. */
|
|
3879
|
+
function argvRead(code, r, seen) {
|
|
3880
|
+
const list = listAt(code, r);
|
|
3881
|
+
if (list === null || !gitArgvReadOnly(list.argv)) return false;
|
|
3882
|
+
seen.push(...list.spans);
|
|
3883
|
+
return true;
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
/**
|
|
3887
|
+
* Whether an argv is git with only safe `-c` globals and a read-only subcommand. A run-time
|
|
3888
|
+
* operand after the subcommand passes on a non-empty prefix without a leading `-`, as every
|
|
3889
|
+
* option that writes a file or runs a program starts with one.
|
|
3890
|
+
*/
|
|
3891
|
+
function gitArgvReadOnly(argv) {
|
|
3892
|
+
const lit = (a) => typeof a === "string";
|
|
3893
|
+
if (argv.length === 0 || !lit(argv[0]) || !GIT_PROGRAM.test(argv[0])) return false;
|
|
3894
|
+
if (argv.some((a) => lit(a) && INTERP_GIT_BANNED.test(a))) return false;
|
|
3895
|
+
let j = 1;
|
|
3896
|
+
for (; lit(argv[j]) && argv[j].startsWith("-"); j += 2) {
|
|
3897
|
+
const value = argv[j + 1];
|
|
3898
|
+
const eq = lit(value) ? value.indexOf("=") : -1;
|
|
3899
|
+
if (argv[j] !== "-c" || eq <= 0 || !safeKey(value.slice(0, eq).toLowerCase(), value.slice(eq + 1))) return false;
|
|
3900
|
+
}
|
|
3901
|
+
const [sub, ...rest] = argv.slice(j);
|
|
3902
|
+
if (!lit(sub)) return false;
|
|
3903
|
+
// `--text` is its own option, not an abbreviation of `--textconv`; `--filters` runs configured filters.
|
|
3904
|
+
const writes = (a) => INTERP_GIT_WRITES.test(a) || longOpt(a, ["output", "open-files-in-pager", "ext-diff", "filters", "no-index"]) || (a !== "--text" && longOpt(a, ["textconv"]));
|
|
3905
|
+
if (rest.some((a) => (lit(a) ? writes(a) : a.prefix === "" || a.prefix.startsWith("-")))) return false;
|
|
3906
|
+
return sub === "branch" ? rest.includes("--list") && rest.every((a) => !lit(a) || !a.startsWith("-") || BRANCH_LIST.test(a)) : INTERP_GIT_READ.has(sub);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
/** Whether find runs a shell that only parses: `-n` set last, no `-i`/`-o`, no `+` start path. */
|
|
3910
|
+
function syntaxOnly(cmd, findArgs) {
|
|
3911
|
+
if (!SHELLS.test(cmd[0] ?? "")) return false;
|
|
3912
|
+
for (const a of findArgs) {
|
|
3913
|
+
if (/^[-(!]/.test(a)) break;
|
|
3914
|
+
if (a.startsWith("+")) return false;
|
|
3915
|
+
}
|
|
3916
|
+
let n = false;
|
|
3917
|
+
for (let j = 1; j < cmd.length && /^[-+]./.test(cmd[j]) && cmd[j] !== "--"; j++) {
|
|
3918
|
+
const a = cmd[j];
|
|
3919
|
+
if (/^--(?:rcfile|init-file)$/.test(a)) j++;
|
|
3920
|
+
else if (!a.startsWith("--")) {
|
|
3921
|
+
if (/[ioO]/.test(a)) return false;
|
|
3922
|
+
if (a.includes("n")) n = a[0] === "-";
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
return n;
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
/**
|
|
3929
|
+
* Git guard reason for a shell or `source`: inline code, a heredoc or a pipe. Inline code xargs or
|
|
3930
|
+
* find runs is judged with the `input` placeholder as an opaque non-git operand; producer text is
|
|
3931
|
+
* never pasted into it, and code the input itself supplies is not judged.
|
|
3932
|
+
* @param {string | null | false | undefined} [input] The xargs placeholder string, or none.
|
|
3933
|
+
*/
|
|
3934
|
+
function shellDenial(name, args, io, words, depth, ctx, appended, input = undefined) {
|
|
3935
|
+
let j = 0;
|
|
3936
|
+
let inline = false;
|
|
3937
|
+
let fromStdin = false;
|
|
3938
|
+
for (; SHELLS.test(name) && j < args.length && /^[-+]/.test(args[j]); j++) {
|
|
3939
|
+
const a = args[j];
|
|
3940
|
+
if (a === "--" || a === "-") {
|
|
3941
|
+
fromStdin ||= a === "-";
|
|
3942
|
+
j++;
|
|
3943
|
+
break;
|
|
3944
|
+
}
|
|
3945
|
+
if (/^[-+][oO]$|^--(?:rcfile|init-file)$/.test(a)) j++;
|
|
3946
|
+
else if (/^-[a-zA-Z]*c/.test(a)) inline = true;
|
|
3947
|
+
else if (/^-[a-zA-Z]*s/.test(a)) fromStdin = true;
|
|
3948
|
+
}
|
|
3949
|
+
if (appended) {
|
|
3950
|
+
// A placeholder inside the options would make the input part of the shell's own arguments.
|
|
3951
|
+
if (!inline || args[j] === undefined || (input && args.slice(0, j).some((a) => a.includes(input)))) return null;
|
|
3952
|
+
return codeDenial(input ? args[j].replaceAll(input, XARGS_INPUT) : args[j], depth + 1, ctx);
|
|
3953
|
+
}
|
|
3954
|
+
const run = (t) => codeDenial(t, depth + 1, ctx);
|
|
3955
|
+
const fed = bodyDenial(shellBody([name, ...args], io, ctx), run);
|
|
3956
|
+
if (fed !== null) return fed;
|
|
3957
|
+
if (inline) return args[j] === undefined ? null : run(args[j]);
|
|
3958
|
+
const script = fromStdin ? undefined : args[j];
|
|
3959
|
+
const piped = script === undefined || script === "-" || STDIN_PATH.test(script);
|
|
3960
|
+
return piped && io.stdin !== null ? codeDenial(io.stdin, depth + 1, ctx) : null;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/** Git guard reason for each text of a reader's body the line shows. */
|
|
3964
|
+
function bodyDenial(body, judge) {
|
|
3965
|
+
if (body === null) return null;
|
|
3966
|
+
for (const t of body.texts) {
|
|
3967
|
+
const found = judge(unmark(t));
|
|
3968
|
+
if (found !== null) return found;
|
|
3969
|
+
}
|
|
3970
|
+
return null;
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/** Config keys that run a program or load config besides GIT_PROGRAM_KEY (lowercase). */
|
|
3974
|
+
const CODE_KEY = /^(?:core\.(?:hookspath|fsmonitor|sshcommand)|include(?:if)?\..+)$/;
|
|
3975
|
+
|
|
3976
|
+
/** Whether config `key` (lowercase) set to `value` (null when unknown) runs code whatever verb follows: an alias to a shell command, a program, a hook path or an include. */
|
|
3977
|
+
function runsCode(key, value) {
|
|
3978
|
+
if (key.startsWith("alias.")) return value === null || /[$`]/.test(value) || value.trimStart().startsWith("!");
|
|
3979
|
+
return (GIT_PROGRAM_KEY.test(key) || CODE_KEY.test(key)) && !safeKey(key, value);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
/**
|
|
3983
|
+
* Whether a `-c` or `--config-env` entry runs code whatever verb follows. A valued entry gets the
|
|
3984
|
+
* key rules direct git applies; a value-less one sets its key to true, which names no program, so
|
|
3985
|
+
* only an alias or a key read as a hook or path counts. A key an expansion builds takes its line
|
|
3986
|
+
* values and default operand; one the line does not fill may be any key when a value is set, while
|
|
3987
|
+
* a lone unknown word (`-c $CFG`, as other tools take it) names no key at all.
|
|
3988
|
+
*/
|
|
3989
|
+
function entryCode(entry, env, ctx) {
|
|
3990
|
+
const eq = entry.indexOf("=");
|
|
3991
|
+
const value = env ? null : eq < 0 ? "" : entry.slice(eq + 1);
|
|
3992
|
+
const keys = keyFill(eq < 0 ? entry : entry.slice(0, eq), ctx);
|
|
3993
|
+
if (keys === null) return env || eq >= 0;
|
|
3994
|
+
return keys.some((k) => {
|
|
3995
|
+
const key = k.toLowerCase();
|
|
3996
|
+
if (key.startsWith("alias.") || (eq < 0 && !env)) return runsCode(key, value);
|
|
3997
|
+
return CONFIG_KEY.test(key) && !safeKey(key, value);
|
|
3998
|
+
});
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
/** Keys a config key word spells with each `$name`, `${name}` or `${name:-default}` filled by its literal line values; null when a part stays unknown. */
|
|
4002
|
+
function keyFill(key, ctx) {
|
|
4003
|
+
if (!/[$`]/.test(key)) return [key];
|
|
4004
|
+
if (ctx === undefined) return null;
|
|
4005
|
+
let out = [""];
|
|
4006
|
+
let last = 0;
|
|
4007
|
+
for (const m of key.matchAll(/\$\{([A-Za-z_]\w*)(?::?[-=]([^$`{}]*))?\}|\$([A-Za-z_]\w*)/g)) {
|
|
4008
|
+
const name = m[1] ?? m[3];
|
|
4009
|
+
const values = SHELL_SET.test(name) ? null : lineValues(name, ctx);
|
|
4010
|
+
if (!values?.length || values.some((v) => /[$`]/.test(v))) return null;
|
|
4011
|
+
const alts = [...values.map((v) => v.replace(/['"\\]/g, "")), ...(m[2] === undefined ? [] : [m[2]])];
|
|
4012
|
+
out = out.flatMap((o) => alts.map((v) => o + key.slice(last, m.index) + v));
|
|
4013
|
+
last = m.index + m[0].length;
|
|
4014
|
+
if (out.length > MAX_VARIANTS) return null;
|
|
4015
|
+
}
|
|
4016
|
+
out = out.map((o) => o + key.slice(last));
|
|
4017
|
+
return out.some((o) => /[$`]/.test(o)) ? null : out;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/** Leading git options of `args`: the argv git reads them as, aliases their entries define, the index past them and whether an entry runs code. */
|
|
4021
|
+
function gitOptions(args, ctx = undefined) {
|
|
4022
|
+
const kept = [];
|
|
4023
|
+
const aliases = new Set();
|
|
4024
|
+
let code = false;
|
|
4025
|
+
let j = 0;
|
|
4026
|
+
for (; j < args.length && args[j].startsWith("-"); j++) {
|
|
4027
|
+
const a = args[j];
|
|
4028
|
+
const env = a === "--config-env" || a.startsWith("--config-env=");
|
|
4029
|
+
code ||= a.startsWith("--exec-path=");
|
|
4030
|
+
// A glued entry is read as git reads it.
|
|
4031
|
+
const entry = /^-c./.test(a) ? a.slice(2) : a === "-c" || a === "--config-env" ? (args[++j] ?? "") : env ? a.slice(a.indexOf("=") + 1) : null;
|
|
4032
|
+
if (entry === null) {
|
|
4033
|
+
kept.push(a);
|
|
4034
|
+
if (GIT_VALUED.test(a)) kept.push(args[++j] ?? "");
|
|
4035
|
+
continue;
|
|
4036
|
+
}
|
|
4037
|
+
kept.push(...(env ? ["--config-env", entry] : ["-c", entry]));
|
|
4038
|
+
const eq = entry.indexOf("=");
|
|
4039
|
+
const key = (eq < 0 ? entry : entry.slice(0, eq)).toLowerCase();
|
|
4040
|
+
code ||= entryCode(entry, env, ctx);
|
|
4041
|
+
if (key.startsWith("alias.")) aliases.add(key.slice(6));
|
|
4042
|
+
}
|
|
4043
|
+
return { kept, aliases, at: j, code };
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
/** Git guard reason for stdin code: a quoted heredoc to a modelled interpreter keeps its spawn rules; ssh runs it as shell code, and another language's code is not judged. */
|
|
4047
|
+
function readerDenial(name, words, text, depth, ctx, appended, unaliased) {
|
|
4048
|
+
if (name === "ssh") return codeDenial(text, depth + 1, ctx);
|
|
4049
|
+
if (!INTERPRETERS.test(name) && name !== "ruby") return null;
|
|
4050
|
+
const doc = [...words, "<<", text];
|
|
4051
|
+
QUOTED_DOCS.set(doc, new Set([...(QUOTED_DOCS.get(words) ?? []), doc.length - 1]));
|
|
4052
|
+
return gitDenial(doc, depth + 1, ctx, appended, unaliased);
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
/** Git guard reason for code parsed again. */
|
|
4056
|
+
function codeDenial(text, depth, ctx, appended = false) {
|
|
4057
|
+
const outer = ctx.code;
|
|
4058
|
+
ctx.code = true;
|
|
4059
|
+
try {
|
|
4060
|
+
return gitCheck(text, depth, ctx, appended);
|
|
4061
|
+
} finally {
|
|
4062
|
+
ctx.code = outer;
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
/** Git guard reason for a command line, or null; a line it cannot split, a longer one or one nested past MAX_DEPTH allows. */
|
|
4067
|
+
function gitCheck(text, depth, ctx, appended = false) {
|
|
4068
|
+
if (depth > MAX_DEPTH) return null;
|
|
4069
|
+
const cmds = text.length <= MAX_CHARS ? splitCommands(text) : null;
|
|
4070
|
+
if (cmds === null) return null;
|
|
4071
|
+
for (const [i, words] of cmds.entries()) {
|
|
4072
|
+
// Quoted text of interpreter code is code, judged by the interpreter rule, not a substitution.
|
|
4073
|
+
const head = words.findIndex((w) => !ASSIGN.test(w));
|
|
4074
|
+
const code = head >= 0 && (INTERPRETERS.test(path.posix.basename(words[head])) || path.posix.basename(words[head]) === "ruby") ? LITERALS.get(words) : undefined;
|
|
4075
|
+
const plain = (w, k) => (k > head ? (code?.get(k) ?? []) : []).reduce((s, [a, b]) => s.slice(0, a) + " ".repeat(b - a) + s.slice(b), w);
|
|
4076
|
+
for (const inner of words.flatMap((w, k) => (QUOTED_DOCS.get(words)?.has(k) ? [] : substitutions(plain(w, k), true)))) {
|
|
4077
|
+
const found = gitCheck(inner, depth + 1, ctx);
|
|
4078
|
+
if (found !== null) return found;
|
|
4079
|
+
}
|
|
4080
|
+
const outer = ctx.feed;
|
|
4081
|
+
ctx.feed = pipedFrom(cmds, i);
|
|
4082
|
+
try {
|
|
4083
|
+
const found = gitDenial(words, depth, ctx, appended);
|
|
4084
|
+
if (found !== null) return found;
|
|
4085
|
+
} finally {
|
|
4086
|
+
ctx.feed = outer;
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
return null;
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
/** The simple command whose output `cmds[i]` reads through a pipe, or null. */
|
|
4093
|
+
function pipedFrom(cmds, i) {
|
|
4094
|
+
return i > 0 && PLACES.get(cmds[i])?.piped && PLACES.get(cmds[i - 1])?.next === "|" ? cmds[i - 1] : null;
|
|
4095
|
+
}
|
|
4096
|
+
|
|
4097
|
+
/** Whether `a` is flags ending in a valued letter (`sudo -nu x`, `env -i0u X`); only env has a digit flag, and its `-S` stays a split string. */
|
|
4098
|
+
function valuedCluster(name, a) {
|
|
4099
|
+
const [valued, flags] = WRAPPERS.get(name) ?? [];
|
|
4100
|
+
if (!valued || !flags || !(name === "env" ? /^-[A-Za-z\d]{2,}$/ : /^-[A-Za-z]{2,}$/).test(a) || (name === "env" && a.endsWith("S"))) return false;
|
|
4101
|
+
return valued.test(`-${a.at(-1)}`) && [...a.slice(1, -1)].every((f) => flags.test(`-${f}`) && !valued.test(`-${f}`));
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
/** `text` with INVISIBLE code points as `\u{XXXX}` (diagnosable, unlike stripping), capped at `max`. */
|
|
4105
|
+
export function visible(text, max = 200) {
|
|
4106
|
+
const parts = Array.from(String(text), (c) => (INVISIBLE.test(c) ? `\\u{${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}}` : c));
|
|
4107
|
+
const whole = parts.join("");
|
|
4108
|
+
if (whole.length <= max) return whole;
|
|
4109
|
+
let out = "";
|
|
4110
|
+
for (const p of parts) {
|
|
4111
|
+
if (out.length + p.length > max - 1) break;
|
|
4112
|
+
out += p;
|
|
4113
|
+
}
|
|
4114
|
+
return `${out}\u2026`;
|
|
69
4115
|
}
|
|
70
4116
|
|
|
71
4117
|
/**
|
|
72
|
-
*
|
|
73
|
-
* @param {string} file Marker path.
|
|
74
|
-
* @param {number} ttlMin Default lifetime from mtime, in minutes.
|
|
75
|
-
* @param {number} now Reference time (ms).
|
|
4118
|
+
* Marker state; `ttlMin` is the lifetime from mtime unless the body sets an expiry.
|
|
76
4119
|
* @returns {"valid" | "expired" | "absent"} State.
|
|
77
4120
|
*/
|
|
78
4121
|
export function markerState(file, ttlMin, now = Date.now()) {
|
|
@@ -82,6 +4125,8 @@ export function markerState(file, ttlMin, now = Date.now()) {
|
|
|
82
4125
|
} catch {
|
|
83
4126
|
return "absent";
|
|
84
4127
|
}
|
|
4128
|
+
// `mkdir` can create the marker path; only a regular file counts.
|
|
4129
|
+
if (!stat.isFile()) return "absent";
|
|
85
4130
|
let expires = stat.mtimeMs + ttlMin * 60_000;
|
|
86
4131
|
try {
|
|
87
4132
|
const body = fs.readFileSync(file, "utf8").trim();
|
|
@@ -93,70 +4138,87 @@ export function markerState(file, ttlMin, now = Date.now()) {
|
|
|
93
4138
|
return now < expires ? "valid" : "expired";
|
|
94
4139
|
}
|
|
95
4140
|
|
|
96
|
-
/**
|
|
97
|
-
* Candidate marker paths for a payload: project first, then user.
|
|
98
|
-
* @param {Record<string, any>} payload Hook payload.
|
|
99
|
-
* @returns {string[]} Absolute paths.
|
|
100
|
-
*/
|
|
4141
|
+
/** Candidate absolute marker paths for a hook payload: project first, then user. */
|
|
101
4142
|
export function markerPaths(payload) {
|
|
102
4143
|
const project = process.env.CLAUDE_PROJECT_DIR || (typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd());
|
|
103
4144
|
return [path.join(project, ".freeharness", MARKER), path.join(os.homedir(), ".claude", "freeharness", MARKER)];
|
|
104
4145
|
}
|
|
105
4146
|
|
|
4147
|
+
/** PreToolUse deny response carrying `reason`. */
|
|
4148
|
+
function deny(reason) {
|
|
4149
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } };
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
/** Git guard reason for a Bash command, or null. */
|
|
4153
|
+
function gitGuard(command, loc) {
|
|
4154
|
+
if ((process.env.FREEHARNESS_HOOK_GIT_GUARD || "").toLowerCase() === "off") return null;
|
|
4155
|
+
return gitCheck(command, 0, gitLine(command, loc));
|
|
4156
|
+
}
|
|
4157
|
+
|
|
106
4158
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
4159
|
+
* Deny response for a parsed PreToolUse payload, or null to allow. The git guard runs first:
|
|
4160
|
+
* no marker opens it, so its reason is the one the user can act on. The guard is a second layer
|
|
4161
|
+
* under the host's permission layer, so a passed deadline, any throw while judging or reading
|
|
4162
|
+
* markers, and a non-string command allow. A command past MAX_COMMAND_BYTES allows before any
|
|
4163
|
+
* pattern runs: no judgment of it fits the hook timeout. --fault respond, argv no session sets,
|
|
4164
|
+
* throws inside the judgment so a test proves the throw allows.
|
|
4165
|
+
* @param {object} payload
|
|
4166
|
+
* @param {{ ttlMin: number, judgeMs?: number, fault?: string }} opts judgeMs defaults to MAX_JUDGE_MS.
|
|
111
4167
|
*/
|
|
112
4168
|
export function respond(payload, opts) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
4169
|
+
try {
|
|
4170
|
+
if (opts.fault === "respond") throw new Error("forced fault");
|
|
4171
|
+
const tool = String(payload.tool_name || "");
|
|
4172
|
+
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
4173
|
+
let targets = [];
|
|
4174
|
+
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) {
|
|
4175
|
+
const file = input.file_path || input.notebook_path || input.path || "";
|
|
4176
|
+
if (file && isProtectedPath(String(file), defaultEnv(payload))) targets = [String(file)];
|
|
4177
|
+
} else if (tool === "Bash") {
|
|
4178
|
+
if (input.command != null && typeof input.command !== "string") return null;
|
|
4179
|
+
const command = input.command ?? "";
|
|
4180
|
+
if (Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
|
4181
|
+
deadline = performance.now() + (opts.judgeMs ?? MAX_JUDGE_MS);
|
|
4182
|
+
const git = gitGuard(command, defaultEnv(payload));
|
|
4183
|
+
if (git !== null) return deny(git);
|
|
4184
|
+
targets = judgeBash(command, defaultEnv(payload));
|
|
4185
|
+
deadline = Infinity;
|
|
4186
|
+
}
|
|
4187
|
+
if (targets.length === 0) return null;
|
|
4188
|
+
const markers = markerPaths(payload);
|
|
4189
|
+
const states = markers.map((m) => markerState(m, opts.ttlMin));
|
|
4190
|
+
if (states.includes("valid")) return null;
|
|
4191
|
+
const expired = states.findIndex((s) => s === "expired");
|
|
4192
|
+
const why = expired >= 0 ? `marker expired: ${visible(markers[expired])}` : `no marker at ${visible(markers[0])}`;
|
|
4193
|
+
const more = targets.length > MAX_NAMED ? ` and ${targets.length - MAX_NAMED} more` : "";
|
|
4194
|
+
return deny(
|
|
4195
|
+
`FreeHarness config guard: ${targets.slice(0, MAX_NAMED).map((t) => visible(t)).join(", ")}${more} is live harness configuration (it runs code or loads instructions in every session); ${why}. ` +
|
|
134
4196
|
`Ask the user to open a ${opts.ttlMin}-minute window from a terminal: \`mkdir -p .freeharness && touch .freeharness/${MARKER}\` (project) or \`touch ~/.claude/freeharness/${MARKER}\` (user), then retry. ` +
|
|
135
4197
|
"Do not create the marker yourself, and do not route the edit through another tool.",
|
|
136
|
-
|
|
137
|
-
}
|
|
4198
|
+
);
|
|
4199
|
+
} catch {
|
|
4200
|
+
return null;
|
|
4201
|
+
} finally {
|
|
4202
|
+
deadline = Infinity;
|
|
4203
|
+
}
|
|
138
4204
|
}
|
|
139
4205
|
|
|
140
|
-
/**
|
|
4206
|
+
/** Silence is an allow: input that cannot be read, parsed or judged writes no decision. */
|
|
141
4207
|
function main() {
|
|
142
4208
|
if ((process.env.FREEHARNESS_HOOK_CONFIG_GUARD || "").toLowerCase() === "off") return;
|
|
143
|
-
let
|
|
144
|
-
try {
|
|
145
|
-
const raw = fs.readFileSync(0, "utf8");
|
|
146
|
-
if (raw.trim()) payload = JSON.parse(raw);
|
|
147
|
-
} catch {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (!payload || typeof payload !== "object") return;
|
|
4209
|
+
let out = null;
|
|
151
4210
|
try {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
4211
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
4212
|
+
if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) out = respond(payload, parseOptions(process.argv.slice(2)));
|
|
154
4213
|
} catch (e) {
|
|
155
4214
|
process.stderr.write(`freeharness-config-guard: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
156
4215
|
}
|
|
4216
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
157
4217
|
}
|
|
158
4218
|
|
|
159
4219
|
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
4220
|
+
// exit() would cut a pending pipe write short; EPIPE is ignored.
|
|
4221
|
+
process.stdout.on("error", () => {});
|
|
4222
|
+
process.stderr.on("error", () => {});
|
|
160
4223
|
main();
|
|
161
|
-
process.exit(0);
|
|
162
4224
|
}
|