From f583c27b4a583a7ca10aada28fb234e0920f29b8 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 27 Jun 2026 19:13:57 -0700 Subject: [PATCH 1/2] fix(security): harden git argv against second-order command-line injection Source-descriptor fields flow into the `git` argv via `execFileSync`: `repo`/`url` reach `git clone`/`git fetch` and `ref`/`sha` reach `git checkout --detach `. `execFileSync` avoids a shell, but `git` itself treats a leading-`-` argument as an option (e.g. a `ref` of `--upload-pack=`), so an option-like value is a second-order argument injection (CodeQL js/second-order-command-line-injection, 3 high alerts in src/resolve.ts). Extend the `reqArg` guard (reject a leading `-`) to every source field that reaches the git argv, applied in `normalizeSource` before any git invocation: - `reqArg` on github.repo, git.url, git-subdir.url, url.url - new `optArg` on the optional ref/sha of every git variant (rejected rather than escaped, since `git checkout` does not accept `--` cleanly before a ref) `resolveSource` already calls `normalizeSource` first, so the guard runs ahead of clone/fetch/checkout for all three alert sites. Spec: add FR-004-CON-3 + FR-004-AC-8 (argv injection guard) and TC-022 rows; src/** stays at 100% coverage; quire validate clean. Closes #6 Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/functional/FR-004-source-resolution.md | 42 ++++--- spec/tests.md | 117 ++++++++++---------- src/sources.ts | 42 ++++++- tests/index.test.ts | 42 +++++++ 4 files changed, 170 insertions(+), 73 deletions(-) diff --git a/spec/functional/FR-004-source-resolution.md b/spec/functional/FR-004-source-resolution.md index 207baa1..f33a5d9 100644 --- a/spec/functional/FR-004-source-resolution.md +++ b/spec/functional/FR-004-source-resolution.md @@ -68,24 +68,40 @@ spec.md §14 Known Limitations). "pipe"] })`; an injected `GitRunner` replaces it so tests run with no real git (FR-004-AC-7). +**Argument-injection guard.** Source-descriptor fields flow into the `git` argv: +`repo`/`url` reach `git clone`/`git fetch` (the clone URL), and `sha`/`ref` reach +`git checkout --detach `. `execFileSync` avoids a _shell_, but `git` +itself treats a leading-`-` argument as an **option** — e.g. a `ref` of +`--upload-pack=` or a `repo` of `--output=…` becomes a flag (a second-order +command-line injection, `js/second-order-command-line-injection`). Because +`resolveSource` calls `normalizeSource` **before** any `git` invocation, the guard +that `normalizeSource` ([FR-001](./FR-001-typed-source-union.md)) applies to these +fields (reject a value beginning with `-`) is sufficient: `github.repo`, +`git.url`, `git-subdir.url`, and `url.url`, plus the optional `ref`/`sha` on every +git variant, are rejected with `SourceError` before they can reach the argv +(FR-004-CON-3). Rejection is preferred over an argv `--` separator because +`git checkout` does not accept `--` cleanly before a ref. + ## Constraints -| ID | Constraint | Type | Validation | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------- | -| FR-004-CON-1 | Git is the sole side effect; resolution performs no other I/O beyond filesystem reads/dir creation and the `git` subprocess. | architectural | Test (TC-011) | -| FR-004-CON-2 | The clone is blobless and no-checkout (`--filter=blob:none --no-checkout`); subdir sources sparse-checkout only the requested path. | performance | Test (TC-008) | +| ID | Constraint | Type | Validation | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------- | +| FR-004-CON-1 | Git is the sole side effect; resolution performs no other I/O beyond filesystem reads/dir creation and the `git` subprocess. | architectural | Test (TC-011) | +| FR-004-CON-2 | The clone is blobless and no-checkout (`--filter=blob:none --no-checkout`); subdir sources sparse-checkout only the requested path. | performance | Test (TC-008) | +| FR-004-CON-3 | `normalizeSource` rejects an option-like (leading `-`) `github.repo`, `git.url`, `git-subdir.url`, `url.url`, or any git-variant `ref`/`sha`, so it cannot reach the `git` argv as a CLI flag (second-order command-line-injection guard). | security | Test (TC-022) | ## Acceptance Criteria -| ID | Criteria | Verification | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| FR-004-AC-1 | A `path` source for an existing directory returns `{ dir }` pointing at that directory (its contents are readable). | Test (TC-006) | -| FR-004-AC-2 | A `path` source for a non-existent directory throws `SourceError`. | Test (TC-006) | -| FR-004-AC-3 | A `url` source and an `npm` source each throw `UnsupportedSourceError`. | Test (TC-007) | -| FR-004-AC-4 | A `git-subdir` source pinned to a tag returns `dir` ending in the subdir path, contains the subdir's files, and reports the tag's `sha` and the requested `ref`. | Test (TC-008) | -| FR-004-AC-5 | A whole-repo `git` source with no pin resolves to `HEAD` (latest commit); re-resolving the same cached URL at a tag exercises the fetch branch and resolves that tag's sha. | Test (TC-009) | -| FR-004-AC-6 | A `git` source pinned by `sha` checks out exactly that commit. | Test (TC-010) | -| FR-004-AC-7 | A `github` source resolved with an injected `GitRunner` performs no real git: the returned `sha` is the runner's output and the first git argv is `clone`. | Test (TC-011) | +| ID | Criteria | Verification | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| FR-004-AC-1 | A `path` source for an existing directory returns `{ dir }` pointing at that directory (its contents are readable). | Test (TC-006) | +| FR-004-AC-2 | A `path` source for a non-existent directory throws `SourceError`. | Test (TC-006) | +| FR-004-AC-3 | A `url` source and an `npm` source each throw `UnsupportedSourceError`. | Test (TC-007) | +| FR-004-AC-4 | A `git-subdir` source pinned to a tag returns `dir` ending in the subdir path, contains the subdir's files, and reports the tag's `sha` and the requested `ref`. | Test (TC-008) | +| FR-004-AC-5 | A whole-repo `git` source with no pin resolves to `HEAD` (latest commit); re-resolving the same cached URL at a tag exercises the fetch branch and resolves that tag's sha. | Test (TC-009) | +| FR-004-AC-6 | A `git` source pinned by `sha` checks out exactly that commit. | Test (TC-010) | +| FR-004-AC-7 | A `github` source resolved with an injected `GitRunner` performs no real git: the returned `sha` is the runner's output and the first git argv is `clone`. | Test (TC-011) | +| FR-004-AC-8 | An option-like (leading `-`) `repo`, `url`, `ref`, or `sha` on a git source throws `SourceError` ("must not begin with `-`") from `normalizeSource`, before any `git` invocation. | Test (TC-022) | ## Dependencies diff --git a/spec/tests.md b/spec/tests.md index 9647f7e..99a4541 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -73,44 +73,45 @@ partial clones work offline. ## Functional Requirement Coverage -| Functional Req | Acceptance Criteria | Test Case · Case String | Coverage Status | -| -------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------- | -| FR-001 | AC-1: all six valid source shapes accepted | TC-001 — `normalizeSource › "accepts every valid shape"` | ✅ Unit | -| FR-001 | AC-2: null / no-`type` → SourceError | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-001 | AC-3: missing required field → SourceError naming the field | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-001 | AC-4: unknown type → SourceError "unknown source type" | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-002 | AC-1: `owner/repo` → GitHub https URL | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-2: `owner/repo.git` strips trailing `.git` | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-3: full `https://` URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-4: `git@…` scp-style URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-5: surrounding whitespace is trimmed | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` (padded-input assertion) | ✅ Unit | -| FR-003 | AC-1: valid manifest with name round-trips | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | -| FR-003 | AC-2: missing name → `name === undefined` | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | -| FR-003 | AC-3: null / non-object manifest → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-4: bad schemaVersion / non-array entries → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-5: null entry / missing entry name → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-6: invalid entry source → SourceError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-004 | AC-1: path source returns the dir | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | -| FR-004 | AC-2: missing path → SourceError | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | -| FR-004 | AC-3: url / npm → UnsupportedSourceError | TC-007 — `resolveSource › "url and npm sources are not yet supported"` | ✅ Unit | -| FR-004 | AC-4: git-subdir sparse-checkout at tag → dir/sha/ref | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | -| FR-004 | AC-5: whole-repo HEAD when unpinned; re-fetch existing cache | TC-009 — `resolveSource › "whole-repo git resolves to HEAD when unpinned, and re-fetches an existing cache"` | ✅ Unit (git) | -| FR-004 | AC-6: sha pin checks out the exact commit | TC-010 — `resolveSource › "sha pin checks out the exact commit"` | ✅ Unit (git) | -| FR-004 | AC-7: github + injected runner needs no real git | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | -| FR-004 | CON-1: git is the sole side effect | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | -| FR-004 | CON-2: blobless + sparse (subdir only) | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | -| FR-005 | AC-1: missing / shape-invalid (`{}`) registry read as empty | TC-012 — `registry › "missing and malformed files read as empty"` | ✅ Unit | -| FR-005 | AC-2: atomic write + nested-dir creation round-trips | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | -| FR-005 | AC-3: upsert replaces by name (count stays 1) | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | -| FR-006 | AC-1: named git-subdir entry materializes + records | TC-014 — `installEntry › "materializes a named git-subdir entry and records it"` | ✅ Unit (git) | -| FR-006 | AC-2: name derived via readName when absent | TC-015 — `installEntry › "derives the name via readName when the entry has none"` | ✅ Unit (git) | -| FR-006 | AC-3: entry.path against a whole-repo source | TC-016 — `installEntry › "honors entry.path against a whole-repo source"` | ✅ Unit (git) | -| FR-006 | AC-4: symlink mode; re-install replaces | TC-017 — `installEntry › "symlink mode links instead of copying, and re-install replaces"` | ✅ Unit (git) | -| FR-007 | AC-1: lazy installs enabled, skips disabled | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | -| FR-007 | AC-2: 2nd lazy reconcile → unchanged, zero git | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | -| FR-007 | AC-3: sync unchanged on stable ref; updated on moved pin | TC-019 — `reconcile › "sync re-resolves: unchanged on a stable ref, updated on a moved pin"` | ✅ Unit (git) | -| FR-007 | AC-4: lazy re-materializes when target dir is gone | TC-020 — `reconcile › "lazy re-materializes when the target dir is gone"` | ✅ Unit (git) | -| FR-007 | AC-5: lazy sha pin → unchanged when matches, updated when differs | TC-021 — `reconcile › "lazy honors a sha pin: unchanged when it matches, updated when it differs"` | ✅ Unit (git) | +| Functional Req | Acceptance Criteria | Test Case · Case String | Coverage Status | +| -------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------- | +| FR-001 | AC-1: all six valid source shapes accepted | TC-001 — `normalizeSource › "accepts every valid shape"` | ✅ Unit | +| FR-001 | AC-2: null / no-`type` → SourceError | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-001 | AC-3: missing required field → SourceError naming the field | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-001 | AC-4: unknown type → SourceError "unknown source type" | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-002 | AC-1: `owner/repo` → GitHub https URL | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-2: `owner/repo.git` strips trailing `.git` | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-3: full `https://` URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-4: `git@…` scp-style URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-5: surrounding whitespace is trimmed | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` (padded-input assertion) | ✅ Unit | +| FR-003 | AC-1: valid manifest with name round-trips | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | +| FR-003 | AC-2: missing name → `name === undefined` | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | +| FR-003 | AC-3: null / non-object manifest → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-4: bad schemaVersion / non-array entries → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-5: null entry / missing entry name → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-6: invalid entry source → SourceError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-004 | AC-1: path source returns the dir | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | +| FR-004 | AC-2: missing path → SourceError | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | +| FR-004 | AC-3: url / npm → UnsupportedSourceError | TC-007 — `resolveSource › "url and npm sources are not yet supported"` | ✅ Unit | +| FR-004 | AC-4: git-subdir sparse-checkout at tag → dir/sha/ref | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | +| FR-004 | AC-5: whole-repo HEAD when unpinned; re-fetch existing cache | TC-009 — `resolveSource › "whole-repo git resolves to HEAD when unpinned, and re-fetches an existing cache"` | ✅ Unit (git) | +| FR-004 | AC-6: sha pin checks out the exact commit | TC-010 — `resolveSource › "sha pin checks out the exact commit"` | ✅ Unit (git) | +| FR-004 | AC-7: github + injected runner needs no real git | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | +| FR-004 | CON-1: git is the sole side effect | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | +| FR-004 | CON-2: blobless + sparse (subdir only) | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | +| FR-004 | AC-8 / CON-3: option-like git argv field rejected (injection guard) | TC-022 — `normalizeSource › "rejects option-like git argv fields (injection guard)"` | ✅ Unit | +| FR-005 | AC-1: missing / shape-invalid (`{}`) registry read as empty | TC-012 — `registry › "missing and malformed files read as empty"` | ✅ Unit | +| FR-005 | AC-2: atomic write + nested-dir creation round-trips | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | +| FR-005 | AC-3: upsert replaces by name (count stays 1) | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | +| FR-006 | AC-1: named git-subdir entry materializes + records | TC-014 — `installEntry › "materializes a named git-subdir entry and records it"` | ✅ Unit (git) | +| FR-006 | AC-2: name derived via readName when absent | TC-015 — `installEntry › "derives the name via readName when the entry has none"` | ✅ Unit (git) | +| FR-006 | AC-3: entry.path against a whole-repo source | TC-016 — `installEntry › "honors entry.path against a whole-repo source"` | ✅ Unit (git) | +| FR-006 | AC-4: symlink mode; re-install replaces | TC-017 — `installEntry › "symlink mode links instead of copying, and re-install replaces"` | ✅ Unit (git) | +| FR-007 | AC-1: lazy installs enabled, skips disabled | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | +| FR-007 | AC-2: 2nd lazy reconcile → unchanged, zero git | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | +| FR-007 | AC-3: sync unchanged on stable ref; updated on moved pin | TC-019 — `reconcile › "sync re-resolves: unchanged on a stable ref, updated on a moved pin"` | ✅ Unit (git) | +| FR-007 | AC-4: lazy re-materializes when target dir is gone | TC-020 — `reconcile › "lazy re-materializes when the target dir is gone"` | ✅ Unit (git) | +| FR-007 | AC-5: lazy sha pin → unchanged when matches, updated when differs | TC-021 — `reconcile › "lazy honors a sha pin: unchanged when it matches, updated when it differs"` | ✅ Unit (git) | --- @@ -150,28 +151,31 @@ partial clones work offline. | TC-019 | reconcile sync unchanged-stable / updated-moved | Unit (git) | P0 | FR-007-AC-3, US-001-AC-3 | ✅ | | TC-020 | reconcile lazy re-materializes vanished target | Unit (git) | P1 | FR-007-AC-4 | ✅ | | TC-021 | reconcile lazy sha pin unchanged/updated | Unit (git) | P0 | FR-007-AC-5 | ✅ | +| TC-022 | normalizeSource rejects option-like git argv fields (`-x`) | Unit | P0 | FR-004-AC-8, -CON-3 | ✅ | --- ## Constraint Boundary Tests -| Constraint | Boundary / Case | Test Value | Test Case | Expected | -| ------------ | --------------------------- | ------------------------- | --------- | ------------------------------------------ | -| FR-004-CON-1 | git is the sole side effect | injected fake `GitRunner` | TC-011 | resolves with no real git; argv[0]=`clone` | -| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | +| Constraint | Boundary / Case | Test Value | Test Case | Expected | +| ------------ | --------------------------- | --------------------------------------------------------------- | --------- | ------------------------------------------------------------ | +| FR-004-CON-1 | git is the sole side effect | injected fake `GitRunner` | TC-011 | resolves with no real git; argv[0]=`clone` | +| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | +| FR-004-CON-3 | option-like git argv field | `repo`/`url`/`ref`/`sha` = `-x` (e.g. `ref: "--upload-pack=…"`) | TC-022 | `SourceError` "must not begin with `-`"; no `git` invocation | --- ## Error-Path Coverage -| Error | Trigger | Test Case | Status | -| ---------------------------- | -------------------------------------------------- | --------- | ------ | -| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | -| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | -| `UnsupportedSourceError` | `url` / `npm` passed to `resolveSource` | TC-007 | ✅ | -| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | -| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | -| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | +| Error | Trigger | Test Case | Status | +| ---------------------------- | ----------------------------------------------------------------- | --------- | ------ | +| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | +| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | +| `SourceError` | option-like (`-`) `repo`/`url`/`ref`/`sha` (argv injection guard) | TC-022 | ✅ | +| `UnsupportedSourceError` | `url` / `npm` passed to `resolveSource` | TC-007 | ✅ | +| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | +| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | +| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | --- @@ -189,16 +193,17 @@ partial clones work offline. ## Coverage Summary -- **Acceptance Criteria → Test Case coverage: 36 of 36 functional ACs (100%) map to +- **Acceptance Criteria → Test Case coverage: 37 of 37 functional ACs (100%) map to an executed Test Case.** All ACs of FR-001…FR-007 (incl. FR-002-AC-5 whitespace - trimming via the padded-input assertion in TC-003), both FR-004 constraints, and - all 10 user-story ACs map to a real test in `tests/index.test.ts`. NFR-001…NFR-004 - are covered by the coverage gate, the zero-git assertion, inspection, and analysis. -- The 21 TCs are **1:1** with the tests in `tests/index.test.ts`. All pass under + trimming via the padded-input assertion in TC-003, and FR-004-AC-8 the argv + injection guard via TC-022), all three FR-004 constraints, and all 10 user-story + ACs map to a real test in `tests/index.test.ts`. NFR-001…NFR-004 are covered by + the coverage gate, the zero-git assertion, inspection, and analysis. +- The 22 TCs are **1:1** with the tests in `tests/index.test.ts`. All pass under `make test` at the 100% coverage gate. - All six test-matrix rules are satisfied: every AC has a TC (Rule 1); the copy/symlink materialize options are both exercised (Rule 2, TC-014/TC-017); the - two FR-004 constraints are boundary-tested (Rule 3); every documented error is + three FR-004 constraints are boundary-tested (Rule 3); every documented error is triggered (Rule 4); reconcile's installed/unchanged/updated/skipped outcomes are all reached (Rule 5); and the edge cases above are explicit (Rule 6). diff --git a/src/sources.ts b/src/sources.ts index e9b66b6..ba41a6f 100644 --- a/src/sources.ts +++ b/src/sources.ts @@ -47,6 +47,32 @@ function req(value: unknown, field: string): string { return value; } +/** + * Like {@link req}, but also rejects an option-like value (leading `-`). Source + * fields that flow into a `git` argv must not be interpretable as a CLI flag — + * e.g. a `ref` of `--upload-pack=` or a `repo` of `--output=…` would become + * a `git` option (a second-order command-line injection), so it is rejected up + * front, before any git invocation. + */ +function reqArg(value: unknown, field: string): string { + const v = req(value, field); + if (v.startsWith("-")) { + throw new SourceError(`source field "${field}" must not begin with "-"`); + } + return v; +} + +/** + * Like {@link reqArg}, but for an optional field (`ref`/`sha`): `undefined` + * passes through, but any present value must be a non-empty, non-option-like + * string. `git checkout` does not accept a `--` separator cleanly before a ref, + * so an option-like ref/sha is rejected rather than escaped. + */ +function optArg(value: unknown, field: string): void { + if (value === undefined) return; + reqArg(value, field); +} + /** Validate a source descriptor, throwing {@link SourceError} on malformed input. */ export function normalizeSource(source: Source): Source { if (!source || typeof (source as { type?: unknown }).type !== "string") { @@ -54,17 +80,25 @@ export function normalizeSource(source: Source): Source { } switch (source.type) { case "github": - req(source.repo, "repo"); + reqArg(source.repo, "repo"); + optArg(source.ref, "ref"); + optArg(source.sha, "sha"); return source; case "git": - req(source.url, "url"); + reqArg(source.url, "url"); + optArg(source.ref, "ref"); + optArg(source.sha, "sha"); return source; case "git-subdir": - req(source.url, "url"); + reqArg(source.url, "url"); req(source.path, "path"); + optArg(source.ref, "ref"); + optArg(source.sha, "sha"); return source; case "url": - req(source.url, "url"); + reqArg(source.url, "url"); + optArg(source.ref, "ref"); + optArg(source.sha, "sha"); return source; case "path": req(source.path, "path"); diff --git a/tests/index.test.ts b/tests/index.test.ts index fbd11a7..33e35cc 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -152,6 +152,48 @@ describe("normalizeSource", () => { normalizeSource({ type: "bogus" } as unknown as Source), ).toThrow(/unknown source type/); }); + + // FR-004-CON-3..6: source fields that flow into the `git` argv must not be + // interpretable as a CLI flag (leading `-`), or git treats them as an option + // (e.g. `ref` of `--upload-pack=`) — a second-order command-line + // injection. `normalizeSource` rejects them before any git invocation. + test("rejects option-like git argv fields (injection guard)", () => { + // repo / url reach `git clone` / `git fetch` + expect(() => + normalizeSource({ type: "github", repo: "-x" } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => normalizeSource({ type: "git", url: "-x" } as Source)).toThrow( + /must not begin with "-"/, + ); + expect(() => + normalizeSource({ type: "git-subdir", url: "-x", path: "p" } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => normalizeSource({ type: "url", url: "-x" } as Source)).toThrow( + /must not begin with "-"/, + ); + // ref / sha reach `git checkout --detach ` + expect(() => + normalizeSource({ + type: "github", + repo: "a/b", + ref: "--upload-pack=touch /tmp/pwned", + } as Source), + ).toThrow(SourceError); + expect(() => + normalizeSource({ type: "git", url: "u", sha: "-x" } as Source), + ).toThrow(SourceError); + expect(() => + normalizeSource({ + type: "git-subdir", + url: "u", + path: "p", + ref: "-r", + } as Source), + ).toThrow(SourceError); + expect(() => + normalizeSource({ type: "url", url: "u", sha: "-s" } as Source), + ).toThrow(SourceError); + }); }); test("toGitUrl expands shorthand and passes through URLs", () => { From 779d0f47942dde88586458d2667b90820545e97e Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 27 Jun 2026 19:29:42 -0700 Subject: [PATCH 2/2] fix(security): guard trimmed git argv value and git-subdir.path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bypasses of the second-order command-line-injection guard found in review: F1: reqArg validated the RAW field, but toGitUrl does raw.trim() before the value reaches `git clone`/`git fetch`. A leading-whitespace-then-dash payload (e.g. " --upload-pack=… ext://x") passed the raw startsWith("-") check yet trimmed to a git option at the argv (and toGitUrl passed it through unwrapped on its `://`). Affected github.repo, git.url, git-subdir.url. Fix: guard the TRIMMED value so the value validated is the value that reaches the argv. F2: git-subdir.path used req (non-empty only), so an option-like path ("--stdin", "-X") reached `git sparse-checkout set ` as a flag. Fix: validate it with reqArg too. Adds TC-023 (trim-bypass on repo/url) and TC-024 (option-like subdir path); updates FR-004-CON-3 + AC-9/AC-10 and the test matrix. 100% src coverage retained. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/functional/FR-004-source-resolution.md | 58 ++++++++++++--------- spec/tests.md | 49 ++++++++++------- src/sources.ts | 10 +++- tests/index.test.ts | 47 +++++++++++++++++ 4 files changed, 118 insertions(+), 46 deletions(-) diff --git a/spec/functional/FR-004-source-resolution.md b/spec/functional/FR-004-source-resolution.md index f33a5d9..d3e7b49 100644 --- a/spec/functional/FR-004-source-resolution.md +++ b/spec/functional/FR-004-source-resolution.md @@ -69,39 +69,49 @@ spec.md §14 Known Limitations). (FR-004-AC-7). **Argument-injection guard.** Source-descriptor fields flow into the `git` argv: -`repo`/`url` reach `git clone`/`git fetch` (the clone URL), and `sha`/`ref` reach +`repo`/`url` reach `git clone`/`git fetch` (the clone URL), `git-subdir.path` +reaches `git sparse-checkout set `, and `sha`/`ref` reach `git checkout --detach `. `execFileSync` avoids a _shell_, but `git` itself treats a leading-`-` argument as an **option** — e.g. a `ref` of -`--upload-pack=` or a `repo` of `--output=…` becomes a flag (a second-order -command-line injection, `js/second-order-command-line-injection`). Because -`resolveSource` calls `normalizeSource` **before** any `git` invocation, the guard -that `normalizeSource` ([FR-001](./FR-001-typed-source-union.md)) applies to these -fields (reject a value beginning with `-`) is sufficient: `github.repo`, -`git.url`, `git-subdir.url`, and `url.url`, plus the optional `ref`/`sha` on every -git variant, are rejected with `SourceError` before they can reach the argv -(FR-004-CON-3). Rejection is preferred over an argv `--` separator because +`--upload-pack=`, a `repo` of `--output=…`, or a `path` of `--stdin` becomes +a flag (a second-order command-line injection, +`js/second-order-command-line-injection`). Because `resolveSource` calls +`normalizeSource` **before** any `git` invocation, the guard that +`normalizeSource` ([FR-001](./FR-001-typed-source-union.md)) applies to these +fields (reject a value whose **trimmed** form begins with `-`) is sufficient: +`github.repo`, `git.url`, `git-subdir.url`, `git-subdir.path`, and `url.url`, plus +the optional `ref`/`sha` on every git variant, are rejected with `SourceError` +before they can reach the argv (FR-004-CON-3). The guard is on the **trimmed** +value because `toGitUrl` does `raw.trim()` before the URL reaches the argv: a raw +`startsWith("-")` check would let a leading-whitespace-then-dash payload (e.g. +`" --upload-pack=… ext://x"`, which `toGitUrl` also passes through unwrapped on +its `://`) slip past the guard yet trim to an option at the argv. Guarding the +trimmed value makes the value that is validated the value that actually reaches +the argv. Rejection is preferred over an argv `--` separator because `git checkout` does not accept `--` cleanly before a ref. ## Constraints -| ID | Constraint | Type | Validation | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------- | -| FR-004-CON-1 | Git is the sole side effect; resolution performs no other I/O beyond filesystem reads/dir creation and the `git` subprocess. | architectural | Test (TC-011) | -| FR-004-CON-2 | The clone is blobless and no-checkout (`--filter=blob:none --no-checkout`); subdir sources sparse-checkout only the requested path. | performance | Test (TC-008) | -| FR-004-CON-3 | `normalizeSource` rejects an option-like (leading `-`) `github.repo`, `git.url`, `git-subdir.url`, `url.url`, or any git-variant `ref`/`sha`, so it cannot reach the `git` argv as a CLI flag (second-order command-line-injection guard). | security | Test (TC-022) | +| ID | Constraint | Type | Validation | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------- | +| FR-004-CON-1 | Git is the sole side effect; resolution performs no other I/O beyond filesystem reads/dir creation and the `git` subprocess. | architectural | Test (TC-011) | +| FR-004-CON-2 | The clone is blobless and no-checkout (`--filter=blob:none --no-checkout`); subdir sources sparse-checkout only the requested path. | performance | Test (TC-008) | +| FR-004-CON-3 | `normalizeSource` rejects a value whose **trimmed** form is option-like (begins with `-`) for `github.repo`, `git.url`, `git-subdir.url`, `git-subdir.path`, `url.url`, or any git-variant `ref`/`sha`, so it cannot reach the `git` argv as a CLI flag — including a leading-whitespace-then-dash payload that `toGitUrl` would otherwise trim to an option (second-order command-line-injection guard). | security | Test (TC-022, TC-023, TC-024) | ## Acceptance Criteria -| ID | Criteria | Verification | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| FR-004-AC-1 | A `path` source for an existing directory returns `{ dir }` pointing at that directory (its contents are readable). | Test (TC-006) | -| FR-004-AC-2 | A `path` source for a non-existent directory throws `SourceError`. | Test (TC-006) | -| FR-004-AC-3 | A `url` source and an `npm` source each throw `UnsupportedSourceError`. | Test (TC-007) | -| FR-004-AC-4 | A `git-subdir` source pinned to a tag returns `dir` ending in the subdir path, contains the subdir's files, and reports the tag's `sha` and the requested `ref`. | Test (TC-008) | -| FR-004-AC-5 | A whole-repo `git` source with no pin resolves to `HEAD` (latest commit); re-resolving the same cached URL at a tag exercises the fetch branch and resolves that tag's sha. | Test (TC-009) | -| FR-004-AC-6 | A `git` source pinned by `sha` checks out exactly that commit. | Test (TC-010) | -| FR-004-AC-7 | A `github` source resolved with an injected `GitRunner` performs no real git: the returned `sha` is the runner's output and the first git argv is `clone`. | Test (TC-011) | -| FR-004-AC-8 | An option-like (leading `-`) `repo`, `url`, `ref`, or `sha` on a git source throws `SourceError` ("must not begin with `-`") from `normalizeSource`, before any `git` invocation. | Test (TC-022) | +| ID | Criteria | Verification | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| FR-004-AC-1 | A `path` source for an existing directory returns `{ dir }` pointing at that directory (its contents are readable). | Test (TC-006) | +| FR-004-AC-2 | A `path` source for a non-existent directory throws `SourceError`. | Test (TC-006) | +| FR-004-AC-3 | A `url` source and an `npm` source each throw `UnsupportedSourceError`. | Test (TC-007) | +| FR-004-AC-4 | A `git-subdir` source pinned to a tag returns `dir` ending in the subdir path, contains the subdir's files, and reports the tag's `sha` and the requested `ref`. | Test (TC-008) | +| FR-004-AC-5 | A whole-repo `git` source with no pin resolves to `HEAD` (latest commit); re-resolving the same cached URL at a tag exercises the fetch branch and resolves that tag's sha. | Test (TC-009) | +| FR-004-AC-6 | A `git` source pinned by `sha` checks out exactly that commit. | Test (TC-010) | +| FR-004-AC-7 | A `github` source resolved with an injected `GitRunner` performs no real git: the returned `sha` is the runner's output and the first git argv is `clone`. | Test (TC-011) | +| FR-004-AC-8 | An option-like (leading `-`) `repo`, `url`, `ref`, or `sha` on a git source throws `SourceError` ("must not begin with `-`") from `normalizeSource`, before any `git` invocation. | Test (TC-022) | +| FR-004-AC-9 | A leading-whitespace-then-dash `repo`/`url` (e.g. `" --upload-pack=… ext://x"`), which `toGitUrl` would trim to an option at the argv, throws `SourceError` from `normalizeSource` — the guard validates the trimmed value. | Test (TC-023) | +| FR-004-AC-10 | An option-like (leading `-`) `git-subdir.path` (e.g. `"--stdin"`, `"-X"`), which would reach `git sparse-checkout set ` as a flag, throws `SourceError` from `normalizeSource`. | Test (TC-024) | ## Dependencies diff --git a/spec/tests.md b/spec/tests.md index 99a4541..9845138 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -100,6 +100,8 @@ partial clones work offline. | FR-004 | CON-1: git is the sole side effect | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | | FR-004 | CON-2: blobless + sparse (subdir only) | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | | FR-004 | AC-8 / CON-3: option-like git argv field rejected (injection guard) | TC-022 — `normalizeSource › "rejects option-like git argv fields (injection guard)"` | ✅ Unit | +| FR-004 | AC-9 / CON-3: leading-whitespace trim-bypass repo/url rejected | TC-023 — `normalizeSource › "rejects leading-whitespace option-like repo/url (trim bypass)"` | ✅ Unit | +| FR-004 | AC-10 / CON-3: option-like `git-subdir.path` rejected | TC-024 — `normalizeSource › "rejects option-like git-subdir path"` | ✅ Unit | | FR-005 | AC-1: missing / shape-invalid (`{}`) registry read as empty | TC-012 — `registry › "missing and malformed files read as empty"` | ✅ Unit | | FR-005 | AC-2: atomic write + nested-dir creation round-trips | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | | FR-005 | AC-3: upsert replaces by name (count stays 1) | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | @@ -152,30 +154,36 @@ partial clones work offline. | TC-020 | reconcile lazy re-materializes vanished target | Unit (git) | P1 | FR-007-AC-4 | ✅ | | TC-021 | reconcile lazy sha pin unchanged/updated | Unit (git) | P0 | FR-007-AC-5 | ✅ | | TC-022 | normalizeSource rejects option-like git argv fields (`-x`) | Unit | P0 | FR-004-AC-8, -CON-3 | ✅ | +| TC-023 | normalizeSource rejects leading-ws trim-bypass repo/url | Unit | P0 | FR-004-AC-9, -CON-3 | ✅ | +| TC-024 | normalizeSource rejects option-like `git-subdir.path` | Unit | P0 | FR-004-AC-10, -CON-3 | ✅ | --- ## Constraint Boundary Tests -| Constraint | Boundary / Case | Test Value | Test Case | Expected | -| ------------ | --------------------------- | --------------------------------------------------------------- | --------- | ------------------------------------------------------------ | -| FR-004-CON-1 | git is the sole side effect | injected fake `GitRunner` | TC-011 | resolves with no real git; argv[0]=`clone` | -| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | -| FR-004-CON-3 | option-like git argv field | `repo`/`url`/`ref`/`sha` = `-x` (e.g. `ref: "--upload-pack=…"`) | TC-022 | `SourceError` "must not begin with `-`"; no `git` invocation | +| Constraint | Boundary / Case | Test Value | Test Case | Expected | +| ------------ | --------------------------- | --------------------------------------------------------------- | --------- | -------------------------------------------------------------- | +| FR-004-CON-1 | git is the sole side effect | injected fake `GitRunner` | TC-011 | resolves with no real git; argv[0]=`clone` | +| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | +| FR-004-CON-3 | option-like git argv field | `repo`/`url`/`ref`/`sha` = `-x` (e.g. `ref: "--upload-pack=…"`) | TC-022 | `SourceError` "must not begin with `-`"; no `git` invocation | +| FR-004-CON-3 | trim-bypass (leading ws) | `repo`/`url` = `" --upload-pack=… ext://x"` | TC-023 | `SourceError` "must not begin with `-`"; trimmed value guarded | +| FR-004-CON-3 | option-like subdir path | `git-subdir.path` = `"--stdin"` / `"-X"` | TC-024 | `SourceError` "must not begin with `-`"; no `git` invocation | --- ## Error-Path Coverage -| Error | Trigger | Test Case | Status | -| ---------------------------- | ----------------------------------------------------------------- | --------- | ------ | -| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | -| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | -| `SourceError` | option-like (`-`) `repo`/`url`/`ref`/`sha` (argv injection guard) | TC-022 | ✅ | -| `UnsupportedSourceError` | `url` / `npm` passed to `resolveSource` | TC-007 | ✅ | -| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | -| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | -| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | +| Error | Trigger | Test Case | Status | +| ---------------------------- | ------------------------------------------------------------------ | --------- | ------ | +| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | +| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | +| `SourceError` | option-like (`-`) `repo`/`url`/`ref`/`sha` (argv injection guard) | TC-022 | ✅ | +| `SourceError` | leading-whitespace trim-bypass `repo`/`url` (argv injection guard) | TC-023 | ✅ | +| `SourceError` | option-like (`-`) `git-subdir.path` (argv injection guard) | TC-024 | ✅ | +| `UnsupportedSourceError` | `url` / `npm` passed to `resolveSource` | TC-007 | ✅ | +| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | +| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | +| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | --- @@ -193,13 +201,14 @@ partial clones work offline. ## Coverage Summary -- **Acceptance Criteria → Test Case coverage: 37 of 37 functional ACs (100%) map to +- **Acceptance Criteria → Test Case coverage: 39 of 39 functional ACs (100%) map to an executed Test Case.** All ACs of FR-001…FR-007 (incl. FR-002-AC-5 whitespace - trimming via the padded-input assertion in TC-003, and FR-004-AC-8 the argv - injection guard via TC-022), all three FR-004 constraints, and all 10 user-story - ACs map to a real test in `tests/index.test.ts`. NFR-001…NFR-004 are covered by - the coverage gate, the zero-git assertion, inspection, and analysis. -- The 22 TCs are **1:1** with the tests in `tests/index.test.ts`. All pass under + trimming via the padded-input assertion in TC-003, and the FR-004 argv injection + guard: AC-8 via TC-022, the trim-bypass AC-9 via TC-023, and the + `git-subdir.path` AC-10 via TC-024), all three FR-004 constraints, and all 10 + user-story ACs map to a real test in `tests/index.test.ts`. NFR-001…NFR-004 are + covered by the coverage gate, the zero-git assertion, inspection, and analysis. +- The 24 TCs are **1:1** with the tests in `tests/index.test.ts`. All pass under `make test` at the 100% coverage gate. - All six test-matrix rules are satisfied: every AC has a TC (Rule 1); the copy/symlink materialize options are both exercised (Rule 2, TC-014/TC-017); the diff --git a/src/sources.ts b/src/sources.ts index ba41a6f..02b8758 100644 --- a/src/sources.ts +++ b/src/sources.ts @@ -53,10 +53,16 @@ function req(value: unknown, field: string): string { * e.g. a `ref` of `--upload-pack=` or a `repo` of `--output=…` would become * a `git` option (a second-order command-line injection), so it is rejected up * front, before any git invocation. + * + * The check is on the **trimmed** value: `toGitUrl` does `raw.trim()` before the + * value reaches `git clone`/`git fetch`, so a leading-whitespace-then-dash + * payload (e.g. `" --upload-pack=…"`) would otherwise pass the raw guard yet + * trim to an option at the argv. Guarding the trimmed value makes the value that + * is validated the value that actually reaches the argv. */ function reqArg(value: unknown, field: string): string { const v = req(value, field); - if (v.startsWith("-")) { + if (v.trim().startsWith("-")) { throw new SourceError(`source field "${field}" must not begin with "-"`); } return v; @@ -91,7 +97,7 @@ export function normalizeSource(source: Source): Source { return source; case "git-subdir": reqArg(source.url, "url"); - req(source.path, "path"); + reqArg(source.path, "path"); optArg(source.ref, "ref"); optArg(source.sha, "sha"); return source; diff --git a/tests/index.test.ts b/tests/index.test.ts index 33e35cc..d5f0009 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -194,6 +194,53 @@ describe("normalizeSource", () => { normalizeSource({ type: "url", url: "u", sha: "-s" } as Source), ).toThrow(SourceError); }); + + // TC-023 (F1): the argv guard must validate the value that actually reaches the + // `git` argv. `toGitUrl` trims its input (and passes a `://` value through + // unwrapped), so a leading-whitespace-then-dash payload (e.g. + // `" --upload-pack=touch /tmp/pwned ext://x"`) would slip past a raw + // `startsWith("-")` check yet trim to an *option* at the argv — a second-order + // command-line injection. The guard must reject the TRIMMED value. + test("rejects leading-whitespace option-like repo/url (trim bypass)", () => { + const payload = " --upload-pack=touch /tmp/pwned ext://x"; + expect(() => + normalizeSource({ type: "github", repo: payload } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => + normalizeSource({ type: "git", url: payload } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => + normalizeSource({ + type: "git-subdir", + url: payload, + path: "p", + } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => + normalizeSource({ type: "url", url: payload } as Source), + ).toThrow(/must not begin with "-"/); + }); + + // TC-024 (F2): `git-subdir.path` flows into `git sparse-checkout set ` + // and was only checked for non-emptiness (`req`), so an option-like value + // (`--stdin`, `-X`) reached the argv as a flag. Guard it like the other argv + // fields. + test("rejects option-like git-subdir path", () => { + expect(() => + normalizeSource({ + type: "git-subdir", + url: "u", + path: "--stdin", + } as Source), + ).toThrow(/must not begin with "-"/); + expect(() => + normalizeSource({ + type: "git-subdir", + url: "u", + path: "-X", + } as Source), + ).toThrow(/must not begin with "-"/); + }); }); test("toGitUrl expands shorthand and passes through URLs", () => {