Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,15 @@ jobs:
# validates the output with vexctl when it's on PATH. vexctl is
# a Go binary distributed via `go install`. Setting up Go here
# is the cheapest way to give every test job a usable vexctl.
# Go must be >= 1.24: its linker only began emitting an LC_UUID load
# command then, and the macOS-latest runner's dyld (Sequoia+) refuses
# to load a Mach-O binary without one ("missing LC_UUID load command"),
# so a 1.22-built vexctl crashes on launch and every e2e_vex assertion
# fails. ubuntu/windows are unaffected, but the matrix shares this pin.
# SHA pin resolved from `gh api repos/actions/setup-go/git/refs/tags/v6.4.0`.
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.22'
go-version: '1.24'
cache: false

- name: Install vexctl
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar
| `--proxy-url <url>` | `SOCKET_PROXY_URL` | Public proxy URL used when no API token is set. |
| `-e, --ecosystems <list>` | `SOCKET_ECOSYSTEMS` | Restrict to specific ecosystems (comma-separated, e.g. `npm,pypi`). |
| `--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). |
| `--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. |
| `--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. |
| `--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. |
| `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. |
| `-g, --global` | `SOCKET_GLOBAL` | Operate on globally-installed packages. |
| `--global-prefix <path>` | `SOCKET_GLOBAL_PREFIX` | Override the path used to discover globally-installed packages. |
Expand Down
46 changes: 46 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha
| `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token |
| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec<String>` | Restrict to these ecosystems |
| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `package` \| `file` | Patch artifact format |
| `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`auto`** | enum: `auto` \| `service` \| `build` | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") |
| `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request |
| `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) |
| `--offline` | — | `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network |
| `--global` | `-g` | `SOCKET_GLOBAL` | `false` | bool | Operate on globally-installed packages |
| `--global-prefix` | — | `SOCKET_GLOBAL_PREFIX` | (auto) | path | Override global packages root |
Expand Down Expand Up @@ -326,6 +329,46 @@ machines with **no socket-patch installed and no Socket API access** (registry a
unvendored dependencies may still be needed). Every mechanism below was validated against the real
package managers (`spikes/PHASE0-FINDINGS.txt`).

**Prebuilt vendor artifacts (`--vendor-source`)**: by default (`auto`) `vendor` first tries to
DOWNLOAD the already-built patched artifact + integrity from the patch.socket.dev vendoring service,
and silently falls back to building it locally on any non-fatal miss. `service` requires the service
(fail-closed); `build` always builds locally (the pre-service behavior). The download is a two-step
flow on the configured API/proxy host (`--vendor-url` overrides it): a package-reference POST
(`/v0/orgs/{slug}/patches/package` authenticated, else the public proxy's `/patch/package`) yields a
grant-tokenized serve URL + integrity, then a GET fetches the archive (`--patch-server-url` rewrites
that URL's host for local-dev / testing). The downloaded bytes are ALWAYS integrity-verified before
use (sha512 SRI for every ecosystem; golang additionally the `h1:` module dirhash) — a mismatch is a
hard error, never a silent fallback. A service-vended package reports each patched file as
`AlreadyPatched` (trust is the verified service integrity, not a local re-apply). The fallback ladder
per service outcome:

| Service outcome | `auto` | `service` |
|---|---|---|
| granted/reused, integrity ok | **use service** | **use service** |
| integrity mismatch | local build + `vendor_prebuilt_integrity_mismatch` | refuse (`vendor_prebuilt_required`) |
| still building (`pending_build` / serve 408) | local build + `vendor_prebuilt_pending` | refuse |
| not built / withdrawn / not found / no usable artifact | local build (quiet) | refuse |
| 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse |
| `--offline` | local build | refuse (`vendor_service_offline_conflict`) |

Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo**
(download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:`
dirhash, wire the `replace`), **composer** (download + extract the dist zip), and **gem** (download +
extract the `.gem`, plus a `gem-stub-gemspec` SECOND artifact). The Tier-B ecosystems
(cargo/golang/composer/gem) download the patched archive and extract it into the vendor directory —
the same source tree the local build commits — then run the existing path-dep wiring; their
build-equivalence is exercised by the toolchain-backed e2e suites (which skip when the package
manager is absent). **gem** needs the extra `gem-stub-gemspec` artifact because a path-sourced gem
needs an eval-able stub gemspec that the `.gem` archive doesn't carry in bundler's required form (a
`.gem` keeps the gemspec as YAML in `metadata.gz`); the converter generates that stub and serves it
alongside the `.gem`, and the gem backend downloads + integrity-verifies both. A served gem whose
stub is missing (a native-extension gem, for which the converter emits no stub, or a patch built
before the stub rollout) is treated as a service miss — `auto` falls back to the local build,
`service` refuses (`vendor_prebuilt_required`). For any ecosystem with no service path at all
`auto`/`build` build locally as before, and `service` refuses with
`vendor_service_unsupported_ecosystem`. A successful service vend emits `vendor_prebuilt_downloaded`.
Unrelated to `--download-mode` (which selects the patch-CONTENT format for the local build).

