Skip to content

Commit f6448f7

Browse files
feat(vendor): download prebuilt patched packages from patch.socket.dev (--vendor-source) (#116)
* feat(vendor): download prebuilt patched packages from patch.socket.dev (npm + shared core) Add a service-download path to `vendor`: instead of always building the installable patched artifact locally, download the already-built tarball + integrity from the patch.socket.dev vendoring service, falling back to the local build on any miss. This commit lands the shared infrastructure + the npm flavor (package-lock / pnpm / yarn-classic / yarn-berry / bun); other ecosystems follow. - config: --vendor-source {auto|service|build} (SOCKET_VENDOR_SOURCE, default auto), --vendor-url (SOCKET_VENDOR_URL), --patch-server-url (SOCKET_PATCH_SERVER_URL); all env-var-backed with parse/tripwire tests - api client: ApiClient::fetch_vendor_package — two-step package-reference POST (/v0/orgs/{slug}/patches/package or proxy /patch/package) -> grant-tokenized serve GET, with host rewrite + status mapping; 12 wiremock tests - core: VendorServiceConfig, service_fetch (sha512 + golang-h1 verify, fail-closed), PackedTarball::from_bytes (DRY with pack_deterministic) - threading: Option<&VendorServiceConfig> through the vendor dispatch chain (scan --vendor / repair pass None = build-only, unchanged) - npm: service path in stage_patch_pack with the auto/service/build fallback table; integrity always re-verified before write; 9 integration tests cover both the service download and the local-build fallback Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): pypi service-download path (wheel, sha256 recompute) Extend the prebuilt-package download to pypi. `vendor_pypi` now acquires the patched wheel service-first (skipping the installed-dist requirement), falling back to the local wheel build on any miss. - acquire_patched_wheel: service-first then local-build; the service path writes the downloaded wheel, recomputes sha256 (lockfiles embed sha256 while the service reports sha512), and derives the platform-locked advisory from the wheel filename's tag triple - only .whl artifacts are usable (pypi vendoring is wheel-based) — an sdist (or any miss) falls back under `auto` and hard-fails under `service` - in_sync_outcome refactored onto a shared synthesized_apply_result - 5 integration tests: service success (wheel written + requirements line wired to the recomputed sha256), sdist-fallback (auto) / sdist-hard-fail (service), integrity-mismatch hard-fail, offline+service refusal - box the large service-decision enum variants (clippy large_enum_variant) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): cargo service-download path (download + extract .crate) Extend the prebuilt-package download to cargo (the first Tier-B / directory- vendored ecosystem). `vendor_cargo_crate` now materialises the patched copy service-first: download the prebuilt `.crate`, verify sha512, and extract it into `.socket/vendor/cargo/<uuid>/<name>-<version>/` (dropping any `.cargo-checksum.json` so it stays a path dep) — no pristine source needed. Falls back to the existing copy-pristine-and-patch build on any miss. - expose registry_fetch::extract_tgz as pub(crate) for the .crate extraction - cargo_service_copy helper + boxed CargoServiceCopy enum; auto/service fallback policy; offline+service refusal; existing config + Cargo.lock wiring is unchanged (it never read the copy contents) - 4 integration tests: service success (extracts patched crate, wires config, no sidecar, no pristine needed), integrity-mismatch hard-fail, not-built auto-fallback-to-build, offline+service refusal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): gate service mode for not-yet-covered ecosystems + docs Close the fail-closed gap for the partial rollout and document the feature. - dispatch_vendor_one: under `--vendor-source=service`, ecosystems without a service path yet (golang, gem, composer, maven, nuget) now refuse with `vendor_service_unsupported_ecosystem` instead of silently building locally (which would violate the fail-closed contract). `auto`/`build` are unchanged. - CLI_CONTRACT.md: --vendor-source/--vendor-url/--patch-server-url flag rows, the env-var table, and a "Prebuilt vendor artifacts" section (two-step flow, fail-closed integrity, per-outcome fallback table, current ecosystem coverage npm/pypi/cargo, and the new event codes) - README.md: the three new flags + env vars Service coverage today: npm (all lock flavors), pypi (wheel), cargo (.crate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): golang service-download path (download + extract module zip) Extend the prebuilt-package download to golang. `vendor_go_module` now materialises the patched module service-first: download the prebuilt module zip, verify it (sha512 + the `h1:` dirhash), extract it into `.socket/vendor/golang/<uuid>/<module>@<version>/` (stripping the zip's `{module}@{version}/` prefix), synthesize a minimal go.mod if absent, and wire the go.mod `replace` via `ensure_replace_entry` — the same end state `apply_go_redirect` produces, minus the copy + local apply, and with no pristine module source needed. Falls back to the engine build on any miss. - expose registry_fetch::extract_zip_with_prefix + go_redirect::ensure_module_go_mod as pub(crate) - go_service_redirect helper + boxed GoServiceRedirect enum; auto/service fallback; offline+service refusal; empty-files patches defer to the engine - add golang to dispatch_vendor_one's SERVICE_ECOSYSTEMS allowlist - 4 integration tests: service success (extracts module, wires replace, no pristine needed), wrong-h1-dirhash hard-fail (exercises the golang dirhash check), not-built auto-fallback, offline+service refusal Service coverage now: npm, pypi, cargo, golang. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): composer service-download path (download + extract dist zip) Extend the prebuilt-package download to composer. `vendor_composer` now materialises the patched copy service-first: download the prebuilt dist zip, verify sha512, and extract it into `.socket/vendor/composer/<uuid>/<vendor>/<name>@<version>/` (dropping the zip's variable top-level dir) — no installed package needed. Falls back to copy-installed-and-patch on any miss. - expose registry_fetch::extract_zip as pub(crate) - composer_service_copy helper + boxed ComposerServiceCopy enum; auto/service fallback; offline+service refusal; composer.lock dist->path rewiring unchanged - add composer to dispatch_vendor_one's SERVICE_ECOSYSTEMS allowlist - 4 integration tests: service success (extracts dist, rewrites lock, no install needed), integrity-mismatch hard-fail, not-built auto-fallback, offline+service refusal Service coverage now: npm, pypi, cargo, golang, composer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(vendor): reflect final service coverage (npm/pypi/cargo/golang/composer); gem gated gem stays build-local: a path-sourced gem needs a stub gemspec that the `.gem` archive doesn't carry in bundler's required eval-able form (it's metadata.gz YAML; RubyGems generates the stub into specifications/). A clean service path can't produce it without the local install or Ruby-specific serialization. - dispatch_vendor_one gate comment + detail message updated to the final set - CLI_CONTRACT.md "Coverage today" + README.md flag doc updated; note Tier-B build-equivalence is exercised by the toolchain-backed e2e suites Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vendor): gem service-download path (.gem + gem-stub-gemspec second artifact) Close the last gap in `vendor --vendor-source`: gem now downloads the prebuilt patched `.gem` from patch.socket.dev instead of always building locally, like npm/pypi/cargo/golang/composer. Bundler's path source needs an eval-able Ruby `<name>.gemspec`, but a `.gem` only carries the gemspec as YAML inside `metadata.gz`. The converter generates that stub and serves it as a `gem-stub-gemspec` SECOND artifact alongside the `.gem` (mirroring npm's `yarn-berry-zip`); the gem backend downloads and integrity-verifies BOTH, extracts the `.gem`'s `data.tar.gz` into the vendor copy dir, and writes the stub as `<name>.gemspec`. The Gemfile + Gemfile.lock pair wiring is unchanged — only how the copy dir + its `.gemspec` are produced differs. - api/client.rs: surface non-tarball served artifacts on `FetchedVendorPackage` as `secondary_artifacts` (host-rewritten URL + sha512), and add `download_artifact` to fetch one lazily. - service_fetch.rs: carry the secondary refs on `VerifiedArchive` and add `fetch_verified_secondary` (download + fail-closed sha512 verify). - registry_fetch.rs: factor a `pub(crate) extract_gem_data` out of `fetch_gem` so the service path reuses the exact same `.gem` extraction. - gem.rs: thread `service` through `vendor_gem`; `gem_service_copy` downloads + verifies the `.gem` and the stub (absent stub => miss: native-ext gem or a pre-rollout patch), refuses a native-ext stub, extracts, writes the stub; `materialise_patched_copy` unifies service-first / local-fallback across both the full path and the hot-path artifact rebuild. The local stub read is now non-fatal so an auto-fetched (not-installed) gem can still vendor via the service. 8 new wiremock-backed tests. - vendor.rs: add `gem` to `SERVICE_ECOSYSTEMS`; pass `service` to `vendor_gem`. - README / CLI_CONTRACT: gem is now service-covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: bump Go to 1.24 for vexctl so it loads on macOS (LC_UUID) The `test (macos-latest)` matrix job installs vexctl via `go install` and runs tests/e2e_vex.rs against it. The macОS-latest runner image (Sequoia+) has a dyld that refuses to load a Mach-O binary lacking an LC_UUID load command, and Go's linker only began emitting one in 1.24 — so the 1.22-built vexctl crashed on launch ("dyld: missing LC_UUID load command in .../vexctl") and every e2e_vex assertion failed with "vexctl rejected the document". Environmental, not a code regression (ubuntu/windows were unaffected); the shared matrix pin just needed bumping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eedc16a commit f6448f7

27 files changed

Lines changed: 4660 additions & 309 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,15 @@ jobs:
9999
# validates the output with vexctl when it's on PATH. vexctl is
100100
# a Go binary distributed via `go install`. Setting up Go here
101101
# is the cheapest way to give every test job a usable vexctl.
102+
# Go must be >= 1.24: its linker only began emitting an LC_UUID load
103+
# command then, and the macOS-latest runner's dyld (Sequoia+) refuses
104+
# to load a Mach-O binary without one ("missing LC_UUID load command"),
105+
# so a 1.22-built vexctl crashes on launch and every e2e_vex assertion
106+
# fails. ubuntu/windows are unaffected, but the matrix shares this pin.
102107
# SHA pin resolved from `gh api repos/actions/setup-go/git/refs/tags/v6.4.0`.
103108
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
104109
with:
105-
go-version: '1.22'
110+
go-version: '1.24'
106111
cache: false
107112

108113
- name: Install vexctl

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar
115115
| `--proxy-url <url>` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set. |
116116
| `-e, --ecosystems <list>` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). |
117117
| `--download-mode <mode>` | `SOCKET_DOWNLOAD_MODE` | Artifact to fetch when local files are missing: `diff` (default, smallest delta), `package` (full per-package tarball), or `file` (legacy per-file blobs). |
118+
| `--vendor-source <mode>` | `SOCKET_VENDOR_SOURCE` | How `vendor` acquires the installable artifact: `auto` (default — download the prebuilt package from patch.socket.dev, fall back to a local build on any miss), `service` (require the service, fail-closed), or `build` (always build locally). Covers npm, pypi, cargo, golang, composer, and gem. |
119+
| `--vendor-url <url>` | `SOCKET_VENDOR_URL` | Base host for the vendoring service's package-reference request (default: the active `--api-url`/`--proxy-url` base). Point at staging / local dev for testing. |
120+
| `--patch-server-url <url>` | `SOCKET_PATCH_SERVER_URL` | Override the host of the prebuilt-archive download URL the service returns (default: as returned). Mainly for local-dev / testing. |
118121
| `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. |
119122
| `-g, --global` | `SOCKET_GLOBAL` | Operate on globally-installed packages. |
120123
| `--global-prefix <path>` | `SOCKET_GLOBAL_PREFIX` | Override the path used to discover globally-installed packages. |

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha
3535
| `--proxy-url` || `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token |
3636
| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec<String>` | Restrict to these ecosystems |
3737
| `--download-mode` || `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `package` \| `file` | Patch artifact format |
38+
| `--vendor-source` || `SOCKET_VENDOR_SOURCE` | **`auto`** | enum: `auto` \| `service` \| `build` | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") |
39+
| `--vendor-url` || `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request |
40+
| `--patch-server-url` || `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) |
3841
| `--offline` || `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network |
3942
| `--global` | `-g` | `SOCKET_GLOBAL` | `false` | bool | Operate on globally-installed packages |
4043
| `--global-prefix` || `SOCKET_GLOBAL_PREFIX` | (auto) | path | Override global packages root |
@@ -326,6 +329,46 @@ machines with **no socket-patch installed and no Socket API access** (registry a
326329
unvendored dependencies may still be needed). Every mechanism below was validated against the real
327330
package managers (`spikes/PHASE0-FINDINGS.txt`).
328331

332+
**Prebuilt vendor artifacts (`--vendor-source`)**: by default (`auto`) `vendor` first tries to
333+
DOWNLOAD the already-built patched artifact + integrity from the patch.socket.dev vendoring service,
334+
and silently falls back to building it locally on any non-fatal miss. `service` requires the service
335+
(fail-closed); `build` always builds locally (the pre-service behavior). The download is a two-step
336+
flow on the configured API/proxy host (`--vendor-url` overrides it): a package-reference POST
337+
(`/v0/orgs/{slug}/patches/package` authenticated, else the public proxy's `/patch/package`) yields a
338+
grant-tokenized serve URL + integrity, then a GET fetches the archive (`--patch-server-url` rewrites
339+
that URL's host for local-dev / testing). The downloaded bytes are ALWAYS integrity-verified before
340+
use (sha512 SRI for every ecosystem; golang additionally the `h1:` module dirhash) — a mismatch is a
341+
hard error, never a silent fallback. A service-vended package reports each patched file as
342+
`AlreadyPatched` (trust is the verified service integrity, not a local re-apply). The fallback ladder
343+
per service outcome:
344+
345+
| Service outcome | `auto` | `service` |
346+
|---|---|---|
347+
| granted/reused, integrity ok | **use service** | **use service** |
348+
| integrity mismatch | local build + `vendor_prebuilt_integrity_mismatch` | refuse (`vendor_prebuilt_required`) |
349+
| still building (`pending_build` / serve 408) | local build + `vendor_prebuilt_pending` | refuse |
350+
| not built / withdrawn / not found / no usable artifact | local build (quiet) | refuse |
351+
| 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse |
352+
| `--offline` | local build | refuse (`vendor_service_offline_conflict`) |
353+
354+
Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo**
355+
(download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:`
356+
dirhash, wire the `replace`), **composer** (download + extract the dist zip), and **gem** (download +
357+
extract the `.gem`, plus a `gem-stub-gemspec` SECOND artifact). The Tier-B ecosystems
358+
(cargo/golang/composer/gem) download the patched archive and extract it into the vendor directory —
359+
the same source tree the local build commits — then run the existing path-dep wiring; their
360+
build-equivalence is exercised by the toolchain-backed e2e suites (which skip when the package
361+
manager is absent). **gem** needs the extra `gem-stub-gemspec` artifact because a path-sourced gem
362+
needs an eval-able stub gemspec that the `.gem` archive doesn't carry in bundler's required form (a
363+
`.gem` keeps the gemspec as YAML in `metadata.gz`); the converter generates that stub and serves it
364+
alongside the `.gem`, and the gem backend downloads + integrity-verifies both. A served gem whose
365+
stub is missing (a native-extension gem, for which the converter emits no stub, or a patch built
366+
before the stub rollout) is treated as a service miss — `auto` falls back to the local build,
367+
`service` refuses (`vendor_prebuilt_required`). For any ecosystem with no service path at all
368+
`auto`/`build` build locally as before, and `service` refuses with
369+
`vendor_service_unsupported_ecosystem`. A successful service vend emits `vendor_prebuilt_downloaded`.
370+
Unrelated to `--download-mode` (which selects the patch-CONTENT format for the local build).
371+
329372
**Patch sources stay in memory (v3.4)**: vendoring never writes `.socket/blobs/`, `.socket/diffs/`,
330373
or temporary patch files. Pre-existing `.socket/` artifacts (from a prior `apply`/`get`/`repair`)
331374
are read in place; already-vendored purls re-stage patch content from the committed artifact itself
@@ -523,6 +566,9 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names
523566
| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | **Renamed in v3.0** (was `SOCKET_PATCH_PROXY_URL`). |
524567
| `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. |
525568
| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `package` / `file`. |
569+
| `SOCKET_VENDOR_SOURCE` | `--vendor-source` | `auto` | One of `auto` / `service` / `build`. |
570+
| `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. |
571+
| `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. |
526572
| `SOCKET_OFFLINE` | `--offline` | `false` ||
527573
| `SOCKET_GLOBAL` | `--global` / `-g` | `false` ||
528574
| `SOCKET_GLOBAL_PREFIX` | `--global-prefix` | (auto) ||

crates/socket-patch-cli/src/args.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use socket_patch_core::constants::{
2222
DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_SOCKET_API_URL,
2323
};
2424
use socket_patch_core::crawlers::Ecosystem;
25+
use socket_patch_core::patch::vendor::VendorSource;
2526

2627
/// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token.
2728
///
@@ -49,6 +50,16 @@ fn parse_supported_ecosystem(s: &str) -> Result<String, String> {
4950
}
5051
}
5152

53+
/// clap value-parser for `--vendor-source` / `SOCKET_VENDOR_SOURCE`.
54+
///
55+
/// Validates the token against [`VendorSource`] (`auto` | `service` | `build`,
56+
/// case-insensitive) at parse time so a typo fails the command immediately
57+
/// rather than at vendor time, and normalizes it to the canonical lowercase
58+
/// tag. Mirrors [`parse_supported_ecosystem`]'s fail-loud-on-typo posture.
59+
fn parse_vendor_source(s: &str) -> Result<String, String> {
60+
VendorSource::parse(s).map(|v| v.as_tag().to_string())
61+
}
62+
5263
/// clap value-parser for boolean flags backed by an env var.
5364
///
5465
/// Identical to clap's stock `BoolishValueParser` (case-insensitive
@@ -134,6 +145,35 @@ pub struct GlobalArgs {
134145
)]
135146
pub download_mode: String,
136147

148+
/// Where `vendor` acquires the installable patched artifact. `auto`
149+
/// (default) downloads the prebuilt archive from the patch.socket.dev
150+
/// vendoring service and silently falls back to a local build on any miss;
151+
/// `service` requires the service and fails closed; `build` always builds
152+
/// locally (the pre-service behavior). Only `vendor` uses this; other
153+
/// subcommands accept it silently.
154+
#[arg(
155+
long = "vendor-source",
156+
env = "SOCKET_VENDOR_SOURCE",
157+
default_value = "auto",
158+
value_parser = parse_vendor_source,
159+
)]
160+
pub vendor_source: String,
161+
162+
/// Base URL for the patch vendoring service's package-reference request
163+
/// (the step-1 POST). Defaults to the active API base (`--api-url`) when
164+
/// authenticated or the proxy base (`--proxy-url`) otherwise. Override to
165+
/// point `vendor` at staging / local dev independently of `--api-url`.
166+
#[arg(long = "vendor-url", env = "SOCKET_VENDOR_URL")]
167+
pub vendor_url: Option<String>,
168+
169+
/// Override the host of the prebuilt-archive download URL the vendoring
170+
/// service returns (the step-2 GET). When set, the CLI rewrites the
171+
/// scheme + host (+ port) of the returned URL to this base, preserving the
172+
/// path. Mainly for local-dev / testing, where the host the server bakes
173+
/// into the URL is not the one to actually fetch from.
174+
#[arg(long = "patch-server-url", env = "SOCKET_PATCH_SERVER_URL")]
175+
pub patch_server_url: Option<String>,
176+
137177
/// Strict airgap: never contact the network. Operations that need remote
138178
/// data fail loudly when this is set.
139179
#[arg(
@@ -330,6 +370,9 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[
330370
"SOCKET_PROXY_URL",
331371
"SOCKET_ECOSYSTEMS",
332372
"SOCKET_DOWNLOAD_MODE",
373+
"SOCKET_VENDOR_SOURCE",
374+
"SOCKET_VENDOR_URL",
375+
"SOCKET_PATCH_SERVER_URL",
333376
"SOCKET_OFFLINE",
334377
"SOCKET_GLOBAL",
335378
"SOCKET_GLOBAL_PREFIX",
@@ -390,6 +433,9 @@ impl Default for GlobalArgs {
390433
proxy_url: String::new(),
391434
ecosystems: None,
392435
download_mode: "diff".to_string(),
436+
vendor_source: "auto".to_string(),
437+
vendor_url: None,
438+
patch_server_url: None,
393439
offline: false,
394440
strict: false,
395441
global: false,
@@ -524,6 +570,7 @@ mod tests {
524570
std::env::set_var("SOCKET_GLOBAL_PREFIX", "");
525571
std::env::set_var("SOCKET_ECOSYSTEMS", "");
526572
std::env::set_var("SOCKET_DOWNLOAD_MODE", "");
573+
std::env::set_var("SOCKET_VENDOR_SOURCE", "");
527574
std::env::set_var("SOCKET_MANIFEST_PATH", "keep.json");
528575
std::env::set_var("SOCKET_ORG_SLUG", " ");
529576

@@ -552,10 +599,95 @@ mod tests {
552599
assert!(cli.common.global_prefix.is_none());
553600
assert!(cli.common.ecosystems.is_none());
554601
assert_eq!(cli.common.download_mode, "diff");
602+
assert_eq!(
603+
cli.common.vendor_source, "auto",
604+
"empty SOCKET_VENDOR_SOURCE must fall back to the `auto` default"
605+
);
555606
assert_eq!(cli.common.manifest_path, "keep.json");
556607
});
557608
}
558609

610+
/// `--vendor-source` parses every known token, normalizes case, honors the
611+
/// env var, and defaults to `auto`; an unknown token aborts the parse.
612+
#[test]
613+
#[serial_test::serial]
614+
fn vendor_source_flag_parses_normalizes_and_defaults() {
615+
with_clean_socket_env(|| {
616+
// Default when unset.
617+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
618+
assert_eq!(cli.common.vendor_source, "auto");
619+
620+
// CLI value, case-normalized to the canonical tag.
621+
let cli =
622+
TestCli::try_parse_from(["socket-patch", "--vendor-source", "SERVICE"]).unwrap();
623+
assert_eq!(cli.common.vendor_source, "service");
624+
625+
// Env var honored.
626+
std::env::set_var("SOCKET_VENDOR_SOURCE", "build");
627+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
628+
assert_eq!(cli.common.vendor_source, "build");
629+
std::env::remove_var("SOCKET_VENDOR_SOURCE");
630+
631+
// Garbage is rejected at parse time.
632+
assert!(
633+
TestCli::try_parse_from(["socket-patch", "--vendor-source", "download"]).is_err(),
634+
"an unknown vendor source must fail the parse",
635+
);
636+
});
637+
}
638+
639+
/// The new URL knobs flow through to the parsed args from CLI and env.
640+
#[test]
641+
#[serial_test::serial]
642+
fn vendor_url_and_patch_server_url_flow_from_cli_and_env() {
643+
with_clean_socket_env(|| {
644+
let cli = TestCli::try_parse_from([
645+
"socket-patch",
646+
"--vendor-url",
647+
"https://patch.socket-staging.dev",
648+
"--patch-server-url",
649+
"http://localhost:4026",
650+
])
651+
.unwrap();
652+
assert_eq!(
653+
cli.common.vendor_url.as_deref(),
654+
Some("https://patch.socket-staging.dev")
655+
);
656+
assert_eq!(
657+
cli.common.patch_server_url.as_deref(),
658+
Some("http://localhost:4026")
659+
);
660+
661+
std::env::set_var("SOCKET_VENDOR_URL", "https://from-env.example");
662+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
663+
assert_eq!(
664+
cli.common.vendor_url.as_deref(),
665+
Some("https://from-env.example")
666+
);
667+
std::env::remove_var("SOCKET_VENDOR_URL");
668+
// Unset by default.
669+
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
670+
assert!(cli.common.vendor_url.is_none());
671+
assert!(cli.common.patch_server_url.is_none());
672+
});
673+
}
674+
675+
/// Single-source-of-truth guard: the new env vars must be registered in
676+
/// `GLOBAL_ARG_ENV_VARS` (drives the scrub + clean-env harness).
677+
#[test]
678+
fn global_arg_env_vars_includes_vendor_knobs() {
679+
for var in [
680+
"SOCKET_VENDOR_SOURCE",
681+
"SOCKET_VENDOR_URL",
682+
"SOCKET_PATCH_SERVER_URL",
683+
] {
684+
assert!(
685+
GLOBAL_ARG_ENV_VARS.contains(&var),
686+
"{var} must be in GLOBAL_ARG_ENV_VARS",
687+
);
688+
}
689+
}
690+
559691
/// `parse_bool_flag` accepts the same vocabulary as clap's
560692
/// `BoolishValueParser`, case-insensitively and with surrounding whitespace
561693
/// trimmed.

crates/socket-patch-cli/src/commands/repair_vendor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,8 @@ pub(crate) async fn repair_vendored_artifacts(
602602
&vendored_at,
603603
false,
604604
false,
605+
// Repair rebuilds locally from the recorded patch — no service.
606+
None,
605607
)
606608
.await;
607609
match outcome {

crates/socket-patch-cli/src/commands/scan.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1277,8 +1277,10 @@ fn boxed_vendor_records<'a>(
12771277
detached: bool,
12781278
env: &'a mut Envelope,
12791279
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + 'a>> {
1280+
// `scan --vendor` builds locally (no vendoring-service config); the
1281+
// `vendor` command is the service-download entry point.
12801282
Box::pin(vendor_records(
1281-
common, records, sources, detached, false, env,
1283+
common, records, sources, detached, false, env, None,
12821284
))
12831285
}
12841286

0 commit comments

Comments
 (0)