diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbd1dddec..0d51607c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,9 +96,8 @@ jobs: - name: Smoke-test addon run: node --test crates/socket-patch-node/npm/test/smoke.mjs - # Lint the out-of-workspace packaging artifacts: the RubyGems CLI launcher - # gem (Ruby), and the curl|sh installer. Ruby is - # pre-installed on the ubuntu-latest runner. + # Check the standalone installer, release scripts, and native installer + # test harnesses. lint-ecosystems: runs-on: ubuntu-latest timeout-minutes: 10 @@ -108,17 +107,11 @@ jobs: with: persist-credentials: false - - name: Ruby — syntax-check + build the launcher gem - run: | - ( cd gem/socket-patch && ruby -c lib/socket_patch/launcher.rb && ruby -c exe/socket-patch && gem build socket-patch.gemspec ) - - name: Python — test native installer harnesses run: python3 -B -m unittest discover -s scripts/tests -v - name: Shell — shellcheck the curl|sh installer - # install.sh is the other distribution artifact this job lints; it - # had no coverage anywhere before the self-update work touched the - # same surface. shellcheck is pre-installed on ubuntu-latest. + # shellcheck is pre-installed on ubuntu-latest. run: shellcheck --shell=sh scripts/install.sh - name: Shell — run the installer end to end @@ -685,14 +678,6 @@ jobs: - name: Run npm dispatch tests run: node --test npm/socket-patch/bin/socket-patch.test.mjs - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.12.x' - - - name: Run pypi dispatch tests - run: python pypi/socket-patch/test_dispatch.py - # Compiles the CLI and every CLI test target once per OS (--all-features, # so this is also the feature-gated suites' compile-rot check) and uploads # the binaries the e2e, e2e-full and cargo-vex legs run. The legs run the diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index 1c97969f0..000000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Publish PyPI -run-name: "Publish PyPI ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" - -# Publishes socket-patch (platform wheels) to PyPI for an existing -# v release. (The socket-patch-hook .pth wheel is no longer -# published: `setup` was removed in v5.) Dispatched two -# ways, both as a plain workflow_dispatch run: -# - by release.yml (scripts/dispatch-publish.sh), as one leg of the -# single-dispatch release fan-out — distinct-id carries the release -# run's correlation id into this run's name; -# - manually (Actions → Publish PyPI → Run workflow), to retry just this -# registry after a mid-release failure: fix the cause, enter the release -# version, leave distinct-id blank. -# Every run checks out refs/tags/v and takes the prebuilt binaries -# from the GitHub release's assets (verified against SHA256SUMS), so a -# manual retry publishes exactly what the release run would have — no -# rebuild needed. The GitHub release must already exist with all assets. -# -# Idempotent: uploads run with skip-existing, so re-runs and retries after a -# partial upload are safe. -# -# OIDC trusted publishing: each project's PyPI trusted publisher is keyed on -# this repo + THIS file's name (publish-pypi.yml) + environment `pypi`. -# Because this workflow only ever runs as its own top-level -# workflow_dispatch run (never as a called reusable workflow), the OIDC -# token's workflow_ref and job_workflow_ref claims both name this file — so -# the one registration per project keeps working when PyPI flips its -# matching from job_workflow_ref to workflow_ref (warehouse PR #20083). - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (X.Y.Z; the v GitHub release must exist with binaries + SHA256SUMS)' - required: true - type: string - distinct-id: - description: 'Correlation id set by release.yml to track its dispatched run — leave blank for manual runs' - required: false - default: '' - type: string - sums-digest: - description: 'Expected sha256 of the release''s SHA256SUMS asset — set by release.yml to pin the assets to what its build produced; leave blank for manual runs' - required: false - default: '' - type: string - -# Serialize same-version runs: uploads are skip-existing but the wheel -# builds and both project uploads are not atomic, so keep two runs for one -# version from interleaving. A duplicate (e.g. re-dispatched by a -# release-run watcher whose `gh run watch` timed out while this run was -# still going) waits behind the live run and then no-ops. NOTE: the group -# holds at most ONE waiting run — a further same-version dispatch displaces -# (cancels) the waiting duplicate, and a watcher following the displaced -# run reports that as a failure; the publish itself is unaffected (the -# surviving runs no-op or publish). -concurrency: - group: publish-pypi-${{ inputs.version }} - -permissions: {} - -jobs: - pypi-publish: - runs-on: ubuntu-latest - # Bounds how long a wedged run (hung registry call) can hold this - # workflow's per-version concurrency group before retries can proceed. - # (A pending environment approval pauses the run BEFORE the job starts, - # so it does not consume this timeout.) - timeout-minutes: 45 - # OIDC trusted publishing scoped to a deployment environment; also lets a - # maintainer gate publishing with required reviewers. Auto-created with no - # protection rules until configured. NOTE: if required reviewers are ever - # configured, a pending approval pauses THIS run while the release run's - # watcher counts toward its own timeout-minutes — approve promptly. If - # the watcher timed out, re-running it dispatches a NEW run that needs - # its own approval before it can no-op; this run is unaffected either way. - environment: pypi - permissions: - contents: read - id-token: write - steps: - - name: Validate version input - env: - VERSION: ${{ inputs.version }} - run: | - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::'${VERSION}' is not a plain X.Y.Z release version" - exit 1 - fi - - - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: refs/tags/v${{ inputs.version }} - persist-credentials: false - - - name: Verify tag carries the requested version - env: - VERSION: ${{ inputs.version }} - run: | - CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - if [ "$CARGO_VERSION" != "$VERSION" ]; then - echo "::error::tag v${VERSION} carries workspace version ${CARGO_VERSION} — refusing to publish" - exit 1 - fi - - - name: Download binaries from the GitHub release - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version }} - SUMS_DIGEST: ${{ inputs.sums-digest }} - run: | - mkdir artifacts - gh release download "v${VERSION}" \ - --repo "$GITHUB_REPOSITORY" \ - --dir artifacts \ - --pattern '*.tar.gz' --pattern '*.zip' --pattern 'SHA256SUMS' - cd artifacts - # Release assets are mutable (contents:write can clobber them), and - # SHA256SUMS is itself an asset of the same release — so when the - # release run dispatched us it pinned the sums file by digest, - # binding this publish to exactly what that run built (the same - # provenance the pre-split inline jobs got from same-run - # artifacts). Manual retries leave the pin blank: integrity-only. - if [ -n "$SUMS_DIGEST" ]; then - echo "${SUMS_DIGEST} SHA256SUMS" | sha256sum -c - - fi - # -c also fails on a file that SHA256SUMS lists but the download - # missed, so this doubles as a completeness check: the sums file - # was generated from the full 14-target artifact set. - sha256sum -c SHA256SUMS - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.12.13' - - - name: Copy README for PyPI package - run: cp README.md pypi/socket-patch/README.md - - - name: Build wheels (platform socket-patch) - env: - VERSION: ${{ inputs.version }} - run: python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist - - - name: Publish socket-patch to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/ - # Idempotent for re-runs: already-uploaded files skip. - skip-existing: true diff --git a/.github/workflows/publish-rubygems.yml b/.github/workflows/publish-rubygems.yml deleted file mode 100644 index c65f850a0..000000000 --- a/.github/workflows/publish-rubygems.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Publish RubyGems -run-name: "Publish RubyGems ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" - -# Publishes the CLI launcher gem (gem/socket-patch) via OIDC trusted -# publishing. The gem downloads the prebuilt binary from the GitHub release -# at its own version, so this workflow only needs the GitHub release + -# SHA256SUMS to exist. (The socket-patch-bundler plugin gem is no longer -# published: `setup` was removed in v5.) -# -# Dispatched two ways, both as a plain workflow_dispatch run: -# - by release.yml (scripts/dispatch-publish.sh), as one leg of the -# single-dispatch release fan-out — distinct-id carries the release -# run's correlation id into this run's name; -# - manually (Actions → Publish RubyGems → Run workflow), to retry just -# this registry after a mid-release failure: fix the cause, enter the -# release version, leave distinct-id blank. -# Every run checks out refs/tags/v, so a manual retry publishes -# exactly what the release run would have. -# -# Idempotent: already-published versions are probed and skipped, so re-runs -# and partial-failure retries are safe. -# -# OIDC trusted publishing: the RubyGems trusted publisher on each gem is -# keyed on this repo + THIS file's name (publish-rubygems.yml) + environment -# `rubygems`. Because this workflow only ever runs as its own top-level -# workflow_dispatch run (never as a called reusable workflow), the OIDC -# token's workflow_ref and job_workflow_ref claims both name this file — one -# publisher registration covers every path. - -on: - workflow_dispatch: - inputs: - version: - description: 'Release version (X.Y.Z; the v GitHub release must exist — the launcher gem downloads its binary from it)' - required: true - type: string - distinct-id: - description: 'Correlation id set by release.yml to track its dispatched run — leave blank for manual runs' - required: false - default: '' - type: string - -# Serialize same-version runs: the already-published probes below are -# check-then-act, so two CONCURRENT runs for one version could both pass a -# probe and the loser would hard-fail on the registry ("Repushing of gem -# versions is not allowed"). Serialized, a duplicate (e.g. re-dispatched by -# a release-run watcher whose `gh run watch` timed out while this run was -# still going) waits behind the live run and then no-ops. NOTE: the group -# holds at most ONE waiting run — a further same-version dispatch displaces -# (cancels) the waiting duplicate, and a watcher following the displaced -# run reports that as a failure; the publish itself is unaffected (the -# surviving runs no-op or publish). -concurrency: - group: publish-rubygems-${{ inputs.version }} - -permissions: {} - -jobs: - rubygems-publish: - runs-on: ubuntu-latest - # Bounds how long a wedged run (hung registry call) can hold this - # workflow's per-version concurrency group before retries can proceed. - # (A pending environment approval pauses the run BEFORE the job starts, - # so it does not consume this timeout.) - timeout-minutes: 45 - # OIDC trusted publishing scoped to a deployment environment; also lets a - # maintainer gate publishing with required reviewers. Auto-created with no - # protection rules until configured. NOTE: if required reviewers are ever - # configured, a pending approval pauses THIS run while the release run's - # watcher counts toward its own timeout-minutes — approve promptly. If - # the watcher timed out, re-running it dispatches a NEW run that needs - # its own approval before it can no-op; this run is unaffected either way. - environment: rubygems - permissions: - contents: read - id-token: write - steps: - - name: Validate version input - env: - VERSION: ${{ inputs.version }} - run: | - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::'${VERSION}' is not a plain X.Y.Z release version" - exit 1 - fi - - - name: Checkout release tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: refs/tags/v${{ inputs.version }} - persist-credentials: false - - # Ruby is pre-installed on ubuntu-latest; no setup action needed. - - name: Lint + version-check the launcher gem - working-directory: gem/socket-patch - env: - EXPECTED_VERSION: ${{ inputs.version }} - run: | - ruby -c lib/socket_patch/launcher.rb - ruby -c exe/socket-patch - # The gemspec version is baked at the tag by scripts/version-sync.sh. - gemver="$(ruby -e 'print Gem::Specification.load("socket-patch.gemspec").version')" - if [ "$gemver" != "$EXPECTED_VERSION" ]; then - echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" - exit 1 - fi - - - name: Configure RubyGems credentials (OIDC trusted publishing) - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - - - name: Publish socket-patch to RubyGems - working-directory: gem/socket-patch - env: - VERSION: ${{ inputs.version }} - run: | - gem build socket-patch.gemspec - # `gem list -r -e -a` prints `socket-patch (3.3.0, 3.2.0, ...)`; match - # this version as a precise list element (preceded by `(`/space, - # followed by `,`/`)`). - if gem list --remote --exact --all socket-patch 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then - echo "socket-patch ${VERSION} already on RubyGems; skipping." - exit 0 - fi - gem push "socket-patch-${VERSION}.gem" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaf6eda9d..6fbee7000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,27 +2,21 @@ name: Release # One-dispatch release orchestrator: version gate, build matrix, tag, and # GitHub release live here; each registry publish lives in its own -# independently-runnable workflow (publish-cargo.yml, publish-npm.yml, -# publish-pypi.yml, publish-rubygems.yml). The fan-out jobs below dispatch -# each of those at the release tag via `gh workflow run` +# independently-runnable workflow (publish-cargo.yml, publish-npm.yml). +# The fan-out jobs below dispatch each at the release tag via `gh workflow run` # (scripts/dispatch-publish.sh) and watch the dispatched run to completion, # so this run's job graph still reflects every registry's real outcome and # "Re-run failed jobs" re-dispatches exactly the failed legs. A failed leg # can also be retried without this run at all: fix the cause and dispatch # that registry's workflow manually with the release version — no rebuild -# happens either way. The npm and PyPI legs take the prebuilt binaries from +# happens either way. The npm leg takes the prebuilt binaries from # the GitHub release's assets (verified against SHA256SUMS, pinned by digest -# on the fan-out path), and the launcher gem downloads its binary from the -# release at run time — which is why npm/PyPI/RubyGems wait on -# `github-release` below. +# on the fan-out path), which is why it waits on `github-release` below. # # Why dispatch instead of `uses:` (reusable workflows) — two GitHub/registry # facts, verified 2026-08-21 against the registries' docs and source: -# 1. Registry OIDC trusted publishers are keyed on a workflow FILENAME, -# but the registries disagree on WHICH one: npm and crates.io match the -# top-level workflow (`workflow_ref` claim), while PyPI and RubyGems -# match the file defining the job (`job_workflow_ref`) — and PyPI plans -# to flip to top-level matching (warehouse PR #20083). npm additionally +# 1. npm and crates.io OIDC trusted publishers match the top-level +# workflow filename (`workflow_ref` claim). npm additionally # allows only ONE trusted publisher per package, so a leg that is # sometimes `uses:`-called (top-level = release.yml) and sometimes # dispatched (top-level = its own file) can never be authorized for @@ -35,14 +29,11 @@ name: Release # are documented exceptions, so `gh workflow run` with GITHUB_TOKEN # works without a PAT/GitHub App token. # -# Credentials / deployment-environment matrix (per-registry): +# Registry credentials: # - crates.io: OIDC trusted publishing (rust-lang/crates-io-auth-action); # no long-lived secret, no environment. # - npm: OIDC via `npm stage publish`; staged versions require # manual 2FA approval (see the npm run's step summary). -# - PyPI: OIDC trusted publishing; environment `pypi`. -# - RubyGems: OIDC trusted publishing; environment `rubygems` -# (the `socket-patch` launcher gem). # Every registry's trusted publisher is keyed on the repo + the publish # workflow's own filename (see each publish-*.yml header), NOT release.yml. @@ -240,9 +231,9 @@ jobs: permissions: contents: write outputs: - # sha256 of the SHA256SUMS file uploaded below. The npm/PyPI fan-out - # passes it to those legs, which re-download the assets from the - # release: pinning the sums file by digest binds what the legs publish + # sha256 of the SHA256SUMS file uploaded below. The npm fan-out + # passes it to the publish leg, which re-downloads the assets from the + # release: pinning the sums file by digest binds what it publishes # to exactly what THIS run built, restoring the same-run-artifact # provenance the pre-split inline jobs had (release assets are mutable; # anyone with contents:write could clobber them between jobs). @@ -318,9 +309,8 @@ jobs: VERSION: ${{ needs.version.outputs.version }} run: bash scripts/dispatch-publish.sh publish-cargo.yml "$VERSION" - # npm, PyPI, and RubyGems consume the GitHub release (npm/PyPI take the - # prebuilt binaries from its assets; the launcher gem downloads its binary - # from it at run time), so all three wait on `github-release`. + # npm takes the prebuilt binaries from the GitHub release's assets, + # so it waits on `github-release`. npm-publish: needs: [version, github-release] if: ${{ !inputs.dry-run }} @@ -348,51 +338,3 @@ jobs: exit 1 fi bash scripts/dispatch-publish.sh publish-npm.yml "$VERSION" "sums-digest=${SUMS_DIGEST}" - - pypi-publish: - needs: [version, github-release] - if: ${{ !inputs.dry-run }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - actions: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Dispatch and watch Publish PyPI - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - SUMS_DIGEST: ${{ needs.github-release.outputs.sums-digest }} - run: | - # Fail closed: an empty digest would silently downgrade the leg's - # asset check from provenance-pinned to integrity-only. - if [ -z "$SUMS_DIGEST" ]; then - echo "::error::github-release produced no SHA256SUMS digest; refusing to dispatch an unpinned publish" - exit 1 - fi - bash scripts/dispatch-publish.sh publish-pypi.yml "$VERSION" "sums-digest=${SUMS_DIGEST}" - - rubygems-publish: - needs: [version, github-release] - if: ${{ !inputs.dry-run }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - actions: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Dispatch and watch Publish RubyGems - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.version.outputs.version }} - run: bash scripts/dispatch-publish.sh publish-rubygems.yml "$VERSION" diff --git a/.gitignore b/.gitignore index d7c6d412c..c2de49e76 100644 --- a/.gitignore +++ b/.gitignore @@ -151,7 +151,6 @@ npm/socket-patch/bin/socket-patch-* # READMEs copied at publish time crates/socket-patch-cli/README.md npm/socket-patch/README.md -pypi/socket-patch/README.md # Generated by scripts/study-crates.ts study-output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cb575eb08..5e31e5ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,14 @@ into the new version's section — see docs/releasing.md. and the commands to move to hosted mode or keep agent mode. Agent mode is now `socket-patch scan --mode agent` once (commit `.socket/`), then `socket-patch apply` in CI after every install. Hosted and vendored mode never needed a hook. -- **The `socket-patch-hook` PyPI wheel and the `socket-patch-bundler` gem - are no longer built or published**, and the `socket-patch[hook]` extra is - gone from the `socket-patch` wheel (pip warns about the unknown extra and - installs the CLI). Their sources stay in the tree, frozen, for reference. +- **PyPI and RubyGems distributions are removed.** The `socket-patch` wheel + and launcher gem, `socket-patch-hook` wheel, and `socket-patch-bundler` gem + are no longer built or published. Their sources, package tests, publishing + workflows, wheel builder, and version-sync entries are removed. Install + the standalone binary via `https://install.socket.dev/patch` (preferred), + `cargo install socket-patch-cli`, or `npm install -g @socketsecurity/socket-patch`. + npm remains available for the official Socket CLI. Python and Ruby dependency + patching remain supported; see the README's migration instructions. - **`vex` no longer drops agent-mode patches whose ecosystem has no install hook** ("Property 7"). A manifest patch that verifies as applied (or any manifest patch under `--no-verify`) is now attested whatever the diff --git a/README.md b/README.md index 6305f33bc..9d71e3753 100644 --- a/README.md +++ b/README.md @@ -21,85 +21,37 @@ your repo when installs must work offline. ## Installation -One-line install (macOS / Linux): +Install the standalone binary (**recommended**, macOS / Linux): ```bash curl -fsSL https://install.socket.dev/patch | sh ``` -Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, verifies it -against the release's `SHA256SUMS`, and installs to `/usr/local/bin` or `~/.local/bin`. -Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. Pin a version with -`SOCKET_PATCH_VERSION=3.3.0 sh` instead of plain `sh`. +The installer detects your platform, downloads the latest binary, verifies it against +the release's `SHA256SUMS`, and installs to `/usr/local/bin` or `~/.local/bin`. +It needs no language runtime. Set `SOCKET_PATCH_INSTALL_DIR` to choose a directory or +`SOCKET_PATCH_VERSION` to pin a release; pass either variable to `sh` after the pipe. +You can inspect the [installer source](scripts/install.sh) and read about +[mirrors and restricted networks](docs/installer-hosting.md#installing-without-reaching-githubcom). -On a network that blocks or distrusts `github.com`, set `SOCKET_PATCH_BASE_URL` so the -archives come from Socket too — `install.socket.dev` relays them from the GitHub release, -checksums included: - -```bash -curl -fsSL https://install.socket.dev/patch \ - | SOCKET_PATCH_BASE_URL=https://install.socket.dev/patch/SocketDev/socket-patch/releases sh -``` - -`install.socket.dev` serves a copy of [`scripts/install.sh`](scripts/install.sh) from -this repository — read it before you run it, either there or at -[install.socket.dev/patch](https://install.socket.dev/patch). If you would rather not -depend on the Socket domain, `curl -fsSL -https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh` -does the same thing from the same bytes. See -[docs/installer-hosting.md](docs/installer-hosting.md) for how the hosted copy is -published. - -On Windows, install via npm (below), or grab a prebuilt +On Windows, download a prebuilt `socket-patch-*-pc-windows-msvc.zip` from the -[latest release](https://github.com/SocketDev/socket-patch/releases/latest). +[latest release](https://github.com/SocketDev/socket-patch/releases/latest), extract it +into a directory on your `PATH`, or install via npm below. The full +[platform list](docs/ecosystems.md#supported-platforms) includes Linux, macOS, Windows, +and Android release archives. + +### Cargo and npm -Or install through your package manager: +These are the supported package-manager distributions: | Package manager | Command | |-----------------|---------| -| npm | `npm install -g @socketsecurity/socket-patch` (or one-shot: `npx @socketsecurity/socket-patch`) | -| pip | `pip install socket-patch` | | cargo | `cargo install socket-patch-cli` (builds from source with every ecosystem compiled in) | -| gem | `gem install socket-patch` | - -The gem package is a thin launcher: on first run it downloads the prebuilt binary for -your platform from the matching GitHub release, verifies its SHA-256, caches it, and -execs it. Set `SOCKET_PATCH_BIN` to an existing binary to skip the download. - -
-Manual download - -Download a prebuilt binary from the [latest release](https://github.com/SocketDev/socket-patch/releases/latest): - -```bash -# macOS (Apple Silicon) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-aarch64-apple-darwin.tar.gz | tar xz - -# macOS (Intel) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-x86_64-apple-darwin.tar.gz | tar xz - -# Linux (x86_64) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-x86_64-unknown-linux-musl.tar.gz | tar xz - -# Linux (ARM64) -curl -fsSL https://github.com/SocketDev/socket-patch/releases/latest/download/socket-patch-aarch64-unknown-linux-musl.tar.gz | tar xz -``` - -The musl builds are fully static and run on any distro; glibc (`-gnu`) variants are also -on the releases page, alongside Windows (`socket-patch-x86_64-pc-windows-msvc.zip`) and -other targets. - -Then move the binary onto your `PATH`: - -```bash -sudo mv socket-patch /usr/local/bin/ -``` - -The full list of prebuilt targets (Windows, 32-bit ARM, i686, Android) is in -[docs/ecosystems.md](docs/ecosystems.md#supported-platforms). +| npm | `npm install -g @socketsecurity/socket-patch` (or one-shot: `npx @socketsecurity/socket-patch`) | -
+The npm distribution also supplies Socket Patch to the official +[Socket CLI](https://docs.socket.dev/docs/socket-cli). ### Updating @@ -116,6 +68,19 @@ installs are detected and pointed at their own upgrade command instead (e.g. interactive runs print a once-a-day reminder on stderr — set `SOCKET_NO_UPDATE_CHECK=1` to turn that off. +### Migrating from PyPI or RubyGems + +Starting with v5, Socket Patch is distributed as standalone binaries, Cargo crates, +and npm packages. The `socket-patch` PyPI package and Ruby gem, along with +`socket-patch-hook` and `socket-patch-bundler`, are no longer published. +Python and Ruby projects remain fully supported by all three distributions. + +Uninstall the old CLI with the manager that installed it (`pip uninstall socket-patch`, +`pipx uninstall socket-patch`, or `gem uninstall socket-patch`), then use one of the +installation methods above. Remove it from project dependencies and CI bootstrap +commands too. Run `socket-patch --version` to confirm your shell finds the new binary. +Projects that used install hooks should also follow [Upgrading from `setup`](#upgrading-from-setup). + ## Five-minute tutorial No account or token is needed to follow along — without an API token `socket-patch` @@ -1259,8 +1224,9 @@ project that ran `setup`: `scripts.post-install-cmd` and `scripts.post-update-cmd`. - **Python**: remove `socket-patch[hook]` from `requirements.txt`, or from `[project].dependencies` / `[tool.poetry.dependencies]` in `pyproject.toml`, then - `pip uninstall socket-patch-hook` in each environment that has it (the wheel is no - longer published, so a fresh install fails while the dependency is still listed). + `pip uninstall socket-patch-hook` in each environment that has it. Those packages + no longer receive releases; install the CLI separately using the + [v5 installation methods](#installation). - **Bundler**: delete the managed `plugin "socket-patch", path: ...` block from the `Gemfile`, then `bundle plugin uninstall socket-patch`, and delete `.socket/bundler-plugin/`, `.socket/gem-plugin-stamp` and the `/gem-plugin-stamp` diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index fd980fc8e..70f217f79 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -974,28 +974,33 @@ Synopsis and behavior: | Invocation | Behavior | |---|---| | `--update` | Resolve the latest release; install it if newer than the running version. Already-newest (including a dev build newer than any release): informational no-op, exit 0. `latest` never downgrades. | -| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. The inline `--update=3.4.0` spelling is equivalent. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` and the gem launcher honor); a malformed version is a usage error (exit 2). | +| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. The inline `--update=3.4.0` spelling is equivalent. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` honors); a malformed version is a usage error (exit 2). | | `--update --force` | Reinstall/downgrade even when already at the target version, and proceed past a managed-install refusal (with a warning that the owning manager's next upgrade will overwrite the binary). Env: `SOCKET_FORCE`. | | `--update --dry-run` | **Check-only**: one metadata request, zero downloads, zero mutation, exit 0 — and always the `verified`/`update_check` event shape, whether or not an update exists. `--json` details carry `{current, latest, updateAvailable, target, asset, path}` — the cheap scriptable "is an update available" probe. | | `--update --offline` | Refused up front (strict airgap, before any client exists), exit 1. `--force` does **not** bypass it. | Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the confirm prompt; `--json` also auto-confirms), `--dry-run`, `--offline`, `--verbose`, `--debug`, `--no-telemetry`. Other global flags parse and are ignored (the `list --global` precedent). -**Managed-install refusal.** The canonicalized executable path (symlinked invocations resolve to the real file) is classified before any network I/O; non-standalone channels exit 1 with `errorCode: managed_install` and the owning manager's command: +**Managed-install refusal.** The canonicalized executable path (symlinked invocations resolve to the real file) is classified before any network I/O; non-standalone channels exit 1 with `errorCode: managed_install` and an upgrade or migration command: | Detected channel | Hint | |---|---| | npm (`node_modules` path component) | project-local (the directory holding the outermost `node_modules` has a `package.json`, and it is not directly under `lib`/`npm` or below a yarn/pnpm `global` store): `npm install @socketsecurity/socket-patch@latest`, or `vlt install @socketsecurity/socket-patch@latest` when that directory holds `vlt-lock.json`, or `vlx -y -- @socketsecurity/socket-patch@latest …` when its `package.json` is vlx's (`"name": "vlx"`, the vlx cache); otherwise global (including version-manager prefixes such as nvm-windows and fnm): `npm update -g @socketsecurity/socket-patch` | -| PyPI wheel (`site-packages`/`dist-packages`) | `pip install --upgrade socket-patch` | +| Legacy PyPI wheel (`site-packages`/`dist-packages`) | `pip uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | | `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` | -| gem launcher cache (`/socket-patch/bin/…`) | `gem update socket-patch` | +| Legacy gem launcher cache (`/socket-patch/bin/…`) | `gem uninstall socket-patch` followed by the standalone installer (macOS/Linux) or `npm install -g @socketsecurity/socket-patch` (Windows) | | Homebrew (`Cellar`, `/opt/homebrew`) | `brew upgrade socket-patch` | +v5 publishes only standalone binaries, Cargo crates, and npm packages. Legacy +PyPI and RubyGems locations remain detectable so self-update does not silently +replace a binary owned by an old package. The standalone migration command is +`curl -fsSL https://install.socket.dev/patch | sh`. + **Pipeline order** (each step gates the next; a failure at any point leaves the installed binary untouched): fetch `SHA256SUMS` → fetch the archive (`socket-patch-.tar.gz`/`.zip`, explicit timeouts, size caps) → verify the SHA-256 **before** extraction → extract the single expected member → stage as an executable sibling **in the install directory** (`EACCES` here is the permissions preflight → exit 1 with a sudo hint; system temp is never used, so `noexec` mounts don't matter) → run the staged binary's `--version` self-check (against real GitHub the reported version must equal the release tag; under a `SOCKET_UPDATE_BASE_URL` override a mismatch only warns) → one atomic rename over the install path (mode-preserving; a **setuid/setgid** target — or, on Linux, one carrying **file capabilities** (`setcap`) — is refused, since an unprivileged swap cannot restore those grants; Windows uses the rename-dance via `self-replace`). Concurrent updates are single-flighted per environment by an advisory lock at `/update.lock` (`errorCode: update_in_progress`; the OS releases a dead holder's lock, so there is no stale-lock state). Two updaters whose state dirs diverge (e.g. different `$HOME`s targeting one shared `/usr/local/bin`) are not serialized, but every path to the destination is a whole-file rename and stage cleanup is age-gated — the worst case is duplicated work, never a torn binary. **Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Non-fatal advisories ride the run-level `warnings[]` (`{code, detail}`, omitted when empty) — human runs print the same text to stderr as `Warning: ` (first letter capitalized), and `--json` (which silences stderr) carries them here instead so an override is never silent: `managed_install_override` (a `--force` run replaced a package-manager-owned binary that manager's next upgrade will overwrite) and `update_warning` (a non-fatal note from the update engine, today the relaxed version self-check under a `SOCKET_UPDATE_BASE_URL` override). Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. -**Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh and the launcher wrappers): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. +**Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. ### Passive update notice @@ -1124,11 +1129,10 @@ Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild pa ### Internal env vars (no stability guarantee) -These exist for staged rollouts and the launcher wrappers. They are **internal**: names, semantics, and existence may change in any release without a semver bump. +These exist for mirrors and testing. They are **internal**: names, semantics, and existence may change in any release without a semver bump. | Env var | Purpose | |---|---| -| `SOCKET_PATCH_BIN` | Points the RubyGems CLI launcher and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run). | | `SOCKET_UPDATE_BASE_URL` | Points BOTH the release-metadata and asset-download routes of `--update`/the update notice at one base (mirror or test fixture) instead of `github.com` + `api.github.com`. Overriding it relaxes the downloaded binary's version self-check from hard-fail to warning. | | `SOCKET_UPDATE_STATE_DIR` | Overrides the per-user dir holding `update-check.json` + `update.lock` (tests point it into a tempdir). | | `SOCKET_UPDATE_TIMEOUT_MS` | Caps the update fetches' connect/metadata/download budgets (defaults 10 s / 30 s / 300 s; the notice's fetch defaults to 2 s). Doubles as the slow-network escape hatch. | @@ -1655,7 +1659,7 @@ When verification is enabled (the default) and a patch is omitted, the failed PU ## Semver policy -Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to every ecosystem wrapper and launcher package by **`scripts/version-sync.sh `** (the full list of stamped files is below). +Versioning lives in **`Cargo.toml`** at the workspace root (`version = "..."`) and is propagated to the Cargo and npm packages by **`scripts/version-sync.sh `** (the full list of stamped files is below). | Change | Bump | |---|---| @@ -1687,20 +1691,19 @@ scripts/version-sync.sh This syncs the workspace package version into: -- `npm/socket-patch/package.json` (and its `optionalDependencies`) +- `Cargo.toml` (workspace version and the exact `socket-patch-core` dependency pin) +- `npm/socket-patch/package.json` (and its `optionalDependencies`) and `package-lock.json` - every per-platform `npm/socket-patch-*/package.json` -- `pypi/socket-patch/pyproject.toml` -- `gem/socket-patch/socket-patch.gemspec` + its launcher `VERSION` (the RubyGems CLI launcher) - -All ecosystem publishing fans out from the single -**`.github/workflows/release.yml`** dispatch: one run publishes crates.io, -npm, and PyPI plus the CLI launcher gem (`socket-patch` on RubyGems). Each -registry leg lives in its own workflow -(`.github/workflows/publish-{cargo,npm,pypi,rubygems}.yml`), dispatched at -the release tag by the release run and also independently dispatchable to -retry one registry against an existing release. The npm, PyPI, and -launcher-gem legs are gated on the GitHub release — with its binaries and -`SHA256SUMS` — existing. + +Publishing fans out from the single **`.github/workflows/release.yml`** +dispatch: one run creates a GitHub release with standalone binaries and +`SHA256SUMS`, and publishes the crates.io and npm packages. The binary is +the preferred install via `https://install.socket.dev/patch`; npm also +supplies the official Socket CLI. Each registry leg lives in its own +workflow (`.github/workflows/publish-{cargo,npm}.yml`), dispatched at the +release tag and independently dispatchable to retry one registry against +an existing release. The npm leg waits for the GitHub release so it can +package those same binaries. See [the release runbook](../../docs/releasing.md). ## How the contract is enforced diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index d75138fd1..fea0d06a5 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -41,8 +41,7 @@ pub struct UpdateArgs { /// Exact version to install instead of the latest release (e.g. /// `socket-patch --update 3.4.0`). An explicit pin installs that /// version even if it is older than the current one. Also settable via - /// SOCKET_PATCH_VERSION — the same pin install.sh and the gem launcher - /// honor. + /// SOCKET_PATCH_VERSION — the same pin install.sh honors. // // Not named `version`: under `propagate_version` clap already owns a // `--version` arg id on every subcommand, and the collision panics at @@ -55,7 +54,7 @@ pub struct UpdateArgs { pub pin_version: Option, /// Proceed even when this install looks package-manager-managed - /// (npm/pip/cargo/Homebrew/launcher), and reinstall even when already + /// (e.g. npm or cargo), and reinstall even when already /// on the requested version. #[arg( long, @@ -144,10 +143,13 @@ fn confirm_prompt(current: &semver::Version, target: &semver::Version) -> String } } - /// The result line after a successful install, naming the same action as /// [`confirm_prompt`]. -fn installed_message(current: &semver::Version, target: &semver::Version, path: &std::path::Path) -> String { +fn installed_message( + current: &semver::Version, + target: &semver::Version, + path: &std::path::Path, +) -> String { let path = path.display(); if target < current { format!("Downgraded socket-patch {current} \u{2192} {target} ({path})") diff --git a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs index 526100ba9..6ec85f7ef 100644 --- a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs @@ -53,7 +53,10 @@ async fn npm_project_local_refuses_with_local_hint() { "a project install must get the in-project upgrade command: {stderr}" ); assert!(!stderr.contains("npm update -g"), "{stderr}"); - assert!(stderr.starts_with("Error: This socket-patch binary ("), "{stderr}"); + assert!( + stderr.starts_with("Error: This socket-patch binary ("), + "{stderr}" + ); } /// An npm-bundled binary (any `node_modules` component) refuses with the @@ -109,10 +112,10 @@ async fn npm_bundled_refuses_with_npm_hint() { release.verify_request_hygiene().await; } -/// A PyPI-wheel-bundled binary (`site-packages` component) refuses with -/// the pip upgrade command. +/// A legacy PyPI-wheel-bundled binary (`site-packages` component) refuses +/// in-place updates and points at a supported v5 distribution. #[tokio::test] -async fn pip_bundled_refuses_with_pip_hint() { +async fn pip_bundled_refuses_with_migration_hint() { let install = staged_install_at("venv/lib/python3.12/site-packages/socket_patch/bin"); let (code, _stdout, stderr) = run_installed( @@ -125,8 +128,13 @@ async fn pip_bundled_refuses_with_pip_hint() { "pip-managed install must refuse.\nstderr:\n{stderr}" ); assert!( - stderr.contains("pip install --upgrade socket-patch"), - "refusal must route to pip's own upgrade command: {stderr}" + stderr.contains("pip uninstall socket-patch && ") + && stderr.contains(if cfg!(windows) { + "npm install -g @socketsecurity/socket-patch" + } else { + "curl -fsSL https://install.socket.dev/patch | sh" + }), + "refusal must route to a supported v5 distribution: {stderr}" ); install.assert_binary_intact(); @@ -173,14 +181,14 @@ async fn cargo_install_refuses_with_cargo_hint() { install.assert_only_binary_present(); } -/// The gem launcher execs a per-version cached binary under +/// The legacy gem launcher execs a per-version cached binary under /// `/socket-patch/bin///`; replacing the cache /// entry is meaningless (the launcher re-resolves every run), so the -/// refusal points at the gem's own upgrade command. +/// refusal points at a supported v5 distribution. /// Unix resolution goes through XDG_CACHE_HOME. #[cfg(unix)] #[tokio::test] -async fn launcher_cache_refuses_with_gem_hint() { +async fn launcher_cache_refuses_with_migration_hint() { let install = staged_install_at("cache/socket-patch/bin/3.3.0/x86_64-unknown-linux-gnu"); let cache_root = install .root @@ -203,9 +211,10 @@ async fn launcher_cache_refuses_with_gem_hint() { "launcher-cache install must refuse.\nstderr:\n{stderr}" ); assert!( - stderr.contains("gem update"), - "the launcher-cache refusal must point at the gem's own upgrade \ - command: {stderr}" + stderr.contains( + "gem uninstall socket-patch && curl -fsSL https://install.socket.dev/patch | sh" + ), + "the launcher-cache refusal must point at the standalone installer: {stderr}" ); install.assert_binary_intact(); @@ -216,7 +225,7 @@ async fn launcher_cache_refuses_with_gem_hint() { /// %LOCALAPPDATA% there (no ~/.cache convention). #[cfg(windows)] #[tokio::test] -async fn launcher_cache_refuses_with_gem_hint_windows() { +async fn launcher_cache_refuses_with_migration_hint_windows() { let install = staged_install_at("cache/socket-patch/bin/3.3.0/x86_64-pc-windows-msvc"); // Canonicalized for the same reason as the unix rows: the exe path is // canonicalized (verbatim \\?\ form on Windows), so the root must be @@ -242,9 +251,9 @@ async fn launcher_cache_refuses_with_gem_hint_windows() { "launcher-cache install must refuse.\nstderr:\n{stderr}" ); assert!( - stderr.contains("gem update"), - "the launcher-cache refusal must point at the gem's own upgrade \ - command: {stderr}" + stderr + .contains("gem uninstall socket-patch && npm install -g @socketsecurity/socket-patch"), + "the launcher-cache refusal must point at the npm distribution: {stderr}" ); install.assert_binary_intact(); diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 32a8c788d..0b5e332aa 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -2,13 +2,12 @@ //! //! socket-patch ships through several channels, and only the standalone //! ones (install.sh, manual tarball copy) own a binary that self-update may -//! replace. npm and PyPI bundle the binary inside a version-pinned package -//! directory — swapping it there desyncs the package manager's metadata and -//! the next `npm install` / `pip install` silently reverts the update. The -//! gem launcher execs a per-version cached binary it re-resolves on every -//! run, so replacing the cache entry is meaningless. -//! For all of those, `--update` refuses and prints the channel's own -//! upgrade command instead (`--force` overrides). +//! replace. npm bundles the binary inside a version-pinned package directory; +//! swapping it there desyncs the package manager's metadata. Legacy PyPI +//! installs and RubyGems launcher caches remain protected for the same reason, +//! but their hints migrate to a supported distribution: v5 no longer publishes +//! those packages. `--update` refuses managed installs and prints an upgrade +//! or migration command instead (`--force` overrides). //! //! Detection is a pure function over the canonicalized executable path plus //! a snapshot of the relevant environment, so the heuristics are @@ -25,13 +24,13 @@ pub enum InstallChannel { /// Inside a `node_modules` tree (the npm platform packages bundle the /// binary; the JS shim spawns it from there). Npm, - /// Inside `site-packages`/`dist-packages` (the PyPI wheel bundles the + /// Inside `site-packages`/`dist-packages` (the pre-v5 PyPI wheel bundled the /// binary under `socket_patch/bin/`). Pypi, /// Under `$CARGO_HOME/bin` — managed by `cargo install`. Cargo, /// Under the launcher cache (`/socket-patch/bin/…`) used by the - /// RubyGems launcher. + /// pre-v5 RubyGems launcher. LauncherCache, /// Under a Homebrew prefix (`Cellar`, `/opt/homebrew`). Homebrew, @@ -100,14 +99,24 @@ pub fn detect_channel(canonical_exe: &Path, env: &ChannelEnv) -> InstallChannel InstallChannel::Standalone } -/// The channel's own upgrade command, shown when `--update` refuses. +/// The channel's upgrade or migration command, shown when `--update` refuses. pub fn upgrade_hint(channel: InstallChannel) -> &'static str { match channel { InstallChannel::Standalone => "socket-patch --update", InstallChannel::Npm => "npm update -g @socketsecurity/socket-patch", - InstallChannel::Pypi => "pip install --upgrade socket-patch", + InstallChannel::Pypi if cfg!(windows) => { + "pip uninstall socket-patch && npm install -g @socketsecurity/socket-patch" + } + InstallChannel::Pypi => { + "pip uninstall socket-patch && curl -fsSL https://install.socket.dev/patch | sh" + } InstallChannel::Cargo => "cargo install socket-patch-cli", - InstallChannel::LauncherCache => "gem update socket-patch", + InstallChannel::LauncherCache if cfg!(windows) => { + "gem uninstall socket-patch && npm install -g @socketsecurity/socket-patch" + } + InstallChannel::LauncherCache => { + "gem uninstall socket-patch && curl -fsSL https://install.socket.dev/patch | sh" + } InstallChannel::Homebrew => "brew upgrade socket-patch", } } @@ -142,8 +151,7 @@ fn is_vlx_cache_dir(dir: &Path) -> bool { crate::utils::fs::read_regular_to_string_sync(&dir.join("package.json")) .ok() .and_then(|text| { - serde_json::from_str::(crate::utils::serde::strip_bom(&text)) - .ok() + serde_json::from_str::(crate::utils::serde::strip_bom(&text)).ok() }) .is_some_and(|pkg| pkg.get("name").and_then(|n| n.as_str()) == Some("vlx")) } @@ -215,11 +223,10 @@ fn cargo_bin_dir(env: &ChannelEnv) -> Option { env.home.as_ref().map(|h| h.join(".cargo").join("bin")) } -/// Cache roots the gem launcher resolves, in its probe order: +/// Cache roots the pre-v5 gem launcher resolved, in its probe order: /// `$XDG_CACHE_HOME`, `~/.cache`, `%LOCALAPPDATA%`, and the launcher's /// Windows fallback when LOCALAPPDATA is unset — `~/AppData/Local` -/// (launcher.rb: `ENV["LOCALAPPDATA"] || File.join(Dir.home, "AppData", -/// "Local")`). +/// (`ENV["LOCALAPPDATA"] || File.join(Dir.home, "AppData", "Local")`). fn launcher_cache_roots(env: &ChannelEnv) -> Vec { let mut roots = Vec::new(); if let Some(xdg) = &env.xdg_cache_home { @@ -655,9 +662,7 @@ mod tests { #[test] fn hints_route_to_the_owning_manager() { assert!(upgrade_hint(InstallChannel::Npm).contains("npm update -g")); - assert!(upgrade_hint(InstallChannel::Pypi).contains("pip install --upgrade")); assert!(upgrade_hint(InstallChannel::Cargo).contains("cargo install")); - assert!(upgrade_hint(InstallChannel::LauncherCache).contains("gem update")); assert!(upgrade_hint(InstallChannel::Homebrew).contains("brew upgrade")); assert!(upgrade_hint(InstallChannel::Standalone).contains("--update")); } diff --git a/crates/socket-patch-core/src/update/state.rs b/crates/socket-patch-core/src/update/state.rs index 0a2ef0c2c..d0ec6669b 100644 --- a/crates/socket-patch-core/src/update/state.rs +++ b/crates/socket-patch-core/src/update/state.rs @@ -3,8 +3,7 @@ //! notifier never nags about a version the user just installed). //! //! This is disposable *cache* state, not configuration: it lives under the -//! per-user cache root (the same root the gem launcher uses for its -//! binary cache) and every read tolerates absence, corruption, and +//! per-user cache root and every read tolerates absence, corruption, and //! clock skew by degrading to "never checked". Nothing in here may ever //! fail a command — callers treat all errors as "skip the check". @@ -51,8 +50,7 @@ pub fn unix_now() -> u64 { /// Directory holding the state file (and the update lock). Resolution: /// `SOCKET_UPDATE_STATE_DIR` (internal override so tests never touch the /// real per-user dir) → `$XDG_CACHE_HOME` → `~/.cache` (all Unix flavors, -/// macOS included — deliberately the launchers' shared cache root, not -/// `~/Library/Caches`) → `%LOCALAPPDATA%` → `%USERPROFILE%\AppData\Local` +/// macOS included) → `%LOCALAPPDATA%` → `%USERPROFILE%\AppData\Local` /// (Windows). `None` = no resolvable base; callers silently skip. pub fn state_dir() -> Option { fn env_dir(name: &str) -> Option { diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 7d3612f2e..9a516aa13 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -170,21 +170,19 @@ patch-UI review. ### WS7 — Remove `setup` *(branch `v5/remove-setup`)* - Delete the `setup` subcommand, core/setup/**, package_json/** helpers only setup uses, the setup-matrix CI job, and the Bundler plugin gem + - `socket-patch-hook` wheel publishing (or mark them deprecated/unpublished; - confirm with owner before deleting release workflows). Keep `apply`. + `socket-patch-hook` wheel publishing. Keep `apply`. Docs: agent mode = `scan --mode agent` + `socket-patch apply` in CI. - **Status (branch `v5/remove-setup-and-ui`):** subcommand, core `setup/` + `package_json/`, setup tests, `setup-e2e` feature, setup-matrix CI job, `tests/setup_matrix/`, `scripts/setup-matrix.sh` and the setup-only `Dockerfile.gem-b1`/`gem-b4` are deleted. vex's "Property 7" filter went with it (agent patches attest on verification; `setup.manual` is parsed - but ignored). The `socket-patch-hook` wheel and `socket-patch-bundler` gem - are out of `publish-pypi.yml` / `publish-rubygems.yml`, - `build-pypi-wheels.py` and `version-sync.sh`, and the `socket-patch[hook]` - extra is dropped. **Owner decision pending:** `pypi/socket-patch-hook/` - and `gem/socket-patch-bundler/` sources are kept (frozen, README marked - deprecated) — delete them, and optionally yank/deprecate the published - packages and remove their PyPI/RubyGems trusted publishers, once confirmed. + but ignored). The v5 distribution cleanup removes the PyPI and RubyGems + CLI packages and both hook packages, including their sources, tests, + publishing workflows, wheel builder, and version-sync entries. The + supported distributions are the standalone binary via `install.socket.dev` + (preferred), Cargo crates, and npm (required by the official Socket CLI). + Previously published package versions remain available for older users. ### WS8 — Patch UI streamlining *(branch `v5/ui`)* - `-h` shows ~8 options (hide_short_help for the rest); hide deprecated diff --git a/docs/installer-hosting.md b/docs/installer-hosting.md index 03dd68a2e..ae03fd500 100644 --- a/docs/installer-hosting.md +++ b/docs/installer-hosting.md @@ -70,7 +70,7 @@ Unchanged by the hosting move, and worth being precise about: - **The binary** is fetched from the GitHub release and verified against that release's `SHA256SUMS` before it is unpacked. Neither the script nor the checksums are signed — this is checksum integrity rooted in HTTPS plus GitHub, - the same model `--update` and the gem launcher use (see + the same model `--update` uses (see [CLI_CONTRACT.md](../crates/socket-patch-cli/CLI_CONTRACT.md)). - Nothing in the install path sends a Socket API token anywhere. diff --git a/docs/releasing.md b/docs/releasing.md index a20205fb1..383d6ed74 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,15 +1,17 @@ # Releasing socket-patch — publish runbook One release = one version-bump PR + one dispatch of the **Release** workflow. -The CLI publishes to five channels, all from that single dispatch: +The CLI publishes to three channels, all from that single dispatch: | Channel | Package(s) | Auth | |---------|------------|------| -| GitHub release | prebuilt binaries for 14 targets + `SHA256SUMS` (also feeds `install.socket.dev` / `scripts/install.sh`, `--update`, and the gem launcher) | workflow `GITHUB_TOKEN` | +| Standalone binary (preferred) | GitHub release archives for 14 targets + `SHA256SUMS`; installed via `install.socket.dev/patch`, updated with `--update` | workflow `GITHUB_TOKEN` | | crates.io | `socket-patch-core`, `socket-patch-cli` | OIDC trusted publishing, no environment | | npm | `@socketsecurity/socket-patch` + 14 platform packages | OIDC via `npm stage publish`; **manual 2FA approval** | -| PyPI | `socket-patch`, `socket-patch-hook` | OIDC trusted publishing; environment `pypi` | -| RubyGems | `socket-patch` (launcher gem), `socket-patch-bundler` | OIDC trusted publishing; environment `rubygems` | + +The npm distribution is also required by the official Socket CLI. v5 no longer +builds or publishes the PyPI and RubyGems CLI packages or their install hooks. +See the [migration instructions](../README.md#migrating-from-pypi-or-rubygems). ## 1. Write the release notes @@ -22,14 +24,13 @@ a release whose CHANGELOG section is missing or empty. From a developer machine (preferred — CI runs on the PR normally): ```sh -scripts/bump-version.sh 3.4.0 --pr +scripts/bump-version.sh 5.0.0 --pr ``` -This stamps `3.4.0` into every packaging site (`scripts/version-sync.sh`: -`Cargo.toml`, the npm main + platform packages and lockfile, both PyPI -`pyproject.toml`s, both gemspecs and the gem launcher constant), rolls -`[Unreleased]` into a dated `## [3.4.0]` section, and opens a -`release/v3.4.0` PR whose body carries the rolled-over notes. +This stamps `5.0.0` into every packaging site (`scripts/version-sync.sh`: +`Cargo.toml`, the npm main + platform packages and lockfile), rolls +`[Unreleased]` into a dated `## [5.0.0]` section, and opens a +`release/v5.0.0` PR whose body carries the rolled-over notes. Alternatively, dispatch the **Version Bump** workflow from the Actions tab (input: the new version). Caveat: a PR opened by a workflow's `GITHUB_TOKEN` @@ -50,12 +51,11 @@ publishing. The real run: re-verifies the release gate → builds the matrix → creates and pushes `v` → creates the GitHub release with `SHA256SUMS` → fans out -to crates.io, npm, PyPI, and RubyGems in parallel (all OIDC trusted +to crates.io and npm in parallel (both use OIDC trusted publishing; no long-lived registry secrets). Each registry leg is its own -workflow (`publish-cargo.yml`, `publish-npm.yml`, `publish-pypi.yml`, -`publish-rubygems.yml`), dispatched at the release tag by the release run -and watched to completion, so the release run's job graph still reflects -each registry's outcome (its step summaries link the four leg runs) — and +workflow (`publish-cargo.yml`, `publish-npm.yml`), dispatched at the release +tag by the release run and watched to completion, so the job graph still reflects +each registry's outcome (its step summaries link both leg runs) — and each leg can equally be dispatched by hand (see "If a job fails mid-release"). The `release.yml` header records why the legs are dispatched runs rather than reusable workflows (registry trusted-publisher filename @@ -71,19 +71,15 @@ release run's `npm-publish` job summary links to that run), the [org staged-packages dashboard](https://www.npmjs.com/settings/socketsecurity/staged-packages), or the CLI (`npm stage list` / `npm stage approve `, npm 11.15+). -The other channels go live without human action; the RubyGems launcher gem -fetches its binary from the GitHub release at run time. +The standalone binaries and Cargo crates go live without human action. ## 5. Verify ```sh -V=3.4.0 +V=5.0.0 gh release view "v$V" --repo SocketDev/socket-patch # binaries + SHA256SUMS cargo info socket-patch-cli | grep "$V" # crates.io npm view "@socketsecurity/socket-patch@$V" version # npm (after approval) -curl -sf "https://pypi.org/pypi/socket-patch/$V/json" >/dev/null && echo pypi ok -curl -sf "https://pypi.org/pypi/socket-patch-hook/$V/json" >/dev/null && echo hook ok -gem list --remote --exact --all socket-patch | grep "$V" # rubygems ``` End-to-end smoke test of the installer path: @@ -106,12 +102,12 @@ partial release never requires deleting tags or re-bumping. 2. **Dispatch the failed registry's own workflow** — right when the fix needed a change (registry-side config such as a trusted publisher, or a workflow edit landed on the default branch): Actions → **Publish - crates.io** / **Publish npm** / **Publish PyPI** / **Publish RubyGems** → + crates.io** / **Publish npm** → Run workflow, entering the release version (`X.Y.Z`, no `v`) and leaving the other inputs blank. This runs the publish workflow as it exists on the dispatched branch (default: the default branch), so workflow fixes apply. Nothing rebuilds: each publish workflow checks out the `v` tag - and (npm/PyPI) takes the prebuilt binaries from the GitHub release's + and npm takes the prebuilt binaries from the GitHub release's assets, verified against `SHA256SUMS` — the same inputs the release run would have published. The GitHub release must exist with all assets, so failures in `build`, `tag`, or `github-release` itself are still fixed @@ -119,14 +115,9 @@ partial release never requires deleting tags or re-bumping. ## One-time registry setup -Deployment environments (`pypi`, `rubygems`) and each registry's trusted -publisher are listed in the checklist of -[PR #138](https://github.com/SocketDev/socket-patch/pull/138). All four -registry channels authenticate via OIDC trusted publishing, so a missing or -misconfigured trusted publisher (or environment) **fails that channel's -job** — configure it before dispatching a real release. The one -non-blocking push is the `socket-patch-bundler` gem (`continue-on-error` -Phase-2 scaffolding). +Both registries authenticate via OIDC trusted publishing, so a missing or +misconfigured trusted publisher **fails that channel's job** — configure it +before dispatching a real release. Neither workflow uses a deployment environment. Since the publish legs moved into their own workflow files, each trusted publisher is registered against repo `SocketDev/socket-patch` + **the @@ -134,20 +125,15 @@ publish workflow's filename** (not `release.yml`). The legs only ever run as top-level `workflow_dispatch` runs of their own file — whether the release run dispatched them or a maintainer did — so the OIDC token's `workflow_ref` and `job_workflow_ref` claims both name that file, and one -registration per package satisfies every registry's matching rule (npm and -crates.io match the top-level workflow; PyPI and RubyGems match the -job-defining workflow). - -| Registry | Publisher workflow | Environment | -|----------|--------------------|-------------| -| crates.io (`socket-patch-core`, `socket-patch-cli`) | `publish-cargo.yml` | — | -| npm (main + 14 platform packages) | `publish-npm.yml` | — | -| PyPI (`socket-patch`, `socket-patch-hook`) | `publish-pypi.yml` | `pypi` | -| RubyGems (`socket-patch`, `socket-patch-bundler`) | `publish-rubygems.yml` | `rubygems` | - -**Migration from the `release.yml` publishers:** crates.io (up to 5 configs -per crate), PyPI, and RubyGems (both: multiple publishers per package) can -carry the old `release.yml` publisher alongside the new one until every +registration per package satisfies both registries' top-level workflow matching. + +| Registry | Publisher workflow | +|----------|--------------------| +| crates.io (`socket-patch-core`, `socket-patch-cli`) | `publish-cargo.yml` | +| npm (main + 14 platform packages) | `publish-npm.yml` | + +**Migration from the `release.yml` publishers:** crates.io can carry the +old `release.yml` publisher alongside the new one until every release run predating this split — whose re-run legs still authenticate as `release.yml` — has fully landed; then delete the `release.yml` publishers. npm allows only **one** trusted publisher per package, so its cutover is diff --git a/gem/socket-patch-bundler/README.md b/gem/socket-patch-bundler/README.md deleted file mode 100644 index 5e49e857a..000000000 --- a/gem/socket-patch-bundler/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# socket-patch-bundler - -> **Deprecated — no longer published.** `socket-patch setup` (which wired this -> plugin) was removed in socket-patch v5, and this gem is no longer built or -> published. In agent mode, run `socket-patch apply` in CI after -> `bundle install` instead. The source is kept for reference only. -> To remove the hook from a project, see -> [Upgrading from `setup`](https://github.com/SocketDev/socket-patch#upgrading-from-setup). - -A [Bundler plugin](https://bundler.io/guides/bundler_plugins.html) that keeps the -gem patches recorded in your project's `.socket/manifest.json` applied on every -`bundle install` — cached **and** fresh — by re-running the -[`socket-patch`](https://github.com/SocketDev/socket-patch) CLI. - -> **Status: Phase 2 (scaffolding).** `socket-patch setup` currently wires the gem -> ecosystem by committing an in-tree copy of this plugin under -> `.socket/bundler-plugin/` and referencing it from the `Gemfile` via a `path:` -> source (`plugin 'socket-patch', path: File.expand_path('.socket/bundler-plugin', __dir__)`). -> This published gem is the planned replacement; once it is published to -> RubyGems, a follow-up switches the generated `Gemfile` directive to -> `plugin "socket-patch-bundler", "~> "`. - -## Requirements - -The `socket-patch` CLI must be on `PATH` (or pointed at by `SOCKET_PATCH_BIN`) -wherever `bundle install` runs — the same requirement as the in-tree plugin and -the cargo build-time guard. - -## How it works - -Two triggers feed one idempotent applier: a load-time pass (covers cached/no-op -installs) and an `after-install-all` hook (covers fresh installs). A digest of -the manifest + committed `.socket/` files + `Gemfile.lock` + the patch-target -files gates the work; the digest is cached in `.socket/gem-plugin-stamp` -(machine-local, safe to gitignore or delete). On a patch failure it warns -(naming the failure and the remediation) and lets `bundle install` continue; -set `SOCKET_PATCH_STRICT=1` to raise `Bundler::BundlerError` instead. - -## License - -MIT diff --git a/gem/socket-patch-bundler/plugins.rb b/gem/socket-patch-bundler/plugins.rb deleted file mode 100644 index 32b3bfdb2..000000000 --- a/gem/socket-patch-bundler/plugins.rb +++ /dev/null @@ -1,314 +0,0 @@ -# socket-patch Bundler plugin (published-gem form). -# -# Keeps the gem patches recorded in .socket/manifest.json applied by -# re-running the socket-patch CLI whenever Bundler touches the gem set. This -# is the Phase-2 published-gem counterpart of the in-tree plugin generated by -# `socket-patch setup` under .socket/bundler-plugin/; the applier logic is -# identical, but because a published plugin is loaded from the gem cache (not -# from inside the repo) it resolves the project root from the bundle context -# rather than relative to its own location. -# -# When each trigger actually runs (verified against bundler 2.7 and 4.0; hook -# subscriptions are recorded in .bundle/plugin/index at plugin REGISTRATION, -# and bundler evaluates this file whenever a subscribed event first fires in a -# bundle process): -# -# * plugin registration — the FIRST `bundle install` evaluates this file -# BEFORE any project gem is installed, so the load-time trigger is -# bootstrap-gated (no patch target exists yet) and quietly no-ops there; -# the install hooks below re-apply once the gems land. -# * every `bundle install` — fresh AND fully cached — fires the per-gem -# `after-install` events and then `after-install-all`; the forced -# `after-install-all` re-apply is the actual patch point. -# * `bundle pristine` fires ONLY the per-gem `after-install` events, so that -# digest-gated hook is what catches pristine's patch reversion in the same -# run. (A checkout registered by an older plugin version keeps its old -# subscription set until it re-registers — fresh clones and CI always -# re-register, a dev checkout can `rm -rf .bundle/plugin`.) -# * nothing fires on `bundle exec` / `bundle check` / plain `ruby`, and -# `gem pristine` bypasses bundler entirely — a reversion via those is only -# healed at the NEXT `bundle install`. -# -# A digest of (manifest + every committed file under .socket/ + Gemfile.lock + -# the on-disk content of every gem-patch target file) gates the non-forced -# triggers. The stamp is a pure digest cache at .socket/gem-plugin-stamp — -# machine-local state, safe to gitignore or delete (deleting only forces one -# re-probe); a stale copy that reaches version control anyway is harmless on -# a fresh clone, because the bootstrap gate keys on the patch targets -# existing on disk, never on the stamp. Older plugin versions stamped a -# fixed-name file under Bundler.bundle_path (machine-global when no bundle -# path is configured); that legacy stamp is deleted best-effort when seen. -# -# A patch failure NEVER breaks `bundle install`: it prints a warning naming -# what failed and the remediation. Set SOCKET_PATCH_STRICT=1 to restore -# raise-on-failure (Bundler::BundlerError). The socket-patch CLI must be on -# PATH (or pointed at by SOCKET_PATCH_BIN). - -require "digest" -require "fileutils" -require "json" - -module SocketPatch - # Bundler evaluates this file twice in a bootstrap install (registration + - # first hook load), so constant assignments are guarded against re-runs. - BIN_ENV = "SOCKET_PATCH_BIN".freeze unless defined?(BIN_ENV) - STRICT_ENV = "SOCKET_PATCH_STRICT".freeze unless defined?(STRICT_ENV) - STAMP_NAME = "gem-plugin-stamp".freeze unless defined?(STAMP_NAME) - LEGACY_STAMP_NAME = ".socket-patch-gem-stamp".freeze unless defined?(LEGACY_STAMP_NAME) - # Bundler's parallel installer can fire per-gem hooks from worker threads; - # one applier runs at a time so a single bundle process never races - # concurrent `socket-patch apply` children against each other. - APPLY_LOCK = Mutex.new unless defined?(APPLY_LOCK) - - module_function - - # A published plugin is loaded from the gem cache, so the project root (where - # the Gemfile / .socket/ live) is resolved from the bundle context: prefer - # `Bundler.root` (the Gemfile's directory), then walk up from the working - # directory to a dir containing .socket/manifest.json, else fall back to cwd. - def project_root - begin - return Bundler.root.to_s if defined?(Bundler) && Bundler.respond_to?(:root) && Bundler.root - rescue StandardError - # Bundler.root raises outside a bundle context — fall through to the walk. - end - dir = Dir.pwd - loop do - return dir if File.file?(File.join(dir, ".socket", "manifest.json")) - parent = File.dirname(dir) - break if parent == dir - dir = parent - end - Dir.pwd - end - - def socket_dir - File.join(project_root, ".socket") - end - - def manifest_path - File.join(socket_dir, "manifest.json") - end - - def socket_bin - env = ENV[BIN_ENV] - env && !env.empty? ? env : "socket-patch" - end - - def strict? - %w[1 true].include?(ENV[STRICT_ENV].to_s) - end - - def bundle_path - Bundler.bundle_path.to_s - rescue StandardError - File.join(project_root, "vendor", "bundle") - end - - def stamp_path - File.join(socket_dir, STAMP_NAME) - end - - # The on-disk files the manifest's gem patches target: - # /gems/-[-]/. - # Paths are collected whether or not the file exists — `current_digest` - # folds an absence marker, so a gem appearing or vanishing flips the digest. - def patch_target_files - records = begin - JSON.parse(File.read(manifest_path)).fetch("patches", {}) - rescue StandardError - return [] - end - return [] unless records.is_a?(Hash) - gems_dir = File.join(bundle_path, "gems") - # Dir.glob treats `\` as an escape on EVERY platform, so a Windows-style - # bundle path (Bundler.bundle_path carries backslash separators through - # verbatim) would never match the platform-gem wildcard below: platform - # installs (nokogiri-1.15.0-x64-mingw-ucrt) drop out of the digest and a - # `bundle pristine` reversion of them leaves the stamp matching. Forward - # slashes are valid separators on Windows, so normalize the GLOB BASE - # only — the direct join below is not a pattern and stays byte-faithful. - glob_gems_dir = gems_dir.tr("\\", "/") - targets = [] - records.each do |purl, record| - next unless purl.is_a?(String) && purl.start_with?("pkg:gem/") - coordinate = purl.split("pkg:gem/", 2).last.split("?", 2).first - name, at, version = coordinate.rpartition("@") - next if at.empty? || name.empty? || version.empty? - files = record.is_a?(Hash) ? record["files"] : nil - next unless files.is_a?(Hash) - files.each_key do |key| - rel = key.to_s.sub(%r{\Apackage/}, "") - targets << File.join(gems_dir, "#{name}-#{version}", rel) - targets.concat(Dir.glob(File.join(glob_gems_dir, "#{name}-#{version}-*", rel))) - end - end - targets.uniq.sort - end - - # Files whose change must force a reapply: the manifest, every committed file - # under .socket/ (patch blobs etc. — the stamp itself excluded, or each write - # would invalidate the digest it records), Gemfile.lock, and the current - # on-disk state of every patch target. - def digest_inputs - inputs = [manifest_path] - lock = File.join(project_root, "Gemfile.lock") - inputs << lock if File.file?(lock) - if File.directory?(socket_dir) - Dir.glob(File.join(socket_dir, "**", "*")).sort.each do |p| - inputs << p if File.file?(p) && p != stamp_path - end - end - inputs.concat(patch_target_files) - inputs.uniq - end - - def current_digest - d = Digest::SHA256.new - digest_inputs.each do |path| - d.update(path) - d.update("\0") - d.update(File.file?(path) ? "+" : "-") - begin - d.update(File.binread(path)) - rescue StandardError - # Unreadable now -> contributes only its path + absence marker; a later - # readable state changes the digest and forces a reapply. - end - d.update("\0") - end - d.hexdigest - end - - def stamped?(digest) - File.file?(stamp_path) && File.read(stamp_path).strip == digest - rescue StandardError - false - end - - def write_stamp(digest) - FileUtils.mkdir_p(File.dirname(stamp_path)) - File.write(stamp_path, digest) - rescue StandardError - # Best-effort: a missing/unwritable stamp just means we re-probe next time. - end - - # Older plugin versions stamped under Bundler.bundle_path. It is never read - # anymore; delete it (best-effort, once per process) so it does not linger - # as an orphan in a shared gem dir. - def remove_legacy_stamp - return if @legacy_stamp_checked - @legacy_stamp_checked = true - legacy = File.join(bundle_path, LEGACY_STAMP_NAME) - File.delete(legacy) if File.file?(legacy) - rescue StandardError - # Best-effort cleanup only. - end - - # Tolerant by default: a patch failure must never break `bundle install` — - # the first install of a fresh checkout runs the applier before any project - # gem exists, and raising there deadlocks the project on its own bootstrap - # (plugin registration fails, so every retry fails identically). Warn once - # per process with the remediation; SOCKET_PATCH_STRICT=1 restores the raise - # for builds that must not proceed unpatched. The trailer states what the - # ACTIVE mode does — the strict raise must not claim the install continues. - def failure_trailer - if strict? - "Failing `bundle install` because #{STRICT_ENV} is set; unset it to " \ - "warn and continue instead." - else - "`bundle install` continues; set #{STRICT_ENV}=1 to make patch " \ - "failures fatal." - end - end - - def report_failure(message) - message = "#{message} #{failure_trailer}" - if strict? - raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) - end - return if @warned - @warned = true - warn(message) - end - - # Idempotent applier behind every trigger. No manifest -> the project does - # not use socket-patch, nothing to do. - # force: skip the digest gate (the installer just changed the gem set). - # bootstrap_gate: bail while NONE of the manifest's gem-patch targets exist - # on disk. The load-time trigger and the per-gem after-install hook use it - # so a bootstrap install's early evaluations (plugin REGISTRATION runs - # before any project gem lands) never shell out, warn, or — in strict mode — - # raise while there is nothing to patch; the forced after-install-all pass - # does the first real apply once the gems exist. The gate reads only the - # live gem tree, never the stamp: a stale stamp committed by mistake cannot - # re-open the bootstrap deadlock on a fresh clone, and deleting the stamp - # costs one re-probe instead of disabling these triggers. - def apply!(force: false, bootstrap_gate: false) - APPLY_LOCK.synchronize do - return unless File.file?(manifest_path) - remove_legacy_stamp - return if bootstrap_gate && patch_target_files.none? { |t| File.file?(t) } - return if !force && stamped?(current_digest) - - ok = system( - socket_bin, "apply", - "--ecosystems", "gem", "--offline", "--silent", - "--cwd", project_root - ) - - if ok.nil? - report_failure( - "socket-patch: could not run `#{socket_bin} apply` — the gem patches in " \ - ".socket/manifest.json are NOT applied. Install the socket-patch CLI (or set " \ - "#{BIN_ENV} to its path), then run `socket-patch apply --ecosystems gem` " \ - "manually." - ) - return - elsif !ok - report_failure( - "socket-patch: `#{socket_bin} apply --ecosystems gem` failed — the gem patches " \ - "in .socket/manifest.json may NOT be applied. Run `socket-patch apply " \ - "--ecosystems gem` in #{project_root} to apply them manually." - ) - return - end - - if @warned - @warned = false - warn("socket-patch: gem patches applied; the earlier warning is resolved.") - end - # Recompute: the apply just rewrote the target files the digest folds in. - write_stamp(current_digest) - end - end -end - -# Trigger 1 — load time. Runs at plugin registration and whenever a subscribed -# hook event first loads the plugin in a bundle process. Bootstrap-gated on -# the patch targets existing on disk (never on the stamp — a committed stale -# stamp must not re-open the registration deadlock): on the bootstrap install -# no gems exist to patch, so this quietly defers to Trigger 3. In strict mode -# a genuine patch failure (Bundler::BundlerError) still propagates. -begin - SocketPatch.apply!(bootstrap_gate: true) -rescue StandardError => e - raise if defined?(Bundler::BundlerError) && e.is_a?(Bundler::BundlerError) -end - -# Trigger 2 — after each individual gem (re)install. The only event bundler -# fires during `bundle pristine`, so this is what catches pristine's patch -# reversion in the same run — even when the stamp was deleted, since the gate -# reads the gem tree, not the stamp. Digest- and bootstrap-gated: on a fresh -# install's per-gem events the targets are only just landing and Trigger 3 is -# about to do the real work. -Bundler::Plugin.add_hook("after-install") do |_spec_install| - SocketPatch.apply!(bootstrap_gate: true) -end - -# Trigger 3 — after the installer finishes (fresh AND fully-cached installs). -# Forced, because the install just changed the gem set; the applier is -# idempotent so a redundant run on an already-patched tree is a cheap no-op. -Bundler::Plugin.add_hook("after-install-all") do |_install| - SocketPatch.apply!(force: true) -end diff --git a/gem/socket-patch-bundler/socket-patch-bundler.gemspec b/gem/socket-patch-bundler/socket-patch-bundler.gemspec deleted file mode 100644 index 986ee96ed..000000000 --- a/gem/socket-patch-bundler/socket-patch-bundler.gemspec +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# Published form of the socket-patch Bundler plugin (CLI_CONTRACT property: -# "gem" support matrix, Phase 2). `socket-patch setup` today references the -# in-tree plugin under `.socket/bundler-plugin/` via `path:`; once this gem is -# published, a follow-up switches the Gemfile directive to -# `plugin "socket-patch-bundler", "~> "`. The version is kept in -# sync with the workspace by `scripts/version-sync.sh`. -Gem::Specification.new do |s| - s.name = "socket-patch-bundler" - s.version = "4.0.0" - s.summary = "Bundler plugin that keeps socket-patch gem patches applied on every bundle install." - s.description = "Re-applies the gem patches recorded in a project's .socket/manifest.json on " \ - "every `bundle install` (cached and fresh) by invoking the socket-patch CLI. " \ - "The CLI must be on PATH (or pointed at by SOCKET_PATCH_BIN)." - s.authors = ["Socket"] - s.license = "MIT" - s.homepage = "https://github.com/SocketDev/socket-patch" - s.files = ["plugins.rb", "README.md"] - s.required_ruby_version = ">= 2.6.0" - s.metadata = { - "source_code_uri" => "https://github.com/SocketDev/socket-patch", - "rubygems_mfa_required" => "true", - } -end diff --git a/gem/socket-patch/README.md b/gem/socket-patch/README.md deleted file mode 100644 index c2e1791a4..000000000 --- a/gem/socket-patch/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# socket-patch (RubyGems) - -Distributes the [`socket-patch`](https://github.com/SocketDev/socket-patch) CLI -through RubyGems so it can be installed in Ruby / Bundler environments: - -```sh -gem install socket-patch -socket-patch --help -``` - -This is a thin **launcher** gem. On first run it downloads the prebuilt binary -for your platform from the GitHub release **matching the installed gem's own -version** (so `gem install socket-patch -v 3.2.0` fetches the `v3.2.0` binary), -verifies it against the release's `SHA256SUMS`, caches it under your user cache -(`~/.cache/socket-patch/bin/` or `%LOCALAPPDATA%\socket-patch\bin\` on Windows), -and execs it. Subsequent runs use the cached binary. - -## Airgapped / offline use - -The launcher downloads on first run, so for offline CI either pre-warm the cache -or point it at an already-installed binary: - -```sh -export SOCKET_PATCH_BIN=/usr/local/bin/socket-patch -``` - -When `SOCKET_PATCH_BIN` is set to an executable, the launcher skips the download -entirely and execs it. (The npm and PyPI distributions bundle the binary instead -of downloading; a future hardening may ship platform-specific gems that bundle -the binary too.) - -## License - -MIT diff --git a/gem/socket-patch/exe/socket-patch b/gem/socket-patch/exe/socket-patch deleted file mode 100755 index ab6326028..000000000 --- a/gem/socket-patch/exe/socket-patch +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Executable shim for the `socket-patch` launcher gem. Resolves the platform -# binary (download-on-first-run, cached) and execs it, replacing this process so -# exit codes / signals pass through unchanged. -require "socket_patch/launcher" - -SocketPatch::Launcher.run(ARGV) diff --git a/gem/socket-patch/lib/socket_patch/launcher.rb b/gem/socket-patch/lib/socket_patch/launcher.rb deleted file mode 100644 index 557c772c9..000000000 --- a/gem/socket-patch/lib/socket_patch/launcher.rb +++ /dev/null @@ -1,286 +0,0 @@ -# frozen_string_literal: true - -require "rbconfig" -require "digest" -require "fileutils" -require "net/http" -require "uri" -require "tmpdir" - -module SocketPatch - # Resolves and runs the prebuilt `socket-patch` binary for the host platform. - # - # Strategy (mirrors scripts/install.sh's target mapping): - # 1. honor SOCKET_PATCH_BIN if it points at an executable (airgap escape); - # 2. else use a cached binary under the per-user cache, keyed by - # version + target; - # 3. else download `socket-patch-.{tar.gz,zip}` from the matching - # GitHub release, verify its SHA-256 against the release's SHA256SUMS, - # extract the binary, cache it, and run it. - module Launcher - # Fallback version, used ONLY when the installed gem's version can't be read - # (e.g. running this file from a checkout). In a real `gem install` the - # download uses the installed gem's own version — see `version`. - VERSION = "4.0.0" - REPO = "SocketDev/socket-patch" - BINARY = "socket-patch" - - module_function - - def run(argv) - bin = resolve_binary - if Gem.win_platform? - # Windows has no exec() that replaces the process cleanly for console - # apps; spawn + wait and propagate the child's real exit status (a - # blanket 1 would erase the CLI's meaningful non-zero codes, e.g. - # `vex`'s usage-error 2 vs nothing-attested 1). - ok = system(bin, *argv) - raise LauncherError, "could not run #{bin}" if ok.nil? - exit($?.exitstatus || 1) - else - exec([bin, bin], *argv) - end - rescue LauncherError => e - warn("socket-patch: #{e.message}") - exit(1) - rescue StandardError => e - # First-run download/extract failures outside our own error type (DNS - # outages, TLS errors, ...) must exit cleanly, not escape as raw - # backtraces. - warn("socket-patch: #{e.class}: #{e.message}") - exit(1) - end - - class LauncherError < StandardError; end - - # ── binary resolution ───────────────────────────────────────────────────── - - def resolve_binary - env = ENV["SOCKET_PATCH_BIN"] - return env if env && !env.empty? && File.executable?(env) - - ver = version - target, ext = detect_target - exe = BINARY + (Gem.win_platform? ? ".exe" : "") - cached = File.join(cache_dir, ver, target, exe) - # Cache hit: the cached binary was SHA-256-verified when first downloaded - # and lives under the user's own cache dir. We trust it without - # re-verifying (re-verification would require re-fetching SHA256SUMS every - # run), matching npx / pip / rustup; an attacker able to write here can - # already replace the installed gem or the binary itself. - return cached if File.executable?(cached) - - download_binary(ver, target, ext, cached) - cached - end - - # The version to fetch — the binary MUST match the CLI package the user - # actually installed, so derive it from the installed gem's own spec rather - # than trusting the `VERSION` constant (which `version-sync.sh` keeps current - # but which could drift). Falls back to the constant when the gem isn't - # activated (e.g. running this file directly from a checkout). - def version - if (spec = Gem.loaded_specs["socket-patch"]) - return spec.version.to_s - end - Gem::Specification.find_by_name("socket-patch").version.to_s - rescue StandardError, Gem::LoadError - # Gem::MissingSpecError (the gem isn't installed at all — running from - # a checkout) is a Gem::LoadError, which is NOT a StandardError, so it - # must be rescued by name for the fallback to engage. - VERSION - end - - # Map the host to a release target triple + archive extension. Mirrors - # scripts/install.sh. - def detect_target - host_os = RbConfig::CONFIG["host_os"].downcase - host_cpu = RbConfig::CONFIG["host_cpu"].downcase - - arch = - case host_cpu - when /x86_64|x64|amd64/ then "x86_64" - when /aarch64|arm64/ then "aarch64" - when /i[3-6]86|x86/ then "i686" - when /armv7|armhf|arm\b/ then "arm" - else raise LauncherError, "unsupported CPU architecture: #{host_cpu}" - end - - case host_os - when /darwin|mac/ - raise LauncherError, "unsupported macOS arch: #{arch}" unless %w[x86_64 aarch64].include?(arch) - ["#{arch}-apple-darwin", "tar.gz"] - when /mswin|mingw|cygwin|windows/ - win = - case arch - when "x86_64" then "x86_64-pc-windows-msvc" - when "aarch64" then "aarch64-pc-windows-msvc" - when "i686" then "i686-pc-windows-msvc" - else raise LauncherError, "unsupported Windows arch: #{arch}" - end - [win, "zip"] - when /linux/ - libc = musl? ? "musl" : "gnu" - suffix = arch == "arm" ? "eabihf" : "" - ["#{arch}-unknown-linux-#{libc}#{suffix}", "tar.gz"] - else - raise LauncherError, "unsupported OS: #{host_os}" - end - end - - def musl? - return true if RbConfig::CONFIG["host_os"].downcase.include?("musl") - Dir.glob("/lib/ld-musl-*.so.1").any? - rescue StandardError - false - end - - def cache_dir - base = - if Gem.win_platform? - ENV["LOCALAPPDATA"] || File.join(Dir.home, "AppData", "Local") - else - ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache") - end - File.join(base, "socket-patch", "bin") - end - - # ── download + verify + extract ─────────────────────────────────────────── - - def download_binary(ver, target, ext, dest) - archive = "#{BINARY}-#{target}.#{ext}" - base = "https://github.com/#{REPO}/releases/download/v#{ver}" - - Dir.mktmpdir("socket-patch") do |tmp| - archive_path = File.join(tmp, archive) - fetch("#{base}/#{archive}", archive_path) - - sums = fetch_string("#{base}/SHA256SUMS") - verify_sha256!(archive_path, archive, sums) - - extract(archive_path, ext, tmp) - exe = BINARY + (ext == "zip" ? ".exe" : "") - extracted = File.join(tmp, exe) - unless File.file?(extracted) - raise LauncherError, "release archive #{archive} did not contain #{exe}" - end - - install_executable(extracted, dest) - end - end - - # Publish the verified binary into the cache atomically: copy to a temp - # file in the destination dir, set the exec bit, then rename over the - # final path — a concurrent first run can only ever see a complete, - # executable binary, never a torn or not-yet-chmodded one. - def install_executable(src, dest) - FileUtils.mkdir_p(File.dirname(dest)) - tmp = File.join(File.dirname(dest), ".#{File.basename(dest)}.#{Process.pid}.tmp") - begin - FileUtils.cp(src, tmp) - File.chmod(0o755, tmp) unless Gem.win_platform? - begin - File.rename(tmp, dest) - rescue SystemCallError - # Windows rename cannot replace an existing file: a concurrent - # first run already published the (identical, verified) binary. - raise unless File.exist?(dest) - end - ensure - begin - File.delete(tmp) if File.file?(tmp) - rescue StandardError - # Leftover temp cleanup is best-effort. - end - end - end - - # Require HTTPS for every request — including after a redirect. GitHub - # release downloads redirect to a CDN (still HTTPS); a redirect to http:// - # would let a network attacker serve a malicious binary AND a matching - # SHA256SUMS (both attacker-controlled), defeating the checksum check. So a - # non-HTTPS URL — initial or redirect target — is refused. - def https_uri(url) - uri = URI(url) - unless uri.is_a?(URI::HTTPS) - raise LauncherError, "refusing non-HTTPS URL: #{url}" - end - uri - end - - # Follow redirects (GitHub release downloads redirect to a CDN) and stream - # the body to `dest`. Relative redirects are resolved against the current - # URL; the result must still be HTTPS (see `https_uri`). - def fetch(url, dest, redirects = 10) - raise LauncherError, "too many redirects fetching #{url}" if redirects.zero? - uri = https_uri(url) - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request(Net::HTTP::Get.new(uri)) do |res| - case res - when Net::HTTPRedirection - return fetch(URI.join(url, res["location"]).to_s, dest, redirects - 1) - when Net::HTTPSuccess - File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } } - else - raise LauncherError, "download failed (#{res.code}) for #{url}" - end - end - end - end - - def fetch_string(url, redirects = 10) - raise LauncherError, "too many redirects fetching #{url}" if redirects.zero? - uri = https_uri(url) - res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request(Net::HTTP::Get.new(uri)) - end - case res - when Net::HTTPRedirection then fetch_string(URI.join(url, res["location"]).to_s, redirects - 1) - when Net::HTTPSuccess then res.body - else raise LauncherError, "download failed (#{res.code}) for #{url}" - end - end - - # SHA256SUMS lines are " " (some tools prefix the name - # with `*` for binary mode); match either. - def verify_sha256!(path, archive, sums) - expected = nil - sums.each_line do |line| - hex, name = line.split(/\s+/, 2) - next unless name - name = name.strip.sub(/\A\*/, "") - if name == archive - expected = hex.strip - break - end - end - raise LauncherError, "no SHA256SUMS entry for #{archive}" unless expected - actual = Digest::SHA256.file(path).hexdigest - return if actual.casecmp?(expected) - raise LauncherError, "checksum mismatch for #{archive} (expected #{expected}, got #{actual})" - end - - # Quote a value for interpolation into a PowerShell command: single-quoted - # strings are literal except for embedded single quotes, which are escaped - # by doubling them (paths like `it's here` would otherwise break the - # command). - def powershell_quote(value) - "'#{value.gsub("'", "''")}'" - end - - def extract(archive_path, ext, dir) - ok = - if ext == "zip" - # bsdtar (the `tar` on modern Windows) extracts zip; fall back to - # PowerShell Expand-Archive. - system("tar", "-xf", archive_path, "-C", dir) || - system("powershell", "-NoProfile", "-Command", - "Expand-Archive -Force -LiteralPath #{powershell_quote(archive_path)} " \ - "-DestinationPath #{powershell_quote(dir)}") - else - system("tar", "xzf", archive_path, "-C", dir) - end - raise LauncherError, "failed to extract #{File.basename(archive_path)}" unless ok - end - end -end diff --git a/gem/socket-patch/socket-patch.gemspec b/gem/socket-patch/socket-patch.gemspec deleted file mode 100644 index 81545bf29..000000000 --- a/gem/socket-patch/socket-patch.gemspec +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -# RubyGems distribution of the `socket-patch` CLI. A thin launcher gem: on first -# run it downloads the prebuilt binary for the host platform from the matching -# GitHub release (`v`), verifies it against SHA256SUMS, caches it, and -# execs it. `gem install socket-patch` therefore puts `socket-patch` on PATH — -# useful in Bundler/Ruby environments (e.g. a CI `socket-patch apply` step after -# `bundle install`). Set `SOCKET_PATCH_BIN` to an existing binary to skip the -# download (airgapped CI). The version is synced with the workspace by -# `scripts/version-sync.sh`. -Gem::Specification.new do |s| - s.name = "socket-patch" - s.version = "4.0.0" - s.summary = "CLI tool for applying security patches to dependencies." - s.description = "Launcher gem for the socket-patch CLI: downloads the prebuilt binary for the " \ - "host platform from the matching GitHub release, verifies its SHA-256, caches " \ - "it, and execs it. Set SOCKET_PATCH_BIN to bypass the download." - s.authors = ["Socket Security"] - s.license = "MIT" - s.homepage = "https://github.com/SocketDev/socket-patch" - s.files = ["lib/socket_patch/launcher.rb", "exe/socket-patch", "README.md"] - s.bindir = "exe" - s.executables = ["socket-patch"] - s.require_paths = ["lib"] - s.required_ruby_version = ">= 2.6.0" - s.metadata = { - "source_code_uri" => "https://github.com/SocketDev/socket-patch", - "rubygems_mfa_required" => "true", - } -end diff --git a/pypi/socket-patch-hook/README.md b/pypi/socket-patch-hook/README.md deleted file mode 100644 index 885170361..000000000 --- a/pypi/socket-patch-hook/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# socket-patch-hook - -> **Deprecated — no longer published.** `socket-patch setup` (which added the -> `socket-patch[hook]` dependency) was removed in socket-patch v5, and this -> wheel is no longer built or published; the `socket-patch[hook]` extra is -> gone too. In agent mode, run `socket-patch apply` in CI after install -> instead. The source is kept for reference only. -> To remove the hook from a project, see -> [Upgrading from `setup`](https://github.com/SocketDev/socket-patch#upgrading-from-setup). - -A tiny, package-manager-agnostic **post-install hook** for -[`socket-patch`](https://pypi.org/project/socket-patch/). - -Python package managers (pip, uv, poetry, pdm, hatch) have no universal -post-install step, so a `pip install` / `--force-reinstall` can silently revert -files that `socket-patch` previously patched. This package closes that gap. - -## How it works - -Installing this wheel lays down a startup `.pth` file in `site-packages` -(RECORD-tracked, so `pip uninstall` removes it cleanly). At interpreter startup -the hook does a microsecond-cheap check of whether the set of installed -distributions changed since the last run; only then does it re-apply your -project's **committed** patches by invoking `socket-patch apply --offline`. All -real patching (hash verification, atomic writes, locking) is done by the -`socket-patch` binary — this package only *triggers* it. - -Because it rides on Python's interpreter-startup `.pth` mechanism (not on any -one installer's hooks), it works the same under every Python package manager. - -## Safety - -A `.pth` that runs code at startup deserves a careful safety model. This one: - -- **Fail-open** — every code path is wrapped so it can never raise into the - interpreter; the worst outcome of any bug is that patches aren't re-applied. -- **Venv-anchored** — it applies only the `.socket/manifest.json` of the project - that owns the virtualenv it's installed in, never whatever `.socket/` happens - to sit above the current working directory. -- **Hash-verified, in-tree only** — the underlying `socket-patch apply` verifies - each file's hash before patching and refuses manifest keys that would write - outside the installed package directory. -- **Trusted binary** — it runs the `socket-patch` binary from the installed - `socket-patch` package, not the first one found on `PATH`. -- **Offline + cheap** — no network at startup; the no-change path is a couple of - syscalls. It only spawns `socket-patch` when installed packages changed. -- **Opt-in + easy off** — present only when a project committed it; disable any - interpreter with `SOCKET_PATCH_HOOK=off`. - -## Activating it - -Don't add this by hand. Run, in your project: - -``` -socket-patch setup -``` - -That commits a `socket-patch[hook]` dependency to your repo — the `[hook]` -extra on the main `socket-patch` package, which pulls in both the CLI and this -wheel (you never reference `socket-patch-hook` directly). The committed -dependency is the source of truth — there's no separate marker file. The hook -then activates automatically in CI after install. Remove it with `socket-patch -setup --remove` followed by `pip uninstall socket-patch-hook`. (Classic Poetry -can't express an extra as a bare key, so there `setup` writes the equivalent -`socket-patch = { extras = ["hook"] }`.) - -## Disabling at runtime - -Set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`) to fully bypass the hook for -a given interpreter — checked before any hook code runs. diff --git a/pypi/socket-patch-hook/pyproject.toml b/pypi/socket-patch-hook/pyproject.toml deleted file mode 100644 index 469c2301c..000000000 --- a/pypi/socket-patch-hook/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["setuptools>=64"] -build-backend = "setuptools.build_meta" - -[project] -name = "socket-patch-hook" -version = "4.0.0" -description = "Auto-apply Socket security patches after install via a package-manager-agnostic .pth startup hook" -readme = "README.md" -license = "MIT" -requires-python = ">=3.8" -authors = [ - { name = "Socket Security" } -] -keywords = ["security", "patch", "hook", "dependencies", "pth"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Topic :: Security", - "Topic :: Software Development :: Build Tools", -] -# Intentionally NO dependency on socket-patch: the hook is version-agnostic. It -# runs the binary bundled in an installed `socket_patch` package, falls back to -# `socket-patch` on PATH, and no-ops if neither exists. Projects commit -# `socket-patch[hook]`, which pulls in both the CLI and this wheel. -# (The canonical build is scripts/build-pypi-wheels.py, which also lays down the -# startup .pth; this block keeps the directory a valid project for `pip install .`.) -dependencies = [] - -[project.urls] -Homepage = "https://github.com/SocketDev/socket-patch" -Repository = "https://github.com/SocketDev/socket-patch" - -[tool.setuptools] -packages = ["socket_patch_hook"] diff --git a/pypi/socket-patch-hook/socket_patch_hook.pth b/pypi/socket-patch-hook/socket_patch_hook.pth deleted file mode 100644 index 38c4307f8..000000000 --- a/pypi/socket-patch-hook/socket_patch_hook.pth +++ /dev/null @@ -1,13 +0,0 @@ -# socket-patch post-install hook — installed by the `socket-patch-hook` wheel. -# Re-applies this project's committed Socket security patches (.socket/) after a -# pip/uv/poetry/etc. install reverts a patched file. At interpreter startup it -# does a cheap "did the installed packages change?" check and, only then, runs -# `socket-patch apply --offline`. Fail-open: every error is swallowed so it can -# never break interpreter startup, and it does nothing unless this environment's -# project has a committed .socket/manifest.json. -# Disable (this interpreter): SOCKET_PATCH_HOOK=off (or SOCKET_NO_HOOK=1) -# Remove (this project): socket-patch setup --remove then pip uninstall socket-patch-hook -# Details: https://github.com/SocketDev/socket-patch -# (Lines starting with `#` are ignored by Python's site module; the single -# `import` line below is the only code it executes.) -import os; exec("try:\n import socket_patch_hook as _h; _h.run()\nexcept Exception: pass") if (os.environ.get('SOCKET_PATCH_HOOK','').strip().lower() not in ('off','0','false','no') and os.environ.get('SOCKET_NO_HOOK','').strip().lower() not in ('1','true','yes','on')) else None diff --git a/pypi/socket-patch-hook/socket_patch_hook/__init__.py b/pypi/socket-patch-hook/socket_patch_hook/__init__.py deleted file mode 100644 index 9e3dee760..000000000 --- a/pypi/socket-patch-hook/socket_patch_hook/__init__.py +++ /dev/null @@ -1,294 +0,0 @@ -"""socket-patch post-install hook (package-manager-agnostic). - -This module is imported at Python interpreter startup by a wheel-shipped -``socket_patch_hook.pth`` file (the same ``.pth`` ``import``-line mechanism -coverage.py uses). When the set of installed distributions has changed since the -last run -- e.g. ``pip install`` / ``--force-reinstall`` / ``uv sync`` reverted a -file that Socket had patched -- it re-applies the project's committed patches by -invoking the hardened ``socket-patch apply`` binary in offline mode. All actual -patching (hash verification, atomic writes, locking) stays in that binary; this -module only *triggers* it. - -Hard safety contract: - * ``run()`` must NEVER raise into ``site.py`` (a raise here would hit every - interpreter start in the environment). Every step is failure-swallowing. - * The common, no-change path must cost only a few syscalls (it does: a bounded - parent walk, one ``scandir`` of site-packages, and one small file read). - * The worst outcome of any bug here is that patches are simply not re-applied. - -Disable entirely with ``SOCKET_PATCH_HOOK=off`` (also checked in the ``.pth`` -line before this module is even imported) or ``SOCKET_NO_HOOK=1``. -""" - -import os -import sys - -__all__ = ["run"] - -# Set in the environment of the spawned ``apply`` process so a nested -# interpreter started underneath it does not re-trigger the hook. (The apply -# binary itself is native Rust, but it -- or a tool it shells out to -- may -# invoke ``python``, which would re-process the ``.pth``.) -_REENTRANCY_ENV = "_SOCKET_PATCH_HOOK_ACTIVE" - -# Upper bound on the parent-directory walk used to locate the project root. -_MAX_PARENTS = 40 - -# Generous safety net for a single hook-triggered apply. The apply is offline -# and local, so this only ever fires if something is badly wrong; it exists so a -# hung apply can never wedge interpreter startup forever. -_APPLY_TIMEOUT_SECONDS = 120 - - -def _truthy(value): - return str(value or "").strip().lower() in ("1", "true", "yes", "on") - - -def _disabled(): - """True if the user has switched the hook off via env var.""" - if _truthy(os.environ.get("SOCKET_NO_HOOK")): - return True - return os.environ.get("SOCKET_PATCH_HOOK", "").strip().lower() in ( - "off", - "0", - "false", - "no", - ) - - -def _site_packages_dir(): - # __file__ == /socket_patch_hook/__init__.py - return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def _find_project_root(): - """Locate the project whose committed ``.socket/manifest.json`` this - environment opted into. Returns ``None`` (hook no-ops) if none is found. - - SECURITY — which manifest do we trust? When running inside a virtualenv we - anchor the search to the **venv** (``sys.prefix``), NOT the current working - directory: the committed ``socket-patch[hook]`` dependency installed this - hook into THIS venv, so the owning project is an ancestor of the venv (e.g. - ``/.venv``). Anchoring to the venv ties the patches we apply to the - project that opted in, instead of whatever ``.socket/`` happens to sit above - the cwd — which could belong to an unrelated or hostile parent/sibling - project (a `python` started from elsewhere must not pull in a foreign - manifest). Only when there is no venv (a system / container interpreter, - where there is nothing to anchor to) do we fall back to the cwd. - """ - in_venv = getattr(sys, "prefix", "") != getattr(sys, "base_prefix", getattr(sys, "prefix", "")) - anchors = [] - if in_venv: - anchors.append(sys.prefix) - env_venv = os.environ.get("VIRTUAL_ENV") - if env_venv: - anchors.append(env_venv) - else: - try: - anchors.append(os.getcwd()) - except OSError: - pass - - seen = set() - for start in anchors: - try: - d = os.path.abspath(start) - except OSError: - continue - for _ in range(_MAX_PARENTS): - if d in seen: - break - seen.add(d) - if os.path.isfile(os.path.join(d, ".socket", "manifest.json")): - return d - parent = os.path.dirname(d) - if parent == d: # reached the filesystem root - break - d = parent - return None - - -def _fingerprint(site_dir): - """Cheap signature of the installed distributions in ``site_dir``. - - A SHA-1 of the sorted ``(name, mtime)`` of every ``*.dist-info`` / - ``*.egg-info`` entry. This changes on any install / reinstall / uninstall, - but is deliberately immune to: - * our own patch writes (which touch package *files*, not the metadata - dirs), so the fingerprint is stable across an apply -- no re-apply loop; - * the stamp file (kept in a user cache, outside site-packages); - * ``__pycache__`` / ``.pyc`` churn. - Returns ``"?"`` on error so we fail toward a (harmless, idempotent) re-apply. - """ - import hashlib - - try: - items = [] - with os.scandir(site_dir) as it: - for entry in it: - name = entry.name - if name.endswith(".dist-info") or name.endswith(".egg-info"): - try: - mtime = entry.stat().st_mtime_ns - except OSError: - mtime = 0 - items.append("%s:%d" % (name, mtime)) - items.sort() - return hashlib.sha1( - "\n".join(items).encode("utf-8", "replace") - ).hexdigest() - except OSError: - return "?" - - -def _cache_dir(): - if os.name == "nt": - base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - else: - base = os.environ.get("XDG_CACHE_HOME") or os.path.join( - os.path.expanduser("~"), ".cache" - ) - return os.path.join(base, "socket-patch", "hook-stamps") - - -def _stamp_path(site_dir): - """Per-site-packages stamp file, in a user cache so writing it never - perturbs the site-packages fingerprint and never dirties the repo.""" - import hashlib - - key = hashlib.sha1( - os.path.abspath(site_dir).encode("utf-8", "replace") - ).hexdigest() - return os.path.join(_cache_dir(), key) - - -def _read_stamp(path): - try: - with open(path, "r") as f: - return f.read().strip() - except OSError: - return None - - -def _write_stamp(path, value): - tmp = None - try: - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = "%s.%d.tmp" % (path, os.getpid()) - with open(tmp, "w") as f: - f.write(value) - os.replace(tmp, path) - except OSError: - if tmp: - try: - os.unlink(tmp) - except OSError: - pass - - -def _resolve_binary(): - """Locate the ``socket-patch`` binary to run. - - SECURITY — order matters. We prefer the binary **bundled in the installed - ``socket_patch`` package** (the one `socket-patch[hook]` pulls in: a - RECORD-tracked file resolved by the dependency solver) and only fall back to - ``PATH`` if that package isn't present. Resolving via ``PATH`` first would - let a malicious ``socket-patch`` placed earlier on ``PATH`` (or `.` on PATH) - be executed at every interpreter startup. Returns ``None`` if neither is - found, in which case the hook no-ops. - """ - try: - import socket_patch - - resolver = getattr(socket_patch, "_resolve_binary", None) - if resolver is not None: - path = resolver() - if path: - return path - except Exception: - pass - try: - import shutil - - return shutil.which("socket-patch") - except Exception: - return None - - -def _apply(binary, project_root): - """Run ``socket-patch apply`` synchronously, offline, best-effort. - - Synchronous so the patched bytes are in place before the interpreter - proceeds to user imports. Offline so it only ever re-heals from the - committed ``.socket/`` cache and never blocks startup on the network. - ``--lock-timeout 0`` so a parallel interpreter that loses the apply lock - (e.g. under ``pytest -n``) skips instantly instead of piling up. - - Returns ``True`` only if apply exited 0. A non-zero exit (e.g. losing the - apply lock to a sibling interpreter) returns ``False`` so the caller does - NOT stamp the state as handled and the heal is retried on the next start. - """ - import subprocess - - argv = [ - binary, - "apply", - "--offline", - "--silent", - "--ecosystems", - "pypi", - "--cwd", - project_root, - "--lock-timeout", - "0", - ] - env = dict(os.environ) - env[_REENTRANCY_ENV] = "1" - kwargs = { - "cwd": project_root, - "env": env, - "stdin": subprocess.DEVNULL, - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - "timeout": _APPLY_TIMEOUT_SECONDS, - } - # Don't flash a console window for a pythonw-hosted (no-console) app. - if os.name == "nt": - kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) - try: - return subprocess.run(argv, **kwargs).returncode == 0 - except Exception: - # Includes TimeoutExpired and OSError (binary vanished mid-run). - return False - - -def run(): - """Entry point invoked by the ``.pth`` line. Never raises.""" - try: - # Cheapest possible bail-outs first. - if os.environ.get(_REENTRANCY_ENV): - return - if _disabled(): - return - project_root = _find_project_root() - if project_root is None: - return - site_dir = _site_packages_dir() - fp = _fingerprint(site_dir) - stamp_path = _stamp_path(site_dir) - if _read_stamp(stamp_path) == fp: - return # nothing installed/reinstalled since the last apply - binary = _resolve_binary() - if not binary: - return - # Stamp only on a successful apply. The dist-info fingerprint is - # unchanged by an apply (which patches package files, not metadata - # dirs), so storing the pre-apply value is correct -- and gating on - # success means a lock-contended / failed apply is retried next start - # rather than being silently marked as handled. - if _apply(binary, project_root): - _write_stamp(stamp_path, fp) - except Exception: - # Final backstop. The .pth wrapper also guards, but a raise here would - # hit every interpreter start, so never rely on a single layer. - return diff --git a/pypi/socket-patch-hook/test_hook.py b/pypi/socket-patch-hook/test_hook.py deleted file mode 100644 index e843f981b..000000000 --- a/pypi/socket-patch-hook/test_hook.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Tests for the socket-patch startup hook. - -Run with: ``python -m unittest test_hook`` (no third-party deps required). - -The overriding contract under test is *safety*: the hook must never raise, must -no-op cheaply when there is nothing to do, must invoke ``socket-patch apply`` -with the right offline arguments only when the installed distributions have -changed, and must only ever apply the manifest of the project that owns this -environment (never a foreign one above the cwd). -""" - -import os -import sys -import unittest -from unittest import mock - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -import socket_patch_hook as hook # noqa: E402 - - -class HookTestBase(unittest.TestCase): - def setUp(self): - self._cwd = os.getcwd() - # Isolate env: clear switches + reentrancy + venv + cache redirect. - self._saved_env = dict(os.environ) - for k in ("SOCKET_PATCH_HOOK", "SOCKET_NO_HOOK", "VIRTUAL_ENV", hook._REENTRANCY_ENV): - os.environ.pop(k, None) - self._tmp = self._mkdtemp() - os.environ["XDG_CACHE_HOME"] = os.path.join(self._tmp, "cache") - os.environ["LOCALAPPDATA"] = os.path.join(self._tmp, "cache") - - def tearDown(self): - os.chdir(self._cwd) - os.environ.clear() - os.environ.update(self._saved_env) - - def _mkdtemp(self): - import tempfile - - d = tempfile.mkdtemp() - self.addCleanup(self._rmtree, d) - return d - - @staticmethod - def _rmtree(path): - import shutil - - shutil.rmtree(path, ignore_errors=True) - - def _make_project(self): - """A temp dir that looks like a socket-patch project (has a manifest).""" - root = self._mkdtemp() - os.makedirs(os.path.join(root, ".socket")) - with open(os.path.join(root, ".socket", "manifest.json"), "w") as f: - f.write('{"patches": {}}') - return root - - -class TestRunSpawning(HookTestBase): - # These exercise the spawn/guard/stamp logic; project discovery is mocked - # (it has its own tests in TestProjectRootDiscovery). - def test_applies_when_manifest_present_and_state_changed(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run", return_value=mock.Mock(returncode=0)) as run: - hook.run() - self.assertEqual(run.call_count, 1) - argv = run.call_args[0][0] - self.assertEqual(argv[0], "/fake/socket-patch") - self.assertIn("apply", argv) - self.assertIn("--offline", argv) - self.assertIn("--silent", argv) - self.assertEqual(argv[argv.index("--ecosystems") + 1], "pypi") - self.assertEqual( - os.path.realpath(argv[argv.index("--cwd") + 1]), - os.path.realpath(root), - ) - self.assertEqual(argv[argv.index("--lock-timeout") + 1], "0") - env = run.call_args[1]["env"] - self.assertEqual(env[hook._REENTRANCY_ENV], "1") - - def test_second_run_is_a_noop_when_state_unchanged(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run", return_value=mock.Mock(returncode=0)) as run: - hook.run() # first run applies + writes the stamp (success) - hook.run() # second run: fingerprint matches stamp -> skip - self.assertEqual(run.call_count, 1) - - def test_failed_apply_does_not_stamp_so_it_retries(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run", return_value=mock.Mock(returncode=1)) as run: - hook.run() - hook.run() - self.assertEqual(run.call_count, 2, "a failed apply must be retried next start") - - def test_noop_without_manifest(self): - with mock.patch.object(hook, "_find_project_root", return_value=None), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run") as run: - hook.run() - run.assert_not_called() - - def test_noop_when_binary_missing(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value=None), \ - mock.patch("subprocess.run") as run: - hook.run() - run.assert_not_called() - - -class TestDisableSwitches(HookTestBase): - def _run_disabled(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run") as run: - hook.run() - return run - - def test_socket_patch_hook_off(self): - os.environ["SOCKET_PATCH_HOOK"] = "off" - self._run_disabled().assert_not_called() - - def test_socket_no_hook(self): - os.environ["SOCKET_NO_HOOK"] = "1" - self._run_disabled().assert_not_called() - - def test_reentrancy_guard(self): - os.environ[hook._REENTRANCY_ENV] = "1" - self._run_disabled().assert_not_called() - - -class TestNeverRaises(HookTestBase): - def test_run_swallows_resolver_errors(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", side_effect=RuntimeError("boom")): - hook.run() # must not propagate - - def test_run_swallows_subprocess_errors(self): - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch("subprocess.run", side_effect=OSError("no such binary")): - hook.run() # must not raise - - def test_apply_timeout_is_swallowed(self): - import subprocess - - root = self._make_project() - with mock.patch.object(hook, "_find_project_root", return_value=root), \ - mock.patch.object(hook, "_resolve_binary", return_value="/fake/socket-patch"), \ - mock.patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="x", timeout=1), - ): - hook.run() # must not raise - - def test_run_swallows_discovery_errors(self): - with mock.patch.object(hook, "_find_project_root", side_effect=RuntimeError("boom")), \ - mock.patch("subprocess.run") as run: - hook.run() # must not raise - run.assert_not_called() - - -class TestProjectRootDiscovery(HookTestBase): - """The hook must apply only the manifest of the project that OWNS this - environment — anchored to the venv, not whatever .socket/ sits above cwd.""" - - def _socket(self, d): - os.makedirs(os.path.join(d, ".socket")) - with open(os.path.join(d, ".socket", "manifest.json"), "w") as f: - f.write('{"patches": {}}') - - def test_anchors_to_venv_not_cwd(self): - # venv at /.venv; manifest at ; cwd is elsewhere. - proj = os.path.join(self._tmp, "proj") - self._socket(proj) - venv = os.path.join(proj, ".venv") - elsewhere = os.path.join(self._tmp, "elsewhere") - os.makedirs(elsewhere) - os.chdir(elsewhere) - with mock.patch.object(sys, "prefix", venv), \ - mock.patch.object(sys, "base_prefix", self._tmp): # in_venv = True - got = hook._find_project_root() - self.assertEqual(os.path.realpath(got), os.path.realpath(proj)) - - def test_in_venv_ignores_unrelated_cwd_manifest(self): - # SECURITY: a hostile .socket/ above the cwd must NOT be picked up when - # running inside a venv whose project committed no manifest. - proj = os.path.join(self._tmp, "proj") # venv's project: NO .socket - os.makedirs(proj) - venv = os.path.join(proj, ".venv") - attacker = os.path.join(self._tmp, "attacker") - self._socket(attacker) - os.chdir(attacker) - with mock.patch.object(sys, "prefix", venv), \ - mock.patch.object(sys, "base_prefix", self._tmp): # in_venv = True - got = hook._find_project_root() - self.assertIsNone(got, "must not apply a foreign manifest found above cwd") - - def test_system_python_falls_back_to_cwd(self): - # No venv (sys.prefix == base_prefix): the container/system case, where - # the project is wherever the process runs from. - proj = os.path.join(self._tmp, "proj") - self._socket(proj) - os.chdir(proj) - with mock.patch.object(sys, "prefix", "/usr"), \ - mock.patch.object(sys, "base_prefix", "/usr"): # in_venv = False - got = hook._find_project_root() - self.assertEqual(os.path.realpath(got), os.path.realpath(proj)) - - -class TestPthLine(unittest.TestCase): - """The .pth must be valid: comment lines are ignored by site.py, the import - line execs, and the kill switch short-circuits before importing.""" - - def _pth_import_line(self): - # site.py execs only lines starting with `import`; `#` lines are - # comments. Mirror that: run the import line(s) the way site would. - here = os.path.dirname(os.path.abspath(__file__)) - with open(os.path.join(here, "socket_patch_hook.pth")) as f: - lines = [ - ln.rstrip("\n") - for ln in f - if ln.strip() and not ln.lstrip().startswith("#") - ] - # Exactly one executable (import) line. - assert len(lines) == 1, f"expected one import line, got {lines!r}" - assert lines[0].startswith("import "), lines[0] - return lines[0] - - def test_pth_line_executes_and_calls_run(self): - line = self._pth_import_line() - with mock.patch.object(hook, "run") as run: - os.environ.pop("SOCKET_PATCH_HOOK", None) - os.environ.pop("SOCKET_NO_HOOK", None) - exec(compile(line, "socket_patch_hook.pth", "exec"), {}) - run.assert_called_once() - - def test_pth_line_respects_off_switch(self): - line = self._pth_import_line() - with mock.patch.object(hook, "run") as run: - os.environ["SOCKET_PATCH_HOOK"] = "off" - try: - exec(compile(line, "socket_patch_hook.pth", "exec"), {}) - finally: - os.environ.pop("SOCKET_PATCH_HOOK", None) - run.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/pypi/socket-patch/pyproject.toml b/pypi/socket-patch/pyproject.toml deleted file mode 100644 index adc2bc40a..000000000 --- a/pypi/socket-patch/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["setuptools>=64"] -build-backend = "setuptools.build_meta" - -[project] -name = "socket-patch" -version = "4.0.0" -description = "CLI tool for applying security patches to dependencies" -readme = "README.md" -license = "MIT" -requires-python = ">=3.8" -authors = [ - { name = "Socket Security" } -] -keywords = ["security", "patch", "cli", "dependencies"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Topic :: Security", - "Topic :: Software Development :: Build Tools", -] - -[project.urls] -Homepage = "https://github.com/SocketDev/socket-patch" -Repository = "https://github.com/SocketDev/socket-patch" - -[project.scripts] -socket-patch = "socket_patch:main" - -[tool.setuptools.package-data] -socket_patch = ["bin/*"] diff --git a/pypi/socket-patch/socket_patch/__init__.py b/pypi/socket-patch/socket_patch/__init__.py deleted file mode 100644 index f754d5530..000000000 --- a/pypi/socket-patch/socket_patch/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import sys -import subprocess - - -def _resolve_binary(): - """Locate the bundled socket-patch binary, or return ``None``. - - Single source of truth for binary discovery, reused by both ``main()`` (the - console-script entry point) and the legacy ``socket_patch_hook`` startup hook - (no longer published since v5, but older installs still import this). Never - raises: returns ``None`` if the binary can't be found, so callers that run at - interpreter startup stay safe. - """ - bin_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin") - try: - entries = os.listdir(bin_dir) - except OSError: - return None - bins = [e for e in entries if e.startswith("socket-patch")] - if len(bins) != 1: - return None - bin_path = os.path.join(bin_dir, bins[0]) - try: - if not os.access(bin_path, os.X_OK): - os.chmod(bin_path, os.stat(bin_path).st_mode | 0o111) - except OSError: - return None - return bin_path - - -def main(): - bin_path = _resolve_binary() - if bin_path is None: - bin_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin") - try: - count = len([e for e in os.listdir(bin_dir) if e.startswith("socket-patch")]) - except OSError: - count = 0 - print( - f"Expected exactly one socket-patch binary in {bin_dir}, found {count}", - file=sys.stderr, - ) - sys.exit(1) - raise SystemExit(subprocess.call([bin_path] + sys.argv[1:])) diff --git a/pypi/socket-patch/socket_patch/bin/.gitkeep b/pypi/socket-patch/socket_patch/bin/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/pypi/socket-patch/test_dispatch.py b/pypi/socket-patch/test_dispatch.py deleted file mode 100644 index 07380f4c6..000000000 --- a/pypi/socket-patch/test_dispatch.py +++ /dev/null @@ -1,128 +0,0 @@ -import ast -import os -import stat -import sys -import tempfile -import textwrap -import unittest -from pathlib import Path -from unittest import mock - -# Import the module source for inspection -INIT_PATH = Path(__file__).parent / "socket_patch" / "__init__.py" -INIT_SRC = INIT_PATH.read_text() - - -class TestInitModule(unittest.TestCase): - """Test that __init__.py correctly finds and runs the single binary.""" - - def test_source_parses(self): - """Verify __init__.py is valid Python.""" - ast.parse(INIT_SRC) - - def test_main_defined(self): - """Verify main() function exists.""" - tree = ast.parse(INIT_SRC) - func_names = [ - node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) - ] - self.assertIn("main", func_names) - - def test_dispatches_single_binary(self): - """main() should find the single binary in bin/ and call it.""" - with tempfile.TemporaryDirectory() as tmpdir: - bin_dir = os.path.join(tmpdir, "bin") - os.makedirs(bin_dir) - fake_bin = os.path.join(bin_dir, "socket-patch-test") - Path(fake_bin).write_text("#!/bin/sh\nexit 0\n") - os.chmod(fake_bin, os.stat(fake_bin).st_mode | stat.S_IEXEC) - - with mock.patch("socket_patch.os.path.dirname", return_value=tmpdir): - with mock.patch("socket_patch.subprocess.call", return_value=42) as mock_call: - with self.assertRaises(SystemExit) as cm: - import socket_patch - - socket_patch.main() - self.assertEqual(cm.exception.code, 42) - mock_call.assert_called_once() - called_args = mock_call.call_args[0][0] - self.assertEqual(called_args[0], fake_bin) - - def test_errors_on_no_binary(self): - """main() should exit with error if no binary found.""" - with tempfile.TemporaryDirectory() as tmpdir: - bin_dir = os.path.join(tmpdir, "bin") - os.makedirs(bin_dir) - - with mock.patch("socket_patch.os.path.dirname", return_value=tmpdir): - with self.assertRaises(SystemExit) as cm: - import socket_patch - - socket_patch.main() - self.assertEqual(cm.exception.code, 1) - - def test_errors_on_multiple_binaries(self): - """main() should exit with error if multiple binaries found.""" - with tempfile.TemporaryDirectory() as tmpdir: - bin_dir = os.path.join(tmpdir, "bin") - os.makedirs(bin_dir) - Path(os.path.join(bin_dir, "socket-patch-a")).touch() - Path(os.path.join(bin_dir, "socket-patch-b")).touch() - - with mock.patch("socket_patch.os.path.dirname", return_value=tmpdir): - with self.assertRaises(SystemExit) as cm: - import socket_patch - - socket_patch.main() - self.assertEqual(cm.exception.code, 1) - - def test_errors_on_missing_bin_dir(self): - """main() should exit with error if bin/ dir doesn't exist.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Don't create bin_dir - with mock.patch("socket_patch.os.path.dirname", return_value=tmpdir): - with self.assertRaises(SystemExit) as cm: - import socket_patch - - socket_patch.main() - self.assertEqual(cm.exception.code, 1) - - -class TestWheelBuilder(unittest.TestCase): - """Test the wheel builder script configuration.""" - - def test_wheel_builder_exists(self): - """Verify the wheel builder script exists.""" - script_path = Path(__file__).parent.parent.parent / "scripts" / "build-pypi-wheels.py" - self.assertTrue(script_path.exists(), f"Wheel builder script not found at {script_path}") - - def test_wheel_builder_parses(self): - """Verify the wheel builder script is valid Python.""" - script_path = Path(__file__).parent.parent.parent / "scripts" / "build-pypi-wheels.py" - ast.parse(script_path.read_text()) - - def test_wheel_builder_targets(self): - """Verify the wheel builder covers all expected targets.""" - script_path = Path(__file__).parent.parent.parent / "scripts" / "build-pypi-wheels.py" - src = script_path.read_text() - - expected_targets = [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-gnu", - "arm-unknown-linux-gnueabihf", - "i686-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "i686-pc-windows-msvc", - "aarch64-pc-windows-msvc", - ] - for target in expected_targets: - self.assertIn(target, src, f"Target {target} missing from wheel builder") - - # Android should NOT be in the targets - self.assertNotIn('"aarch64-linux-android"', src) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/build-pypi-wheels.py b/scripts/build-pypi-wheels.py deleted file mode 100755 index 47718e870..000000000 --- a/scripts/build-pypi-wheels.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env python3 -"""Build platform-tagged PyPI wheels for socket-patch. - -Each wheel contains only the binary for a single platform, so users download -only the ~4 MB they need instead of ~40 MB for all platforms. -""" - -import argparse -import csv -import hashlib -import io -import os -import re -import stat -import subprocess -import sys -import tempfile -import zipfile -from base64 import urlsafe_b64encode -from pathlib import Path - -# Mapping from Rust target triple to: -# (wheel platform tag(s), archive extension, binary name inside archive) -# Android is omitted — no standard PyPI platform tag exists for it. -TARGETS = { - "aarch64-apple-darwin": { - "platform_tag": "macosx_11_0_arm64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "x86_64-apple-darwin": { - "platform_tag": "macosx_10_12_x86_64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "x86_64-unknown-linux-gnu": { - "platform_tag": "manylinux_2_17_x86_64.manylinux2014_x86_64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "x86_64-unknown-linux-musl": { - "platform_tag": "musllinux_1_1_x86_64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "aarch64-unknown-linux-gnu": { - "platform_tag": "manylinux_2_17_aarch64.manylinux2014_aarch64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "aarch64-unknown-linux-musl": { - "platform_tag": "musllinux_1_1_aarch64", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "arm-unknown-linux-gnueabihf": { - "platform_tag": "manylinux_2_17_armv7l.manylinux2014_armv7l", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "arm-unknown-linux-musleabihf": { - "platform_tag": "musllinux_1_1_armv7l", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "i686-unknown-linux-gnu": { - "platform_tag": "manylinux_2_17_i686.manylinux2014_i686", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "i686-unknown-linux-musl": { - "platform_tag": "musllinux_1_1_i686", - "archive_ext": "tar.gz", - "binary_name": "socket-patch", - }, - "x86_64-pc-windows-msvc": { - "platform_tag": "win_amd64", - "archive_ext": "zip", - "binary_name": "socket-patch.exe", - }, - "i686-pc-windows-msvc": { - "platform_tag": "win32", - "archive_ext": "zip", - "binary_name": "socket-patch.exe", - }, - "aarch64-pc-windows-msvc": { - "platform_tag": "win_arm64", - "archive_ext": "zip", - "binary_name": "socket-patch.exe", - }, -} - -DIST_NAME = "socket_patch" -PKG_NAME = "socket-patch" - - -def sha256_digest(data: bytes) -> str: - """Return the URL-safe base64 SHA-256 digest for RECORD.""" - h = hashlib.sha256(data) - return "sha256=" + urlsafe_b64encode(h.digest()).decode("ascii").rstrip("=") - - -def extract_binary(artifacts_dir: Path, target: str, info: dict) -> bytes: - """Extract the binary from the artifact archive and return its contents.""" - ext = info["archive_ext"] - archive_path = artifacts_dir / f"socket-patch-{target}.{ext}" - if not archive_path.exists(): - raise FileNotFoundError(f"Artifact not found: {archive_path}") - - binary_name = info["binary_name"] - if ext == "tar.gz": - import tarfile - - with tarfile.open(archive_path, "r:gz") as tf: - member = tf.getmember(binary_name) - f = tf.extractfile(member) - if f is None: - raise ValueError(f"Could not extract {binary_name} from {archive_path}") - return f.read() - elif ext == "zip": - with zipfile.ZipFile(archive_path, "r") as zf: - return zf.read(binary_name) - else: - raise ValueError(f"Unknown archive extension: {ext}") - - -def read_pyproject_metadata(pyproject_dir: Path) -> dict: - """Read metadata fields from pyproject.toml (simple parser, no toml dep).""" - pyproject_path = pyproject_dir / "pyproject.toml" - text = pyproject_path.read_text() - - def extract_field(name: str) -> str: - m = re.search(rf'^{name}\s*=\s*"(.*?)"', text, re.MULTILINE) - if not m: - raise ValueError(f"Could not find {name} in {pyproject_path}") - return m.group(1) - - readme_path = pyproject_dir / "README.md" - readme = readme_path.read_text() if readme_path.exists() else "" - - return { - "name": extract_field("name"), - "version": extract_field("version"), - "description": extract_field("description"), - "license": extract_field("license"), - "requires_python": extract_field("requires-python"), - "readme": readme, - } - - -def read_init_py(pyproject_dir: Path) -> bytes: - """Read the __init__.py file for inclusion in wheels.""" - init_path = pyproject_dir / "socket_patch" / "__init__.py" - return init_path.read_bytes() - - -def build_wheel( - target: str, - info: dict, - version: str, - metadata: dict, - init_py: bytes, - binary_data: bytes, - dist_dir: Path, -) -> Path: - """Build a single platform-tagged wheel and return the path.""" - platform_tag = info["platform_tag"] - binary_name = info["binary_name"] - - # Wheel filename: {name}-{version}-{python tag}-{abi tag}-{platform tag}.whl - wheel_name = f"{DIST_NAME}-{version}-py3-none-{platform_tag}.whl" - wheel_path = dist_dir / wheel_name - - dist_info = f"{DIST_NAME}-{version}.dist-info" - - # Build file entries: (archive_name, data, is_executable) - files = [] - - # __init__.py - files.append((f"{DIST_NAME}/__init__.py", init_py, False)) - - # Binary - files.append((f"{DIST_NAME}/bin/{binary_name}", binary_data, True)) - - # METADATA - metadata_header = ( - f"Metadata-Version: 2.1\n" - f"Name: {metadata['name']}\n" - f"Version: {version}\n" - f"Summary: {metadata['description']}\n" - f"License: {metadata['license']}\n" - f"Requires-Python: {metadata['requires_python']}\n" - ) - if metadata.get("readme"): - metadata_header += "Description-Content-Type: text/markdown\n" - metadata_header += f"\n{metadata['readme']}" - metadata_content = metadata_header.encode() - files.append((f"{dist_info}/METADATA", metadata_content, False)) - - # WHEEL - wheel_content = ( - f"Wheel-Version: 1.0\n" - f"Generator: build-pypi-wheels.py\n" - f"Root-Is-Purelib: false\n" - f"Tag: py3-none-{platform_tag}\n" - ).encode() - files.append((f"{dist_info}/WHEEL", wheel_content, False)) - - # entry_points.txt - entry_points_content = ( - "[console_scripts]\n" "socket-patch = socket_patch:main\n" - ).encode() - files.append((f"{dist_info}/entry_points.txt", entry_points_content, False)) - - # Build RECORD (must be last, references all other files) - record_lines = [] - for name, data, _ in files: - record_lines.append(f"{name},{sha256_digest(data)},{len(data)}") - # RECORD itself has no hash - record_name = f"{dist_info}/RECORD" - record_lines.append(f"{record_name},,") - record_content = "\n".join(record_lines).encode() - files.append((record_name, record_content, False)) - - # Write the zip - with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf: - for name, data, is_exec in files: - info_obj = zipfile.ZipInfo(name) - # Set external_attr for executable files (unix permissions) - if is_exec: - info_obj.external_attr = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) << 16 - else: - info_obj.external_attr = (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) << 16 - info_obj.compress_type = zipfile.ZIP_DEFLATED - zf.writestr(info_obj, data) - - return wheel_path - - -def main(): - parser = argparse.ArgumentParser( - description="Build platform-tagged PyPI wheels for socket-patch" - ) - parser.add_argument( - "--version", - required=True, - help="Package version (e.g., 1.5.0)", - ) - parser.add_argument( - "--artifacts", - required=True, - help="Directory containing build artifacts", - ) - parser.add_argument( - "--dist", - default="dist", - help="Output directory for wheels (default: dist)", - ) - parser.add_argument( - "--pyproject-dir", - default=None, - help="Directory containing pyproject.toml (default: pypi/socket-patch relative to script)", - ) - args = parser.parse_args() - - dist_dir = Path(args.dist) - dist_dir.mkdir(parents=True, exist_ok=True) - - repo_root = Path(__file__).resolve().parent.parent - - built = [] - skipped = [] - - artifacts_dir = Path(args.artifacts) - - if args.pyproject_dir: - pyproject_dir = Path(args.pyproject_dir) - else: - pyproject_dir = repo_root / "pypi" / "socket-patch" - - metadata = read_pyproject_metadata(pyproject_dir) - init_py = read_init_py(pyproject_dir) - - for target, info in TARGETS.items(): - archive_ext = info["archive_ext"] - archive_path = artifacts_dir / f"socket-patch-{target}.{archive_ext}" - if not archive_path.exists(): - skipped.append(target) - continue - - print(f"Building wheel for {target} ({info['platform_tag']})...") - binary_data = extract_binary(artifacts_dir, target, info) - wheel_path = build_wheel( - target=target, - info=info, - version=args.version, - metadata=metadata, - init_py=init_py, - binary_data=binary_data, - dist_dir=dist_dir, - ) - size_mb = wheel_path.stat().st_size / (1024 * 1024) - print(f" -> {wheel_path.name} ({size_mb:.1f} MB)") - built.append(wheel_path) - - print(f"\nBuilt {len(built)} wheel(s) in {dist_dir}/") - if skipped: - print(f"Skipped {len(skipped)} target(s) (artifact not found): {', '.join(skipped)}") - - if not built: - print("ERROR: No wheels were built!", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 061e175b3..d80cd24c2 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -7,7 +7,7 @@ # scripts/release-lint.sh, run by CI on the bump PR and again by the `version` # job in release.yml), so this script is the intended way to start a release: # -# scripts/bump-version.sh 3.4.0 --pr +# scripts/bump-version.sh 5.0.0 --pr # # or dispatch the "Version Bump" workflow (.github/workflows/version-bump.yml), # which runs this script on a fresh checkout of main. Running it locally is @@ -147,7 +147,7 @@ ${NOTES} 1. Dispatch the **Release** workflow on the default branch (optionally with \`dry-run: true\` first). It builds all targets, tags \`v${VERSION}\`, creates - the GitHub release, and publishes every ecosystem package. + the GitHub release, and publishes the crates.io and npm packages. 2. Approve the staged npm versions with 2FA — platform packages first, then \`@socketsecurity/socket-patch\` (link in the run's step summary). 3. On a partial failure: fix the cause and use "Re-run failed jobs" on the diff --git a/scripts/dispatch-publish.sh b/scripts/dispatch-publish.sh index 235a47fa5..349e7ca5d 100755 --- a/scripts/dispatch-publish.sh +++ b/scripts/dispatch-publish.sh @@ -13,7 +13,7 @@ # X.Y.Z; the tag v must exist (the dispatch runs # the workflow file as of that tag) # [key=value]... extra workflow inputs, passed through as `-f key=value` -# (e.g. sums-digest= for the npm/PyPI legs) +# (e.g. sums-digest= for the npm leg) # # Requires: gh authenticated via GH_TOKEN with actions:write on # $GITHUB_REPOSITORY; GITHUB_RUN_ID/GITHUB_RUN_ATTEMPT for run correlation. diff --git a/scripts/release-lint.sh b/scripts/release-lint.sh index 6f52493e1..64d542846 100755 --- a/scripts/release-lint.sh +++ b/scripts/release-lint.sh @@ -7,7 +7,7 @@ # Checks (all failures are collected and reported together): # 1. The version is a plain X.Y.Z release version and matches Cargo.toml. # 2. Version coherence: `scripts/version-sync.sh ` is a no-op — -# every stamped site (npm/pypi/gem/cargo) already +# every stamped site (npm/cargo) already # carries the workspace version. Catches hand-edited drift in any single # site. NOTE: this runs version-sync, which refreshes the npm lockfile # (network); files the sync touches are restored afterwards, so the tree diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 7b06e4ef7..1aa225148 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -3,8 +3,6 @@ # - Cargo.toml (workspace version + socket-patch-core exact pin) # - npm/socket-patch/package.json (+ optionalDependencies, package-lock.json) # - npm/socket-patch-*/package.json (per-platform packages) -# - pypi/socket-patch/pyproject.toml -# - gem/socket-patch/socket-patch.gemspec + lib/socket_patch/launcher.rb set -euo pipefail VERSION="${1:?Usage: version-sync.sh }" @@ -63,25 +61,4 @@ for platform_dir in "$REPO_ROOT"/npm/socket-patch-*/; do fi done -# Update PyPI package version -pyproject="$REPO_ROOT/pypi/socket-patch/pyproject.toml" -sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$pyproject" -rm -f "$pyproject.bak" - -# pypi/socket-patch-hook and gem/socket-patch-bundler are frozen: `setup` -# was removed in v5 and neither is built or published any more. - -# Update the RubyGems CLI launcher gem (gemspec version + the VERSION constant -# the launcher uses to pick the matching GitHub release binary). -ruby_cli_gemspec="$REPO_ROOT/gem/socket-patch/socket-patch.gemspec" -if [ -f "$ruby_cli_gemspec" ]; then - sed -i.bak "s/s\.version *= *\".*\"/s.version = \"$VERSION\"/" "$ruby_cli_gemspec" - rm -f "$ruby_cli_gemspec.bak" -fi -ruby_cli_launcher="$REPO_ROOT/gem/socket-patch/lib/socket_patch/launcher.rb" -if [ -f "$ruby_cli_launcher" ]; then - sed -i.bak "s/VERSION = \".*\"/VERSION = \"$VERSION\"/" "$ruby_cli_launcher" - rm -f "$ruby_cli_launcher.bak" -fi - echo "Synced version to $VERSION"