**Patch sources stay in memory (v3.4)**: vendoring never writes `.socket/blobs/`, `.socket/diffs/`,
or temporary patch files. Pre-existing `.socket/` artifacts (from a prior `apply`/`get`/`repair`)
are read in place; already-vendored purls re-stage patch content from the committed artifact itself
Expand Down Expand Up @@ -523,6 +566,9 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names
| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | **Renamed in v3.0** (was `SOCKET_PATCH_PROXY_URL`). |
| `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. |
| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `package` / `file`. |
| `SOCKET_VENDOR_SOURCE` | `--vendor-source` | `auto` | One of `auto` / `service` / `build`. |
| `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. |
| `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. |
| `SOCKET_OFFLINE` | `--offline` | `false` | — |
| `SOCKET_GLOBAL` | `--global` / `-g` | `false` | — |
| `SOCKET_GLOBAL_PREFIX` | `--global-prefix` | (auto) | — |
Expand Down
132 changes: 132 additions & 0 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use socket_patch_core::constants::{
DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_SOCKET_API_URL,
};
use socket_patch_core::crawlers::Ecosystem;
use socket_patch_core::patch::vendor::VendorSource;

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

/// clap value-parser for `--vendor-source` / `SOCKET_VENDOR_SOURCE`.
///
/// Validates the token against [`VendorSource`] (`auto` | `service` | `build`,
/// case-insensitive) at parse time so a typo fails the command immediately
/// rather than at vendor time, and normalizes it to the canonical lowercase
/// tag. Mirrors [`parse_supported_ecosystem`]'s fail-loud-on-typo posture.
fn parse_vendor_source(s: &str) -> Result<String, String> {
VendorSource::parse(s).map(|v| v.as_tag().to_string())
}

/// clap value-parser for boolean flags backed by an env var.
///
/// Identical to clap's stock `BoolishValueParser` (case-insensitive
Expand Down Expand Up @@ -134,6 +145,35 @@ pub struct GlobalArgs {
)]
pub download_mode: String,

/// Where `vendor` acquires the installable patched artifact. `auto`
/// (default) downloads the prebuilt archive from the patch.socket.dev
/// vendoring service and silently falls back to a local build on any miss;
/// `service` requires the service and fails closed; `build` always builds
/// locally (the pre-service behavior). Only `vendor` uses this; other
/// subcommands accept it silently.
#[arg(
long = "vendor-source",
env = "SOCKET_VENDOR_SOURCE",
default_value = "auto",
value_parser = parse_vendor_source,
)]
pub vendor_source: String,

/// Base URL for the patch vendoring service's package-reference request
/// (the step-1 POST). Defaults to the active API base (`--api-url`) when
/// authenticated or the proxy base (`--proxy-url`) otherwise. Override to
/// point `vendor` at staging / local dev independently of `--api-url`.
#[arg(long = "vendor-url", env = "SOCKET_VENDOR_URL")]
pub vendor_url: Option<String>,

/// Override the host of the prebuilt-archive download URL the vendoring
/// service returns (the step-2 GET). When set, the CLI rewrites the
/// scheme + host (+ port) of the returned URL to this base, preserving the
/// path. Mainly for local-dev / testing, where the host the server bakes
/// into the URL is not the one to actually fetch from.
#[arg(long = "patch-server-url", env = "SOCKET_PATCH_SERVER_URL")]
pub patch_server_url: Option<String>,

/// Strict airgap: never contact the network. Operations that need remote
/// data fail loudly when this is set.
#[arg(
Expand Down Expand Up @@ -330,6 +370,9 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[
"SOCKET_PROXY_URL",
"SOCKET_ECOSYSTEMS",
"SOCKET_DOWNLOAD_MODE",
"SOCKET_VENDOR_SOURCE",
"SOCKET_VENDOR_URL",
"SOCKET_PATCH_SERVER_URL",
"SOCKET_OFFLINE",
"SOCKET_GLOBAL",
"SOCKET_GLOBAL_PREFIX",
Expand Down Expand Up @@ -390,6 +433,9 @@ impl Default for GlobalArgs {
proxy_url: String::new(),
ecosystems: None,
download_mode: "diff".to_string(),
vendor_source: "auto".to_string(),
vendor_url: None,
patch_server_url: None,
offline: false,
strict: false,
global: false,
Expand Down Expand Up @@ -524,6 +570,7 @@ mod tests {
std::env::set_var("SOCKET_GLOBAL_PREFIX", "");
std::env::set_var("SOCKET_ECOSYSTEMS", "");
std::env::set_var("SOCKET_DOWNLOAD_MODE", "");
std::env::set_var("SOCKET_VENDOR_SOURCE", "");
std::env::set_var("SOCKET_MANIFEST_PATH", "keep.json");
std::env::set_var("SOCKET_ORG_SLUG", " ");

Expand Down Expand Up @@ -552,10 +599,95 @@ mod tests {
assert!(cli.common.global_prefix.is_none());
assert!(cli.common.ecosystems.is_none());
assert_eq!(cli.common.download_mode, "diff");
assert_eq!(
cli.common.vendor_source, "auto",
"empty SOCKET_VENDOR_SOURCE must fall back to the `auto` default"
);
assert_eq!(cli.common.manifest_path, "keep.json");
});
}

/// `--vendor-source` parses every known token, normalizes case, honors the
/// env var, and defaults to `auto`; an unknown token aborts the parse.
#[test]
#[serial_test::serial]
fn vendor_source_flag_parses_normalizes_and_defaults() {
with_clean_socket_env(|| {
// Default when unset.
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
assert_eq!(cli.common.vendor_source, "auto");

// CLI value, case-normalized to the canonical tag.
let cli =
TestCli::try_parse_from(["socket-patch", "--vendor-source", "SERVICE"]).unwrap();
assert_eq!(cli.common.vendor_source, "service");

// Env var honored.
std::env::set_var("SOCKET_VENDOR_SOURCE", "build");
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
assert_eq!(cli.common.vendor_source, "build");
std::env::remove_var("SOCKET_VENDOR_SOURCE");

// Garbage is rejected at parse time.
assert!(
TestCli::try_parse_from(["socket-patch", "--vendor-source", "download"]).is_err(),
"an unknown vendor source must fail the parse",
);
});
}

/// The new URL knobs flow through to the parsed args from CLI and env.
#[test]
#[serial_test::serial]
fn vendor_url_and_patch_server_url_flow_from_cli_and_env() {
with_clean_socket_env(|| {
let cli = TestCli::try_parse_from([
"socket-patch",
"--vendor-url",
"https://patch.socket-staging.dev",
"--patch-server-url",
"http://localhost:4026",
])
.unwrap();
assert_eq!(
cli.common.vendor_url.as_deref(),
Some("https://patch.socket-staging.dev")
);
assert_eq!(
cli.common.patch_server_url.as_deref(),
Some("http://localhost:4026")
);

std::env::set_var("SOCKET_VENDOR_URL", "https://from-env.example");
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
assert_eq!(
cli.common.vendor_url.as_deref(),
Some("https://from-env.example")
);
std::env::remove_var("SOCKET_VENDOR_URL");
// Unset by default.
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
assert!(cli.common.vendor_url.is_none());
assert!(cli.common.patch_server_url.is_none());
});
}

/// Single-source-of-truth guard: the new env vars must be registered in
/// `GLOBAL_ARG_ENV_VARS` (drives the scrub + clean-env harness).
#[test]
fn global_arg_env_vars_includes_vendor_knobs() {
for var in [
"SOCKET_VENDOR_SOURCE",
"SOCKET_VENDOR_URL",
"SOCKET_PATCH_SERVER_URL",
] {
assert!(
GLOBAL_ARG_ENV_VARS.contains(&var),
"{var} must be in GLOBAL_ARG_ENV_VARS",
);
}
}

/// `parse_bool_flag` accepts the same vocabulary as clap's
/// `BoolishValueParser`, case-insensitively and with surrounding whitespace
/// trimmed.
Expand Down
2 changes: 2 additions & 0 deletions crates/socket-patch-cli/src/commands/repair_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,8 @@ pub(crate) async fn repair_vendored_artifacts(
&vendored_at,
false,
false,
// Repair rebuilds locally from the recorded patch — no service.
None,
)
.await;
match outcome {
Expand Down
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,8 +1277,10 @@ fn boxed_vendor_records<'a>(
detached: bool,
env: &'a mut Envelope,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + 'a>> {
// `scan --vendor` builds locally (no vendoring-service config); the
// `vendor` command is the service-download entry point.
Box::pin(vendor_records(
common, records, sources, detached, false, env,
common, records, sources, detached, false, env, None,
))
}

Expand Down
Loading
Loading