From 67d864161c1802d400fa55371aa361f7a2f9bdd2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 3 Mar 2026 18:07:24 -0500 Subject: [PATCH 01/10] feat: add cross-compilation, npm distribution, and release workflows - Add release.yml workflow triggered by v* tags: builds 5 targets (darwin-arm64, darwin-x64, linux-x64-musl, linux-arm64, win32-x64), creates GitHub Release, publishes npm platform packages - Add ci.yml workflow for PRs/pushes: cargo clippy + cargo test - Add npm/ directory with esbuild-style optionalDependencies pattern (root wrapper + 5 platform packages with os/cpu fields) - Add scripts/version-sync.sh to propagate versions across Cargo.toml and all npm package.json files - Update publish.yml to bump version, sync, and push tag to trigger release pipeline - Add rust-toolchain.toml pinning stable channel - Update .gitignore for Rust target/ and npm platform binaries Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 37 + .github/workflows/publish.yml | 51 +- .github/workflows/release.yml | 168 ++ .gitignore | 8 + Cargo.lock | 2115 +++++++++++++++++ Cargo.toml | 32 + crates/socket-patch-cli/Cargo.toml | 23 + crates/socket-patch-cli/src/commands/apply.rs | 378 +++ crates/socket-patch-cli/src/commands/get.rs | 674 ++++++ crates/socket-patch-cli/src/commands/list.rs | 141 ++ crates/socket-patch-cli/src/commands/mod.rs | 8 + .../socket-patch-cli/src/commands/remove.rs | 195 ++ .../socket-patch-cli/src/commands/repair.rs | 133 ++ .../socket-patch-cli/src/commands/rollback.rs | 492 ++++ crates/socket-patch-cli/src/commands/scan.rs | 360 +++ crates/socket-patch-cli/src/commands/setup.rs | 152 ++ crates/socket-patch-cli/src/main.rs | 62 + crates/socket-patch-core/Cargo.toml | 24 + .../socket-patch-core/src/api/blob_fetcher.rs | 453 ++++ crates/socket-patch-core/src/api/client.rs | 724 ++++++ crates/socket-patch-core/src/api/mod.rs | 6 + crates/socket-patch-core/src/api/types.rs | 80 + crates/socket-patch-core/src/constants.rs | 17 + crates/socket-patch-core/src/crawlers/mod.rs | 7 + .../src/crawlers/npm_crawler.rs | 832 +++++++ .../src/crawlers/python_crawler.rs | 717 ++++++ .../socket-patch-core/src/crawlers/types.rs | 40 + .../socket-patch-core/src/hash/git_sha256.rs | 89 + crates/socket-patch-core/src/hash/mod.rs | 3 + crates/socket-patch-core/src/lib.rs | 8 + crates/socket-patch-core/src/manifest/mod.rs | 5 + .../src/manifest/operations.rs | 453 ++++ .../src/manifest/recovery.rs | 550 +++++ .../socket-patch-core/src/manifest/schema.rs | 152 ++ .../src/package_json/detect.rs | 172 ++ .../src/package_json/find.rs | 322 +++ .../socket-patch-core/src/package_json/mod.rs | 3 + .../src/package_json/update.rs | 107 + crates/socket-patch-core/src/patch/apply.rs | 522 ++++ .../socket-patch-core/src/patch/file_hash.rs | 75 + crates/socket-patch-core/src/patch/mod.rs | 3 + .../socket-patch-core/src/patch/rollback.rs | 606 +++++ .../src/utils/cleanup_blobs.rs | 419 ++++ .../socket-patch-core/src/utils/enumerate.rs | 109 + .../src/utils/fuzzy_match.rs | 266 +++ .../src/utils/global_packages.rs | 186 ++ crates/socket-patch-core/src/utils/mod.rs | 6 + crates/socket-patch-core/src/utils/purl.rs | 211 ++ .../socket-patch-core/src/utils/telemetry.rs | 632 +++++ npm/socket-patch-darwin-arm64/package.json | 22 + npm/socket-patch-darwin-x64/package.json | 22 + npm/socket-patch-linux-arm64/package.json | 22 + npm/socket-patch-linux-x64/package.json | 22 + npm/socket-patch-win32-x64/package.json | 22 + npm/socket-patch/bin/socket-patch | 33 + npm/socket-patch/package.json | 33 + rust-toolchain.toml | 2 + scripts/version-sync.sh | 46 + 58 files changed, 13014 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/socket-patch-cli/Cargo.toml create mode 100644 crates/socket-patch-cli/src/commands/apply.rs create mode 100644 crates/socket-patch-cli/src/commands/get.rs create mode 100644 crates/socket-patch-cli/src/commands/list.rs create mode 100644 crates/socket-patch-cli/src/commands/mod.rs create mode 100644 crates/socket-patch-cli/src/commands/remove.rs create mode 100644 crates/socket-patch-cli/src/commands/repair.rs create mode 100644 crates/socket-patch-cli/src/commands/rollback.rs create mode 100644 crates/socket-patch-cli/src/commands/scan.rs create mode 100644 crates/socket-patch-cli/src/commands/setup.rs create mode 100644 crates/socket-patch-cli/src/main.rs create mode 100644 crates/socket-patch-core/Cargo.toml create mode 100644 crates/socket-patch-core/src/api/blob_fetcher.rs create mode 100644 crates/socket-patch-core/src/api/client.rs create mode 100644 crates/socket-patch-core/src/api/mod.rs create mode 100644 crates/socket-patch-core/src/api/types.rs create mode 100644 crates/socket-patch-core/src/constants.rs create mode 100644 crates/socket-patch-core/src/crawlers/mod.rs create mode 100644 crates/socket-patch-core/src/crawlers/npm_crawler.rs create mode 100644 crates/socket-patch-core/src/crawlers/python_crawler.rs create mode 100644 crates/socket-patch-core/src/crawlers/types.rs create mode 100644 crates/socket-patch-core/src/hash/git_sha256.rs create mode 100644 crates/socket-patch-core/src/hash/mod.rs create mode 100644 crates/socket-patch-core/src/lib.rs create mode 100644 crates/socket-patch-core/src/manifest/mod.rs create mode 100644 crates/socket-patch-core/src/manifest/operations.rs create mode 100644 crates/socket-patch-core/src/manifest/recovery.rs create mode 100644 crates/socket-patch-core/src/manifest/schema.rs create mode 100644 crates/socket-patch-core/src/package_json/detect.rs create mode 100644 crates/socket-patch-core/src/package_json/find.rs create mode 100644 crates/socket-patch-core/src/package_json/mod.rs create mode 100644 crates/socket-patch-core/src/package_json/update.rs create mode 100644 crates/socket-patch-core/src/patch/apply.rs create mode 100644 crates/socket-patch-core/src/patch/file_hash.rs create mode 100644 crates/socket-patch-core/src/patch/mod.rs create mode 100644 crates/socket-patch-core/src/patch/rollback.rs create mode 100644 crates/socket-patch-core/src/utils/cleanup_blobs.rs create mode 100644 crates/socket-patch-core/src/utils/enumerate.rs create mode 100644 crates/socket-patch-core/src/utils/fuzzy_match.rs create mode 100644 crates/socket-patch-core/src/utils/global_packages.rs create mode 100644 crates/socket-patch-core/src/utils/mod.rs create mode 100644 crates/socket-patch-core/src/utils/purl.rs create mode 100644 crates/socket-patch-core/src/utils/telemetry.rs create mode 100644 npm/socket-patch-darwin-arm64/package.json create mode 100644 npm/socket-patch-darwin-x64/package.json create mode 100644 npm/socket-patch-linux-arm64/package.json create mode 100644 npm/socket-patch-linux-x64/package.json create mode 100644 npm/socket-patch-win32-x64/package.json create mode 100755 npm/socket-patch/bin/socket-patch create mode 100644 npm/socket-patch/package.json create mode 100644 rust-toolchain.toml create mode 100755 scripts/version-sync.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb320ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Run clippy + run: cargo clippy --workspace -- -D warnings + + - name: Run tests + run: cargo test --workspace diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 288efdc..8ea680c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: 📦 Publish +name: Publish on: workflow_dispatch: @@ -11,35 +11,19 @@ on: - patch - minor - major - dist-tag: - description: 'npm dist-tag (latest, next, beta, canary, backport, etc.)' - required: false - default: 'latest' - type: string - debug: - description: 'Enable debug output' - required: false - default: '0' - type: choice - options: - - '0' - - '1' permissions: contents: write - id-token: write jobs: - bump-version: + bump-and-tag: runs-on: ubuntu-latest - outputs: - new-tag: ${{ steps.bump.outputs.new-tag }} steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@v4 with: node-version: '20' @@ -48,23 +32,14 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Bump version - id: bump + - name: Bump version and sync run: | - npm version ${{ inputs.version-bump }} -m "v%s" - echo "new-tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" - - - name: Push changes + npm version ${{ inputs.version-bump }} --no-git-tag-version + VERSION=$(node -p "require('./package.json').version") + bash scripts/version-sync.sh "$VERSION" + git add Cargo.toml package.json npm/ + git commit -m "v$VERSION" + git tag "v$VERSION" + + - name: Push changes and tag run: git push && git push --tags - - publish: - needs: bump-version - uses: SocketDev/socket-registry/.github/workflows/provenance.yml@main - with: - debug: ${{ inputs.debug }} - dist-tag: ${{ inputs.dist-tag }} - package-name: '@socketsecurity/socket-patch' - publish-script: 'publish:ci' - ref: ${{ needs.bump-version.outputs.new-tag }} - setup-script: 'pnpm run build' - use-trusted-publishing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2fd2bb8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,168 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + id-token: write + +jobs: + build: + strategy: + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-14 + archive: tar.gz + build-tool: cargo + - target: x86_64-apple-darwin + runner: macos-13 + archive: tar.gz + build-tool: cargo + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + archive: tar.gz + build-tool: cross + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + archive: tar.gz + build-tool: cross + - target: x86_64-pc-windows-msvc + runner: windows-latest + archive: zip + build-tool: cargo + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.build-tool == 'cross' + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build (cargo) + if: matrix.build-tool == 'cargo' + run: cargo build --release --target ${{ matrix.target }} + + - name: Build (cross) + if: matrix.build-tool == 'cross' + run: cross build --release --target ${{ matrix.target }} + + - name: Package (unix) + if: matrix.archive == 'tar.gz' + run: | + cd target/${{ matrix.target }}/release + tar czf ../../../socket-patch-${{ matrix.target }}.tar.gz socket-patch + cd ../../.. + + - name: Package (windows) + if: matrix.archive == 'zip' + shell: pwsh + run: | + Compress-Archive -Path "target/${{ matrix.target }}/release/socket-patch.exe" -DestinationPath "socket-patch-${{ matrix.target }}.zip" + + - name: Upload artifact (tar.gz) + if: matrix.archive == 'tar.gz' + uses: actions/upload-artifact@v4 + with: + name: socket-patch-${{ matrix.target }} + path: socket-patch-${{ matrix.target }}.tar.gz + + - name: Upload artifact (zip) + if: matrix.archive == 'zip' + uses: actions/upload-artifact@v4 + with: + name: socket-patch-${{ matrix.target }} + path: socket-patch-${{ matrix.target }}.zip + + github-release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --generate-notes \ + artifacts/* + + npm-publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Extract version and sync + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + bash scripts/version-sync.sh "$VERSION" + + - name: Stage darwin-arm64 binary + run: | + tar xzf artifacts/socket-patch-aarch64-apple-darwin.tar.gz -C npm/socket-patch-darwin-arm64/bin/ + + - name: Stage darwin-x64 binary + run: | + tar xzf artifacts/socket-patch-x86_64-apple-darwin.tar.gz -C npm/socket-patch-darwin-x64/bin/ + + - name: Stage linux-x64 binary + run: | + tar xzf artifacts/socket-patch-x86_64-unknown-linux-musl.tar.gz -C npm/socket-patch-linux-x64/bin/ + + - name: Stage linux-arm64 binary + run: | + tar xzf artifacts/socket-patch-aarch64-unknown-linux-gnu.tar.gz -C npm/socket-patch-linux-arm64/bin/ + + - name: Stage win32-x64 binary + run: | + cd npm/socket-patch-win32-x64/bin + unzip ../../../artifacts/socket-patch-x86_64-pc-windows-msvc.zip + + - name: Publish platform packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + for pkg in \ + npm/socket-patch-darwin-arm64 \ + npm/socket-patch-darwin-x64 \ + npm/socket-patch-linux-x64 \ + npm/socket-patch-linux-arm64 \ + npm/socket-patch-win32-x64; do + npm publish "$pkg" --provenance --access public + done + + - name: Publish root package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish npm/socket-patch --provenance --access public diff --git a/.gitignore b/.gitignore index 9a5aced..727f410 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,11 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Rust +target/ + +# npm platform binaries (populated at publish time) +npm/*/bin/socket-patch +npm/*/bin/socket-patch.exe +!npm/socket-patch/bin/socket-patch diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..04f8a78 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2115 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket-patch-cli" +version = "1.2.0" +dependencies = [ + "clap", + "dialoguer", + "indicatif", + "regex", + "serde", + "serde_json", + "socket-patch-core", + "tempfile", + "tokio", + "uuid", +] + +[[package]] +name = "socket-patch-core" +version = "1.2.0" +dependencies = [ + "hex", + "once_cell", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "uuid", + "walkdir", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a6da89e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = ["crates/socket-patch-core", "crates/socket-patch-cli"] +resolver = "2" + +[workspace.package] +version = "1.2.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/SocketDev/socket-patch" + +[workspace.dependencies] +socket-patch-core = { path = "crates/socket-patch-core" } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +hex = "0.4" +reqwest = { version = "0.12", features = ["rustls-tls", "json"], default-features = false } +tokio = { version = "1", features = ["full"] } +thiserror = "2" +walkdir = "2" +uuid = { version = "1", features = ["v4"] } +dialoguer = "0.11" +indicatif = "0.17" +tempfile = "3" +regex = "1" +once_cell = "1" + +[profile.release] +strip = true +lto = true +opt-level = "s" diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml new file mode 100644 index 0000000..ef4a3fc --- /dev/null +++ b/crates/socket-patch-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "socket-patch-cli" +description = "CLI binary for socket-patch: apply, rollback, get, scan security patches" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "socket-patch" +path = "src/main.rs" + +[dependencies] +socket-patch-core = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +dialoguer = { workspace = true } +indicatif = { workspace = true } +uuid = { workspace = true } +regex = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs new file mode 100644 index 0000000..24aeb7d --- /dev/null +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -0,0 +1,378 @@ +use clap::Args; +use socket_patch_core::api::blob_fetcher::{ + fetch_missing_blobs, format_fetch_result, get_missing_blobs, +}; +use socket_patch_core::api::client::get_api_client_from_env; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; +use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::patch::apply::{apply_package_patch, verify_file_patch, ApplyResult}; +use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; +use socket_patch_core::utils::purl::{is_npm_purl, is_pypi_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Args)] +pub struct ApplyArgs { + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Verify patches can be applied without modifying files + #[arg(short = 'd', long = "dry-run", default_value_t = false)] + pub dry_run: bool, + + /// Only output errors + #[arg(short = 's', long, default_value_t = false)] + pub silent: bool, + + /// Path to patch manifest file + #[arg(short = 'm', long = "manifest-path", default_value = DEFAULT_PATCH_MANIFEST_PATH)] + pub manifest_path: String, + + /// Do not download missing blobs, fail if any are missing + #[arg(long, default_value_t = false)] + pub offline: bool, + + /// Apply patches to globally installed npm packages + #[arg(short = 'g', long, default_value_t = false)] + pub global: bool, + + /// Custom path to global node_modules + #[arg(long = "global-prefix")] + pub global_prefix: Option, + + /// Restrict patching to specific ecosystems + #[arg(long, value_delimiter = ',')] + pub ecosystems: Option>, +} + +pub async fn run(args: ApplyArgs) -> i32 { + let api_token = std::env::var("SOCKET_API_TOKEN").ok(); + let org_slug = std::env::var("SOCKET_ORG_SLUG").ok(); + + let manifest_path = if Path::new(&args.manifest_path).is_absolute() { + PathBuf::from(&args.manifest_path) + } else { + args.cwd.join(&args.manifest_path) + }; + + // Check if manifest exists - exit successfully if no .socket folder is set up + if tokio::fs::metadata(&manifest_path).await.is_err() { + if !args.silent { + println!("No .socket folder found, skipping patch application."); + } + return 0; + } + + match apply_patches_inner(&args, &manifest_path).await { + Ok((success, results)) => { + // Print results + if !args.silent && !results.is_empty() { + let patched: Vec<_> = results.iter().filter(|r| r.success).collect(); + let already_patched: Vec<_> = results + .iter() + .filter(|r| { + r.files_verified + .iter() + .all(|f| f.status == socket_patch_core::patch::apply::VerifyStatus::AlreadyPatched) + }) + .collect(); + + if args.dry_run { + println!("\nPatch verification complete:"); + println!(" {} package(s) can be patched", patched.len()); + if !already_patched.is_empty() { + println!(" {} package(s) already patched", already_patched.len()); + } + } else { + println!("\nPatched packages:"); + for result in &patched { + if !result.files_patched.is_empty() { + println!(" {}", result.package_key); + } else if result.files_verified.iter().all(|f| { + f.status == socket_patch_core::patch::apply::VerifyStatus::AlreadyPatched + }) { + println!(" {} (already patched)", result.package_key); + } + } + } + } + + // Track telemetry + let patched_count = results + .iter() + .filter(|r| r.success && !r.files_patched.is_empty()) + .count(); + if success { + track_patch_applied(patched_count, args.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + } else { + track_patch_apply_failed("One or more patches failed to apply", args.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + } + + if success { 0 } else { 1 } + } + Err(e) => { + track_patch_apply_failed(&e, args.dry_run, api_token.as_deref(), org_slug.as_deref()).await; + if !args.silent { + eprintln!("Error: {e}"); + } + 1 + } + } +} + +async fn apply_patches_inner( + args: &ApplyArgs, + manifest_path: &Path, +) -> Result<(bool, Vec), String> { + let manifest = read_manifest(manifest_path) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Invalid manifest".to_string())?; + + let socket_dir = manifest_path.parent().unwrap(); + let blobs_path = socket_dir.join("blobs"); + tokio::fs::create_dir_all(&blobs_path) + .await + .map_err(|e| e.to_string())?; + + // Check for and download missing blobs + let missing_blobs = get_missing_blobs(&manifest, &blobs_path).await; + if !missing_blobs.is_empty() { + if args.offline { + if !args.silent { + eprintln!( + "Error: {} blob(s) are missing and --offline mode is enabled.", + missing_blobs.len() + ); + eprintln!("Run \"socket-patch repair\" to download missing blobs."); + } + return Ok((false, Vec::new())); + } + + if !args.silent { + println!("Downloading {} missing blob(s)...", missing_blobs.len()); + } + + let (client, _) = get_api_client_from_env(None); + let fetch_result = fetch_missing_blobs(&manifest, &blobs_path, &client, None).await; + + if !args.silent { + println!("{}", format_fetch_result(&fetch_result)); + } + + if fetch_result.failed > 0 { + if !args.silent { + eprintln!("Some blobs could not be downloaded. Cannot apply patches."); + } + return Ok((false, Vec::new())); + } + } + + // Partition manifest PURLs by ecosystem + let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); + let mut npm_purls: Vec = manifest_purls.iter().filter(|p| is_npm_purl(p)).cloned().collect(); + let mut pypi_purls: Vec = manifest_purls.iter().filter(|p| is_pypi_purl(p)).cloned().collect(); + + // Filter by ecosystem if specified + if let Some(ref ecosystems) = args.ecosystems { + if !ecosystems.iter().any(|e| e == "npm") { + npm_purls.clear(); + } + if !ecosystems.iter().any(|e| e == "pypi") { + pypi_purls.clear(); + } + } + + let crawler_options = CrawlerOptions { + cwd: args.cwd.clone(), + global: args.global, + global_prefix: args.global_prefix.clone(), + batch_size: 100, + }; + + let mut all_packages: HashMap = HashMap::new(); + + // Find npm packages + if !npm_purls.is_empty() { + let npm_crawler = NpmCrawler; + match npm_crawler.get_node_modules_paths(&crawler_options).await { + Ok(nm_paths) => { + if (args.global || args.global_prefix.is_some()) && !args.silent { + if let Some(first) = nm_paths.first() { + println!("Using global npm packages at: {}", first.display()); + } + } + for nm_path in &nm_paths { + if let Ok(packages) = npm_crawler.find_by_purls(nm_path, &npm_purls).await { + for (purl, pkg) in packages { + all_packages.entry(purl).or_insert(pkg.path); + } + } + } + } + Err(e) => { + if !args.silent { + eprintln!("Failed to find npm packages: {e}"); + } + } + } + } + + // Find Python packages + if !pypi_purls.is_empty() { + let python_crawler = PythonCrawler; + let base_pypi_purls: Vec = pypi_purls + .iter() + .map(|p| strip_purl_qualifiers(p).to_string()) + .collect::>() + .into_iter() + .collect(); + + match python_crawler.get_site_packages_paths(&crawler_options).await { + Ok(sp_paths) => { + for sp_path in &sp_paths { + if let Ok(packages) = python_crawler.find_by_purls(sp_path, &base_pypi_purls).await { + for (purl, pkg) in packages { + all_packages.entry(purl).or_insert(pkg.path); + } + } + } + } + Err(e) => { + if !args.silent { + eprintln!("Failed to find Python packages: {e}"); + } + } + } + } + + if all_packages.is_empty() && npm_purls.is_empty() && pypi_purls.is_empty() { + if !args.silent { + if args.global || args.global_prefix.is_some() { + eprintln!("No global packages found"); + } else { + eprintln!("No package directories found"); + } + } + return Ok((false, Vec::new())); + } + + if all_packages.is_empty() { + if !args.silent { + println!("No packages found that match available patches"); + } + return Ok((true, Vec::new())); + } + + // Apply patches + let mut results: Vec = Vec::new(); + let mut has_errors = false; + + // Group pypi PURLs by base + let mut pypi_qualified_groups: HashMap> = HashMap::new(); + for purl in &pypi_purls { + let base = strip_purl_qualifiers(purl).to_string(); + pypi_qualified_groups + .entry(base) + .or_default() + .push(purl.clone()); + } + + let mut applied_base_purls: HashSet = HashSet::new(); + + for (purl, pkg_path) in &all_packages { + if is_pypi_purl(purl) { + let base_purl = strip_purl_qualifiers(purl).to_string(); + if applied_base_purls.contains(&base_purl) { + continue; + } + + let variants = pypi_qualified_groups + .get(&base_purl) + .cloned() + .unwrap_or_else(|| vec![base_purl.clone()]); + let mut applied = false; + + for variant_purl in &variants { + let patch = match manifest.patches.get(variant_purl) { + Some(p) => p, + None => continue, + }; + + // Check first file hash match + if let Some((file_name, file_info)) = patch.files.iter().next() { + let verify = verify_file_patch(pkg_path, file_name, file_info).await; + if verify.status == socket_patch_core::patch::apply::VerifyStatus::HashMismatch { + continue; + } + } + + let result = apply_package_patch( + variant_purl, + pkg_path, + &patch.files, + &blobs_path, + args.dry_run, + ) + .await; + + if result.success { + applied = true; + applied_base_purls.insert(base_purl.clone()); + results.push(result); + break; + } else { + results.push(result); + } + } + + if !applied { + has_errors = true; + if !args.silent { + eprintln!("Failed to patch {base_purl}: no matching variant found"); + } + } + } else { + // npm PURLs: direct lookup + let patch = match manifest.patches.get(purl) { + Some(p) => p, + None => continue, + }; + + let result = apply_package_patch( + purl, + pkg_path, + &patch.files, + &blobs_path, + args.dry_run, + ) + .await; + + if !result.success { + has_errors = true; + if !args.silent { + eprintln!( + "Failed to patch {}: {}", + purl, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + results.push(result); + } + } + + // Clean up unused blobs + if !args.silent { + if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, args.dry_run).await { + if cleanup_result.blobs_removed > 0 { + println!("\n{}", format_cleanup_result(&cleanup_result, args.dry_run)); + } + } + } + + Ok((!has_errors, results)) +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs new file mode 100644 index 0000000..662f320 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -0,0 +1,674 @@ +use clap::Args; +use regex::Regex; +use socket_patch_core::api::client::get_api_client_from_env; +use socket_patch_core::api::types::{PatchSearchResult, SearchResponse}; +use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler}; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, +}; +use socket_patch_core::utils::fuzzy_match::fuzzy_match_packages; +use socket_patch_core::utils::purl::is_purl; +use std::collections::HashMap; +use std::io::{self, Write}; +use std::path::PathBuf; + +#[derive(Args)] +pub struct GetArgs { + /// Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name) + pub identifier: String, + + /// Organization slug + #[arg(long)] + pub org: Option, + + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Force identifier to be treated as a patch UUID + #[arg(long, default_value_t = false)] + pub id: bool, + + /// Force identifier to be treated as a CVE ID + #[arg(long, default_value_t = false)] + pub cve: bool, + + /// Force identifier to be treated as a GHSA ID + #[arg(long, default_value_t = false)] + pub ghsa: bool, + + /// Force identifier to be treated as a package name + #[arg(short = 'p', long = "package", default_value_t = false)] + pub package: bool, + + /// Skip confirmation prompt for multiple patches + #[arg(short = 'y', long, default_value_t = false)] + pub yes: bool, + + /// Socket API URL (overrides SOCKET_API_URL env var) + #[arg(long = "api-url")] + pub api_url: Option, + + /// Socket API token (overrides SOCKET_API_TOKEN env var) + #[arg(long = "api-token")] + pub api_token: Option, + + /// Download patch without applying it + #[arg(long = "no-apply", default_value_t = false)] + pub no_apply: bool, + + /// Apply patch to globally installed npm packages + #[arg(short = 'g', long, default_value_t = false)] + pub global: bool, + + /// Custom path to global node_modules + #[arg(long = "global-prefix")] + pub global_prefix: Option, + + /// Apply patch immediately without saving to .socket folder + #[arg(long = "one-off", default_value_t = false)] + pub one_off: bool, +} + +#[derive(Debug, PartialEq)] +enum IdentifierType { + Uuid, + Cve, + Ghsa, + Purl, + Package, +} + +fn detect_identifier_type(identifier: &str) -> Option { + let uuid_re = Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap(); + let cve_re = Regex::new(r"(?i)^CVE-\d{4}-\d+$").unwrap(); + let ghsa_re = Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$").unwrap(); + + if uuid_re.is_match(identifier) { + Some(IdentifierType::Uuid) + } else if cve_re.is_match(identifier) { + Some(IdentifierType::Cve) + } else if ghsa_re.is_match(identifier) { + Some(IdentifierType::Ghsa) + } else if is_purl(identifier) { + Some(IdentifierType::Purl) + } else { + None + } +} + +pub async fn run(args: GetArgs) -> i32 { + // Validate flags + let type_flags = [args.id, args.cve, args.ghsa, args.package] + .iter() + .filter(|&&f| f) + .count(); + if type_flags > 1 { + eprintln!("Error: Only one of --id, --cve, --ghsa, or --package can be specified"); + return 1; + } + if args.one_off && args.no_apply { + eprintln!("Error: --one-off and --no-apply cannot be used together"); + return 1; + } + + // Override env vars + if let Some(ref url) = args.api_url { + std::env::set_var("SOCKET_API_URL", url); + } + if let Some(ref token) = args.api_token { + std::env::set_var("SOCKET_API_TOKEN", token); + } + + let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref()); + + if !use_public_proxy && args.org.is_none() { + eprintln!("Error: --org is required when using SOCKET_API_TOKEN. Provide an organization slug."); + return 1; + } + + let effective_org_slug = if use_public_proxy { + None + } else { + args.org.as_deref() + }; + + // Determine identifier type + let id_type = if args.id { + IdentifierType::Uuid + } else if args.cve { + IdentifierType::Cve + } else if args.ghsa { + IdentifierType::Ghsa + } else if args.package { + IdentifierType::Package + } else { + match detect_identifier_type(&args.identifier) { + Some(t) => { + println!("Detected identifier type: {:?}", t); + t + } + None => { + println!("Treating \"{}\" as a package name search", args.identifier); + IdentifierType::Package + } + } + }; + + // Handle UUID: fetch and download directly + if id_type == IdentifierType::Uuid { + println!("Fetching patch by UUID: {}", args.identifier); + match api_client + .fetch_patch(effective_org_slug, &args.identifier) + .await + { + Ok(Some(patch)) => { + if patch.tier == "paid" && use_public_proxy { + println!("\n\x1b[33mThis patch requires a paid subscription to download.\x1b[0m"); + println!("\n Patch: {}", patch.purl); + println!(" Tier: \x1b[33mpaid\x1b[0m"); + println!("\n Upgrade at: \x1b[36mhttps://socket.dev/pricing\x1b[0m\n"); + return 0; + } + + // Save to manifest + return save_and_apply_patch(&args, &patch.purl, &patch.uuid, effective_org_slug) + .await; + } + Ok(None) => { + println!("No patch found with UUID: {}", args.identifier); + return 0; + } + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + } + } + + // For CVE/GHSA/PURL/package, search first + let search_response: SearchResponse = match id_type { + IdentifierType::Cve => { + println!("Searching patches for CVE: {}", args.identifier); + match api_client + .search_patches_by_cve(effective_org_slug, &args.identifier) + .await + { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + } + } + IdentifierType::Ghsa => { + println!("Searching patches for GHSA: {}", args.identifier); + match api_client + .search_patches_by_ghsa(effective_org_slug, &args.identifier) + .await + { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + } + } + IdentifierType::Purl => { + println!("Searching patches for PURL: {}", args.identifier); + match api_client + .search_patches_by_package(effective_org_slug, &args.identifier) + .await + { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + } + } + IdentifierType::Package => { + println!("Enumerating packages..."); + let crawler_options = CrawlerOptions { + cwd: args.cwd.clone(), + global: args.global, + global_prefix: args.global_prefix.clone(), + batch_size: 100, + }; + let npm_crawler = NpmCrawler; + let python_crawler = PythonCrawler; + let npm_packages = npm_crawler.crawl_all(&crawler_options).await; + let python_packages = python_crawler.crawl_all(&crawler_options).await; + let mut all_packages = npm_packages; + all_packages.extend(python_packages); + + if all_packages.is_empty() { + if args.global { + println!("No global packages found."); + } else { + println!("No packages found. Run npm/yarn/pnpm/pip install first."); + } + return 0; + } + + println!("Found {} packages", all_packages.len()); + + let matches = fuzzy_match_packages(&args.identifier, &all_packages, 20); + + if matches.is_empty() { + println!("No packages matching \"{}\" found.", args.identifier); + return 0; + } + + println!( + "Found {} matching package(s), checking for available patches...", + matches.len() + ); + + // Search for patches for the best match + let best_match = &matches[0]; + match api_client + .search_patches_by_package(effective_org_slug, &best_match.purl) + .await + { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + } + } + _ => unreachable!(), + }; + + if search_response.patches.is_empty() { + println!( + "No patches found for {:?}: {}", + id_type, args.identifier + ); + return 0; + } + + // Display results + display_search_results(&search_response.patches, search_response.can_access_paid_patches); + + // Filter accessible patches + let accessible: Vec<_> = search_response + .patches + .iter() + .filter(|p| p.tier == "free" || search_response.can_access_paid_patches) + .collect(); + + if accessible.is_empty() { + println!("\n\x1b[33mAll available patches require a paid subscription.\x1b[0m"); + println!("\n Upgrade at: \x1b[36mhttps://socket.dev/pricing\x1b[0m\n"); + return 0; + } + + // Prompt for confirmation + if accessible.len() > 1 && !args.yes { + print!("Download {} patch(es)? [y/N] ", accessible.len()); + io::stdout().flush().unwrap(); + let mut answer = String::new(); + io::stdin().read_line(&mut answer).unwrap(); + let answer = answer.trim().to_lowercase(); + if answer != "y" && answer != "yes" { + println!("Download cancelled."); + return 0; + } + } + + // Download and save patches + let socket_dir = args.cwd.join(".socket"); + let blobs_dir = socket_dir.join("blobs"); + let manifest_path = socket_dir.join("manifest.json"); + + tokio::fs::create_dir_all(&socket_dir).await.ok(); + tokio::fs::create_dir_all(&blobs_dir).await.ok(); + + let mut manifest = match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + _ => PatchManifest::new(), + }; + + println!("\nDownloading {} patch(es)...", accessible.len()); + + let mut patches_added = 0; + let mut patches_skipped = 0; + let mut patches_failed = 0; + + for search_result in &accessible { + match api_client + .fetch_patch(effective_org_slug, &search_result.uuid) + .await + { + Ok(Some(patch)) => { + // Check if already in manifest + if manifest + .patches + .get(&patch.purl) + .map_or(false, |p| p.uuid == patch.uuid) + { + println!(" [skip] {} (already in manifest)", patch.purl); + patches_skipped += 1; + continue; + } + + // Save blob contents (afterHash only) + let mut files = HashMap::new(); + for (file_path, file_info) in &patch.files { + if let (Some(ref before), Some(ref after)) = + (&file_info.before_hash, &file_info.after_hash) + { + files.insert( + file_path.clone(), + PatchFileInfo { + before_hash: before.clone(), + after_hash: after.clone(), + }, + ); + } + + // Save after blob content + if let (Some(ref blob_content), Some(ref after_hash)) = + (&file_info.blob_content, &file_info.after_hash) + { + if let Ok(decoded) = + base64_decode(blob_content) + { + let blob_path = blobs_dir.join(after_hash); + tokio::fs::write(&blob_path, &decoded).await.ok(); + } + } + } + + // Build vulnerabilities + let vulnerabilities: HashMap = patch + .vulnerabilities + .iter() + .map(|(id, v)| { + ( + id.clone(), + VulnerabilityInfo { + cves: v.cves.clone(), + summary: v.summary.clone(), + severity: v.severity.clone(), + description: v.description.clone(), + }, + ) + }) + .collect(); + + manifest.patches.insert( + patch.purl.clone(), + PatchRecord { + uuid: patch.uuid.clone(), + exported_at: patch.published_at.clone(), + files, + vulnerabilities, + description: patch.description.clone(), + license: patch.license.clone(), + tier: patch.tier.clone(), + }, + ); + + println!(" [add] {}", patch.purl); + patches_added += 1; + } + Ok(None) => { + println!(" [fail] {} (could not fetch details)", search_result.purl); + patches_failed += 1; + } + Err(e) => { + println!(" [fail] {} ({e})", search_result.purl); + patches_failed += 1; + } + } + } + + // Write manifest + if let Err(e) = write_manifest(&manifest_path, &manifest).await { + eprintln!("Error writing manifest: {e}"); + return 1; + } + + println!("\nPatches saved to {}", manifest_path.display()); + println!(" Added: {patches_added}"); + if patches_skipped > 0 { + println!(" Skipped: {patches_skipped}"); + } + if patches_failed > 0 { + println!(" Failed: {patches_failed}"); + } + + // Auto-apply unless --no-apply + if !args.no_apply && patches_added > 0 { + println!("\nApplying patches..."); + let apply_args = super::apply::ApplyArgs { + cwd: args.cwd.clone(), + dry_run: false, + silent: false, + manifest_path: manifest_path.display().to_string(), + offline: false, + global: args.global, + global_prefix: args.global_prefix.clone(), + ecosystems: None, + }; + let code = super::apply::run(apply_args).await; + if code != 0 { + eprintln!("\nSome patches could not be applied."); + } + } + + 0 +} + +fn display_search_results(patches: &[PatchSearchResult], can_access_paid: bool) { + println!("\nFound patches:\n"); + + for (i, patch) in patches.iter().enumerate() { + let tier_label = if patch.tier == "paid" { + " [PAID]" + } else { + " [FREE]" + }; + let access_label = if patch.tier == "paid" && !can_access_paid { + " (no access)" + } else { + "" + }; + + println!(" {}. {}{}{}", i + 1, patch.purl, tier_label, access_label); + println!(" UUID: {}", patch.uuid); + if !patch.description.is_empty() { + let desc = if patch.description.len() > 80 { + format!("{}...", &patch.description[..77]) + } else { + patch.description.clone() + }; + println!(" Description: {desc}"); + } + + let vuln_ids: Vec<_> = patch.vulnerabilities.keys().collect(); + if !vuln_ids.is_empty() { + let vuln_summary: Vec = patch + .vulnerabilities + .iter() + .map(|(id, vuln)| { + let cves = if vuln.cves.is_empty() { + id.to_string() + } else { + vuln.cves.join(", ") + }; + format!("{cves} ({})", vuln.severity) + }) + .collect(); + println!(" Fixes: {}", vuln_summary.join(", ")); + } + println!(); + } +} + +async fn save_and_apply_patch( + args: &GetArgs, + _purl: &str, + uuid: &str, + _org_slug: Option<&str>, +) -> i32 { + // For UUID mode, fetch and save + let (api_client, _) = get_api_client_from_env(args.org.as_deref()); + let effective_org = if args.org.is_some() { + args.org.as_deref() + } else { + None + }; + + let patch = match api_client.fetch_patch(effective_org, uuid).await { + Ok(Some(p)) => p, + Ok(None) => { + println!("No patch found with UUID: {uuid}"); + return 0; + } + Err(e) => { + eprintln!("Error: {e}"); + return 1; + } + }; + + let socket_dir = args.cwd.join(".socket"); + let blobs_dir = socket_dir.join("blobs"); + let manifest_path = socket_dir.join("manifest.json"); + + tokio::fs::create_dir_all(&blobs_dir).await.ok(); + + let mut manifest = match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + _ => PatchManifest::new(), + }; + + // Build and save patch record + let mut files = HashMap::new(); + for (file_path, file_info) in &patch.files { + if let (Some(ref before), Some(ref after)) = + (&file_info.before_hash, &file_info.after_hash) + { + files.insert( + file_path.clone(), + PatchFileInfo { + before_hash: before.clone(), + after_hash: after.clone(), + }, + ); + } + if let (Some(ref blob_content), Some(ref after_hash)) = + (&file_info.blob_content, &file_info.after_hash) + { + if let Ok(decoded) = base64_decode(blob_content) { + tokio::fs::write(blobs_dir.join(after_hash), &decoded) + .await + .ok(); + } + } + } + + let vulnerabilities: HashMap = patch + .vulnerabilities + .iter() + .map(|(id, v)| { + ( + id.clone(), + VulnerabilityInfo { + cves: v.cves.clone(), + summary: v.summary.clone(), + severity: v.severity.clone(), + description: v.description.clone(), + }, + ) + }) + .collect(); + + let added = !manifest + .patches + .get(&patch.purl) + .map_or(false, |p| p.uuid == patch.uuid); + + manifest.patches.insert( + patch.purl.clone(), + PatchRecord { + uuid: patch.uuid.clone(), + exported_at: patch.published_at.clone(), + files, + vulnerabilities, + description: patch.description.clone(), + license: patch.license.clone(), + tier: patch.tier.clone(), + }, + ); + + if let Err(e) = write_manifest(&manifest_path, &manifest).await { + eprintln!("Error writing manifest: {e}"); + return 1; + } + + println!("\nPatch saved to {}", manifest_path.display()); + if added { + println!(" Added: 1"); + } else { + println!(" Skipped: 1 (already exists)"); + } + + if !args.no_apply { + println!("\nApplying patches..."); + let apply_args = super::apply::ApplyArgs { + cwd: args.cwd.clone(), + dry_run: false, + silent: false, + manifest_path: manifest_path.display().to_string(), + offline: false, + global: args.global, + global_prefix: args.global_prefix.clone(), + ecosystems: None, + }; + let code = super::apply::run(apply_args).await; + if code != 0 { + eprintln!("\nSome patches could not be applied."); + } + } + + 0 +} + +fn base64_decode(input: &str) -> Result, String> { + // Simple base64 decoder + let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut table = [255u8; 256]; + for (i, &c) in chars.iter().enumerate() { + table[c as usize] = i as u8; + } + + let input = input.as_bytes(); + let mut output = Vec::with_capacity(input.len() * 3 / 4); + + let mut buf = 0u32; + let mut bits = 0u32; + + for &b in input { + if b == b'=' || b == b'\n' || b == b'\r' { + continue; + } + let val = table[b as usize]; + if val == 255 { + return Err(format!("Invalid base64 character: {}", b as char)); + } + buf = (buf << 6) | val as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + output.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + + Ok(output) +} diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs new file mode 100644 index 0000000..d678fca --- /dev/null +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -0,0 +1,141 @@ +use clap::Args; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; +use socket_patch_core::manifest::operations::read_manifest; +use std::path::{Path, PathBuf}; + +#[derive(Args)] +pub struct ListArgs { + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Path to patch manifest file + #[arg(short = 'm', long = "manifest-path", default_value = DEFAULT_PATCH_MANIFEST_PATH)] + pub manifest_path: String, + + /// Output as JSON + #[arg(long, default_value_t = false)] + pub json: bool, +} + +pub async fn run(args: ListArgs) -> i32 { + let manifest_path = if Path::new(&args.manifest_path).is_absolute() { + PathBuf::from(&args.manifest_path) + } else { + args.cwd.join(&args.manifest_path) + }; + + // Check if manifest exists + if tokio::fs::metadata(&manifest_path).await.is_err() { + if args.json { + println!( + "{}", + serde_json::json!({ + "error": "Manifest not found", + "path": manifest_path.display().to_string() + }) + ); + } else { + eprintln!("Manifest not found at {}", manifest_path.display()); + } + return 1; + } + + match read_manifest(&manifest_path).await { + Ok(Some(manifest)) => { + let patch_entries: Vec<_> = manifest.patches.iter().collect(); + + if patch_entries.is_empty() { + if args.json { + println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "patches": [] })).unwrap()); + } else { + println!("No patches found in manifest."); + } + return 0; + } + + if args.json { + let json_output = serde_json::json!({ + "patches": patch_entries.iter().map(|(purl, patch)| { + serde_json::json!({ + "purl": purl, + "uuid": patch.uuid, + "exportedAt": patch.exported_at, + "tier": patch.tier, + "license": patch.license, + "description": patch.description, + "files": patch.files.keys().collect::>(), + "vulnerabilities": patch.vulnerabilities.iter().map(|(id, vuln)| { + serde_json::json!({ + "id": id, + "cves": vuln.cves, + "summary": vuln.summary, + "severity": vuln.severity, + "description": vuln.description, + }) + }).collect::>(), + }) + }).collect::>() + }); + println!("{}", serde_json::to_string_pretty(&json_output).unwrap()); + } else { + println!("Found {} patch(es):\n", patch_entries.len()); + + for (purl, patch) in &patch_entries { + println!("Package: {purl}"); + println!(" UUID: {}", patch.uuid); + println!(" Tier: {}", patch.tier); + println!(" License: {}", patch.license); + println!(" Exported: {}", patch.exported_at); + + if !patch.description.is_empty() { + println!(" Description: {}", patch.description); + } + + let vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); + if !vuln_entries.is_empty() { + println!(" Vulnerabilities ({}):", vuln_entries.len()); + for (id, vuln) in &vuln_entries { + let cve_list = if vuln.cves.is_empty() { + String::new() + } else { + format!(" ({})", vuln.cves.join(", ")) + }; + println!(" - {id}{cve_list}"); + println!(" Severity: {}", vuln.severity); + println!(" Summary: {}", vuln.summary); + } + } + + let file_list: Vec<_> = patch.files.keys().collect(); + if !file_list.is_empty() { + println!(" Files patched ({}):", file_list.len()); + for file_path in &file_list { + println!(" - {file_path}"); + } + } + + println!(); + } + } + + 0 + } + Ok(None) => { + if args.json { + println!("{}", serde_json::json!({ "error": "Invalid manifest" })); + } else { + eprintln!("Error: Invalid manifest at {}", manifest_path.display()); + } + 1 + } + Err(e) => { + if args.json { + println!("{}", serde_json::json!({ "error": e.to_string() })); + } else { + eprintln!("Error: {e}"); + } + 1 + } + } +} diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs new file mode 100644 index 0000000..499366f --- /dev/null +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -0,0 +1,8 @@ +pub mod apply; +pub mod get; +pub mod list; +pub mod remove; +pub mod repair; +pub mod rollback; +pub mod scan; +pub mod setup; diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs new file mode 100644 index 0000000..f05379a --- /dev/null +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -0,0 +1,195 @@ +use clap::Args; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; +use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed}; +use std::path::{Path, PathBuf}; + +use super::rollback::rollback_patches; + +#[derive(Args)] +pub struct RemoveArgs { + /// Package PURL or patch UUID + pub identifier: String, + + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Path to patch manifest file + #[arg(short = 'm', long = "manifest-path", default_value = DEFAULT_PATCH_MANIFEST_PATH)] + pub manifest_path: String, + + /// Skip rolling back files before removing (only update manifest) + #[arg(long = "skip-rollback", default_value_t = false)] + pub skip_rollback: bool, + + /// Remove patches from globally installed npm packages + #[arg(short = 'g', long, default_value_t = false)] + pub global: bool, + + /// Custom path to global node_modules + #[arg(long = "global-prefix")] + pub global_prefix: Option, +} + +pub async fn run(args: RemoveArgs) -> i32 { + let api_token = std::env::var("SOCKET_API_TOKEN").ok(); + let org_slug = std::env::var("SOCKET_ORG_SLUG").ok(); + + let manifest_path = if Path::new(&args.manifest_path).is_absolute() { + PathBuf::from(&args.manifest_path) + } else { + args.cwd.join(&args.manifest_path) + }; + + if tokio::fs::metadata(&manifest_path).await.is_err() { + eprintln!("Manifest not found at {}", manifest_path.display()); + return 1; + } + + // First, rollback the patch if not skipped + if !args.skip_rollback { + println!("Rolling back patch before removal..."); + match rollback_patches( + &args.cwd, + &manifest_path, + Some(&args.identifier), + false, + false, + false, + args.global, + args.global_prefix.clone(), + None, + ) + .await + { + Ok((success, results)) => { + if !success { + track_patch_remove_failed( + "Rollback failed during patch removal", + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + eprintln!("\nRollback failed. Use --skip-rollback to remove from manifest without restoring files."); + return 1; + } + + let rolled_back = results + .iter() + .filter(|r| r.success && !r.files_rolled_back.is_empty()) + .count(); + let already_original = results + .iter() + .filter(|r| { + r.success + && r.files_verified.iter().all(|f| { + f.status + == socket_patch_core::patch::rollback::VerifyRollbackStatus::AlreadyOriginal + }) + }) + .count(); + + if rolled_back > 0 { + println!("Rolled back {rolled_back} package(s)"); + } + if already_original > 0 { + println!("{already_original} package(s) already in original state"); + } + if results.is_empty() { + println!("No packages found to rollback (not installed)"); + } + println!(); + } + Err(e) => { + track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + eprintln!("Error during rollback: {e}"); + eprintln!("\nRollback failed. Use --skip-rollback to remove from manifest without restoring files."); + return 1; + } + } + } + + // Now remove from manifest + match remove_patch_from_manifest(&args.identifier, &manifest_path).await { + Ok((removed, manifest)) => { + if removed.is_empty() { + track_patch_remove_failed( + &format!("No patch found matching identifier: {}", args.identifier), + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + eprintln!( + "No patch found matching identifier: {}", + args.identifier + ); + return 1; + } + + println!("Removed {} patch(es) from manifest:", removed.len()); + for purl in &removed { + println!(" - {purl}"); + } + + println!("\nManifest updated at {}", manifest_path.display()); + + // Clean up unused blobs + let socket_dir = manifest_path.parent().unwrap(); + let blobs_path = socket_dir.join("blobs"); + if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, false).await { + if cleanup_result.blobs_removed > 0 { + println!("\n{}", format_cleanup_result(&cleanup_result, false)); + } + } + + track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + 0 + } + Err(e) => { + track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + eprintln!("Error: {e}"); + 1 + } + } +} + +async fn remove_patch_from_manifest( + identifier: &str, + manifest_path: &Path, +) -> Result<(Vec, PatchManifest), String> { + let mut manifest = read_manifest(manifest_path) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Invalid manifest".to_string())?; + + let mut removed = Vec::new(); + + if identifier.starts_with("pkg:") { + if manifest.patches.remove(identifier).is_some() { + removed.push(identifier.to_string()); + } + } else { + let purls_to_remove: Vec = manifest + .patches + .iter() + .filter(|(_, patch)| patch.uuid == identifier) + .map(|(purl, _)| purl.clone()) + .collect(); + + for purl in purls_to_remove { + manifest.patches.remove(&purl); + removed.push(purl); + } + } + + if !removed.is_empty() { + write_manifest(manifest_path, &manifest) + .await + .map_err(|e| e.to_string())?; + } + + Ok((removed, manifest)) +} diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs new file mode 100644 index 0000000..581b608 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -0,0 +1,133 @@ +use clap::Args; +use socket_patch_core::api::blob_fetcher::{ + fetch_missing_blobs, format_fetch_result, get_missing_blobs, +}; +use socket_patch_core::api::client::get_api_client_from_env; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; +use std::path::{Path, PathBuf}; + +#[derive(Args)] +pub struct RepairArgs { + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Path to patch manifest file + #[arg(short = 'm', long = "manifest-path", default_value = DEFAULT_PATCH_MANIFEST_PATH)] + pub manifest_path: String, + + /// Show what would be done without actually doing it + #[arg(short = 'd', long = "dry-run", default_value_t = false)] + pub dry_run: bool, + + /// Skip network operations (cleanup only) + #[arg(long, default_value_t = false)] + pub offline: bool, + + /// Only download missing blobs, do not clean up + #[arg(long = "download-only", default_value_t = false)] + pub download_only: bool, +} + +pub async fn run(args: RepairArgs) -> i32 { + let manifest_path = if Path::new(&args.manifest_path).is_absolute() { + PathBuf::from(&args.manifest_path) + } else { + args.cwd.join(&args.manifest_path) + }; + + if tokio::fs::metadata(&manifest_path).await.is_err() { + eprintln!("Manifest not found at {}", manifest_path.display()); + return 1; + } + + match repair_inner(&args, &manifest_path).await { + Ok(()) => 0, + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + } +} + +async fn repair_inner(args: &RepairArgs, manifest_path: &Path) -> Result<(), String> { + let manifest = read_manifest(manifest_path) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Invalid manifest".to_string())?; + + let socket_dir = manifest_path.parent().unwrap(); + let blobs_path = socket_dir.join("blobs"); + + // Step 1: Check for and download missing blobs + if !args.offline { + let missing_blobs = get_missing_blobs(&manifest, &blobs_path).await; + + if !missing_blobs.is_empty() { + println!("Found {} missing blob(s)", missing_blobs.len()); + + if args.dry_run { + println!("\nDry run - would download:"); + for hash in missing_blobs.iter().take(10) { + println!(" - {}...", &hash[..12.min(hash.len())]); + } + if missing_blobs.len() > 10 { + println!(" ... and {} more", missing_blobs.len() - 10); + } + } else { + println!("\nDownloading missing blobs..."); + let (client, _) = get_api_client_from_env(None); + let fetch_result = fetch_missing_blobs(&manifest, &blobs_path, &client, None).await; + println!("{}", format_fetch_result(&fetch_result)); + } + } else { + println!("All blobs are present locally."); + } + } else { + let missing_blobs = get_missing_blobs(&manifest, &blobs_path).await; + if !missing_blobs.is_empty() { + println!( + "Warning: {} blob(s) are missing (offline mode - not downloading)", + missing_blobs.len() + ); + for hash in missing_blobs.iter().take(5) { + println!(" - {}...", &hash[..12.min(hash.len())]); + } + if missing_blobs.len() > 5 { + println!(" ... and {} more", missing_blobs.len() - 5); + } + } else { + println!("All blobs are present locally."); + } + } + + // Step 2: Clean up unused blobs + if !args.download_only { + println!(); + match cleanup_unused_blobs(&manifest, &blobs_path, args.dry_run).await { + Ok(cleanup_result) => { + if cleanup_result.blobs_checked == 0 { + println!("No blobs directory found, nothing to clean up."); + } else if cleanup_result.blobs_removed == 0 { + println!( + "Checked {} blob(s), all are in use.", + cleanup_result.blobs_checked + ); + } else { + println!("{}", format_cleanup_result(&cleanup_result, args.dry_run)); + } + } + Err(e) => { + eprintln!("Warning: cleanup failed: {e}"); + } + } + } + + if !args.dry_run { + println!("\nRepair complete."); + } + + Ok(()) +} diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs new file mode 100644 index 0000000..08784d1 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -0,0 +1,492 @@ +use clap::Args; +use socket_patch_core::api::blob_fetcher::{ + fetch_blobs_by_hash, format_fetch_result, +}; +use socket_patch_core::api::client::get_api_client_from_env; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; +use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult}; +use socket_patch_core::utils::global_packages::get_global_prefix; +use socket_patch_core::utils::purl::{is_pypi_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Args)] +pub struct RollbackArgs { + /// Package PURL or patch UUID to rollback. Omit to rollback all patches. + pub identifier: Option, + + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Verify rollback can be performed without modifying files + #[arg(short = 'd', long = "dry-run", default_value_t = false)] + pub dry_run: bool, + + /// Only output errors + #[arg(short = 's', long, default_value_t = false)] + pub silent: bool, + + /// Path to patch manifest file + #[arg(short = 'm', long = "manifest-path", default_value = DEFAULT_PATCH_MANIFEST_PATH)] + pub manifest_path: String, + + /// Do not download missing blobs, fail if any are missing + #[arg(long, default_value_t = false)] + pub offline: bool, + + /// Rollback patches from globally installed npm packages + #[arg(short = 'g', long, default_value_t = false)] + pub global: bool, + + /// Custom path to global node_modules + #[arg(long = "global-prefix")] + pub global_prefix: Option, + + /// Rollback a patch by fetching beforeHash blobs from API (no manifest required) + #[arg(long = "one-off", default_value_t = false)] + pub one_off: bool, + + /// Organization slug + #[arg(long)] + pub org: Option, + + /// Socket API URL (overrides SOCKET_API_URL env var) + #[arg(long = "api-url")] + pub api_url: Option, + + /// Socket API token (overrides SOCKET_API_TOKEN env var) + #[arg(long = "api-token")] + pub api_token: Option, + + /// Restrict rollback to specific ecosystems + #[arg(long, value_delimiter = ',')] + pub ecosystems: Option>, +} + +struct PatchToRollback { + purl: String, + patch: PatchRecord, +} + +fn find_patches_to_rollback( + manifest: &PatchManifest, + identifier: Option<&str>, +) -> Vec { + match identifier { + None => manifest + .patches + .iter() + .map(|(purl, patch)| PatchToRollback { + purl: purl.clone(), + patch: patch.clone(), + }) + .collect(), + Some(id) => { + let mut patches = Vec::new(); + if id.starts_with("pkg:") { + if let Some(patch) = manifest.patches.get(id) { + patches.push(PatchToRollback { + purl: id.to_string(), + patch: patch.clone(), + }); + } + } else { + for (purl, patch) in &manifest.patches { + if patch.uuid == id { + patches.push(PatchToRollback { + purl: purl.clone(), + patch: patch.clone(), + }); + } + } + } + patches + } + } +} + +fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { + let mut blobs = HashSet::new(); + for patch in manifest.patches.values() { + for file_info in patch.files.values() { + blobs.insert(file_info.before_hash.clone()); + } + } + blobs +} + +async fn get_missing_before_blobs( + manifest: &PatchManifest, + blobs_path: &Path, +) -> HashSet { + let before_blobs = get_before_hash_blobs(manifest); + let mut missing = HashSet::new(); + for hash in before_blobs { + let blob_path = blobs_path.join(&hash); + if tokio::fs::metadata(&blob_path).await.is_err() { + missing.insert(hash); + } + } + missing +} + +pub async fn run(args: RollbackArgs) -> i32 { + let api_token = args + .api_token + .clone() + .or_else(|| std::env::var("SOCKET_API_TOKEN").ok()); + let org_slug = args + .org + .clone() + .or_else(|| std::env::var("SOCKET_ORG_SLUG").ok()); + + // Validate one-off requires identifier + if args.one_off && args.identifier.is_none() { + eprintln!("Error: --one-off requires an identifier (UUID or PURL)"); + return 1; + } + + // Override env vars if CLI options provided + if let Some(ref url) = args.api_url { + std::env::set_var("SOCKET_API_URL", url); + } + if let Some(ref token) = args.api_token { + std::env::set_var("SOCKET_API_TOKEN", token); + } + + // Handle one-off mode + if args.one_off { + // One-off mode not fully implemented yet - placeholder + eprintln!("One-off rollback mode: fetching patch data..."); + // TODO: implement one-off rollback + return 1; + } + + let manifest_path = if Path::new(&args.manifest_path).is_absolute() { + PathBuf::from(&args.manifest_path) + } else { + args.cwd.join(&args.manifest_path) + }; + + if tokio::fs::metadata(&manifest_path).await.is_err() { + if !args.silent { + eprintln!("Manifest not found at {}", manifest_path.display()); + } + return 1; + } + + match rollback_patches_inner(&args, &manifest_path).await { + Ok((success, results)) => { + if !args.silent && !results.is_empty() { + let rolled_back: Vec<_> = results + .iter() + .filter(|r| r.success && !r.files_rolled_back.is_empty()) + .collect(); + let already_original: Vec<_> = results + .iter() + .filter(|r| { + r.success + && r.files_verified.iter().all(|f| { + f.status + == socket_patch_core::patch::rollback::VerifyRollbackStatus::AlreadyOriginal + }) + }) + .collect(); + let failed: Vec<_> = results.iter().filter(|r| !r.success).collect(); + + if args.dry_run { + println!("\nRollback verification complete:"); + let can_rollback = results.iter().filter(|r| r.success).count(); + println!(" {can_rollback} package(s) can be rolled back"); + if !already_original.is_empty() { + println!( + " {} package(s) already in original state", + already_original.len() + ); + } + if !failed.is_empty() { + println!(" {} package(s) cannot be rolled back", failed.len()); + } + } else { + if !rolled_back.is_empty() || !already_original.is_empty() { + println!("\nRolled back packages:"); + for result in &rolled_back { + println!(" {}", result.package_key); + } + for result in &already_original { + println!(" {} (already original)", result.package_key); + } + } + if !failed.is_empty() { + println!("\nFailed to rollback:"); + for result in &failed { + println!( + " {}: {}", + result.package_key, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + } + } + + let rolled_back_count = results + .iter() + .filter(|r| r.success && !r.files_rolled_back.is_empty()) + .count(); + if success { + track_patch_rolled_back(rolled_back_count, api_token.as_deref(), org_slug.as_deref()).await; + } else { + track_patch_rollback_failed("One or more rollbacks failed", api_token.as_deref(), org_slug.as_deref()).await; + } + + if success { 0 } else { 1 } + } + Err(e) => { + track_patch_rollback_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; + if !args.silent { + eprintln!("Error: {e}"); + } + 1 + } + } +} + +async fn rollback_patches_inner( + args: &RollbackArgs, + manifest_path: &Path, +) -> Result<(bool, Vec), String> { + let manifest = read_manifest(manifest_path) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Invalid manifest".to_string())?; + + let socket_dir = manifest_path.parent().unwrap(); + let blobs_path = socket_dir.join("blobs"); + tokio::fs::create_dir_all(&blobs_path) + .await + .map_err(|e| e.to_string())?; + + let patches_to_rollback = + find_patches_to_rollback(&manifest, args.identifier.as_deref()); + + if patches_to_rollback.is_empty() { + if args.identifier.is_some() { + return Err(format!( + "No patch found matching identifier: {}", + args.identifier.as_deref().unwrap() + )); + } + if !args.silent { + println!("No patches found in manifest"); + } + return Ok((true, Vec::new())); + } + + // Create filtered manifest + let filtered_manifest = PatchManifest { + patches: patches_to_rollback + .iter() + .map(|p| (p.purl.clone(), p.patch.clone())) + .collect(), + }; + + // Check for missing beforeHash blobs + let missing_blobs = get_missing_before_blobs(&filtered_manifest, &blobs_path).await; + if !missing_blobs.is_empty() { + if args.offline { + if !args.silent { + eprintln!( + "Error: {} blob(s) are missing and --offline mode is enabled.", + missing_blobs.len() + ); + eprintln!("Run \"socket-patch repair\" to download missing blobs."); + } + return Ok((false, Vec::new())); + } + + if !args.silent { + println!("Downloading {} missing blob(s)...", missing_blobs.len()); + } + + let (client, _) = get_api_client_from_env(None); + let fetch_result = fetch_blobs_by_hash(&missing_blobs, &blobs_path, &client, None).await; + + if !args.silent { + println!("{}", format_fetch_result(&fetch_result)); + } + + let still_missing = get_missing_before_blobs(&filtered_manifest, &blobs_path).await; + if !still_missing.is_empty() { + if !args.silent { + eprintln!( + "{} blob(s) could not be downloaded. Cannot rollback.", + still_missing.len() + ); + } + return Ok((false, Vec::new())); + } + } + + // Partition PURLs by ecosystem + let rollback_purls: Vec = patches_to_rollback.iter().map(|p| p.purl.clone()).collect(); + let mut npm_purls: Vec = rollback_purls.iter().filter(|p| !is_pypi_purl(p)).cloned().collect(); + let mut pypi_purls: Vec = rollback_purls.iter().filter(|p| is_pypi_purl(p)).cloned().collect(); + + if let Some(ref ecosystems) = args.ecosystems { + if !ecosystems.iter().any(|e| e == "npm") { + npm_purls.clear(); + } + if !ecosystems.iter().any(|e| e == "pypi") { + pypi_purls.clear(); + } + } + + let crawler_options = CrawlerOptions { + cwd: args.cwd.clone(), + global: args.global, + global_prefix: args.global_prefix.clone(), + batch_size: 100, + }; + + let mut all_packages: HashMap = HashMap::new(); + + // Find npm packages + if !npm_purls.is_empty() { + if args.global || args.global_prefix.is_some() { + match get_global_prefix(args.global_prefix.as_ref().map(|p| p.to_str().unwrap_or(""))) { + Ok(prefix) => { + if !args.silent { + println!("Using global npm packages at: {prefix}"); + } + let npm_crawler = NpmCrawler; + if let Ok(packages) = npm_crawler.find_by_purls(Path::new(&prefix), &npm_purls).await { + for (purl, pkg) in packages { + all_packages.entry(purl).or_insert(pkg.path); + } + } + } + Err(e) => { + if !args.silent { + eprintln!("Failed to find global npm packages: {e}"); + } + return Ok((false, Vec::new())); + } + } + } else { + let npm_crawler = NpmCrawler; + if let Ok(nm_paths) = npm_crawler.get_node_modules_paths(&crawler_options).await { + for nm_path in &nm_paths { + if let Ok(packages) = npm_crawler.find_by_purls(nm_path, &npm_purls).await { + for (purl, pkg) in packages { + all_packages.entry(purl).or_insert(pkg.path); + } + } + } + } + } + } + + // Find Python packages + if !pypi_purls.is_empty() { + let python_crawler = PythonCrawler; + let base_pypi_purls: Vec = pypi_purls + .iter() + .map(|p| strip_purl_qualifiers(p).to_string()) + .collect::>() + .into_iter() + .collect(); + + if let Ok(sp_paths) = python_crawler.get_site_packages_paths(&crawler_options).await { + for sp_path in &sp_paths { + if let Ok(packages) = python_crawler.find_by_purls(sp_path, &base_pypi_purls).await { + for (base_purl, pkg) in packages { + for qualified_purl in &pypi_purls { + if strip_purl_qualifiers(qualified_purl) == base_purl + && !all_packages.contains_key(qualified_purl) + { + all_packages.insert(qualified_purl.clone(), pkg.path.clone()); + } + } + } + } + } + } + } + + if all_packages.is_empty() { + if !args.silent { + println!("No packages found that match patches to rollback"); + } + return Ok((true, Vec::new())); + } + + // Rollback patches + let mut results: Vec = Vec::new(); + let mut has_errors = false; + + for (purl, pkg_path) in &all_packages { + let patch = match filtered_manifest.patches.get(purl) { + Some(p) => p, + None => continue, + }; + + let result = rollback_package_patch( + purl, + pkg_path, + &patch.files, + &blobs_path, + args.dry_run, + ) + .await; + + if !result.success { + has_errors = true; + if !args.silent { + eprintln!( + "Failed to rollback {}: {}", + purl, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + results.push(result); + } + + Ok((!has_errors, results)) +} + +// Export for use by remove command +pub async fn rollback_patches( + cwd: &Path, + manifest_path: &Path, + identifier: Option<&str>, + dry_run: bool, + silent: bool, + offline: bool, + global: bool, + global_prefix: Option, + ecosystems: Option>, +) -> Result<(bool, Vec), String> { + let args = RollbackArgs { + identifier: identifier.map(String::from), + cwd: cwd.to_path_buf(), + dry_run, + silent, + manifest_path: manifest_path.display().to_string(), + offline, + global, + global_prefix, + one_off: false, + org: None, + api_url: None, + api_token: None, + ecosystems, + }; + rollback_patches_inner(&args, manifest_path).await +} diff --git a/crates/socket-patch-cli/src/commands/scan.rs b/crates/socket-patch-cli/src/commands/scan.rs new file mode 100644 index 0000000..1e69761 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan.rs @@ -0,0 +1,360 @@ +use clap::Args; +use socket_patch_core::api::client::get_api_client_from_env; +use socket_patch_core::api::types::BatchPackagePatches; +use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler}; +use std::collections::HashSet; +use std::path::PathBuf; + +const DEFAULT_BATCH_SIZE: usize = 100; + +#[derive(Args)] +pub struct ScanArgs { + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Organization slug + #[arg(long)] + pub org: Option, + + /// Output results as JSON + #[arg(long, default_value_t = false)] + pub json: bool, + + /// Scan globally installed npm packages + #[arg(short = 'g', long, default_value_t = false)] + pub global: bool, + + /// Custom path to global node_modules + #[arg(long = "global-prefix")] + pub global_prefix: Option, + + /// Number of packages to query per API request + #[arg(long = "batch-size", default_value_t = DEFAULT_BATCH_SIZE)] + pub batch_size: usize, + + /// Socket API URL (overrides SOCKET_API_URL env var) + #[arg(long = "api-url")] + pub api_url: Option, + + /// Socket API token (overrides SOCKET_API_TOKEN env var) + #[arg(long = "api-token")] + pub api_token: Option, +} + +pub async fn run(args: ScanArgs) -> i32 { + // Override env vars if CLI options provided + if let Some(ref url) = args.api_url { + std::env::set_var("SOCKET_API_URL", url); + } + if let Some(ref token) = args.api_token { + std::env::set_var("SOCKET_API_TOKEN", token); + } + + let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref()); + + if !use_public_proxy && args.org.is_none() { + eprintln!("Error: --org is required when using SOCKET_API_TOKEN. Provide an organization slug."); + return 1; + } + + let effective_org_slug = if use_public_proxy { + None + } else { + args.org.as_deref() + }; + + let crawler_options = CrawlerOptions { + cwd: args.cwd.clone(), + global: args.global, + global_prefix: args.global_prefix.clone(), + batch_size: args.batch_size, + }; + + let scan_target = if args.global || args.global_prefix.is_some() { + "global packages" + } else { + "packages" + }; + + if !args.json { + eprint!("Scanning {scan_target}..."); + } + + // Crawl packages + let npm_crawler = NpmCrawler; + let python_crawler = PythonCrawler; + + let npm_packages = npm_crawler.crawl_all(&crawler_options).await; + let python_packages = python_crawler.crawl_all(&crawler_options).await; + + let mut all_purls: Vec = Vec::new(); + for pkg in &npm_packages { + all_purls.push(pkg.purl.clone()); + } + for pkg in &python_packages { + all_purls.push(pkg.purl.clone()); + } + + let package_count = all_purls.len(); + let npm_count = npm_packages.len(); + let python_count = python_packages.len(); + + if package_count == 0 { + if !args.json { + eprintln!(); + } + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "scannedPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + })) + .unwrap() + ); + } else if args.global || args.global_prefix.is_some() { + println!("No global packages found."); + } else { + println!("No packages found. Run npm/yarn/pnpm/pip install first."); + } + return 0; + } + + // Build ecosystem summary + let mut eco_parts = Vec::new(); + if npm_count > 0 { + eco_parts.push(format!("{npm_count} npm")); + } + if python_count > 0 { + eco_parts.push(format!("{python_count} python")); + } + let eco_summary = if eco_parts.is_empty() { + String::new() + } else { + format!(" ({})", eco_parts.join(", ")) + }; + + if !args.json { + eprintln!("\rFound {package_count} packages{eco_summary}"); + } + + // Query API in batches + let mut all_packages_with_patches: Vec = Vec::new(); + let mut can_access_paid_patches = false; + let total_batches = (all_purls.len() + args.batch_size - 1) / args.batch_size; + + if !args.json { + eprint!("Querying API for patches... (batch 1/{total_batches})"); + } + + for (batch_idx, chunk) in all_purls.chunks(args.batch_size).enumerate() { + if !args.json { + eprint!( + "\rQuerying API for patches... (batch {}/{})", + batch_idx + 1, + total_batches + ); + } + + let purls: Vec = chunk.to_vec(); + match api_client + .search_patches_batch(effective_org_slug, &purls) + .await + { + Ok(response) => { + if response.can_access_paid_patches { + can_access_paid_patches = true; + } + for pkg in response.packages { + if !pkg.patches.is_empty() { + all_packages_with_patches.push(pkg); + } + } + } + Err(e) => { + if !args.json { + eprintln!("\nError querying batch {}: {e}", batch_idx + 1); + } + } + } + } + + let total_patches_found: usize = all_packages_with_patches + .iter() + .map(|p| p.patches.len()) + .sum(); + + if !args.json { + if total_patches_found > 0 { + eprintln!( + "\rFound {total_patches_found} patches for {} packages", + all_packages_with_patches.len() + ); + } else { + eprintln!("\rAPI query complete"); + } + } + + // Calculate patch counts + let mut free_patches = 0usize; + let mut paid_patches = 0usize; + for pkg in &all_packages_with_patches { + for patch in &pkg.patches { + if patch.tier == "free" { + free_patches += 1; + } else { + paid_patches += 1; + } + } + } + let total_patches = free_patches + paid_patches; + + if args.json { + let result = serde_json::json!({ + "scannedPackages": package_count, + "packagesWithPatches": all_packages_with_patches.len(), + "totalPatches": total_patches, + "freePatches": free_patches, + "paidPatches": paid_patches, + "canAccessPaidPatches": can_access_paid_patches, + "packages": all_packages_with_patches, + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return 0; + } + + if all_packages_with_patches.is_empty() { + println!("\nNo patches available for installed packages."); + return 0; + } + + // Print table + println!("\n{}", "=".repeat(100)); + println!( + "{} {} {} VULNERABILITIES", + "PACKAGE".to_string() + &" ".repeat(33), + "PATCHES".to_string() + &" ".repeat(1), + "SEVERITY".to_string() + &" ".repeat(8), + ); + println!("{}", "=".repeat(100)); + + for pkg in &all_packages_with_patches { + let max_purl_len = 40; + let display_purl = if pkg.purl.len() > max_purl_len { + format!("{}...", &pkg.purl[..max_purl_len - 3]) + } else { + pkg.purl.clone() + }; + + let pkg_free = pkg.patches.iter().filter(|p| p.tier == "free").count(); + let pkg_paid = pkg.patches.iter().filter(|p| p.tier == "paid").count(); + + let count_str = if pkg_paid > 0 { + if can_access_paid_patches { + format!("{}+{}", pkg_free, pkg_paid) + } else { + format!("{}\x1b[33m+{}\x1b[0m", pkg_free, pkg_paid) + } + } else { + format!("{}", pkg_free) + }; + + // Get highest severity + let severity = pkg + .patches + .iter() + .filter_map(|p| p.severity.as_deref()) + .min_by_key(|s| severity_order(s)) + .unwrap_or("unknown"); + + // Collect vuln IDs + let mut all_cves = HashSet::new(); + let mut all_ghsas = HashSet::new(); + for patch in &pkg.patches { + for cve in &patch.cve_ids { + all_cves.insert(cve.clone()); + } + for ghsa in &patch.ghsa_ids { + all_ghsas.insert(ghsa.clone()); + } + } + let vuln_ids: Vec<_> = all_cves.into_iter().chain(all_ghsas).collect(); + let vuln_str = if vuln_ids.len() > 2 { + format!( + "{} (+{})", + vuln_ids[..2].join(", "), + vuln_ids.len() - 2 + ) + } else if vuln_ids.is_empty() { + "-".to_string() + } else { + vuln_ids.join(", ") + }; + + println!( + "{:<40} {:>8} {:<16} {}", + display_purl, + count_str, + format_severity(severity), + vuln_str, + ); + } + + println!("{}", "=".repeat(100)); + + // Summary + if can_access_paid_patches { + println!( + "\nSummary: {} package(s) with {} available patch(es)", + all_packages_with_patches.len(), + total_patches, + ); + } else { + println!( + "\nSummary: {} package(s) with {} free patch(es)", + all_packages_with_patches.len(), + free_patches, + ); + if paid_patches > 0 { + println!( + "\x1b[33m + {} additional patch(es) available with paid subscription\x1b[0m", + paid_patches, + ); + println!( + "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing" + ); + } + } + + println!("\nTo apply a patch, run:"); + println!(" socket-patch get "); + println!(" socket-patch get "); + + 0 +} + +fn severity_order(s: &str) -> u8 { + match s.to_lowercase().as_str() { + "critical" => 0, + "high" => 1, + "medium" => 2, + "low" => 3, + _ => 4, + } +} + +fn format_severity(s: &str) -> String { + match s.to_lowercase().as_str() { + "critical" => "\x1b[31mcritical\x1b[0m".to_string(), + "high" => "\x1b[91mhigh\x1b[0m".to_string(), + "medium" => "\x1b[33mmedium\x1b[0m".to_string(), + "low" => "\x1b[36mlow\x1b[0m".to_string(), + other => other.to_string(), + } +} diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs new file mode 100644 index 0000000..c72a092 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -0,0 +1,152 @@ +use clap::Args; +use socket_patch_core::package_json::find::find_package_json_files; +use socket_patch_core::package_json::update::{update_package_json, UpdateStatus}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Args)] +pub struct SetupArgs { + /// Working directory + #[arg(long, default_value = ".")] + pub cwd: PathBuf, + + /// Preview changes without modifying files + #[arg(short = 'd', long = "dry-run", default_value_t = false)] + pub dry_run: bool, + + /// Skip confirmation prompt + #[arg(short = 'y', long, default_value_t = false)] + pub yes: bool, +} + +pub async fn run(args: SetupArgs) -> i32 { + println!("Searching for package.json files..."); + + let package_json_files = find_package_json_files(&args.cwd).await; + + if package_json_files.is_empty() { + println!("No package.json files found"); + return 0; + } + + println!("Found {} package.json file(s)", package_json_files.len()); + + // Preview changes (always preview first) + let mut preview_results = Vec::new(); + for loc in &package_json_files { + let result = update_package_json(&loc.path, true).await; + preview_results.push(result); + } + + // Display preview + let to_update: Vec<_> = preview_results + .iter() + .filter(|r| r.status == UpdateStatus::Updated) + .collect(); + let already_configured: Vec<_> = preview_results + .iter() + .filter(|r| r.status == UpdateStatus::AlreadyConfigured) + .collect(); + let errors: Vec<_> = preview_results + .iter() + .filter(|r| r.status == UpdateStatus::Error) + .collect(); + + println!("\nPackage.json files to be updated:\n"); + + if !to_update.is_empty() { + println!("Will update:"); + for result in &to_update { + let rel_path = pathdiff(&result.path, &args.cwd); + println!(" + {rel_path}"); + if result.old_script.is_empty() { + println!(" Current: (no postinstall script)"); + } else { + println!(" Current: \"{}\"", result.old_script); + } + println!(" New: \"{}\"", result.new_script); + } + println!(); + } + + if !already_configured.is_empty() { + println!("Already configured (will skip):"); + for result in &already_configured { + let rel_path = pathdiff(&result.path, &args.cwd); + println!(" = {rel_path}"); + } + println!(); + } + + if !errors.is_empty() { + println!("Errors:"); + for result in &errors { + let rel_path = pathdiff(&result.path, &args.cwd); + println!( + " ! {}: {}", + rel_path, + result.error.as_deref().unwrap_or("unknown error") + ); + } + println!(); + } + + if to_update.is_empty() { + println!("All package.json files are already configured with socket-patch!"); + return 0; + } + + // If not dry-run, ask for confirmation + if !args.dry_run { + if !args.yes { + print!("Proceed with these changes? (y/N): "); + io::stdout().flush().unwrap(); + let mut answer = String::new(); + io::stdin().read_line(&mut answer).unwrap(); + let answer = answer.trim().to_lowercase(); + if answer != "y" && answer != "yes" { + println!("Aborted"); + return 0; + } + } + + println!("\nApplying changes..."); + let mut results = Vec::new(); + for loc in &package_json_files { + let result = update_package_json(&loc.path, false).await; + results.push(result); + } + + let updated = results.iter().filter(|r| r.status == UpdateStatus::Updated).count(); + let already = results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count(); + let errs = results.iter().filter(|r| r.status == UpdateStatus::Error).count(); + + println!("\nSummary:"); + println!(" {updated} file(s) updated"); + println!(" {already} file(s) already configured"); + if errs > 0 { + println!(" {errs} error(s)"); + } + + if errs > 0 { 1 } else { 0 } + } else { + let updated = preview_results.iter().filter(|r| r.status == UpdateStatus::Updated).count(); + let already = preview_results.iter().filter(|r| r.status == UpdateStatus::AlreadyConfigured).count(); + let errs = preview_results.iter().filter(|r| r.status == UpdateStatus::Error).count(); + + println!("\nSummary:"); + println!(" {updated} file(s) would be updated"); + println!(" {already} file(s) already configured"); + if errs > 0 { + println!(" {errs} error(s)"); + } + 0 + } +} + +fn pathdiff(path: &str, base: &Path) -> String { + let p = Path::new(path); + p.strip_prefix(base) + .map(|r| r.display().to_string()) + .unwrap_or_else(|_| path.to_string()) +} diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs new file mode 100644 index 0000000..e04f9ce --- /dev/null +++ b/crates/socket-patch-cli/src/main.rs @@ -0,0 +1,62 @@ +mod commands; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "socket-patch", + about = "CLI tool for applying security patches to dependencies", + version, + propagate_version = true +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Apply security patches to dependencies + Apply(commands::apply::ApplyArgs), + + /// Rollback patches to restore original files + Rollback(commands::rollback::RollbackArgs), + + /// Get security patches from Socket API and apply them + #[command(visible_alias = "download")] + Get(commands::get::GetArgs), + + /// Scan installed packages for available security patches + Scan(commands::scan::ScanArgs), + + /// List all patches in the local manifest + List(commands::list::ListArgs), + + /// Remove a patch from the manifest by PURL or UUID (rolls back files first) + Remove(commands::remove::RemoveArgs), + + /// Configure package.json postinstall scripts to apply patches + Setup(commands::setup::SetupArgs), + + /// Download missing blobs and clean up unused blobs + #[command(visible_alias = "gc")] + Repair(commands::repair::RepairArgs), +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + let exit_code = match cli.command { + Commands::Apply(args) => commands::apply::run(args).await, + Commands::Rollback(args) => commands::rollback::run(args).await, + Commands::Get(args) => commands::get::run(args).await, + Commands::Scan(args) => commands::scan::run(args).await, + Commands::List(args) => commands::list::run(args).await, + Commands::Remove(args) => commands::remove::run(args).await, + Commands::Setup(args) => commands::setup::run(args).await, + Commands::Repair(args) => commands::repair::run(args).await, + }; + + std::process::exit(exit_code); +} diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml new file mode 100644 index 0000000..7930beb --- /dev/null +++ b/crates/socket-patch-core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "socket-patch-core" +description = "Core library for socket-patch: manifest, hash, crawlers, patch engine, API client" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +walkdir = { workspace = true } +uuid = { workspace = true } +regex = { workspace = true } +once_cell = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs new file mode 100644 index 0000000..aac019e --- /dev/null +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -0,0 +1,453 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::api::client::ApiClient; +use crate::manifest::operations::get_after_hash_blobs; +use crate::manifest::schema::PatchManifest; + +/// Result of fetching a single blob. +#[derive(Debug, Clone)] +pub struct BlobFetchResult { + pub hash: String, + pub success: bool, + pub error: Option, +} + +/// Aggregate result of a blob-fetch operation. +#[derive(Debug, Clone)] +pub struct FetchMissingBlobsResult { + pub total: usize, + pub downloaded: usize, + pub failed: usize, + pub skipped: usize, + pub results: Vec, +} + +/// Progress callback signature. +/// +/// Called with `(hash, one_based_index, total)` for each blob. +pub type OnProgress = Box; + +// ── Public API ──────────────────────────────────────────────────────── + +/// Determine which `afterHash` blobs referenced in the manifest are +/// missing from disk. +/// +/// Only checks `afterHash` blobs because those are the patched file +/// contents needed for applying patches. `beforeHash` blobs are +/// downloaded on-demand during rollback. +pub async fn get_missing_blobs( + manifest: &PatchManifest, + blobs_path: &Path, +) -> HashSet { + let after_hash_blobs = get_after_hash_blobs(manifest); + let mut missing = HashSet::new(); + + for hash in after_hash_blobs { + let blob_path = blobs_path.join(&hash); + if tokio::fs::metadata(&blob_path).await.is_err() { + missing.insert(hash); + } + } + + missing +} + +/// Download all missing `afterHash` blobs referenced in the manifest. +/// +/// Creates the `blobs_path` directory if it does not exist. +/// +/// # Arguments +/// +/// * `manifest` – Patch manifest whose `afterHash` blobs to check. +/// * `blobs_path` – Directory where blob files are stored (one file per +/// hash). +/// * `client` – [`ApiClient`] used to fetch blobs from the server. +/// * `on_progress` – Optional callback invoked before each download with +/// `(hash, 1-based index, total)`. +pub async fn fetch_missing_blobs( + manifest: &PatchManifest, + blobs_path: &Path, + client: &ApiClient, + on_progress: Option<&OnProgress>, +) -> FetchMissingBlobsResult { + let missing = get_missing_blobs(manifest, blobs_path).await; + + if missing.is_empty() { + return FetchMissingBlobsResult { + total: 0, + downloaded: 0, + failed: 0, + skipped: 0, + results: Vec::new(), + }; + } + + // Ensure blobs directory exists + if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { + // If we cannot create the directory, every blob will fail. + let results: Vec = missing + .iter() + .map(|h| BlobFetchResult { + hash: h.clone(), + success: false, + error: Some(format!("Cannot create blobs directory: {}", e)), + }) + .collect(); + let failed = results.len(); + return FetchMissingBlobsResult { + total: failed, + downloaded: 0, + failed, + skipped: 0, + results, + }; + } + + let hashes: Vec = missing.into_iter().collect(); + download_hashes(&hashes, blobs_path, client, on_progress).await +} + +/// Download specific blobs identified by their hashes. +/// +/// Useful for fetching `beforeHash` blobs during rollback, where only a +/// subset of hashes is required. +/// +/// Blobs that already exist on disk are skipped (counted in `skipped`). +pub async fn fetch_blobs_by_hash( + hashes: &HashSet, + blobs_path: &Path, + client: &ApiClient, + on_progress: Option<&OnProgress>, +) -> FetchMissingBlobsResult { + if hashes.is_empty() { + return FetchMissingBlobsResult { + total: 0, + downloaded: 0, + failed: 0, + skipped: 0, + results: Vec::new(), + }; + } + + // Ensure blobs directory exists + if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { + let results: Vec = hashes + .iter() + .map(|h| BlobFetchResult { + hash: h.clone(), + success: false, + error: Some(format!("Cannot create blobs directory: {}", e)), + }) + .collect(); + let failed = results.len(); + return FetchMissingBlobsResult { + total: failed, + downloaded: 0, + failed, + skipped: 0, + results, + }; + } + + // Filter out hashes that already exist on disk + let mut to_download: Vec = Vec::new(); + let mut skipped: usize = 0; + let mut results: Vec = Vec::new(); + + for hash in hashes { + let blob_path = blobs_path.join(hash); + if tokio::fs::metadata(&blob_path).await.is_ok() { + skipped += 1; + results.push(BlobFetchResult { + hash: hash.clone(), + success: true, + error: None, + }); + } else { + to_download.push(hash.clone()); + } + } + + if to_download.is_empty() { + return FetchMissingBlobsResult { + total: hashes.len(), + downloaded: 0, + failed: 0, + skipped, + results, + }; + } + + let download_result = + download_hashes(&to_download, blobs_path, client, on_progress).await; + + FetchMissingBlobsResult { + total: hashes.len(), + downloaded: download_result.downloaded, + failed: download_result.failed, + skipped, + results: { + let mut combined = results; + combined.extend(download_result.results); + combined + }, + } +} + +/// Format a [`FetchMissingBlobsResult`] as a human-readable string. +pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String { + if result.total == 0 { + return "All blobs are present locally.".to_string(); + } + + let mut lines: Vec = Vec::new(); + + if result.downloaded > 0 { + lines.push(format!("Downloaded {} blob(s)", result.downloaded)); + } + + if result.failed > 0 { + lines.push(format!("Failed to download {} blob(s)", result.failed)); + + let failed_results: Vec<&BlobFetchResult> = + result.results.iter().filter(|r| !r.success).collect(); + + for r in failed_results.iter().take(5) { + let short_hash = if r.hash.len() >= 12 { + &r.hash[..12] + } else { + &r.hash + }; + let err = r.error.as_deref().unwrap_or("unknown error"); + lines.push(format!(" - {}...: {}", short_hash, err)); + } + + if failed_results.len() > 5 { + lines.push(format!(" ... and {} more", failed_results.len() - 5)); + } + } + + lines.join("\n") +} + +// ── Internal helpers ────────────────────────────────────────────────── + +/// Download a list of blob hashes sequentially, writing each to +/// `blobs_path/`. +async fn download_hashes( + hashes: &[String], + blobs_path: &Path, + client: &ApiClient, + on_progress: Option<&OnProgress>, +) -> FetchMissingBlobsResult { + let total = hashes.len(); + let mut downloaded: usize = 0; + let mut failed: usize = 0; + let mut results: Vec = Vec::with_capacity(total); + + for (i, hash) in hashes.iter().enumerate() { + if let Some(ref cb) = on_progress { + cb(hash, i + 1, total); + } + + match client.fetch_blob(hash).await { + Ok(Some(data)) => { + let blob_path: PathBuf = blobs_path.join(hash); + match tokio::fs::write(&blob_path, &data).await { + Ok(()) => { + results.push(BlobFetchResult { + hash: hash.clone(), + success: true, + error: None, + }); + downloaded += 1; + } + Err(e) => { + results.push(BlobFetchResult { + hash: hash.clone(), + success: false, + error: Some(format!("Failed to write blob to disk: {}", e)), + }); + failed += 1; + } + } + } + Ok(None) => { + results.push(BlobFetchResult { + hash: hash.clone(), + success: false, + error: Some("Blob not found on server".to_string()), + }); + failed += 1; + } + Err(e) => { + results.push(BlobFetchResult { + hash: hash.clone(), + success: false, + error: Some(e.to_string()), + }); + failed += 1; + } + } + } + + FetchMissingBlobsResult { + total, + downloaded, + failed, + skipped: 0, + results, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; + use std::collections::HashMap; + + fn make_manifest_with_hashes(after_hashes: &[&str]) -> PatchManifest { + let mut files = HashMap::new(); + for (i, ah) in after_hashes.iter().enumerate() { + files.insert( + format!("package/file{}.js", i), + PatchFileInfo { + before_hash: format!( + "before{}{}", + "0".repeat(58), + format!("{:06}", i) + ), + after_hash: ah.to_string(), + }, + ); + } + + let mut patches = HashMap::new(); + patches.insert( + "pkg:npm/test@1.0.0".to_string(), + PatchRecord { + uuid: "test-uuid".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "test".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + PatchManifest { patches } + } + + #[tokio::test] + async fn test_get_missing_blobs_all_missing() { + let dir = tempfile::tempdir().unwrap(); + let blobs_path = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_path).await.unwrap(); + + let h1 = "a".repeat(64); + let h2 = "b".repeat(64); + let manifest = make_manifest_with_hashes(&[&h1, &h2]); + + let missing = get_missing_blobs(&manifest, &blobs_path).await; + assert_eq!(missing.len(), 2); + assert!(missing.contains(&h1)); + assert!(missing.contains(&h2)); + } + + #[tokio::test] + async fn test_get_missing_blobs_some_present() { + let dir = tempfile::tempdir().unwrap(); + let blobs_path = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_path).await.unwrap(); + + let h1 = "a".repeat(64); + let h2 = "b".repeat(64); + + // Write h1 to disk so it is NOT missing + tokio::fs::write(blobs_path.join(&h1), b"data").await.unwrap(); + + let manifest = make_manifest_with_hashes(&[&h1, &h2]); + let missing = get_missing_blobs(&manifest, &blobs_path).await; + assert_eq!(missing.len(), 1); + assert!(missing.contains(&h2)); + assert!(!missing.contains(&h1)); + } + + #[tokio::test] + async fn test_get_missing_blobs_empty_manifest() { + let dir = tempfile::tempdir().unwrap(); + let blobs_path = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_path).await.unwrap(); + + let manifest = PatchManifest::new(); + let missing = get_missing_blobs(&manifest, &blobs_path).await; + assert!(missing.is_empty()); + } + + #[test] + fn test_format_fetch_result_all_present() { + let result = FetchMissingBlobsResult { + total: 0, + downloaded: 0, + failed: 0, + skipped: 0, + results: Vec::new(), + }; + assert_eq!(format_fetch_result(&result), "All blobs are present locally."); + } + + #[test] + fn test_format_fetch_result_some_downloaded() { + let result = FetchMissingBlobsResult { + total: 3, + downloaded: 2, + failed: 1, + skipped: 0, + results: vec![ + BlobFetchResult { + hash: "a".repeat(64), + success: true, + error: None, + }, + BlobFetchResult { + hash: "b".repeat(64), + success: true, + error: None, + }, + BlobFetchResult { + hash: "c".repeat(64), + success: false, + error: Some("Blob not found on server".to_string()), + }, + ], + }; + let output = format_fetch_result(&result); + assert!(output.contains("Downloaded 2 blob(s)")); + assert!(output.contains("Failed to download 1 blob(s)")); + assert!(output.contains("cccccccccccc...")); + assert!(output.contains("Blob not found on server")); + } + + #[test] + fn test_format_fetch_result_truncates_at_5() { + let results: Vec = (0..8) + .map(|i| BlobFetchResult { + hash: format!("{:0>64}", i), + success: false, + error: Some(format!("error {}", i)), + }) + .collect(); + + let result = FetchMissingBlobsResult { + total: 8, + downloaded: 0, + failed: 8, + skipped: 0, + results, + }; + let output = format_fetch_result(&result); + assert!(output.contains("... and 3 more")); + } +} diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs new file mode 100644 index 0000000..df14827 --- /dev/null +++ b/crates/socket-patch-core/src/api/client.rs @@ -0,0 +1,724 @@ +use std::collections::HashSet; + +use reqwest::header::{self, HeaderMap, HeaderValue}; +use reqwest::StatusCode; +use serde::Serialize; + +use crate::api::types::*; +use crate::constants::{ + DEFAULT_PATCH_API_PROXY_URL, DEFAULT_SOCKET_API_URL, USER_AGENT as USER_AGENT_VALUE, +}; + +/// Check if debug mode is enabled via SOCKET_PATCH_DEBUG env. +fn is_debug_enabled() -> bool { + match std::env::var("SOCKET_PATCH_DEBUG") { + Ok(val) => val == "1" || val == "true", + Err(_) => false, + } +} + +/// Log debug messages when debug mode is enabled. +fn debug_log(message: &str) { + if is_debug_enabled() { + eprintln!("[socket-patch debug] {}", message); + } +} + +/// Severity order for sorting (most severe = lowest number). +fn get_severity_order(severity: Option<&str>) -> u8 { + match severity.map(|s| s.to_lowercase()).as_deref() { + Some("critical") => 0, + Some("high") => 1, + Some("medium") => 2, + Some("low") => 3, + _ => 4, + } +} + +/// Options for constructing an [`ApiClient`]. +#[derive(Debug, Clone)] +pub struct ApiClientOptions { + pub api_url: String, + pub api_token: Option, + /// When true, the client will use the public patch API proxy + /// which only provides access to free patches without authentication. + pub use_public_proxy: bool, + /// Organization slug for authenticated API access. + /// Required when using authenticated API (not public proxy). + pub org_slug: Option, +} + +/// HTTP client for the Socket Patch API. +/// +/// Supports both the authenticated Socket API (`api.socket.dev`) and the +/// public proxy (`patches-api.socket.dev`) which serves free patches +/// without authentication. +#[derive(Debug, Clone)] +pub struct ApiClient { + client: reqwest::Client, + api_url: String, + api_token: Option, + use_public_proxy: bool, + org_slug: Option, +} + +/// Body payload for the batch search POST endpoint. +#[derive(Serialize)] +struct BatchSearchBody { + components: Vec, +} + +#[derive(Serialize)] +struct BatchComponent { + purl: String, +} + +impl ApiClient { + /// Create a new API client from the given options. + /// + /// Constructs a `reqwest::Client` with proper default headers + /// (User-Agent, Accept, and optionally Authorization). + pub fn new(options: ApiClientOptions) -> Self { + let api_url = options.api_url.trim_end_matches('/').to_string(); + + let mut default_headers = HeaderMap::new(); + default_headers.insert( + header::USER_AGENT, + HeaderValue::from_static(USER_AGENT_VALUE), + ); + default_headers.insert( + header::ACCEPT, + HeaderValue::from_static("application/json"), + ); + + if let Some(ref token) = options.api_token { + if let Ok(hv) = HeaderValue::from_str(&format!("Bearer {}", token)) { + default_headers.insert(header::AUTHORIZATION, hv); + } + } + + let client = reqwest::Client::builder() + .default_headers(default_headers) + .build() + .expect("failed to build reqwest client"); + + Self { + client, + api_url, + api_token: options.api_token, + use_public_proxy: options.use_public_proxy, + org_slug: options.org_slug, + } + } + + // ── Internal helpers ────────────────────────────────────────────── + + /// Internal GET that deserialises JSON. Returns `Ok(None)` on 404. + async fn get_json( + &self, + path: &str, + ) -> Result, ApiError> { + let url = format!("{}{}", self.api_url, path); + debug_log(&format!("GET {}", url)); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| ApiError::Network(format!("Network error: {}", e)))?; + + Self::handle_json_response(resp, self.use_public_proxy).await + } + + /// Internal POST that deserialises JSON. Returns `Ok(None)` on 404. + async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result, ApiError> { + let url = format!("{}{}", self.api_url, path); + debug_log(&format!("POST {}", url)); + + let resp = self + .client + .post(&url) + .header(header::CONTENT_TYPE, "application/json") + .json(body) + .send() + .await + .map_err(|e| ApiError::Network(format!("Network error: {}", e)))?; + + Self::handle_json_response(resp, self.use_public_proxy).await + } + + /// Map an HTTP response to `Ok(Some(T))`, `Ok(None)` (404), or `Err`. + async fn handle_json_response( + resp: reqwest::Response, + use_public_proxy: bool, + ) -> Result, ApiError> { + let status = resp.status(); + + match status { + StatusCode::OK => { + let body = resp + .json::() + .await + .map_err(|e| ApiError::Parse(format!("Failed to parse response: {}", e)))?; + Ok(Some(body)) + } + StatusCode::NOT_FOUND => Ok(None), + StatusCode::UNAUTHORIZED => { + Err(ApiError::Unauthorized("Unauthorized: Invalid API token".into())) + } + StatusCode::FORBIDDEN => { + let msg = if use_public_proxy { + "Forbidden: This patch is only available to paid subscribers. \ + Sign up at https://socket.dev to access paid patches." + } else { + "Forbidden: Access denied. This may be a paid patch or \ + you may not have access to this organization." + }; + Err(ApiError::Forbidden(msg.into())) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ApiError::RateLimited( + "Rate limit exceeded. Please try again later.".into(), + )) + } + _ => { + let text = resp.text().await.unwrap_or_default(); + Err(ApiError::Other(format!( + "API request failed with status {}: {}", + status.as_u16(), + text + ))) + } + } + } + + // ── Public API methods ──────────────────────────────────────────── + + /// Fetch a patch by UUID (full details with blob content). + /// + /// Returns `Ok(None)` when the patch is not found (404). + pub async fn fetch_patch( + &self, + org_slug: Option<&str>, + uuid: &str, + ) -> Result, ApiError> { + let path = if self.use_public_proxy { + format!("/patch/view/{}", uuid) + } else { + let slug = org_slug + .or(self.org_slug.as_deref()) + .unwrap_or("default"); + format!("/v0/orgs/{}/patches/view/{}", slug, uuid) + }; + self.get_json(&path).await + } + + /// Search patches by CVE ID. + pub async fn search_patches_by_cve( + &self, + org_slug: Option<&str>, + cve_id: &str, + ) -> Result { + let encoded = urlencoding_encode(cve_id); + let path = if self.use_public_proxy { + format!("/patch/by-cve/{}", encoded) + } else { + let slug = org_slug + .or(self.org_slug.as_deref()) + .unwrap_or("default"); + format!("/v0/orgs/{}/patches/by-cve/{}", slug, encoded) + }; + let result = self.get_json::(&path).await?; + Ok(result.unwrap_or_else(|| SearchResponse { + patches: Vec::new(), + can_access_paid_patches: false, + })) + } + + /// Search patches by GHSA ID. + pub async fn search_patches_by_ghsa( + &self, + org_slug: Option<&str>, + ghsa_id: &str, + ) -> Result { + let encoded = urlencoding_encode(ghsa_id); + let path = if self.use_public_proxy { + format!("/patch/by-ghsa/{}", encoded) + } else { + let slug = org_slug + .or(self.org_slug.as_deref()) + .unwrap_or("default"); + format!("/v0/orgs/{}/patches/by-ghsa/{}", slug, encoded) + }; + let result = self.get_json::(&path).await?; + Ok(result.unwrap_or_else(|| SearchResponse { + patches: Vec::new(), + can_access_paid_patches: false, + })) + } + + /// Search patches by package PURL. + /// + /// The PURL must be a valid Package URL starting with `pkg:`. + /// Examples: `pkg:npm/lodash@4.17.21`, `pkg:pypi/django@3.2.0` + pub async fn search_patches_by_package( + &self, + org_slug: Option<&str>, + purl: &str, + ) -> Result { + let encoded = urlencoding_encode(purl); + let path = if self.use_public_proxy { + format!("/patch/by-package/{}", encoded) + } else { + let slug = org_slug + .or(self.org_slug.as_deref()) + .unwrap_or("default"); + format!("/v0/orgs/{}/patches/by-package/{}", slug, encoded) + }; + let result = self.get_json::(&path).await?; + Ok(result.unwrap_or_else(|| SearchResponse { + patches: Vec::new(), + can_access_paid_patches: false, + })) + } + + /// Search patches for multiple packages (batch). + /// + /// For authenticated API, uses the POST `/patches/batch` endpoint. + /// For the public proxy (which cannot cache POST bodies on CDN), falls + /// back to individual GET requests per PURL with a concurrency limit of + /// 10. + /// + /// Maximum 500 PURLs per request. + pub async fn search_patches_batch( + &self, + org_slug: Option<&str>, + purls: &[String], + ) -> Result { + if !self.use_public_proxy { + let slug = org_slug + .or(self.org_slug.as_deref()) + .unwrap_or("default"); + let path = format!("/v0/orgs/{}/patches/batch", slug); + let body = BatchSearchBody { + components: purls + .iter() + .map(|p| BatchComponent { purl: p.clone() }) + .collect(), + }; + let result = self.post_json::(&path, &body).await?; + return Ok(result.unwrap_or_else(|| BatchSearchResponse { + packages: Vec::new(), + can_access_paid_patches: false, + })); + } + + // Public proxy: fall back to individual per-package GET requests + self.search_patches_batch_via_individual_queries(purls).await + } + + /// Internal: fall back to individual GET requests per PURL when the + /// batch endpoint is not available (public proxy mode). + /// + /// Processes PURLs in batches of `CONCURRENCY_LIMIT` to avoid + /// overwhelming the server while remaining efficient. + async fn search_patches_batch_via_individual_queries( + &self, + purls: &[String], + ) -> Result { + const CONCURRENCY_LIMIT: usize = 10; + + let mut packages: Vec = Vec::new(); + let mut can_access_paid_patches = false; + + // Collect all (purl, response) pairs + let mut all_results: Vec<(String, Option)> = Vec::new(); + + for chunk in purls.chunks(CONCURRENCY_LIMIT) { + // Use tokio::JoinSet for concurrent execution within each chunk + let mut join_set = tokio::task::JoinSet::new(); + + for purl in chunk { + let purl = purl.clone(); + let client = self.clone(); + join_set.spawn(async move { + let resp = client.search_patches_by_package(None, &purl).await; + match resp { + Ok(r) => (purl, Some(r)), + Err(e) => { + debug_log(&format!("Error fetching patches for {}: {}", purl, e)); + (purl, None) + } + } + }); + } + + while let Some(result) = join_set.join_next().await { + match result { + Ok(pair) => all_results.push(pair), + Err(e) => { + debug_log(&format!("Task join error: {}", e)); + } + } + } + } + + // Convert individual SearchResponse results to BatchSearchResponse format + for (purl, response) in all_results { + let response = match response { + Some(r) if !r.patches.is_empty() => r, + _ => continue, + }; + + if response.can_access_paid_patches { + can_access_paid_patches = true; + } + + let batch_patches: Vec = response + .patches + .into_iter() + .map(convert_search_result_to_batch_info) + .collect(); + + packages.push(BatchPackagePatches { + purl, + patches: batch_patches, + }); + } + + Ok(BatchSearchResponse { + packages, + can_access_paid_patches, + }) + } + + /// Fetch a blob by its SHA-256 hash. + /// + /// Returns the raw binary content, or `Ok(None)` if not found. + /// Uses the authenticated endpoint when token and org slug are + /// available, otherwise falls back to the public proxy. + pub async fn fetch_blob(&self, hash: &str) -> Result>, ApiError> { + // Validate hash format: SHA-256 = 64 hex characters + if !is_valid_sha256_hex(hash) { + return Err(ApiError::InvalidHash(format!( + "Invalid hash format: {}. Expected SHA256 hash (64 hex characters).", + hash + ))); + } + + let (url, use_auth) = + if self.api_token.is_some() && self.org_slug.is_some() && !self.use_public_proxy { + // Authenticated endpoint + let slug = self.org_slug.as_deref().unwrap(); + let u = format!("{}/v0/orgs/{}/patches/blob/{}", self.api_url, slug, hash); + (u, true) + } else { + // Public proxy + let proxy_url = std::env::var("SOCKET_PATCH_PROXY_URL") + .unwrap_or_else(|_| DEFAULT_PATCH_API_PROXY_URL.to_string()); + let u = format!("{}/patch/blob/{}", proxy_url.trim_end_matches('/'), hash); + (u, false) + }; + + debug_log(&format!("GET blob {}", url)); + + // Build the request. When fetching from the public proxy (different + // base URL than self.api_url), we use a plain client without auth + // headers to avoid leaking credentials to the proxy. + let resp = if use_auth { + self.client + .get(&url) + .header(header::ACCEPT, "application/octet-stream") + .send() + .await + } else { + let mut headers = HeaderMap::new(); + headers.insert( + header::USER_AGENT, + HeaderValue::from_static(USER_AGENT_VALUE), + ); + headers.insert( + header::ACCEPT, + HeaderValue::from_static("application/octet-stream"), + ); + + let plain_client = reqwest::Client::builder() + .default_headers(headers) + .build() + .expect("failed to build plain reqwest client"); + + plain_client.get(&url).send().await + }; + + let resp = resp.map_err(|e| { + ApiError::Network(format!("Network error fetching blob {}: {}", hash, e)) + })?; + + let status = resp.status(); + + match status { + StatusCode::OK => { + let bytes = resp.bytes().await.map_err(|e| { + ApiError::Network(format!("Error reading blob body for {}: {}", hash, e)) + })?; + Ok(Some(bytes.to_vec())) + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let text = resp.text().await.unwrap_or_default(); + Err(ApiError::Other(format!( + "Failed to fetch blob {}: status {} - {}", + hash, + status.as_u16(), + text, + ))) + } + } + } +} + +// ── Free functions ──────────────────────────────────────────────────── + +/// Get an API client configured from environment variables. +/// +/// If `SOCKET_API_TOKEN` is not set, the client will use the public patch +/// API proxy which provides free access to free-tier patches without +/// authentication. +/// +/// # Environment variables +/// +/// | Variable | Purpose | +/// |---|---| +/// | `SOCKET_API_URL` | Override the API URL (default `https://api.socket.dev`) | +/// | `SOCKET_API_TOKEN` | API token for authenticated access | +/// | `SOCKET_PATCH_PROXY_URL` | Override the public proxy URL (default `https://patches-api.socket.dev`) | +/// | `SOCKET_ORG_SLUG` | Organization slug | +/// +/// Returns `(client, use_public_proxy)`. +pub fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) { + let api_token = std::env::var("SOCKET_API_TOKEN").ok(); + let resolved_org_slug = org_slug + .map(String::from) + .or_else(|| std::env::var("SOCKET_ORG_SLUG").ok()); + + if api_token.is_none() { + let proxy_url = std::env::var("SOCKET_PATCH_PROXY_URL") + .unwrap_or_else(|_| DEFAULT_PATCH_API_PROXY_URL.to_string()); + eprintln!( + "No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only)." + ); + let client = ApiClient::new(ApiClientOptions { + api_url: proxy_url, + api_token: None, + use_public_proxy: true, + org_slug: None, + }); + return (client, true); + } + + let api_url = + std::env::var("SOCKET_API_URL").unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string()); + + let client = ApiClient::new(ApiClientOptions { + api_url, + api_token, + use_public_proxy: false, + org_slug: resolved_org_slug, + }); + (client, false) +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +/// Percent-encode a string for use in URL path segments. +fn urlencoding_encode(input: &str) -> String { + // Encode everything that is not unreserved per RFC 3986. + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => { + out.push('%'); + out.push_str(&format!("{:02X}", byte)); + } + } + } + out +} + +/// Validate that a string is a 64-character hex string (SHA-256). +fn is_valid_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Convert a `PatchSearchResult` into a `BatchPatchInfo`, extracting +/// CVE/GHSA IDs and computing the highest severity. +fn convert_search_result_to_batch_info(patch: PatchSearchResult) -> BatchPatchInfo { + let mut cve_ids: Vec = Vec::new(); + let mut ghsa_ids: Vec = Vec::new(); + let mut highest_severity: Option = None; + let mut title = String::new(); + + let mut seen_cves: HashSet = HashSet::new(); + + for (ghsa_id, vuln) in &patch.vulnerabilities { + ghsa_ids.push(ghsa_id.clone()); + + for cve in &vuln.cves { + if seen_cves.insert(cve.clone()) { + cve_ids.push(cve.clone()); + } + } + + // Track highest severity (lower order number = higher severity) + let current_order = get_severity_order(highest_severity.as_deref()); + let vuln_order = get_severity_order(Some(&vuln.severity)); + if vuln_order < current_order { + highest_severity = Some(vuln.severity.clone()); + } + + // Use first non-empty summary as title + if title.is_empty() && !vuln.summary.is_empty() { + title = if vuln.summary.len() > 100 { + format!("{}...", &vuln.summary[..97]) + } else { + vuln.summary.clone() + }; + } + } + + // Use description as fallback title + if title.is_empty() && !patch.description.is_empty() { + title = if patch.description.len() > 100 { + format!("{}...", &patch.description[..97]) + } else { + patch.description.clone() + }; + } + + cve_ids.sort(); + ghsa_ids.sort(); + + BatchPatchInfo { + uuid: patch.uuid, + purl: patch.purl, + tier: patch.tier, + cve_ids, + ghsa_ids, + severity: highest_severity, + title, + } +} + +// ── Error type ──────────────────────────────────────────────────────── + +/// Errors returned by [`ApiClient`] methods. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("{0}")] + Network(String), + + #[error("{0}")] + Parse(String), + + #[error("{0}")] + Unauthorized(String), + + #[error("{0}")] + Forbidden(String), + + #[error("{0}")] + RateLimited(String), + + #[error("{0}")] + InvalidHash(String), + + #[error("{0}")] + Other(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_urlencoding_basic() { + assert_eq!(urlencoding_encode("hello"), "hello"); + assert_eq!(urlencoding_encode("a b"), "a%20b"); + assert_eq!( + urlencoding_encode("pkg:npm/lodash@4.17.21"), + "pkg%3Anpm%2Flodash%404.17.21" + ); + } + + #[test] + fn test_is_valid_sha256_hex() { + let valid = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + assert!(is_valid_sha256_hex(valid)); + + // Too short + assert!(!is_valid_sha256_hex("abcdef")); + // Non-hex + assert!(!is_valid_sha256_hex( + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + )); + } + + #[test] + fn test_severity_order() { + assert!(get_severity_order(Some("critical")) < get_severity_order(Some("high"))); + assert!(get_severity_order(Some("high")) < get_severity_order(Some("medium"))); + assert!(get_severity_order(Some("medium")) < get_severity_order(Some("low"))); + assert!(get_severity_order(Some("low")) < get_severity_order(None)); + assert_eq!(get_severity_order(Some("unknown")), get_severity_order(None)); + } + + #[test] + fn test_convert_search_result_to_batch_info() { + use std::collections::HashMap; + + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1234-5678-9abc".to_string(), + VulnerabilityResponse { + cves: vec!["CVE-2024-0001".into()], + summary: "Test vulnerability".into(), + severity: "high".into(), + description: "A test vuln".into(), + }, + ); + + let patch = PatchSearchResult { + uuid: "uuid-1".into(), + purl: "pkg:npm/test@1.0.0".into(), + published_at: "2024-01-01".into(), + description: "A patch".into(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: vulns, + }; + + let info = convert_search_result_to_batch_info(patch); + assert_eq!(info.uuid, "uuid-1"); + assert_eq!(info.cve_ids, vec!["CVE-2024-0001"]); + assert_eq!(info.ghsa_ids, vec!["GHSA-1234-5678-9abc"]); + assert_eq!(info.severity, Some("high".into())); + assert_eq!(info.title, "Test vulnerability"); + } + + #[test] + fn test_get_api_client_from_env_no_token() { + // Clear token to ensure public proxy mode + std::env::remove_var("SOCKET_API_TOKEN"); + let (client, is_public) = get_api_client_from_env(None); + assert!(is_public); + assert!(client.use_public_proxy); + } +} diff --git a/crates/socket-patch-core/src/api/mod.rs b/crates/socket-patch-core/src/api/mod.rs new file mode 100644 index 0000000..a0a9feb --- /dev/null +++ b/crates/socket-patch-core/src/api/mod.rs @@ -0,0 +1,6 @@ +pub mod blob_fetcher; +pub mod client; +pub mod types; + +pub use client::ApiClient; +pub use types::*; diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs new file mode 100644 index 0000000..1623037 --- /dev/null +++ b/crates/socket-patch-core/src/api/types.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Full patch response with blob content (from view endpoint). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchResponse { + pub uuid: String, + pub purl: String, + pub published_at: String, + pub files: HashMap, + pub vulnerabilities: HashMap, + pub description: String, + pub license: String, + pub tier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchFileResponse { + pub before_hash: Option, + pub after_hash: Option, + pub socket_blob: Option, + pub blob_content: Option, + pub before_blob_content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VulnerabilityResponse { + pub cves: Vec, + pub summary: String, + pub severity: String, + pub description: String, +} + +/// Lightweight search result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchSearchResult { + pub uuid: String, + pub purl: String, + pub published_at: String, + pub description: String, + pub license: String, + pub tier: String, + pub vulnerabilities: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResponse { + pub patches: Vec, + pub can_access_paid_patches: bool, +} + +/// Minimal patch info from batch search. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchPatchInfo { + pub uuid: String, + pub purl: String, + pub tier: String, + pub cve_ids: Vec, + pub ghsa_ids: Vec, + pub severity: Option, + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchPackagePatches { + pub purl: String, + pub patches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchSearchResponse { + pub packages: Vec, + pub can_access_paid_patches: bool, +} diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs new file mode 100644 index 0000000..1418427 --- /dev/null +++ b/crates/socket-patch-core/src/constants.rs @@ -0,0 +1,17 @@ +/// Default path for the patch manifest file relative to the project root. +pub const DEFAULT_PATCH_MANIFEST_PATH: &str = ".socket/manifest.json"; + +/// Default folder for storing patched file blobs. +pub const DEFAULT_BLOB_FOLDER: &str = ".socket/blob"; + +/// Default Socket directory. +pub const DEFAULT_SOCKET_DIR: &str = ".socket"; + +/// Default public patch API URL for free patches (no auth required). +pub const DEFAULT_PATCH_API_PROXY_URL: &str = "https://patches-api.socket.dev"; + +/// Default Socket API URL for authenticated access. +pub const DEFAULT_SOCKET_API_URL: &str = "https://api.socket.dev"; + +/// User-Agent header value for API requests. +pub const USER_AGENT: &str = "SocketPatchCLI/1.0"; diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs new file mode 100644 index 0000000..8c33de0 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -0,0 +1,7 @@ +pub mod npm_crawler; +pub mod python_crawler; +pub mod types; + +pub use npm_crawler::NpmCrawler; +pub use python_crawler::PythonCrawler; +pub use types::*; diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs new file mode 100644 index 0000000..ea45bdf --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -0,0 +1,832 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::Deserialize; + +use super::types::{CrawledPackage, CrawlerOptions}; + +/// Default batch size for crawling. +#[cfg(test)] +const DEFAULT_BATCH_SIZE: usize = 100; + +/// Directories to skip when searching for workspace node_modules. +const SKIP_DIRS: &[&str] = &[ + "dist", + "build", + "coverage", + "tmp", + "temp", + "__pycache__", + "vendor", +]; + +// --------------------------------------------------------------------------- +// Helper: read and parse package.json +// --------------------------------------------------------------------------- + +/// Minimal fields we need from package.json. +#[derive(Deserialize)] +struct PackageJsonPartial { + name: Option, + version: Option, +} + +/// Read and parse a `package.json` file, returning `(name, version)` if valid. +pub async fn read_package_json(pkg_json_path: &Path) -> Option<(String, String)> { + let content = tokio::fs::read_to_string(pkg_json_path).await.ok()?; + let pkg: PackageJsonPartial = serde_json::from_str(&content).ok()?; + let name = pkg.name?; + let version = pkg.version?; + if name.is_empty() || version.is_empty() { + return None; + } + Some((name, version)) +} + +// --------------------------------------------------------------------------- +// Helper: parse package name into (namespace, name) +// --------------------------------------------------------------------------- + +/// Parse a full npm package name into optional namespace and bare name. +/// +/// Examples: +/// - `"@types/node"` -> `(Some("@types"), "node")` +/// - `"lodash"` -> `(None, "lodash")` +pub fn parse_package_name(full_name: &str) -> (Option, String) { + if full_name.starts_with('@') { + if let Some(slash_idx) = full_name.find('/') { + let namespace = full_name[..slash_idx].to_string(); + let name = full_name[slash_idx + 1..].to_string(); + return (Some(namespace), name); + } + } + (None, full_name.to_string()) +} + +// --------------------------------------------------------------------------- +// Helper: build PURL +// --------------------------------------------------------------------------- + +/// Build a PURL string for an npm package. +pub fn build_npm_purl(namespace: Option<&str>, name: &str, version: &str) -> String { + match namespace { + Some(ns) => format!("pkg:npm/{ns}/{name}@{version}"), + None => format!("pkg:npm/{name}@{version}"), + } +} + +// --------------------------------------------------------------------------- +// Global prefix detection helpers +// --------------------------------------------------------------------------- + +/// Get the npm global `node_modules` path via `npm root -g`. +pub fn get_npm_global_prefix() -> Result { + let output = Command::new("npm") + .args(["root", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("Failed to run `npm root -g`: {e}"))?; + + if !output.status.success() { + return Err( + "Failed to determine npm global prefix. Ensure npm is installed and in PATH." + .to_string(), + ); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Get the yarn global `node_modules` path via `yarn global dir`. +pub fn get_yarn_global_prefix() -> Option { + let output = Command::new("yarn") + .args(["global", "dir"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if dir.is_empty() { + return None; + } + Some(PathBuf::from(dir).join("node_modules").to_string_lossy().to_string()) +} + +/// Get the pnpm global `node_modules` path via `pnpm root -g`. +pub fn get_pnpm_global_prefix() -> Option { + let output = Command::new("pnpm") + .args(["root", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return None; + } + Some(path) +} + +/// Get the bun global `node_modules` path via `bun pm bin -g`. +pub fn get_bun_global_prefix() -> Option { + let output = Command::new("bun") + .args(["pm", "bin", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let bin_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if bin_path.is_empty() { + return None; + } + + let bun_root = PathBuf::from(&bin_path); + let bun_root = bun_root.parent()?; + Some( + bun_root + .join("install") + .join("global") + .join("node_modules") + .to_string_lossy() + .to_string(), + ) +} + +// --------------------------------------------------------------------------- +// NpmCrawler +// --------------------------------------------------------------------------- + +/// NPM ecosystem crawler for discovering packages in `node_modules`. +pub struct NpmCrawler; + +impl NpmCrawler { + /// Create a new `NpmCrawler`. + pub fn new() -> Self { + Self + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /// Get `node_modules` paths based on options. + /// + /// In global mode returns well-known global paths; in local mode walks + /// the project tree looking for `node_modules` directories (including + /// workspace packages). + pub async fn get_node_modules_paths(&self, options: &CrawlerOptions) -> Result, std::io::Error> { + if options.global || options.global_prefix.is_some() { + if let Some(ref custom) = options.global_prefix { + return Ok(vec![custom.clone()]); + } + return Ok(self.get_global_node_modules_paths()); + } + + Ok(self.find_local_node_modules_dirs(&options.cwd).await) + } + + /// Crawl all discovered `node_modules` and return every package found. + pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { + let mut packages = Vec::new(); + let mut seen = HashSet::new(); + + let nm_paths = self.get_node_modules_paths(options).await.unwrap_or_default(); + + for nm_path in &nm_paths { + let found = self.scan_node_modules(nm_path, &mut seen).await; + packages.extend(found); + } + + packages + } + + /// Find specific packages by PURL inside a single `node_modules` tree. + /// + /// This is an efficient O(n) lookup where n = number of PURLs: we parse + /// each PURL to derive the expected directory path, then do a direct stat + /// + `package.json` read. + pub async fn find_by_purls( + &self, + node_modules_path: &Path, + purls: &[String], + ) -> Result, std::io::Error> { + let mut result: HashMap = HashMap::new(); + + // Parse each PURL to extract the directory key and expected version. + struct Target { + namespace: Option, + name: String, + version: String, + #[allow(dead_code)] purl: String, + dir_key: String, + } + + let purl_set: HashSet<&str> = purls.iter().map(|s| s.as_str()).collect(); + let mut targets: Vec = Vec::new(); + + for purl in purls { + if let Some((ns, name, version)) = Self::parse_purl_components(purl) { + let dir_key = match &ns { + Some(ns_str) => format!("{ns_str}/{name}"), + None => name.clone(), + }; + targets.push(Target { + namespace: ns, + name, + version, + purl: purl.clone(), + dir_key, + }); + } + } + + for target in &targets { + let pkg_path = node_modules_path.join(&target.dir_key); + let pkg_json_path = pkg_path.join("package.json"); + + if let Some((_, version)) = read_package_json(&pkg_json_path).await { + if version == target.version { + let purl = build_npm_purl( + target.namespace.as_deref(), + &target.name, + &version, + ); + if purl_set.contains(purl.as_str()) { + result.insert( + purl.clone(), + CrawledPackage { + name: target.name.clone(), + version, + namespace: target.namespace.clone(), + purl, + path: pkg_path.clone(), + }, + ); + } + } + } + } + + Ok(result) + } + + // ------------------------------------------------------------------ + // Private helpers – global paths + // ------------------------------------------------------------------ + + /// Collect global `node_modules` paths from all known package managers. + fn get_global_node_modules_paths(&self) -> Vec { + let mut paths = Vec::new(); + + if let Ok(npm_path) = get_npm_global_prefix() { + paths.push(PathBuf::from(npm_path)); + } + if let Some(pnpm_path) = get_pnpm_global_prefix() { + paths.push(PathBuf::from(pnpm_path)); + } + if let Some(yarn_path) = get_yarn_global_prefix() { + paths.push(PathBuf::from(yarn_path)); + } + if let Some(bun_path) = get_bun_global_prefix() { + paths.push(PathBuf::from(bun_path)); + } + + paths + } + + // ------------------------------------------------------------------ + // Private helpers – local node_modules discovery + // ------------------------------------------------------------------ + + /// Find `node_modules` directories within the project root. + /// Recursively searches for workspace `node_modules` but stays within the + /// project. + async fn find_local_node_modules_dirs(&self, start_path: &Path) -> Vec { + let mut results = Vec::new(); + + // Direct node_modules in start_path + let direct = start_path.join("node_modules"); + if is_dir(&direct).await { + results.push(direct); + } + + // Recursively search for workspace node_modules + Self::find_workspace_node_modules(start_path, &mut results).await; + + results + } + + /// Recursively find `node_modules` in subdirectories (for monorepos / workspaces). + /// Skips symlinks, hidden dirs, and well-known non-workspace dirs. + fn find_workspace_node_modules<'a>( + dir: &'a Path, + results: &'a mut Vec, + ) -> std::pin::Pin + 'a>> { + Box::pin(async move { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(rd) => rd, + Err(_) => return, + }; + + let mut entry_list = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + entry_list.push(entry); + } + + for entry in entry_list { + let file_type = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + + if !file_type.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip node_modules, hidden dirs, and well-known build dirs + if name_str == "node_modules" + || name_str.starts_with('.') + || SKIP_DIRS.contains(&name_str.as_ref()) + { + continue; + } + + let full_path = dir.join(&name); + + // Check if this subdirectory has its own node_modules + let sub_nm = full_path.join("node_modules"); + if is_dir(&sub_nm).await { + results.push(sub_nm); + } + + // Recurse + Self::find_workspace_node_modules(&full_path, results).await; + } + }) + } + + // ------------------------------------------------------------------ + // Private helpers – scanning + // ------------------------------------------------------------------ + + /// Scan a `node_modules` directory, returning all valid packages found. + async fn scan_node_modules( + &self, + node_modules_path: &Path, + seen: &mut HashSet, + ) -> Vec { + let mut results = Vec::new(); + + let mut entries = match tokio::fs::read_dir(node_modules_path).await { + Ok(rd) => rd, + Err(_) => return results, + }; + + let mut entry_list = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + entry_list.push(entry); + } + + for entry in entry_list { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + + // Skip hidden files and node_modules + if name_str.starts_with('.') || name_str == "node_modules" { + continue; + } + + let file_type = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + + // Allow both directories and symlinks (pnpm uses symlinks) + if !file_type.is_dir() && !file_type.is_symlink() { + continue; + } + + let entry_path = node_modules_path.join(&name_str); + + if name_str.starts_with('@') { + // Scoped packages + let scoped = + Self::scan_scoped_packages(&entry_path, seen).await; + results.extend(scoped); + } else { + // Regular package + if let Some(pkg) = Self::check_package(&entry_path, seen).await { + results.push(pkg); + } + // Nested node_modules only for real directories (not symlinks) + if file_type.is_dir() { + let nested = + Self::scan_nested_node_modules(&entry_path, seen).await; + results.extend(nested); + } + } + } + + results + } + + /// Scan a scoped packages directory (`@scope/`). + fn scan_scoped_packages<'a>( + scope_path: &'a Path, + seen: &'a mut HashSet, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let mut results = Vec::new(); + + let mut entries = match tokio::fs::read_dir(scope_path).await { + Ok(rd) => rd, + Err(_) => return results, + }; + + let mut entry_list = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + entry_list.push(entry); + } + + for entry in entry_list { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + + if name_str.starts_with('.') { + continue; + } + + let file_type = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + + if !file_type.is_dir() && !file_type.is_symlink() { + continue; + } + + let pkg_path = scope_path.join(&name_str); + if let Some(pkg) = Self::check_package(&pkg_path, seen).await { + results.push(pkg); + } + + // Nested node_modules only for real directories + if file_type.is_dir() { + let nested = + Self::scan_nested_node_modules(&pkg_path, seen).await; + results.extend(nested); + } + } + + results + }) + } + + /// Scan nested `node_modules` inside a package (if it exists). + fn scan_nested_node_modules<'a>( + pkg_path: &'a Path, + seen: &'a mut HashSet, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + let nested_nm = pkg_path.join("node_modules"); + + let mut entries = match tokio::fs::read_dir(&nested_nm).await { + Ok(rd) => rd, + Err(_) => return Vec::new(), + }; + + let mut results = Vec::new(); + + let mut entry_list = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + entry_list.push(entry); + } + + for entry in entry_list { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + + if name_str.starts_with('.') || name_str == "node_modules" { + continue; + } + + let file_type = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + + if !file_type.is_dir() && !file_type.is_symlink() { + continue; + } + + let entry_path = nested_nm.join(&name_str); + + if name_str.starts_with('@') { + let scoped = + Self::scan_scoped_packages(&entry_path, seen).await; + results.extend(scoped); + } else { + if let Some(pkg) = Self::check_package(&entry_path, seen).await { + results.push(pkg); + } + // Recursively check deeper nested node_modules + let deeper = + Self::scan_nested_node_modules(&entry_path, seen).await; + results.extend(deeper); + } + } + + results + }) + } + + /// Check a package directory and return `CrawledPackage` if valid. + /// Deduplicates by PURL via the `seen` set. + async fn check_package( + pkg_path: &Path, + seen: &mut HashSet, + ) -> Option { + let pkg_json_path = pkg_path.join("package.json"); + let (full_name, version) = read_package_json(&pkg_json_path).await?; + let (namespace, name) = parse_package_name(&full_name); + let purl = build_npm_purl(namespace.as_deref(), &name, &version); + + if seen.contains(&purl) { + return None; + } + seen.insert(purl.clone()); + + Some(CrawledPackage { + name, + version, + namespace, + purl, + path: pkg_path.to_path_buf(), + }) + } + + // ------------------------------------------------------------------ + // Private helpers – PURL parsing + // ------------------------------------------------------------------ + + /// Parse a PURL string to extract namespace, name, and version. + fn parse_purl_components(purl: &str) -> Option<(Option, String, String)> { + // Strip qualifiers + let base = match purl.find('?') { + Some(idx) => &purl[..idx], + None => purl, + }; + + let rest = base.strip_prefix("pkg:npm/")?; + let at_idx = rest.rfind('@')?; + let name_part = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + + if name_part.is_empty() || version.is_empty() { + return None; + } + + if name_part.starts_with('@') { + let slash_idx = name_part.find('/')?; + let namespace = name_part[..slash_idx].to_string(); + let name = name_part[slash_idx + 1..].to_string(); + if name.is_empty() { + return None; + } + Some((Some(namespace), name, version.to_string())) + } else { + Some((None, name_part.to_string(), version.to_string())) + } + } +} + +impl Default for NpmCrawler { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +/// Check whether a path is a directory (follows symlinks). +async fn is_dir(path: &Path) -> bool { + tokio::fs::metadata(path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_package_name_scoped() { + let (ns, name) = parse_package_name("@types/node"); + assert_eq!(ns.as_deref(), Some("@types")); + assert_eq!(name, "node"); + } + + #[test] + fn test_parse_package_name_unscoped() { + let (ns, name) = parse_package_name("lodash"); + assert!(ns.is_none()); + assert_eq!(name, "lodash"); + } + + #[test] + fn test_build_npm_purl_scoped() { + assert_eq!( + build_npm_purl(Some("@types"), "node", "20.0.0"), + "pkg:npm/@types/node@20.0.0" + ); + } + + #[test] + fn test_build_npm_purl_unscoped() { + assert_eq!( + build_npm_purl(None, "lodash", "4.17.21"), + "pkg:npm/lodash@4.17.21" + ); + } + + #[test] + fn test_parse_purl_components_scoped() { + let (ns, name, ver) = + NpmCrawler::parse_purl_components("pkg:npm/@types/node@20.0.0").unwrap(); + assert_eq!(ns.as_deref(), Some("@types")); + assert_eq!(name, "node"); + assert_eq!(ver, "20.0.0"); + } + + #[test] + fn test_parse_purl_components_unscoped() { + let (ns, name, ver) = + NpmCrawler::parse_purl_components("pkg:npm/lodash@4.17.21").unwrap(); + assert!(ns.is_none()); + assert_eq!(name, "lodash"); + assert_eq!(ver, "4.17.21"); + } + + #[test] + fn test_parse_purl_components_invalid() { + assert!(NpmCrawler::parse_purl_components("pkg:pypi/requests@2.0").is_none()); + assert!(NpmCrawler::parse_purl_components("not-a-purl").is_none()); + } + + #[tokio::test] + async fn test_read_package_json_valid() { + let dir = tempfile::tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + tokio::fs::write( + &pkg_json, + r#"{"name": "test-pkg", "version": "1.0.0"}"#, + ) + .await + .unwrap(); + + let result = read_package_json(&pkg_json).await; + assert!(result.is_some()); + let (name, version) = result.unwrap(); + assert_eq!(name, "test-pkg"); + assert_eq!(version, "1.0.0"); + } + + #[tokio::test] + async fn test_read_package_json_missing() { + let dir = tempfile::tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + assert!(read_package_json(&pkg_json).await.is_none()); + } + + #[tokio::test] + async fn test_read_package_json_invalid() { + let dir = tempfile::tempdir().unwrap(); + let pkg_json = dir.path().join("package.json"); + tokio::fs::write(&pkg_json, "not json").await.unwrap(); + assert!(read_package_json(&pkg_json).await.is_none()); + } + + #[tokio::test] + async fn test_crawl_all_basic() { + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("node_modules"); + let pkg_dir = nm.join("foo"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::write( + pkg_dir.join("package.json"), + r#"{"name": "foo", "version": "1.2.3"}"#, + ) + .await + .unwrap(); + + let crawler = NpmCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + batch_size: DEFAULT_BATCH_SIZE, + }; + + let packages = crawler.crawl_all(&options).await; + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "foo"); + assert_eq!(packages[0].version, "1.2.3"); + assert_eq!(packages[0].purl, "pkg:npm/foo@1.2.3"); + assert!(packages[0].namespace.is_none()); + } + + #[tokio::test] + async fn test_crawl_all_scoped() { + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("node_modules"); + let scope_dir = nm.join("@types").join("node"); + tokio::fs::create_dir_all(&scope_dir).await.unwrap(); + tokio::fs::write( + scope_dir.join("package.json"), + r#"{"name": "@types/node", "version": "20.0.0"}"#, + ) + .await + .unwrap(); + + let crawler = NpmCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + batch_size: DEFAULT_BATCH_SIZE, + }; + + let packages = crawler.crawl_all(&options).await; + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "node"); + assert_eq!(packages[0].namespace.as_deref(), Some("@types")); + assert_eq!(packages[0].purl, "pkg:npm/@types/node@20.0.0"); + } + + #[tokio::test] + async fn test_find_by_purls() { + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("node_modules"); + + // Create foo@1.0.0 + let foo_dir = nm.join("foo"); + tokio::fs::create_dir_all(&foo_dir).await.unwrap(); + tokio::fs::write( + foo_dir.join("package.json"), + r#"{"name": "foo", "version": "1.0.0"}"#, + ) + .await + .unwrap(); + + // Create @types/node@20.0.0 + let types_dir = nm.join("@types").join("node"); + tokio::fs::create_dir_all(&types_dir).await.unwrap(); + tokio::fs::write( + types_dir.join("package.json"), + r#"{"name": "@types/node", "version": "20.0.0"}"#, + ) + .await + .unwrap(); + + let crawler = NpmCrawler::new(); + let purls = vec![ + "pkg:npm/foo@1.0.0".to_string(), + "pkg:npm/@types/node@20.0.0".to_string(), + "pkg:npm/not-installed@0.0.1".to_string(), + ]; + + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + assert_eq!(result.len(), 2); + assert!(result.contains_key("pkg:npm/foo@1.0.0")); + assert!(result.contains_key("pkg:npm/@types/node@20.0.0")); + assert!(!result.contains_key("pkg:npm/not-installed@0.0.1")); + } +} diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs new file mode 100644 index 0000000..7037ea4 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -0,0 +1,717 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::types::{CrawledPackage, CrawlerOptions}; + +/// Default batch size for crawling. +const _DEFAULT_BATCH_SIZE: usize = 100; + +// --------------------------------------------------------------------------- +// PEP 503 name canonicalization +// --------------------------------------------------------------------------- + +/// Canonicalize a Python package name per PEP 503. +/// +/// Lowercases, trims, and replaces runs of `[-_.]` with a single `-`. +pub fn canonicalize_pypi_name(name: &str) -> String { + let trimmed = name.trim().to_lowercase(); + let mut result = String::with_capacity(trimmed.len()); + let mut in_separator_run = false; + + for ch in trimmed.chars() { + if ch == '-' || ch == '_' || ch == '.' { + if !in_separator_run { + result.push('-'); + in_separator_run = true; + } + // else: skip consecutive separators + } else { + in_separator_run = false; + result.push(ch); + } + } + + result +} + +// --------------------------------------------------------------------------- +// Helpers: read Python metadata from dist-info +// --------------------------------------------------------------------------- + +/// Read `Name` and `Version` from a `.dist-info/METADATA` file. +pub async fn read_python_metadata(dist_info_path: &Path) -> Option<(String, String)> { + let metadata_path = dist_info_path.join("METADATA"); + let content = tokio::fs::read_to_string(&metadata_path).await.ok()?; + + let mut name: Option = None; + let mut version: Option = None; + + for line in content.lines() { + if name.is_some() && version.is_some() { + break; + } + if let Some(rest) = line.strip_prefix("Name:") { + name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("Version:") { + version = Some(rest.trim().to_string()); + } + // Stop at first empty line (end of headers) + if line.trim().is_empty() && (name.is_some() || version.is_some()) { + break; + } + } + + match (name, version) { + (Some(n), Some(v)) if !n.is_empty() && !v.is_empty() => Some((n, v)), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Helpers: find Python directories with wildcard matching +// --------------------------------------------------------------------------- + +/// Find directories matching a path pattern with wildcard segments. +/// +/// Supported wildcards: +/// - `"python3.*"` — matches directory entries starting with `python3.` +/// - `"*"` — matches any directory entry +/// +/// All other segments are treated as literal path components. +pub async fn find_python_dirs(base_path: &Path, segments: &[&str]) -> Vec { + let mut results = Vec::new(); + + // Check that base_path is a directory + match tokio::fs::metadata(base_path).await { + Ok(m) if m.is_dir() => {} + _ => return results, + } + + if segments.is_empty() { + results.push(base_path.to_path_buf()); + return results; + } + + let first = segments[0]; + let rest = &segments[1..]; + + if first == "python3.*" { + // Wildcard: list directory and match python3.X entries + if let Ok(mut entries) = tokio::fs::read_dir(base_path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("python3.") { + let sub = Box::pin(find_python_dirs( + &base_path.join(entry.file_name()), + rest, + )) + .await; + results.extend(sub); + } + } + } + } else if first == "*" { + // Generic wildcard: match any directory entry + if let Ok(mut entries) = tokio::fs::read_dir(base_path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + let sub = Box::pin(find_python_dirs( + &base_path.join(entry.file_name()), + rest, + )) + .await; + results.extend(sub); + } + } + } else { + // Literal segment: just check if it exists + let sub = + Box::pin(find_python_dirs(&base_path.join(first), rest)).await; + results.extend(sub); + } + + results +} + +// --------------------------------------------------------------------------- +// Helpers: site-packages discovery +// --------------------------------------------------------------------------- + +/// Find `site-packages` (or `dist-packages`) directories under a base dir. +/// +/// Handles both Unix (`lib/python3.X/site-packages`) and macOS/Linux layouts. +pub async fn find_site_packages_under( + base_dir: &Path, + sub_dir_type: &str, // "site-packages" or "dist-packages" +) -> Vec { + if cfg!(windows) { + find_python_dirs(base_dir, &["Lib", sub_dir_type]).await + } else { + find_python_dirs(base_dir, &["lib", "python3.*", sub_dir_type]).await + } +} + +/// Find local virtual environment `site-packages` directories. +/// +/// Checks (in order): +/// 1. `VIRTUAL_ENV` environment variable +/// 2. `.venv` directory in `cwd` +/// 3. `venv` directory in `cwd` +pub async fn find_local_venv_site_packages(cwd: &Path) -> Vec { + let mut results = Vec::new(); + + // 1. Check VIRTUAL_ENV env var + if let Ok(virtual_env) = std::env::var("VIRTUAL_ENV") { + let venv_path = PathBuf::from(&virtual_env); + let matches = find_site_packages_under(&venv_path, "site-packages").await; + results.extend(matches); + if !results.is_empty() { + return results; + } + } + + // 2. Check .venv and venv in cwd + for venv_dir in &[".venv", "venv"] { + let venv_path = cwd.join(venv_dir); + let matches = find_site_packages_under(&venv_path, "site-packages").await; + results.extend(matches); + } + + results +} + +/// Get global/system Python `site-packages` directories. +/// +/// Queries `python3` for site-packages paths, then checks well-known system +/// locations including Homebrew, conda, uv tools, pip --user, etc. +pub async fn get_global_python_site_packages() -> Vec { + let mut results = Vec::new(); + let mut seen = HashSet::new(); + + let add_path = |p: PathBuf, seen: &mut HashSet, results: &mut Vec| { + let resolved = if p.is_absolute() { + p + } else { + std::path::absolute(&p).unwrap_or(p) + }; + if seen.insert(resolved.clone()) { + results.push(resolved); + } + }; + + // 1. Ask Python for site-packages + if let Ok(output) = Command::new("python3") + .args([ + "-c", + "import site; print('\\n'.join(site.getsitepackages())); print(site.getusersitepackages())", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let p = line.trim(); + if !p.is_empty() { + add_path(PathBuf::from(p), &mut seen, &mut results); + } + } + } + } + + // 2. Well-known system paths + let home_dir = std::env::var("HOME").unwrap_or_else(|_| "~".to_string()); + + // Helper closure to scan base/lib/python3.*/[dist|site]-packages + async fn scan_well_known( + base: &Path, + pkg_type: &str, + seen: &mut HashSet, + results: &mut Vec, + ) { + let matches = find_python_dirs(base, &["lib", "python3.*", pkg_type]).await; + for m in matches { + let resolved = if m.is_absolute() { + m + } else { + std::path::absolute(&m).unwrap_or(m) + }; + if seen.insert(resolved.clone()) { + results.push(resolved); + } + } + } + + // Debian/Ubuntu + scan_well_known(Path::new("/usr"), "dist-packages", &mut seen, &mut results).await; + scan_well_known(Path::new("/usr"), "site-packages", &mut seen, &mut results).await; + // Debian pip / most distros / macOS + scan_well_known( + Path::new("/usr/local"), + "dist-packages", + &mut seen, + &mut results, + ) + .await; + scan_well_known( + Path::new("/usr/local"), + "site-packages", + &mut seen, + &mut results, + ) + .await; + // pip --user + let user_local = PathBuf::from(&home_dir).join(".local"); + scan_well_known(&user_local, "site-packages", &mut seen, &mut results).await; + + // macOS-specific + if cfg!(target_os = "macos") { + scan_well_known( + Path::new("/opt/homebrew"), + "site-packages", + &mut seen, + &mut results, + ) + .await; + + // Python.org framework + let fw_matches = find_python_dirs( + Path::new("/Library/Frameworks/Python.framework/Versions"), + &["python3.*", "lib", "python3.*", "site-packages"], + ) + .await; + for m in fw_matches { + add_path(m, &mut seen, &mut results); + } + + let fw_matches2 = find_python_dirs( + Path::new("/Library/Frameworks/Python.framework"), + &["Versions", "*", "lib", "python3.*", "site-packages"], + ) + .await; + for m in fw_matches2 { + add_path(m, &mut seen, &mut results); + } + } + + // Conda + let anaconda = PathBuf::from(&home_dir).join("anaconda3"); + scan_well_known(&anaconda, "site-packages", &mut seen, &mut results).await; + let miniconda = PathBuf::from(&home_dir).join("miniconda3"); + scan_well_known(&miniconda, "site-packages", &mut seen, &mut results).await; + + // uv tools + if cfg!(target_os = "macos") { + let uv_base = PathBuf::from(&home_dir) + .join("Library") + .join("Application Support") + .join("uv") + .join("tools"); + let uv_matches = + find_python_dirs(&uv_base, &["*", "lib", "python3.*", "site-packages"]).await; + for m in uv_matches { + add_path(m, &mut seen, &mut results); + } + } else { + let uv_base = PathBuf::from(&home_dir) + .join(".local") + .join("share") + .join("uv") + .join("tools"); + let uv_matches = + find_python_dirs(&uv_base, &["*", "lib", "python3.*", "site-packages"]).await; + for m in uv_matches { + add_path(m, &mut seen, &mut results); + } + } + + results +} + +// --------------------------------------------------------------------------- +// PythonCrawler +// --------------------------------------------------------------------------- + +/// Python ecosystem crawler for discovering packages in `site-packages`. +pub struct PythonCrawler; + +impl PythonCrawler { + /// Create a new `PythonCrawler`. + pub fn new() -> Self { + Self + } + + /// Get `site-packages` paths based on options. + pub async fn get_site_packages_paths(&self, options: &CrawlerOptions) -> Result, std::io::Error> { + if options.global || options.global_prefix.is_some() { + if let Some(ref custom) = options.global_prefix { + return Ok(vec![custom.clone()]); + } + return Ok(get_global_python_site_packages().await); + } + Ok(find_local_venv_site_packages(&options.cwd).await) + } + + /// Crawl all discovered `site-packages` and return every package found. + pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { + let mut packages = Vec::new(); + let mut seen = HashSet::new(); + + let sp_paths = self.get_site_packages_paths(options).await.unwrap_or_default(); + + for sp_path in &sp_paths { + let found = self.scan_site_packages(sp_path, &mut seen).await; + packages.extend(found); + } + + packages + } + + /// Find specific packages by PURL. + /// + /// Accepts base PURLs (no qualifiers) — the caller should strip qualifiers + /// before calling. + pub async fn find_by_purls( + &self, + site_packages_path: &Path, + purls: &[String], + ) -> Result, std::io::Error> { + let mut result = HashMap::new(); + + // Build lookup: canonicalized-name@version -> purl + let mut purl_lookup: HashMap = HashMap::new(); + for purl in purls { + if let Some((name, version)) = Self::parse_pypi_purl(purl) { + let key = format!("{}@{}", canonicalize_pypi_name(&name), version); + purl_lookup.insert(key, purl.as_str()); + } + } + + if purl_lookup.is_empty() { + return Ok(result); + } + + // Scan all .dist-info dirs + let entries = match tokio::fs::read_dir(site_packages_path).await { + Ok(rd) => { + let mut entries = rd; + let mut v = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + v.push(entry); + } + v + } + Err(_) => return Ok(result), + }; + + for entry in entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.ends_with(".dist-info") { + continue; + } + + let dist_info_path = site_packages_path.join(&*name_str); + if let Some((raw_name, version)) = read_python_metadata(&dist_info_path).await { + let canon_name = canonicalize_pypi_name(&raw_name); + let key = format!("{canon_name}@{version}"); + + if let Some(&matched_purl) = purl_lookup.get(&key) { + result.insert( + matched_purl.to_string(), + CrawledPackage { + name: canon_name, + version, + namespace: None, + purl: matched_purl.to_string(), + path: site_packages_path.to_path_buf(), + }, + ); + } + } + } + + Ok(result) + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + /// Scan a `site-packages` directory for `.dist-info` directories. + async fn scan_site_packages( + &self, + site_packages_path: &Path, + seen: &mut HashSet, + ) -> Vec { + let mut results = Vec::new(); + + let entries = match tokio::fs::read_dir(site_packages_path).await { + Ok(rd) => { + let mut entries = rd; + let mut v = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + v.push(entry); + } + v + } + Err(_) => return results, + }; + + for entry in entries { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.ends_with(".dist-info") { + continue; + } + + let dist_info_path = site_packages_path.join(&*name_str); + if let Some((raw_name, version)) = read_python_metadata(&dist_info_path).await { + let canon_name = canonicalize_pypi_name(&raw_name); + let purl = format!("pkg:pypi/{canon_name}@{version}"); + + if seen.contains(&purl) { + continue; + } + seen.insert(purl.clone()); + + results.push(CrawledPackage { + name: canon_name, + version, + namespace: None, + purl, + path: site_packages_path.to_path_buf(), + }); + } + } + + results + } + + /// Parse a PyPI PURL string to extract name and version. + /// Strips qualifiers before parsing. + fn parse_pypi_purl(purl: &str) -> Option<(String, String)> { + // Strip qualifiers + let base = match purl.find('?') { + Some(idx) => &purl[..idx], + None => purl, + }; + + let rest = base.strip_prefix("pkg:pypi/")?; + let at_idx = rest.rfind('@')?; + let name = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + + if name.is_empty() || version.is_empty() { + return None; + } + + Some((name.to_string(), version.to_string())) + } +} + +impl Default for PythonCrawler { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonicalize_pypi_name_basic() { + assert_eq!(canonicalize_pypi_name("Requests"), "requests"); + assert_eq!(canonicalize_pypi_name("my_package"), "my-package"); + assert_eq!(canonicalize_pypi_name("My.Package"), "my-package"); + assert_eq!(canonicalize_pypi_name("My-._Package"), "my-package"); + } + + #[test] + fn test_canonicalize_pypi_name_runs() { + // Runs of separators collapse to single - + assert_eq!(canonicalize_pypi_name("a__b"), "a-b"); + assert_eq!(canonicalize_pypi_name("a-.-b"), "a-b"); + assert_eq!(canonicalize_pypi_name("a_._-b"), "a-b"); + } + + #[test] + fn test_canonicalize_pypi_name_trim() { + assert_eq!(canonicalize_pypi_name(" requests "), "requests"); + } + + #[test] + fn test_parse_pypi_purl() { + let (name, ver) = PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0").unwrap(); + assert_eq!(name, "requests"); + assert_eq!(ver, "2.28.0"); + } + + #[test] + fn test_parse_pypi_purl_with_qualifiers() { + let (name, ver) = + PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc").unwrap(); + assert_eq!(name, "requests"); + assert_eq!(ver, "2.28.0"); + } + + #[test] + fn test_parse_pypi_purl_invalid() { + assert!(PythonCrawler::parse_pypi_purl("pkg:npm/lodash@4.17.21").is_none()); + assert!(PythonCrawler::parse_pypi_purl("not-a-purl").is_none()); + } + + #[tokio::test] + async fn test_read_python_metadata_valid() { + let dir = tempfile::tempdir().unwrap(); + let dist_info = dir.path().join("requests-2.28.0.dist-info"); + tokio::fs::create_dir_all(&dist_info).await.unwrap(); + tokio::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: Requests\nVersion: 2.28.0\n\nSome description", + ) + .await + .unwrap(); + + let result = read_python_metadata(&dist_info).await; + assert!(result.is_some()); + let (name, version) = result.unwrap(); + assert_eq!(name, "Requests"); + assert_eq!(version, "2.28.0"); + } + + #[tokio::test] + async fn test_read_python_metadata_missing() { + let dir = tempfile::tempdir().unwrap(); + let dist_info = dir.path().join("nonexistent.dist-info"); + assert!(read_python_metadata(&dist_info).await.is_none()); + } + + #[tokio::test] + async fn test_find_python_dirs_literal() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("lib").join("python3.11").join("site-packages"); + tokio::fs::create_dir_all(&target).await.unwrap(); + + let results = + find_python_dirs(dir.path(), &["lib", "python3.*", "site-packages"]).await; + assert_eq!(results.len(), 1); + assert_eq!(results[0], target); + } + + #[tokio::test] + async fn test_find_python_dirs_wildcard() { + let dir = tempfile::tempdir().unwrap(); + let sp1 = dir.path().join("lib").join("python3.10").join("site-packages"); + let sp2 = dir.path().join("lib").join("python3.11").join("site-packages"); + tokio::fs::create_dir_all(&sp1).await.unwrap(); + tokio::fs::create_dir_all(&sp2).await.unwrap(); + + // Also create a non-matching dir + let non_match = dir.path().join("lib").join("ruby3.0").join("site-packages"); + tokio::fs::create_dir_all(&non_match).await.unwrap(); + + let results = + find_python_dirs(dir.path(), &["lib", "python3.*", "site-packages"]).await; + assert_eq!(results.len(), 2); + } + + #[tokio::test] + async fn test_find_python_dirs_star_wildcard() { + let dir = tempfile::tempdir().unwrap(); + let sp1 = dir + .path() + .join("tools") + .join("mytool") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&sp1).await.unwrap(); + + let results = find_python_dirs( + dir.path(), + &["tools", "*", "lib", "python3.*", "site-packages"], + ) + .await; + assert_eq!(results.len(), 1); + assert_eq!(results[0], sp1); + } + + #[tokio::test] + async fn test_crawl_all_python() { + let dir = tempfile::tempdir().unwrap(); + let venv = dir.path().join(".venv"); + let sp = venv.join("lib").join("python3.11").join("site-packages"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + // Create a dist-info dir with METADATA + let dist_info = sp.join("requests-2.28.0.dist-info"); + tokio::fs::create_dir_all(&dist_info).await.unwrap(); + tokio::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: Requests\nVersion: 2.28.0\n", + ) + .await + .unwrap(); + + let crawler = PythonCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + batch_size: 100, + }; + + let packages = crawler.crawl_all(&options).await; + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "requests"); + assert_eq!(packages[0].version, "2.28.0"); + assert_eq!(packages[0].purl, "pkg:pypi/requests@2.28.0"); + assert!(packages[0].namespace.is_none()); + } + + #[tokio::test] + async fn test_find_by_purls_python() { + let dir = tempfile::tempdir().unwrap(); + let sp = dir.path().to_path_buf(); + + // Create dist-info + let dist_info = sp.join("requests-2.28.0.dist-info"); + tokio::fs::create_dir_all(&dist_info).await.unwrap(); + tokio::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: Requests\nVersion: 2.28.0\n", + ) + .await + .unwrap(); + + let crawler = PythonCrawler::new(); + let purls = vec![ + "pkg:pypi/requests@2.28.0".to_string(), + "pkg:pypi/flask@3.0.0".to_string(), + ]; + + let result = crawler.find_by_purls(&sp, &purls).await.unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains_key("pkg:pypi/requests@2.28.0")); + assert!(!result.contains_key("pkg:pypi/flask@3.0.0")); + } +} diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs new file mode 100644 index 0000000..44489a7 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; + +/// Represents a package discovered during crawling. +#[derive(Debug, Clone)] +pub struct CrawledPackage { + /// Package name (without scope). + pub name: String, + /// Package version. + pub version: String, + /// Package scope/namespace (e.g., "@types") - None for unscoped packages. + pub namespace: Option, + /// Full PURL string (e.g., "pkg:npm/@types/node@20.0.0"). + pub purl: String, + /// Absolute path to the package directory. + pub path: PathBuf, +} + +/// Options for package crawling. +#[derive(Debug, Clone)] +pub struct CrawlerOptions { + /// Working directory to start from. + pub cwd: PathBuf, + /// Use global packages instead of local node_modules. + pub global: bool, + /// Custom path to global node_modules (overrides auto-detection). + pub global_prefix: Option, + /// Batch size for yielding packages (default: 100). + pub batch_size: usize, +} + +impl Default for CrawlerOptions { + fn default() -> Self { + Self { + cwd: std::env::current_dir().unwrap_or_default(), + global: false, + global_prefix: None, + batch_size: 100, + } + } +} diff --git a/crates/socket-patch-core/src/hash/git_sha256.rs b/crates/socket-patch-core/src/hash/git_sha256.rs new file mode 100644 index 0000000..b4ccd42 --- /dev/null +++ b/crates/socket-patch-core/src/hash/git_sha256.rs @@ -0,0 +1,89 @@ +use sha2::{Digest, Sha256}; +use std::io; +use tokio::io::AsyncReadExt; + +/// Compute Git-compatible SHA256 hash for a byte slice. +/// +/// Git hashes objects as: SHA256("blob \0" + content) +pub fn compute_git_sha256_from_bytes(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + let header = format!("blob {}\0", data.len()); + hasher.update(header.as_bytes()); + hasher.update(data); + hex::encode(hasher.finalize()) +} + +/// Compute Git-compatible SHA256 hash from an async reader with known size. +/// +/// This streams the content through the hasher without loading it all into memory. +pub async fn compute_git_sha256_from_reader( + size: u64, + mut reader: R, +) -> io::Result { + let mut hasher = Sha256::new(); + let header = format!("blob {}\0", size); + hasher.update(header.as_bytes()); + + let mut buf = [0u8; 8192]; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + + Ok(hex::encode(hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_content() { + let hash = compute_git_sha256_from_bytes(b""); + // SHA256("blob 0\0") - Git-compatible hash of empty content + assert_eq!(hash.len(), 64); + // Verify it's consistent + assert_eq!(hash, compute_git_sha256_from_bytes(b"")); + } + + #[test] + fn test_hello_world() { + let content = b"Hello, World!"; + let hash = compute_git_sha256_from_bytes(content); + assert_eq!(hash.len(), 64); + + // Manually compute expected: SHA256("blob 13\0Hello, World!") + use sha2::{Digest, Sha256}; + let mut expected_hasher = Sha256::new(); + expected_hasher.update(b"blob 13\0Hello, World!"); + let expected = hex::encode(expected_hasher.finalize()); + assert_eq!(hash, expected); + } + + #[test] + fn test_known_vector() { + // Known test vector: SHA256("blob 0\0") + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"blob 0\0"); + let expected = hex::encode(hasher.finalize()); + assert_eq!(compute_git_sha256_from_bytes(b""), expected); + } + + #[tokio::test] + async fn test_async_reader_matches_sync() { + let content = b"test content for async hashing"; + let sync_hash = compute_git_sha256_from_bytes(content); + + let cursor = tokio::io::BufReader::new(&content[..]); + let async_hash = + compute_git_sha256_from_reader(content.len() as u64, cursor) + .await + .unwrap(); + + assert_eq!(sync_hash, async_hash); + } +} diff --git a/crates/socket-patch-core/src/hash/mod.rs b/crates/socket-patch-core/src/hash/mod.rs new file mode 100644 index 0000000..45732e4 --- /dev/null +++ b/crates/socket-patch-core/src/hash/mod.rs @@ -0,0 +1,3 @@ +pub mod git_sha256; + +pub use git_sha256::*; diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs new file mode 100644 index 0000000..3683364 --- /dev/null +++ b/crates/socket-patch-core/src/lib.rs @@ -0,0 +1,8 @@ +pub mod api; +pub mod constants; +pub mod crawlers; +pub mod hash; +pub mod manifest; +pub mod package_json; +pub mod patch; +pub mod utils; diff --git a/crates/socket-patch-core/src/manifest/mod.rs b/crates/socket-patch-core/src/manifest/mod.rs new file mode 100644 index 0000000..39bd775 --- /dev/null +++ b/crates/socket-patch-core/src/manifest/mod.rs @@ -0,0 +1,5 @@ +pub mod operations; +pub mod recovery; +pub mod schema; + +pub use schema::*; diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs new file mode 100644 index 0000000..cec161f --- /dev/null +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -0,0 +1,453 @@ +use std::collections::HashSet; +use std::path::Path; + +use crate::manifest::schema::PatchManifest; + +/// Get all blob hashes referenced by a manifest (both beforeHash and afterHash). +/// Used for garbage collection and validation. +pub fn get_referenced_blobs(manifest: &PatchManifest) -> HashSet { + let mut blobs = HashSet::new(); + + for record in manifest.patches.values() { + for file_info in record.files.values() { + blobs.insert(file_info.before_hash.clone()); + blobs.insert(file_info.after_hash.clone()); + } + } + + blobs +} + +/// Get only afterHash blobs referenced by a manifest. +/// Used for apply operations -- we only need the patched file content, not the original. +/// This saves disk space since beforeHash blobs are not needed for applying patches. +pub fn get_after_hash_blobs(manifest: &PatchManifest) -> HashSet { + let mut blobs = HashSet::new(); + + for record in manifest.patches.values() { + for file_info in record.files.values() { + blobs.insert(file_info.after_hash.clone()); + } + } + + blobs +} + +/// Get only beforeHash blobs referenced by a manifest. +/// Used for rollback operations -- we need the original file content to restore. +pub fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { + let mut blobs = HashSet::new(); + + for record in manifest.patches.values() { + for file_info in record.files.values() { + blobs.insert(file_info.before_hash.clone()); + } + } + + blobs +} + +/// Differences between two manifests. +#[derive(Debug, Clone)] +pub struct ManifestDiff { + /// PURLs present in new but not old. + pub added: HashSet, + /// PURLs present in old but not new. + pub removed: HashSet, + /// PURLs present in both but with different UUIDs. + pub modified: HashSet, +} + +/// Calculate differences between two manifests. +/// Patches are compared by UUID: if the PURL exists in both manifests but the +/// UUID changed, the patch is considered modified. +pub fn diff_manifests(old_manifest: &PatchManifest, new_manifest: &PatchManifest) -> ManifestDiff { + let old_purls: HashSet<&String> = old_manifest.patches.keys().collect(); + let new_purls: HashSet<&String> = new_manifest.patches.keys().collect(); + + let mut added = HashSet::new(); + let mut removed = HashSet::new(); + let mut modified = HashSet::new(); + + // Find added and modified + for purl in &new_purls { + if !old_purls.contains(purl) { + added.insert((*purl).clone()); + } else { + let old_patch = &old_manifest.patches[*purl]; + let new_patch = &new_manifest.patches[*purl]; + if old_patch.uuid != new_patch.uuid { + modified.insert((*purl).clone()); + } + } + } + + // Find removed + for purl in &old_purls { + if !new_purls.contains(purl) { + removed.insert((*purl).clone()); + } + } + + ManifestDiff { + added, + removed, + modified, + } +} + +/// Validate a parsed JSON value as a PatchManifest. +/// Returns Ok(manifest) if valid, or Err(message) if invalid. +pub fn validate_manifest(value: &serde_json::Value) -> Result { + serde_json::from_value::(value.clone()) + .map_err(|e| format!("Invalid manifest: {}", e)) +} + +/// Read and parse a manifest from the filesystem. +/// Returns Ok(None) if the file does not exist or cannot be parsed. +pub async fn read_manifest(path: impl AsRef) -> Result, std::io::Error> { + let path = path.as_ref(); + + let content = match tokio::fs::read_to_string(path).await { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Ok(None), + }; + + let parsed: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + + match validate_manifest(&parsed) { + Ok(manifest) => Ok(Some(manifest)), + Err(_) => Ok(None), + } +} + +/// Write a manifest to the filesystem with pretty-printed JSON. +pub async fn write_manifest( + path: impl AsRef, + manifest: &PatchManifest, +) -> Result<(), std::io::Error> { + let content = serde_json::to_string_pretty(manifest) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + tokio::fs::write(path, content).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::{PatchFileInfo, PatchRecord}; + use std::collections::HashMap; + + const TEST_UUID_1: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_UUID_2: &str = "22222222-2222-4222-8222-222222222222"; + + const BEFORE_HASH_1: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111"; + const AFTER_HASH_1: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111"; + const BEFORE_HASH_2: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222"; + const AFTER_HASH_2: &str = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222"; + const BEFORE_HASH_3: &str = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3333"; + const AFTER_HASH_3: &str = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3333"; + + fn create_test_manifest() -> PatchManifest { + let mut patches = HashMap::new(); + + let mut files_a = HashMap::new(); + files_a.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_1.to_string(), + after_hash: AFTER_HASH_1.to_string(), + }, + ); + files_a.insert( + "package/lib/utils.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_2.to_string(), + after_hash: AFTER_HASH_2.to_string(), + }, + ); + + patches.insert( + "pkg:npm/pkg-a@1.0.0".to_string(), + PatchRecord { + uuid: TEST_UUID_1.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: files_a, + vulnerabilities: HashMap::new(), + description: "Test patch 1".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + let mut files_b = HashMap::new(); + files_b.insert( + "package/main.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_3.to_string(), + after_hash: AFTER_HASH_3.to_string(), + }, + ); + + patches.insert( + "pkg:npm/pkg-b@2.0.0".to_string(), + PatchRecord { + uuid: TEST_UUID_2.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: files_b, + vulnerabilities: HashMap::new(), + description: "Test patch 2".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + PatchManifest { patches } + } + + #[test] + fn test_get_referenced_blobs_returns_all() { + let manifest = create_test_manifest(); + let blobs = get_referenced_blobs(&manifest); + + assert_eq!(blobs.len(), 6); + assert!(blobs.contains(BEFORE_HASH_1)); + assert!(blobs.contains(AFTER_HASH_1)); + assert!(blobs.contains(BEFORE_HASH_2)); + assert!(blobs.contains(AFTER_HASH_2)); + assert!(blobs.contains(BEFORE_HASH_3)); + assert!(blobs.contains(AFTER_HASH_3)); + } + + #[test] + fn test_get_referenced_blobs_empty_manifest() { + let manifest = PatchManifest::new(); + let blobs = get_referenced_blobs(&manifest); + assert_eq!(blobs.len(), 0); + } + + #[test] + fn test_get_referenced_blobs_deduplicates() { + let mut files = HashMap::new(); + files.insert( + "package/file1.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_1.to_string(), + after_hash: AFTER_HASH_1.to_string(), + }, + ); + files.insert( + "package/file2.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_1.to_string(), // same as file1 + after_hash: AFTER_HASH_2.to_string(), + }, + ); + + let mut patches = HashMap::new(); + patches.insert( + "pkg:npm/pkg-a@1.0.0".to_string(), + PatchRecord { + uuid: TEST_UUID_1.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "Test".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + let manifest = PatchManifest { patches }; + let blobs = get_referenced_blobs(&manifest); + // 3 unique hashes, not 4 + assert_eq!(blobs.len(), 3); + } + + #[test] + fn test_get_after_hash_blobs() { + let manifest = create_test_manifest(); + let blobs = get_after_hash_blobs(&manifest); + + assert_eq!(blobs.len(), 3); + assert!(blobs.contains(AFTER_HASH_1)); + assert!(blobs.contains(AFTER_HASH_2)); + assert!(blobs.contains(AFTER_HASH_3)); + assert!(!blobs.contains(BEFORE_HASH_1)); + assert!(!blobs.contains(BEFORE_HASH_2)); + assert!(!blobs.contains(BEFORE_HASH_3)); + } + + #[test] + fn test_get_after_hash_blobs_empty() { + let manifest = PatchManifest::new(); + let blobs = get_after_hash_blobs(&manifest); + assert_eq!(blobs.len(), 0); + } + + #[test] + fn test_get_before_hash_blobs() { + let manifest = create_test_manifest(); + let blobs = get_before_hash_blobs(&manifest); + + assert_eq!(blobs.len(), 3); + assert!(blobs.contains(BEFORE_HASH_1)); + assert!(blobs.contains(BEFORE_HASH_2)); + assert!(blobs.contains(BEFORE_HASH_3)); + assert!(!blobs.contains(AFTER_HASH_1)); + assert!(!blobs.contains(AFTER_HASH_2)); + assert!(!blobs.contains(AFTER_HASH_3)); + } + + #[test] + fn test_get_before_hash_blobs_empty() { + let manifest = PatchManifest::new(); + let blobs = get_before_hash_blobs(&manifest); + assert_eq!(blobs.len(), 0); + } + + #[test] + fn test_after_plus_before_equals_all() { + let manifest = create_test_manifest(); + let all_blobs = get_referenced_blobs(&manifest); + let after_blobs = get_after_hash_blobs(&manifest); + let before_blobs = get_before_hash_blobs(&manifest); + + let union: HashSet = after_blobs.union(&before_blobs).cloned().collect(); + assert_eq!(union.len(), all_blobs.len()); + for blob in &all_blobs { + assert!(union.contains(blob)); + } + } + + #[test] + fn test_diff_manifests_added() { + let old = PatchManifest::new(); + let new_manifest = create_test_manifest(); + + let diff = diff_manifests(&old, &new_manifest); + assert_eq!(diff.added.len(), 2); + assert!(diff.added.contains("pkg:npm/pkg-a@1.0.0")); + assert!(diff.added.contains("pkg:npm/pkg-b@2.0.0")); + assert_eq!(diff.removed.len(), 0); + assert_eq!(diff.modified.len(), 0); + } + + #[test] + fn test_diff_manifests_removed() { + let old = create_test_manifest(); + let new_manifest = PatchManifest::new(); + + let diff = diff_manifests(&old, &new_manifest); + assert_eq!(diff.added.len(), 0); + assert_eq!(diff.removed.len(), 2); + assert!(diff.removed.contains("pkg:npm/pkg-a@1.0.0")); + assert!(diff.removed.contains("pkg:npm/pkg-b@2.0.0")); + assert_eq!(diff.modified.len(), 0); + } + + #[test] + fn test_diff_manifests_modified() { + let old = create_test_manifest(); + let mut new_manifest = create_test_manifest(); + // Change UUID of pkg-a + new_manifest + .patches + .get_mut("pkg:npm/pkg-a@1.0.0") + .unwrap() + .uuid = "33333333-3333-4333-8333-333333333333".to_string(); + + let diff = diff_manifests(&old, &new_manifest); + assert_eq!(diff.added.len(), 0); + assert_eq!(diff.removed.len(), 0); + assert_eq!(diff.modified.len(), 1); + assert!(diff.modified.contains("pkg:npm/pkg-a@1.0.0")); + } + + #[test] + fn test_diff_manifests_same() { + let old = create_test_manifest(); + let new_manifest = create_test_manifest(); + + let diff = diff_manifests(&old, &new_manifest); + assert_eq!(diff.added.len(), 0); + assert_eq!(diff.removed.len(), 0); + assert_eq!(diff.modified.len(), 0); + } + + #[test] + fn test_validate_manifest_valid() { + let json = serde_json::json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "test", + "license": "MIT", + "tier": "free" + } + } + }); + + let result = validate_manifest(&json); + assert!(result.is_ok()); + let manifest = result.unwrap(); + assert_eq!(manifest.patches.len(), 1); + } + + #[test] + fn test_validate_manifest_invalid() { + let json = serde_json::json!({ + "patches": "not-an-object" + }); + + let result = validate_manifest(&json); + assert!(result.is_err()); + } + + #[test] + fn test_validate_manifest_missing_fields() { + let json = serde_json::json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "test" + } + } + }); + + let result = validate_manifest(&json); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_read_manifest_not_found() { + let result = read_manifest("/nonexistent/path/manifest.json").await; + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[tokio::test] + async fn test_write_and_read_manifest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("manifest.json"); + + let manifest = create_test_manifest(); + write_manifest(&path, &manifest).await.unwrap(); + + let read_back = read_manifest(&path).await.unwrap(); + assert!(read_back.is_some()); + let read_back = read_back.unwrap(); + assert_eq!(read_back.patches.len(), 2); + } +} diff --git a/crates/socket-patch-core/src/manifest/recovery.rs b/crates/socket-patch-core/src/manifest/recovery.rs new file mode 100644 index 0000000..4842452 --- /dev/null +++ b/crates/socket-patch-core/src/manifest/recovery.rs @@ -0,0 +1,550 @@ +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; + +use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo}; + +/// Result of manifest recovery operation. +#[derive(Debug, Clone)] +pub struct RecoveryResult { + pub manifest: PatchManifest, + pub repair_needed: bool, + pub invalid_patches: Vec, + pub recovered_patches: Vec, + pub discarded_patches: Vec, +} + +/// Patch data returned from an external source (e.g., database). +#[derive(Debug, Clone)] +pub struct PatchData { + pub uuid: String, + pub purl: String, + pub published_at: String, + pub files: HashMap, + pub vulnerabilities: HashMap, + pub description: String, + pub license: String, + pub tier: String, +} + +/// File info from external patch data (hashes are optional). +#[derive(Debug, Clone)] +pub struct PatchDataFileInfo { + pub before_hash: Option, + pub after_hash: Option, +} + +/// Vulnerability info from external patch data. +#[derive(Debug, Clone)] +pub struct PatchDataVulnerability { + pub cves: Vec, + pub summary: String, + pub severity: String, + pub description: String, +} + +/// Events emitted during recovery. +#[derive(Debug, Clone)] +pub enum RecoveryEvent { + CorruptedManifest, + InvalidPatch { + purl: String, + uuid: Option, + }, + RecoveredPatch { + purl: String, + uuid: String, + }, + DiscardedPatchNotFound { + purl: String, + uuid: String, + }, + DiscardedPatchPurlMismatch { + purl: String, + uuid: String, + db_purl: String, + }, + DiscardedPatchNoUuid { + purl: String, + }, + RecoveryError { + purl: String, + uuid: String, + error: String, + }, +} + +/// Type alias for the refetch callback. +/// Takes (uuid, optional purl) and returns a future resolving to Option. +pub type RefetchPatchFn = Box< + dyn Fn(String, Option) -> Pin, String>> + Send>> + + Send + + Sync, +>; + +/// Type alias for the recovery event callback. +pub type OnRecoveryEventFn = Box; + +/// Options for manifest recovery. +pub struct RecoveryOptions { + /// Optional function to refetch patch data from external source (e.g., database). + /// Should return patch data or None if not found. + pub refetch_patch: Option, + + /// Optional callback for logging recovery events. + pub on_recovery_event: Option, +} + +impl Default for RecoveryOptions { + fn default() -> Self { + Self { + refetch_patch: None, + on_recovery_event: None, + } + } +} + +/// Recover and validate manifest with automatic repair of invalid patches. +/// +/// This function attempts to parse and validate a manifest. If the manifest +/// contains invalid patches, it will attempt to recover them using the provided +/// refetch function. Patches that cannot be recovered are discarded. +pub async fn recover_manifest( + parsed: &serde_json::Value, + options: RecoveryOptions, +) -> RecoveryResult { + let RecoveryOptions { + refetch_patch, + on_recovery_event, + } = options; + + let emit = |event: RecoveryEvent| { + if let Some(ref cb) = on_recovery_event { + cb(event); + } + }; + + // Try strict parse first (fast path for valid manifests) + if let Ok(manifest) = serde_json::from_value::(parsed.clone()) { + return RecoveryResult { + manifest, + repair_needed: false, + invalid_patches: vec![], + recovered_patches: vec![], + discarded_patches: vec![], + }; + } + + // Extract patches object with safety checks + let patches_obj = parsed + .as_object() + .and_then(|obj| obj.get("patches")) + .and_then(|p| p.as_object()); + + let patches_obj = match patches_obj { + Some(obj) => obj, + None => { + // Completely corrupted manifest + emit(RecoveryEvent::CorruptedManifest); + return RecoveryResult { + manifest: PatchManifest::new(), + repair_needed: true, + invalid_patches: vec![], + recovered_patches: vec![], + discarded_patches: vec![], + }; + } + }; + + // Try to recover individual patches + let mut recovered_patches_map: HashMap = HashMap::new(); + let mut invalid_patches: Vec = Vec::new(); + let mut recovered_patches: Vec = Vec::new(); + let mut discarded_patches: Vec = Vec::new(); + + for (purl, patch_data) in patches_obj { + // Try to parse this individual patch + if let Ok(record) = serde_json::from_value::(patch_data.clone()) { + // Valid patch, keep it as-is + recovered_patches_map.insert(purl.clone(), record); + } else { + // Invalid patch, try to recover from external source + let uuid = patch_data + .as_object() + .and_then(|obj| obj.get("uuid")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + invalid_patches.push(purl.clone()); + emit(RecoveryEvent::InvalidPatch { + purl: purl.clone(), + uuid: uuid.clone(), + }); + + if let (Some(ref uuid_str), Some(ref refetch)) = (&uuid, &refetch_patch) { + // Try to refetch from external source + match refetch(uuid_str.clone(), Some(purl.clone())).await { + Ok(Some(patch_from_source)) => { + if patch_from_source.purl == *purl { + // Successfully recovered, reconstruct patch record + let mut manifest_files: HashMap = + HashMap::new(); + for (file_path, file_info) in &patch_from_source.files { + if let (Some(before), Some(after)) = + (&file_info.before_hash, &file_info.after_hash) + { + manifest_files.insert( + file_path.clone(), + PatchFileInfo { + before_hash: before.clone(), + after_hash: after.clone(), + }, + ); + } + } + + let mut vulns: HashMap = HashMap::new(); + for (vuln_id, vuln_data) in &patch_from_source.vulnerabilities { + vulns.insert( + vuln_id.clone(), + VulnerabilityInfo { + cves: vuln_data.cves.clone(), + summary: vuln_data.summary.clone(), + severity: vuln_data.severity.clone(), + description: vuln_data.description.clone(), + }, + ); + } + + recovered_patches_map.insert( + purl.clone(), + PatchRecord { + uuid: patch_from_source.uuid.clone(), + exported_at: patch_from_source.published_at.clone(), + files: manifest_files, + vulnerabilities: vulns, + description: patch_from_source.description.clone(), + license: patch_from_source.license.clone(), + tier: patch_from_source.tier.clone(), + }, + ); + + recovered_patches.push(purl.clone()); + emit(RecoveryEvent::RecoveredPatch { + purl: purl.clone(), + uuid: uuid_str.clone(), + }); + } else { + // PURL mismatch - wrong package! + discarded_patches.push(purl.clone()); + emit(RecoveryEvent::DiscardedPatchPurlMismatch { + purl: purl.clone(), + uuid: uuid_str.clone(), + db_purl: patch_from_source.purl.clone(), + }); + } + } + Ok(None) => { + // Not found in external source (might be unpublished) + discarded_patches.push(purl.clone()); + emit(RecoveryEvent::DiscardedPatchNotFound { + purl: purl.clone(), + uuid: uuid_str.clone(), + }); + } + Err(error_msg) => { + // Error during recovery + discarded_patches.push(purl.clone()); + emit(RecoveryEvent::RecoveryError { + purl: purl.clone(), + uuid: uuid_str.clone(), + error: error_msg, + }); + } + } + } else { + // No UUID or no refetch function, can't recover + discarded_patches.push(purl.clone()); + if uuid.is_none() { + emit(RecoveryEvent::DiscardedPatchNoUuid { + purl: purl.clone(), + }); + } else { + emit(RecoveryEvent::DiscardedPatchNotFound { + purl: purl.clone(), + uuid: uuid.unwrap(), + }); + } + } + } + } + + let repair_needed = !invalid_patches.is_empty(); + + RecoveryResult { + manifest: PatchManifest { + patches: recovered_patches_map, + }, + repair_needed, + invalid_patches, + recovered_patches, + discarded_patches, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn test_valid_manifest_no_repair() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "test", + "license": "MIT", + "tier": "free" + } + } + }); + + let result = recover_manifest(&parsed, RecoveryOptions::default()).await; + assert!(!result.repair_needed); + assert_eq!(result.manifest.patches.len(), 1); + assert!(result.invalid_patches.is_empty()); + assert!(result.recovered_patches.is_empty()); + assert!(result.discarded_patches.is_empty()); + } + + #[tokio::test] + async fn test_corrupted_manifest_no_patches_key() { + let parsed = json!({ + "something": "else" + }); + + let result = recover_manifest(&parsed, RecoveryOptions::default()).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + } + + #[tokio::test] + async fn test_corrupted_manifest_patches_not_object() { + let parsed = json!({ + "patches": "not-an-object" + }); + + let result = recover_manifest(&parsed, RecoveryOptions::default()).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + } + + #[tokio::test] + async fn test_invalid_patch_discarded_no_refetch() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111" + // missing required fields + } + } + }); + + let result = recover_manifest(&parsed, RecoveryOptions::default()).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + assert_eq!(result.invalid_patches.len(), 1); + assert_eq!(result.discarded_patches.len(), 1); + } + + #[tokio::test] + async fn test_invalid_patch_no_uuid_discarded() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "garbage": true + } + } + }); + + + let events_clone = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let events_ref = events_clone.clone(); + + let options = RecoveryOptions { + refetch_patch: None, + on_recovery_event: Some(Box::new(move |event| { + events_ref.lock().unwrap().push(format!("{:?}", event)); + })), + }; + + let result = recover_manifest(&parsed, options).await; + assert!(result.repair_needed); + assert_eq!(result.discarded_patches.len(), 1); + + let logged = events_clone.lock().unwrap(); + assert!(logged.iter().any(|e| e.contains("DiscardedPatchNoUuid"))); + } + + #[tokio::test] + async fn test_mix_valid_and_invalid_patches() { + let parsed = json!({ + "patches": { + "pkg:npm/good@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "good patch", + "license": "MIT", + "tier": "free" + }, + "pkg:npm/bad@1.0.0": { + "uuid": "22222222-2222-4222-8222-222222222222" + // missing required fields + } + } + }); + + let result = recover_manifest(&parsed, RecoveryOptions::default()).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 1); + assert!(result.manifest.patches.contains_key("pkg:npm/good@1.0.0")); + assert_eq!(result.invalid_patches.len(), 1); + assert_eq!(result.discarded_patches.len(), 1); + } + + #[tokio::test] + async fn test_recovery_with_refetch_success() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111" + // missing required fields + } + } + }); + + let options = RecoveryOptions { + refetch_patch: Some(Box::new(|_uuid, _purl| { + Box::pin(async { + Ok(Some(PatchData { + uuid: "11111111-1111-4111-8111-111111111111".to_string(), + purl: "pkg:npm/test@1.0.0".to_string(), + published_at: "2024-01-01T00:00:00Z".to_string(), + files: { + let mut m = HashMap::new(); + m.insert( + "package/index.js".to_string(), + PatchDataFileInfo { + before_hash: Some("aaa".to_string()), + after_hash: Some("bbb".to_string()), + }, + ); + m + }, + vulnerabilities: HashMap::new(), + description: "recovered".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + })) + }) + })), + on_recovery_event: None, + }; + + let result = recover_manifest(&parsed, options).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 1); + assert_eq!(result.recovered_patches.len(), 1); + assert_eq!(result.discarded_patches.len(), 0); + + let record = result.manifest.patches.get("pkg:npm/test@1.0.0").unwrap(); + assert_eq!(record.description, "recovered"); + assert_eq!(record.files.len(), 1); + } + + #[tokio::test] + async fn test_recovery_with_purl_mismatch() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111" + } + } + }); + + let options = RecoveryOptions { + refetch_patch: Some(Box::new(|_uuid, _purl| { + Box::pin(async { + Ok(Some(PatchData { + uuid: "11111111-1111-4111-8111-111111111111".to_string(), + purl: "pkg:npm/other@2.0.0".to_string(), // wrong purl + published_at: "2024-01-01T00:00:00Z".to_string(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: "wrong".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + })) + }) + })), + on_recovery_event: None, + }; + + let result = recover_manifest(&parsed, options).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + assert_eq!(result.discarded_patches.len(), 1); + } + + #[tokio::test] + async fn test_recovery_with_refetch_not_found() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111" + } + } + }); + + let options = RecoveryOptions { + refetch_patch: Some(Box::new(|_uuid, _purl| { + Box::pin(async { Ok(None) }) + })), + on_recovery_event: None, + }; + + let result = recover_manifest(&parsed, options).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + assert_eq!(result.discarded_patches.len(), 1); + } + + #[tokio::test] + async fn test_recovery_with_refetch_error() { + let parsed = json!({ + "patches": { + "pkg:npm/test@1.0.0": { + "uuid": "11111111-1111-4111-8111-111111111111" + } + } + }); + + let options = RecoveryOptions { + refetch_patch: Some(Box::new(|_uuid, _purl| { + Box::pin(async { Err("network error".to_string()) }) + })), + on_recovery_event: None, + }; + + let result = recover_manifest(&parsed, options).await; + assert!(result.repair_needed); + assert_eq!(result.manifest.patches.len(), 0); + assert_eq!(result.discarded_patches.len(), 1); + } +} diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs new file mode 100644 index 0000000..bfd7fe3 --- /dev/null +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Information about a vulnerability fixed by a patch. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VulnerabilityInfo { + pub cves: Vec, + pub summary: String, + pub severity: String, + pub description: String, +} + +/// Hash information for a single patched file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PatchFileInfo { + pub before_hash: String, + pub after_hash: String, +} + +/// A single patch record in the manifest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PatchRecord { + pub uuid: String, + pub exported_at: String, + /// Maps relative file path -> hash info. + pub files: HashMap, + /// Maps vulnerability ID (e.g., "GHSA-...") -> vulnerability info. + pub vulnerabilities: HashMap, + pub description: String, + pub license: String, + pub tier: String, +} + +/// The top-level patch manifest structure. +/// Stored as `.socket/manifest.json`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PatchManifest { + /// Maps package PURL (e.g., "pkg:npm/lodash@4.17.21") -> patch record. + pub patches: HashMap, +} + +impl PatchManifest { + /// Create an empty manifest. + pub fn new() -> Self { + Self { + patches: HashMap::new(), + } + } +} + +impl Default for PatchManifest { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_manifest_roundtrip() { + let manifest = PatchManifest::new(); + let json = serde_json::to_string_pretty(&manifest).unwrap(); + let parsed: PatchManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.patches.len(), 0); + } + + #[test] + fn test_manifest_with_patch_roundtrip() { + let json = r#"{ + "patches": { + "pkg:npm/simplehttpserver@0.0.6": { + "uuid": "12345678-1234-1234-1234-123456789abc", + "exportedAt": "2024-01-15T10:00:00Z", + "files": { + "package/lib/server.js": { + "beforeHash": "aaaa000000000000000000000000000000000000000000000000000000000000", + "afterHash": "bbbb000000000000000000000000000000000000000000000000000000000000" + } + }, + "vulnerabilities": { + "GHSA-jrhj-2j3q-xf3v": { + "cves": ["CVE-2024-1234"], + "summary": "Path traversal vulnerability", + "severity": "high", + "description": "A path traversal vulnerability exists in simplehttpserver" + } + }, + "description": "Fix path traversal vulnerability", + "license": "MIT", + "tier": "free" + } + } +}"#; + + let manifest: PatchManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.patches.len(), 1); + + let patch = manifest.patches.get("pkg:npm/simplehttpserver@0.0.6").unwrap(); + assert_eq!(patch.uuid, "12345678-1234-1234-1234-123456789abc"); + assert_eq!(patch.files.len(), 1); + assert_eq!(patch.vulnerabilities.len(), 1); + assert_eq!(patch.tier, "free"); + + let file_info = patch.files.get("package/lib/server.js").unwrap(); + assert_eq!( + file_info.before_hash, + "aaaa000000000000000000000000000000000000000000000000000000000000" + ); + + let vuln = patch.vulnerabilities.get("GHSA-jrhj-2j3q-xf3v").unwrap(); + assert_eq!(vuln.cves, vec!["CVE-2024-1234"]); + assert_eq!(vuln.severity, "high"); + + // Verify round-trip + let serialized = serde_json::to_string_pretty(&manifest).unwrap(); + let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap(); + assert_eq!(manifest, reparsed); + } + + #[test] + fn test_camel_case_serialization() { + let file_info = PatchFileInfo { + before_hash: "aaa".to_string(), + after_hash: "bbb".to_string(), + }; + let json = serde_json::to_string(&file_info).unwrap(); + assert!(json.contains("beforeHash")); + assert!(json.contains("afterHash")); + assert!(!json.contains("before_hash")); + assert!(!json.contains("after_hash")); + } + + #[test] + fn test_patch_record_camel_case() { + let record = PatchRecord { + uuid: "test-uuid".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: "test".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }; + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("exportedAt")); + assert!(!json.contains("exported_at")); + } +} diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs new file mode 100644 index 0000000..822e2bb --- /dev/null +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -0,0 +1,172 @@ +/// The command to run for applying patches via socket CLI. +const SOCKET_PATCH_COMMAND: &str = "socket patch apply --silent --ecosystems npm"; + +/// Legacy command patterns to detect existing configurations. +const LEGACY_PATCH_PATTERNS: &[&str] = &[ + "socket-patch apply", + "npx @socketsecurity/socket-patch apply", + "socket patch apply", +]; + +/// Status of postinstall script configuration. +#[derive(Debug, Clone)] +pub struct PostinstallStatus { + pub configured: bool, + pub current_script: String, + pub needs_update: bool, +} + +/// Check if a postinstall script is properly configured for socket-patch. +pub fn is_postinstall_configured(package_json: &serde_json::Value) -> PostinstallStatus { + let current_script = package_json + .get("scripts") + .and_then(|s| s.get("postinstall")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let configured = LEGACY_PATCH_PATTERNS + .iter() + .any(|pattern| current_script.contains(pattern)); + + PostinstallStatus { + configured, + current_script, + needs_update: !configured, + } +} + +/// Check if a postinstall script string is configured for socket-patch. +pub fn is_postinstall_configured_str(content: &str) -> PostinstallStatus { + match serde_json::from_str::(content) { + Ok(val) => is_postinstall_configured(&val), + Err(_) => PostinstallStatus { + configured: false, + current_script: String::new(), + needs_update: true, + }, + } +} + +/// Generate an updated postinstall script that includes socket-patch. +pub fn generate_updated_postinstall(current_postinstall: &str) -> String { + let trimmed = current_postinstall.trim(); + + // If empty, just add the socket-patch command. + if trimmed.is_empty() { + return SOCKET_PATCH_COMMAND.to_string(); + } + + // If any socket-patch variant is already present, return unchanged. + let already_configured = LEGACY_PATCH_PATTERNS + .iter() + .any(|pattern| trimmed.contains(pattern)); + if already_configured { + return trimmed.to_string(); + } + + // Prepend socket-patch command so it runs first. + format!("{SOCKET_PATCH_COMMAND} && {trimmed}") +} + +/// Update a package.json Value with the new postinstall script. +/// Returns (modified, new_script). +pub fn update_package_json_object( + package_json: &mut serde_json::Value, +) -> (bool, String) { + let status = is_postinstall_configured(package_json); + + if !status.needs_update { + return (false, status.current_script); + } + + let new_postinstall = generate_updated_postinstall(&status.current_script); + + // Ensure scripts object exists + if package_json.get("scripts").is_none() { + package_json["scripts"] = serde_json::json!({}); + } + + package_json["scripts"]["postinstall"] = + serde_json::Value::String(new_postinstall.clone()); + + (true, new_postinstall) +} + +/// Parse package.json content and update it with socket-patch postinstall. +/// Returns (modified, new_content, old_script, new_script). +pub fn update_package_json_content( + content: &str, +) -> Result<(bool, String, String, String), String> { + let mut package_json: serde_json::Value = + serde_json::from_str(content).map_err(|e| format!("Invalid package.json: {e}"))?; + + let status = is_postinstall_configured(&package_json); + + if !status.needs_update { + return Ok(( + false, + content.to_string(), + status.current_script.clone(), + status.current_script, + )); + } + + let (_, new_script) = update_package_json_object(&mut package_json); + let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n"; + + Ok((true, new_content, status.current_script, new_script)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_not_configured() { + let pkg: serde_json::Value = serde_json::json!({ + "name": "test", + "scripts": { + "build": "tsc" + } + }); + let status = is_postinstall_configured(&pkg); + assert!(!status.configured); + assert!(status.needs_update); + } + + #[test] + fn test_already_configured() { + let pkg: serde_json::Value = serde_json::json!({ + "name": "test", + "scripts": { + "postinstall": "socket patch apply --silent --ecosystems npm" + } + }); + let status = is_postinstall_configured(&pkg); + assert!(status.configured); + assert!(!status.needs_update); + } + + #[test] + fn test_generate_empty() { + assert_eq!( + generate_updated_postinstall(""), + "socket patch apply --silent --ecosystems npm" + ); + } + + #[test] + fn test_generate_prepend() { + assert_eq!( + generate_updated_postinstall("echo done"), + "socket patch apply --silent --ecosystems npm && echo done" + ); + } + + #[test] + fn test_generate_already_configured() { + let current = "socket-patch apply && echo done"; + assert_eq!(generate_updated_postinstall(current), current); + } +} diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs new file mode 100644 index 0000000..714a91e --- /dev/null +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -0,0 +1,322 @@ +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// Workspace configuration type. +#[derive(Debug, Clone)] +pub enum WorkspaceType { + Npm, + Pnpm, + None, +} + +/// Workspace configuration. +#[derive(Debug, Clone)] +pub struct WorkspaceConfig { + pub ws_type: WorkspaceType, + pub patterns: Vec, +} + +/// Location of a discovered package.json file. +#[derive(Debug, Clone)] +pub struct PackageJsonLocation { + pub path: PathBuf, + pub is_root: bool, + pub is_workspace: bool, + pub workspace_pattern: Option, +} + +/// Find all package.json files, respecting workspace configurations. +pub async fn find_package_json_files( + start_path: &Path, +) -> Vec { + let mut results = Vec::new(); + let root_package_json = start_path.join("package.json"); + + let mut root_exists = false; + let mut workspace_config = WorkspaceConfig { + ws_type: WorkspaceType::None, + patterns: Vec::new(), + }; + + if fs::metadata(&root_package_json).await.is_ok() { + root_exists = true; + workspace_config = detect_workspaces(&root_package_json).await; + results.push(PackageJsonLocation { + path: root_package_json, + is_root: true, + is_workspace: false, + workspace_pattern: None, + }); + } + + match workspace_config.ws_type { + WorkspaceType::None => { + if root_exists { + let nested = find_nested_package_json_files(start_path).await; + results.extend(nested); + } + } + _ => { + let ws_packages = + find_workspace_packages(start_path, &workspace_config).await; + results.extend(ws_packages); + } + } + + results +} + +/// Detect workspace configuration from package.json. +pub async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { + let default = WorkspaceConfig { + ws_type: WorkspaceType::None, + patterns: Vec::new(), + }; + + let content = match fs::read_to_string(package_json_path).await { + Ok(c) => c, + Err(_) => return default, + }; + + let pkg: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return default, + }; + + // Check for npm/yarn workspaces + if let Some(workspaces) = pkg.get("workspaces") { + let patterns = if let Some(arr) = workspaces.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + } else if let Some(obj) = workspaces.as_object() { + obj.get("packages") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + return WorkspaceConfig { + ws_type: WorkspaceType::Npm, + patterns, + }; + } + + // Check for pnpm workspaces + let dir = package_json_path.parent().unwrap_or(Path::new(".")); + let pnpm_workspace = dir.join("pnpm-workspace.yaml"); + if let Ok(yaml_content) = fs::read_to_string(&pnpm_workspace).await { + let patterns = parse_pnpm_workspace_patterns(&yaml_content); + return WorkspaceConfig { + ws_type: WorkspaceType::Pnpm, + patterns, + }; + } + + default +} + +/// Simple parser for pnpm-workspace.yaml packages field. +fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { + let mut patterns = Vec::new(); + let mut in_packages = false; + + for line in yaml_content.lines() { + let trimmed = line.trim(); + + if trimmed == "packages:" { + in_packages = true; + continue; + } + + if in_packages { + if !trimmed.is_empty() + && !trimmed.starts_with('-') + && !trimmed.starts_with('#') + { + break; + } + + if let Some(rest) = trimmed.strip_prefix('-') { + let item = rest.trim().trim_matches('\'').trim_matches('"'); + if !item.is_empty() { + patterns.push(item.to_string()); + } + } + } + } + + patterns +} + +/// Find workspace packages based on workspace patterns. +async fn find_workspace_packages( + root_path: &Path, + config: &WorkspaceConfig, +) -> Vec { + let mut results = Vec::new(); + + for pattern in &config.patterns { + let packages = find_packages_matching_pattern(root_path, pattern).await; + for p in packages { + results.push(PackageJsonLocation { + path: p, + is_root: false, + is_workspace: true, + workspace_pattern: Some(pattern.clone()), + }); + } + } + + results +} + +/// Find packages matching a workspace pattern. +async fn find_packages_matching_pattern( + root_path: &Path, + pattern: &str, +) -> Vec { + let mut results = Vec::new(); + let parts: Vec<&str> = pattern.split('/').collect(); + + if parts.len() == 2 && parts[1] == "*" { + let search_path = root_path.join(parts[0]); + search_one_level(&search_path, &mut results).await; + } else if parts.len() == 2 && parts[1] == "**" { + let search_path = root_path.join(parts[0]); + search_recursive(&search_path, &mut results).await; + } else { + let pkg_json = root_path.join(pattern).join("package.json"); + if fs::metadata(&pkg_json).await.is_ok() { + results.push(pkg_json); + } + } + + results +} + +/// Search one level deep for package.json files. +async fn search_one_level(dir: &Path, results: &mut Vec) { + let mut entries = match fs::read_dir(dir).await { + Ok(e) => e, + Err(_) => return, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + let pkg_json = entry.path().join("package.json"); + if fs::metadata(&pkg_json).await.is_ok() { + results.push(pkg_json); + } + } +} + +/// Search recursively for package.json files. +async fn search_recursive(dir: &Path, results: &mut Vec) { + let mut entries = match fs::read_dir(dir).await { + Ok(e) => e, + Err(_) => return, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip hidden directories, node_modules, dist, build + if name_str.starts_with('.') + || name_str == "node_modules" + || name_str == "dist" + || name_str == "build" + { + continue; + } + + let full_path = entry.path(); + let pkg_json = full_path.join("package.json"); + if fs::metadata(&pkg_json).await.is_ok() { + results.push(pkg_json); + } + + Box::pin(search_recursive(&full_path, results)).await; + } +} + +/// Find nested package.json files without workspace configuration. +async fn find_nested_package_json_files( + start_path: &Path, +) -> Vec { + let mut results = Vec::new(); + let root_pkg = start_path.join("package.json"); + search_nested(start_path, &root_pkg, 0, &mut results).await; + results +} + +async fn search_nested( + dir: &Path, + root_pkg: &Path, + depth: usize, + results: &mut Vec, +) { + if depth > 5 { + return; + } + + let mut entries = match fs::read_dir(dir).await { + Ok(e) => e, + Err(_) => return, + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => continue, + }; + if !ft.is_dir() { + continue; + } + + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with('.') + || name_str == "node_modules" + || name_str == "dist" + || name_str == "build" + { + continue; + } + + let full_path = entry.path(); + let pkg_json = full_path.join("package.json"); + if fs::metadata(&pkg_json).await.is_ok() && pkg_json != root_pkg { + results.push(PackageJsonLocation { + path: pkg_json, + is_root: false, + is_workspace: false, + workspace_pattern: None, + }); + } + + Box::pin(search_nested(&full_path, root_pkg, depth + 1, results)).await; + } +} diff --git a/crates/socket-patch-core/src/package_json/mod.rs b/crates/socket-patch-core/src/package_json/mod.rs new file mode 100644 index 0000000..7e1546a --- /dev/null +++ b/crates/socket-patch-core/src/package_json/mod.rs @@ -0,0 +1,3 @@ +pub mod detect; +pub mod find; +pub mod update; diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs new file mode 100644 index 0000000..92ec459 --- /dev/null +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -0,0 +1,107 @@ +use std::path::Path; +use tokio::fs; + +use super::detect::{is_postinstall_configured_str, update_package_json_content}; + +/// Result of updating a single package.json. +#[derive(Debug, Clone)] +pub struct UpdateResult { + pub path: String, + pub status: UpdateStatus, + pub old_script: String, + pub new_script: String, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateStatus { + Updated, + AlreadyConfigured, + Error, +} + +/// Update a single package.json file with socket-patch postinstall script. +pub async fn update_package_json( + package_json_path: &Path, + dry_run: bool, +) -> UpdateResult { + let path_str = package_json_path.display().to_string(); + + let content = match fs::read_to_string(package_json_path).await { + Ok(c) => c, + Err(e) => { + return UpdateResult { + path: path_str, + status: UpdateStatus::Error, + old_script: String::new(), + new_script: String::new(), + error: Some(e.to_string()), + }; + } + }; + + let status = is_postinstall_configured_str(&content); + if !status.needs_update { + return UpdateResult { + path: path_str, + status: UpdateStatus::AlreadyConfigured, + old_script: status.current_script.clone(), + new_script: status.current_script, + error: None, + }; + } + + match update_package_json_content(&content) { + Ok((modified, new_content, old_script, new_script)) => { + if !modified { + return UpdateResult { + path: path_str, + status: UpdateStatus::AlreadyConfigured, + old_script, + new_script, + error: None, + }; + } + + if !dry_run { + if let Err(e) = fs::write(package_json_path, &new_content).await { + return UpdateResult { + path: path_str, + status: UpdateStatus::Error, + old_script, + new_script, + error: Some(e.to_string()), + }; + } + } + + UpdateResult { + path: path_str, + status: UpdateStatus::Updated, + old_script, + new_script, + error: None, + } + } + Err(e) => UpdateResult { + path: path_str, + status: UpdateStatus::Error, + old_script: String::new(), + new_script: String::new(), + error: Some(e), + }, + } +} + +/// Update multiple package.json files. +pub async fn update_multiple_package_jsons( + paths: &[&Path], + dry_run: bool, +) -> Vec { + let mut results = Vec::new(); + for path in paths { + let result = update_package_json(path, dry_run).await; + results.push(result); + } + results +} diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs new file mode 100644 index 0000000..8366b14 --- /dev/null +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -0,0 +1,522 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::manifest::schema::PatchFileInfo; +use crate::patch::file_hash::compute_file_git_sha256; + +/// Status of a file patch verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyStatus { + /// File is ready to be patched (current hash matches beforeHash). + Ready, + /// File is already in the patched state (current hash matches afterHash). + AlreadyPatched, + /// File hash does not match either beforeHash or afterHash. + HashMismatch, + /// File was not found on disk. + NotFound, +} + +/// Result of verifying whether a single file can be patched. +#[derive(Debug, Clone)] +pub struct VerifyResult { + pub file: String, + pub status: VerifyStatus, + pub message: Option, + pub current_hash: Option, + pub expected_hash: Option, + pub target_hash: Option, +} + +/// Result of applying patches to a single package. +#[derive(Debug, Clone)] +pub struct ApplyResult { + pub package_key: String, + pub package_path: String, + pub success: bool, + pub files_verified: Vec, + pub files_patched: Vec, + pub error: Option, +} + +/// Normalize file path by removing the "package/" prefix if present. +/// Patch files come from the API with paths like "package/lib/file.js" +/// but we need relative paths like "lib/file.js" for the actual package directory. +pub fn normalize_file_path(file_name: &str) -> &str { + const PACKAGE_PREFIX: &str = "package/"; + if file_name.starts_with(PACKAGE_PREFIX) { + &file_name[PACKAGE_PREFIX.len()..] + } else { + file_name + } +} + +/// Verify a single file can be patched. +pub async fn verify_file_patch( + pkg_path: &Path, + file_name: &str, + file_info: &PatchFileInfo, +) -> VerifyResult { + let normalized = normalize_file_path(file_name); + let filepath = pkg_path.join(normalized); + + // Check if file exists + if tokio::fs::metadata(&filepath).await.is_err() { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::NotFound, + message: Some("File not found".to_string()), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + + // Compute current hash + let current_hash = match compute_file_git_sha256(&filepath).await { + Ok(h) => h, + Err(e) => { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::NotFound, + message: Some(format!("Failed to hash file: {}", e)), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + }; + + // Check if already patched + if current_hash == file_info.after_hash { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: None, + }; + } + + // Check if matches expected before hash + if current_hash != file_info.before_hash { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::HashMismatch, + message: Some("File hash does not match expected value".to_string()), + current_hash: Some(current_hash), + expected_hash: Some(file_info.before_hash.clone()), + target_hash: Some(file_info.after_hash.clone()), + }; + } + + VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::Ready, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: Some(file_info.after_hash.clone()), + } +} + +/// Apply a patch to a single file. +/// Writes the patched content and verifies the resulting hash. +pub async fn apply_file_patch( + pkg_path: &Path, + file_name: &str, + patched_content: &[u8], + expected_hash: &str, +) -> Result<(), std::io::Error> { + let normalized = normalize_file_path(file_name); + let filepath = pkg_path.join(normalized); + + // Write the patched content + tokio::fs::write(&filepath, patched_content).await?; + + // Verify the hash after writing + let verify_hash = compute_file_git_sha256(&filepath).await?; + if verify_hash != expected_hash { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Hash verification failed after patch. Expected: {}, Got: {}", + expected_hash, verify_hash + ), + )); + } + + Ok(()) +} + +/// Verify and apply patches for a single package. +/// +/// For each file in `files`, this function: +/// 1. Verifies the file is ready to be patched (or already patched). +/// 2. If not dry_run, reads the blob from `blobs_path` and writes it. +/// 3. Returns a summary of what happened. +pub async fn apply_package_patch( + package_key: &str, + pkg_path: &Path, + files: &HashMap, + blobs_path: &Path, + dry_run: bool, +) -> ApplyResult { + let mut result = ApplyResult { + package_key: package_key.to_string(), + package_path: pkg_path.display().to_string(), + success: false, + files_verified: Vec::new(), + files_patched: Vec::new(), + error: None, + }; + + // First, verify all files + for (file_name, file_info) in files { + let verify_result = verify_file_patch(pkg_path, file_name, file_info).await; + + // If any file is not ready or already patched, we can't proceed + if verify_result.status != VerifyStatus::Ready + && verify_result.status != VerifyStatus::AlreadyPatched + { + let msg = verify_result + .message + .clone() + .unwrap_or_else(|| format!("{:?}", verify_result.status)); + result.error = Some(format!( + "Cannot apply patch: {} - {}", + verify_result.file, msg + )); + result.files_verified.push(verify_result); + return result; + } + + result.files_verified.push(verify_result); + } + + // Check if all files are already patched + let all_patched = result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched); + if all_patched { + result.success = true; + return result; + } + + // If dry run, stop here + if dry_run { + result.success = true; + return result; + } + + // Apply patches to files that need it + for (file_name, file_info) in files { + let verify_result = result.files_verified.iter().find(|v| v.file == *file_name); + if let Some(vr) = verify_result { + if vr.status == VerifyStatus::AlreadyPatched { + continue; + } + } + + // Read patched content from blobs + let blob_path = blobs_path.join(&file_info.after_hash); + let patched_content = match tokio::fs::read(&blob_path).await { + Ok(content) => content, + Err(e) => { + result.error = Some(format!( + "Failed to read blob {}: {}", + file_info.after_hash, e + )); + return result; + } + }; + + // Apply the patch + if let Err(e) = apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await { + result.error = Some(e.to_string()); + return result; + } + + result.files_patched.push(file_name.clone()); + } + + result.success = true; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + + #[test] + fn test_normalize_file_path_with_prefix() { + assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js"); + } + + #[test] + fn test_normalize_file_path_without_prefix() { + assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js"); + } + + #[test] + fn test_normalize_file_path_just_prefix() { + assert_eq!(normalize_file_path("package/"), ""); + } + + #[test] + fn test_normalize_file_path_package_not_prefix() { + // "package" without trailing "/" should NOT be stripped + assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js"); + } + + #[tokio::test] + async fn test_verify_file_patch_not_found() { + let dir = tempfile::tempdir().unwrap(); + let file_info = PatchFileInfo { + before_hash: "aaa".to_string(), + after_hash: "bbb".to_string(), + }; + + let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await; + assert_eq!(result.status, VerifyStatus::NotFound); + } + + #[tokio::test] + async fn test_verify_file_patch_ready() { + let dir = tempfile::tempdir().unwrap(); + let content = b"original content"; + let before_hash = compute_git_sha256_from_bytes(content); + let after_hash = "bbbbbbbb".to_string(); + + tokio::fs::write(dir.path().join("index.js"), content) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: before_hash.clone(), + after_hash, + }; + + let result = verify_file_patch(dir.path(), "index.js", &file_info).await; + assert_eq!(result.status, VerifyStatus::Ready); + assert_eq!(result.current_hash.unwrap(), before_hash); + } + + #[tokio::test] + async fn test_verify_file_patch_already_patched() { + let dir = tempfile::tempdir().unwrap(); + let content = b"patched content"; + let after_hash = compute_git_sha256_from_bytes(content); + + tokio::fs::write(dir.path().join("index.js"), content) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: after_hash.clone(), + }; + + let result = verify_file_patch(dir.path(), "index.js", &file_info).await; + assert_eq!(result.status, VerifyStatus::AlreadyPatched); + } + + #[tokio::test] + async fn test_verify_file_patch_hash_mismatch() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("index.js"), b"something else") + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: "bbbb".to_string(), + }; + + let result = verify_file_patch(dir.path(), "index.js", &file_info).await; + assert_eq!(result.status, VerifyStatus::HashMismatch); + } + + #[tokio::test] + async fn test_verify_with_package_prefix() { + let dir = tempfile::tempdir().unwrap(); + let content = b"original content"; + let before_hash = compute_git_sha256_from_bytes(content); + + // File is at lib/server.js but patch refers to package/lib/server.js + tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap(); + tokio::fs::write(dir.path().join("lib/server.js"), content) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: before_hash.clone(), + after_hash: "bbbb".to_string(), + }; + + let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await; + assert_eq!(result.status, VerifyStatus::Ready); + } + + #[tokio::test] + async fn test_apply_file_patch_success() { + let dir = tempfile::tempdir().unwrap(); + let original = b"original"; + let patched = b"patched content"; + let patched_hash = compute_git_sha256_from_bytes(patched); + + tokio::fs::write(dir.path().join("index.js"), original) + .await + .unwrap(); + + apply_file_patch(dir.path(), "index.js", patched, &patched_hash) + .await + .unwrap(); + + let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap(); + assert_eq!(written, patched); + } + + #[tokio::test] + async fn test_apply_file_patch_hash_mismatch() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("index.js"), b"original") + .await + .unwrap(); + + let result = + apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("Hash verification failed")); + } + + #[tokio::test] + async fn test_apply_package_patch_success() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let patched = b"patched content"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + // Write original file + tokio::fs::write(pkg_dir.path().join("index.js"), original) + .await + .unwrap(); + + // Write blob + tokio::fs::write(blobs_dir.path().join(&after_hash), patched) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash: after_hash.clone(), + }, + ); + + let result = + apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false) + .await; + + assert!(result.success); + assert_eq!(result.files_patched.len(), 1); + assert!(result.error.is_none()); + } + + #[tokio::test] + async fn test_apply_package_patch_dry_run() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let before_hash = compute_git_sha256_from_bytes(original); + + tokio::fs::write(pkg_dir.path().join("index.js"), original) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash: "bbbb".to_string(), + }, + ); + + let result = + apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), true) + .await; + + assert!(result.success); + assert_eq!(result.files_patched.len(), 0); // dry run: nothing actually patched + + // File should still have original content + let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap(); + assert_eq!(content, original); + } + + #[tokio::test] + async fn test_apply_package_patch_all_already_patched() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let patched = b"patched content"; + let after_hash = compute_git_sha256_from_bytes(patched); + + tokio::fs::write(pkg_dir.path().join("index.js"), patched) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash, + }, + ); + + let result = + apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false) + .await; + + assert!(result.success); + assert_eq!(result.files_patched.len(), 0); + } + + #[tokio::test] + async fn test_apply_package_patch_hash_mismatch_blocks() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected") + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: "bbbb".to_string(), + }, + ); + + let result = + apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false) + .await; + + assert!(!result.success); + assert!(result.error.is_some()); + } +} diff --git a/crates/socket-patch-core/src/patch/file_hash.rs b/crates/socket-patch-core/src/patch/file_hash.rs new file mode 100644 index 0000000..a9dc362 --- /dev/null +++ b/crates/socket-patch-core/src/patch/file_hash.rs @@ -0,0 +1,75 @@ +use std::path::Path; + +use crate::hash::git_sha256::compute_git_sha256_from_reader; + +/// Compute Git-compatible SHA256 hash of file contents using streaming. +/// +/// Gets the file size first, then streams the file through the hasher +/// without loading the entire file into memory. +pub async fn compute_file_git_sha256(filepath: impl AsRef) -> Result { + let filepath = filepath.as_ref(); + + // Get file size first + let metadata = tokio::fs::metadata(filepath).await?; + let file_size = metadata.len(); + + // Open file for streaming read + let file = tokio::fs::File::open(filepath).await?; + let reader = tokio::io::BufReader::new(file); + + compute_git_sha256_from_reader(file_size, reader).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + + #[tokio::test] + async fn test_compute_file_git_sha256_matches_bytes() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + + let content = b"Hello, World!"; + tokio::fs::write(&file_path, content).await.unwrap(); + + let file_hash = compute_file_git_sha256(&file_path).await.unwrap(); + let bytes_hash = compute_git_sha256_from_bytes(content); + + assert_eq!(file_hash, bytes_hash); + } + + #[tokio::test] + async fn test_compute_file_git_sha256_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("empty.txt"); + + tokio::fs::write(&file_path, b"").await.unwrap(); + + let file_hash = compute_file_git_sha256(&file_path).await.unwrap(); + let bytes_hash = compute_git_sha256_from_bytes(b""); + + assert_eq!(file_hash, bytes_hash); + } + + #[tokio::test] + async fn test_compute_file_git_sha256_not_found() { + let result = compute_file_git_sha256("/nonexistent/file.txt").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_compute_file_git_sha256_large_content() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("large.bin"); + + // Create a file larger than the 8192 byte buffer + let content: Vec = (0..20000).map(|i| (i % 256) as u8).collect(); + tokio::fs::write(&file_path, &content).await.unwrap(); + + let file_hash = compute_file_git_sha256(&file_path).await.unwrap(); + let bytes_hash = compute_git_sha256_from_bytes(&content); + + assert_eq!(file_hash, bytes_hash); + } +} diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs new file mode 100644 index 0000000..e17bd8d --- /dev/null +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -0,0 +1,3 @@ +pub mod apply; +pub mod file_hash; +pub mod rollback; diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs new file mode 100644 index 0000000..c2e3930 --- /dev/null +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -0,0 +1,606 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::manifest::schema::PatchFileInfo; +use crate::patch::file_hash::compute_file_git_sha256; + +/// Status of a file rollback verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyRollbackStatus { + /// File is ready to be rolled back (current hash matches afterHash). + Ready, + /// File is already in the original state (current hash matches beforeHash). + AlreadyOriginal, + /// File hash does not match the expected afterHash. + HashMismatch, + /// File was not found on disk. + NotFound, + /// The before-hash blob needed for rollback is missing from the blobs directory. + MissingBlob, +} + +/// Result of verifying whether a single file can be rolled back. +#[derive(Debug, Clone)] +pub struct VerifyRollbackResult { + pub file: String, + pub status: VerifyRollbackStatus, + pub message: Option, + pub current_hash: Option, + pub expected_hash: Option, + pub target_hash: Option, +} + +/// Result of rolling back patches for a single package. +#[derive(Debug, Clone)] +pub struct RollbackResult { + pub package_key: String, + pub package_path: String, + pub success: bool, + pub files_verified: Vec, + pub files_rolled_back: Vec, + pub error: Option, +} + +/// Normalize file path by removing the "package/" prefix if present. +fn normalize_file_path(file_name: &str) -> &str { + const PACKAGE_PREFIX: &str = "package/"; + if file_name.starts_with(PACKAGE_PREFIX) { + &file_name[PACKAGE_PREFIX.len()..] + } else { + file_name + } +} + +/// Verify a single file can be rolled back. +/// +/// A file is ready for rollback if: +/// 1. The file exists on disk. +/// 2. The before-hash blob exists in the blobs directory. +/// 3. Its current hash matches the afterHash (patched state). +pub async fn verify_file_rollback( + pkg_path: &Path, + file_name: &str, + file_info: &PatchFileInfo, + blobs_path: &Path, +) -> VerifyRollbackResult { + let normalized = normalize_file_path(file_name); + let filepath = pkg_path.join(normalized); + + // Check if file exists + if tokio::fs::metadata(&filepath).await.is_err() { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::NotFound, + message: Some("File not found".to_string()), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + + // Check if before blob exists (required for rollback) + let before_blob_path = blobs_path.join(&file_info.before_hash); + if tokio::fs::metadata(&before_blob_path).await.is_err() { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::MissingBlob, + message: Some(format!( + "Before blob not found: {}. Re-download the patch to enable rollback.", + file_info.before_hash + )), + current_hash: None, + expected_hash: None, + target_hash: Some(file_info.before_hash.clone()), + }; + } + + // Compute current hash + let current_hash = match compute_file_git_sha256(&filepath).await { + Ok(h) => h, + Err(e) => { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::NotFound, + message: Some(format!("Failed to hash file: {}", e)), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + }; + + // Check if already in original state + if current_hash == file_info.before_hash { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::AlreadyOriginal, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: None, + }; + } + + // Check if matches expected patched hash (afterHash) + if current_hash != file_info.after_hash { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::HashMismatch, + message: Some( + "File has been modified after patching. Cannot safely rollback.".to_string(), + ), + current_hash: Some(current_hash), + expected_hash: Some(file_info.after_hash.clone()), + target_hash: Some(file_info.before_hash.clone()), + }; + } + + VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::Ready, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: Some(file_info.before_hash.clone()), + } +} + +/// Rollback a single file to its original state. +/// Writes the original content and verifies the resulting hash. +pub async fn rollback_file_patch( + pkg_path: &Path, + file_name: &str, + original_content: &[u8], + expected_hash: &str, +) -> Result<(), std::io::Error> { + let normalized = normalize_file_path(file_name); + let filepath = pkg_path.join(normalized); + + // Write the original content + tokio::fs::write(&filepath, original_content).await?; + + // Verify the hash after writing + let verify_hash = compute_file_git_sha256(&filepath).await?; + if verify_hash != expected_hash { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Hash verification failed after rollback. Expected: {}, Got: {}", + expected_hash, verify_hash + ), + )); + } + + Ok(()) +} + +/// Verify and rollback patches for a single package. +/// +/// For each file in `files`, this function: +/// 1. Verifies the file is ready to be rolled back (or already original). +/// 2. If not dry_run, reads the before-hash blob and writes it back. +/// 3. Returns a summary of what happened. +pub async fn rollback_package_patch( + package_key: &str, + pkg_path: &Path, + files: &HashMap, + blobs_path: &Path, + dry_run: bool, +) -> RollbackResult { + let mut result = RollbackResult { + package_key: package_key.to_string(), + package_path: pkg_path.display().to_string(), + success: false, + files_verified: Vec::new(), + files_rolled_back: Vec::new(), + error: None, + }; + + // First, verify all files + for (file_name, file_info) in files { + let verify_result = + verify_file_rollback(pkg_path, file_name, file_info, blobs_path).await; + + // If any file has issues (not ready and not already original), we can't proceed + if verify_result.status != VerifyRollbackStatus::Ready + && verify_result.status != VerifyRollbackStatus::AlreadyOriginal + { + let msg = verify_result + .message + .clone() + .unwrap_or_else(|| format!("{:?}", verify_result.status)); + result.error = Some(format!( + "Cannot rollback: {} - {}", + verify_result.file, msg + )); + result.files_verified.push(verify_result); + return result; + } + + result.files_verified.push(verify_result); + } + + // Check if all files are already in original state + let all_original = result + .files_verified + .iter() + .all(|v| v.status == VerifyRollbackStatus::AlreadyOriginal); + if all_original { + result.success = true; + return result; + } + + // If dry run, stop here + if dry_run { + result.success = true; + return result; + } + + // Rollback files that need it + for (file_name, file_info) in files { + let verify_result = result + .files_verified + .iter() + .find(|v| v.file == *file_name); + if let Some(vr) = verify_result { + if vr.status == VerifyRollbackStatus::AlreadyOriginal { + continue; + } + } + + // Read original content from blobs + let blob_path = blobs_path.join(&file_info.before_hash); + let original_content = match tokio::fs::read(&blob_path).await { + Ok(content) => content, + Err(e) => { + result.error = Some(format!( + "Failed to read blob {}: {}", + file_info.before_hash, e + )); + return result; + } + }; + + // Rollback the file + if let Err(e) = + rollback_file_patch(pkg_path, file_name, &original_content, &file_info.before_hash) + .await + { + result.error = Some(e.to_string()); + return result; + } + + result.files_rolled_back.push(file_name.clone()); + } + + result.success = true; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + + #[tokio::test] + async fn test_verify_file_rollback_not_found() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let file_info = PatchFileInfo { + before_hash: "aaa".to_string(), + after_hash: "bbb".to_string(), + }; + + let result = + verify_file_rollback(pkg_dir.path(), "nonexistent.js", &file_info, blobs_dir.path()) + .await; + assert_eq!(result.status, VerifyRollbackStatus::NotFound); + } + + #[tokio::test] + async fn test_verify_file_rollback_missing_blob() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let content = b"patched content"; + tokio::fs::write(pkg_dir.path().join("index.js"), content) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: "missing_blob_hash".to_string(), + after_hash: compute_git_sha256_from_bytes(content), + }; + + let result = + verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await; + assert_eq!(result.status, VerifyRollbackStatus::MissingBlob); + assert!(result.message.unwrap().contains("Before blob not found")); + } + + #[tokio::test] + async fn test_verify_file_rollback_ready() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let patched = b"patched content"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + // File is in patched state + tokio::fs::write(pkg_dir.path().join("index.js"), patched) + .await + .unwrap(); + + // Before blob exists + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: before_hash.clone(), + after_hash: after_hash.clone(), + }; + + let result = + verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await; + assert_eq!(result.status, VerifyRollbackStatus::Ready); + assert_eq!(result.current_hash.unwrap(), after_hash); + } + + #[tokio::test] + async fn test_verify_file_rollback_already_original() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let before_hash = compute_git_sha256_from_bytes(original); + + // File is already in original state + tokio::fs::write(pkg_dir.path().join("index.js"), original) + .await + .unwrap(); + + // Before blob exists + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash: before_hash.clone(), + after_hash: "bbbb".to_string(), + }; + + let result = + verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await; + assert_eq!(result.status, VerifyRollbackStatus::AlreadyOriginal); + } + + #[tokio::test] + async fn test_verify_file_rollback_hash_mismatch() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let before_hash = compute_git_sha256_from_bytes(original); + + // File has been modified to something unexpected + tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected") + .await + .unwrap(); + + // Before blob exists + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let file_info = PatchFileInfo { + before_hash, + after_hash: "expected_after_hash".to_string(), + }; + + let result = + verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await; + assert_eq!(result.status, VerifyRollbackStatus::HashMismatch); + assert!(result + .message + .unwrap() + .contains("modified after patching")); + } + + #[tokio::test] + async fn test_rollback_file_patch_success() { + let dir = tempfile::tempdir().unwrap(); + let original = b"original content"; + let original_hash = compute_git_sha256_from_bytes(original); + + // File currently has patched content + tokio::fs::write(dir.path().join("index.js"), b"patched") + .await + .unwrap(); + + rollback_file_patch(dir.path(), "index.js", original, &original_hash) + .await + .unwrap(); + + let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap(); + assert_eq!(written, original); + } + + #[tokio::test] + async fn test_rollback_file_patch_hash_mismatch() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("index.js"), b"patched") + .await + .unwrap(); + + let result = + rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Hash verification failed")); + } + + #[tokio::test] + async fn test_rollback_package_patch_success() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let patched = b"patched content"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + // File is in patched state + tokio::fs::write(pkg_dir.path().join("index.js"), patched) + .await + .unwrap(); + + // Before blob exists + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: before_hash.clone(), + after_hash, + }, + ); + + let result = rollback_package_patch( + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + blobs_dir.path(), + false, + ) + .await; + + assert!(result.success); + assert_eq!(result.files_rolled_back.len(), 1); + assert!(result.error.is_none()); + + // Verify file was restored + let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap(); + assert_eq!(content, original); + } + + #[tokio::test] + async fn test_rollback_package_patch_dry_run() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let patched = b"patched content"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + tokio::fs::write(pkg_dir.path().join("index.js"), patched) + .await + .unwrap(); + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash, + }, + ); + + let result = rollback_package_patch( + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + blobs_dir.path(), + true, // dry run + ) + .await; + + assert!(result.success); + assert_eq!(result.files_rolled_back.len(), 0); // dry run + + // File should still be patched + let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap(); + assert_eq!(content, patched); + } + + #[tokio::test] + async fn test_rollback_package_patch_all_original() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original content"; + let before_hash = compute_git_sha256_from_bytes(original); + + // File is already original + tokio::fs::write(pkg_dir.path().join("index.js"), original) + .await + .unwrap(); + tokio::fs::write(blobs_dir.path().join(&before_hash), original) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash: "bbbb".to_string(), + }, + ); + + let result = rollback_package_patch( + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + blobs_dir.path(), + false, + ) + .await; + + assert!(result.success); + assert_eq!(result.files_rolled_back.len(), 0); + } + + #[tokio::test] + async fn test_rollback_package_patch_missing_blob_blocks() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + tokio::fs::write(pkg_dir.path().join("index.js"), b"patched content") + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "missing_hash".to_string(), + after_hash: "bbbb".to_string(), + }, + ); + + let result = rollback_package_patch( + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + blobs_dir.path(), + false, + ) + .await; + + assert!(!result.success); + assert!(result.error.is_some()); + } +} diff --git a/crates/socket-patch-core/src/utils/cleanup_blobs.rs b/crates/socket-patch-core/src/utils/cleanup_blobs.rs new file mode 100644 index 0000000..0121cb8 --- /dev/null +++ b/crates/socket-patch-core/src/utils/cleanup_blobs.rs @@ -0,0 +1,419 @@ +use std::path::Path; + +use crate::manifest::operations::get_after_hash_blobs; +use crate::manifest::schema::PatchManifest; + +/// Result of a blob cleanup operation. +#[derive(Debug, Clone)] +pub struct CleanupResult { + pub blobs_checked: usize, + pub blobs_removed: usize, + pub bytes_freed: u64, + pub removed_blobs: Vec, +} + +/// Cleans up unused blob files from the blobs directory. +/// +/// Analyzes the manifest to determine which afterHash blobs are needed for applying patches, +/// then removes any blob files that are not needed. +/// +/// Note: beforeHash blobs are considered "unused" because they are downloaded on-demand +/// during rollback operations. This saves disk space since beforeHash blobs are only +/// needed for rollback, not for applying patches. +pub async fn cleanup_unused_blobs( + manifest: &PatchManifest, + blobs_dir: &Path, + dry_run: bool, +) -> Result { + // Only keep afterHash blobs - beforeHash blobs are downloaded on-demand during rollback + let used_blobs = get_after_hash_blobs(manifest); + + // Check if blobs directory exists + if tokio::fs::metadata(blobs_dir).await.is_err() { + // Blobs directory doesn't exist, nothing to clean up + return Ok(CleanupResult { + blobs_checked: 0, + blobs_removed: 0, + bytes_freed: 0, + removed_blobs: vec![], + }); + } + + // Read all files in the blobs directory + let mut read_dir = tokio::fs::read_dir(blobs_dir).await?; + let mut blob_entries = Vec::new(); + + while let Some(entry) = read_dir.next_entry().await? { + blob_entries.push(entry); + } + + let mut result = CleanupResult { + blobs_checked: blob_entries.len(), + blobs_removed: 0, + bytes_freed: 0, + removed_blobs: vec![], + }; + + // Check each blob file + for entry in &blob_entries { + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy().to_string(); + + // Skip hidden files and directories + if file_name_str.starts_with('.') { + continue; + } + + let blob_path = blobs_dir.join(&file_name_str); + + // Check if it's a file (not a directory) + let metadata = tokio::fs::metadata(&blob_path).await?; + if !metadata.is_file() { + continue; + } + + // If this blob is not in use, remove it + if !used_blobs.contains(&file_name_str) { + result.blobs_removed += 1; + result.bytes_freed += metadata.len(); + result.removed_blobs.push(file_name_str); + + if !dry_run { + tokio::fs::remove_file(&blob_path).await?; + } + } + } + + Ok(result) +} + +/// Formats the cleanup result for human-readable output. +pub fn format_cleanup_result(result: &CleanupResult, dry_run: bool) -> String { + if result.blobs_checked == 0 { + return "No blobs directory found, nothing to clean up.".to_string(); + } + + if result.blobs_removed == 0 { + return format!( + "Checked {} blob(s), all are in use.", + result.blobs_checked + ); + } + + let action = if dry_run { "Would remove" } else { "Removed" }; + let bytes_formatted = format_bytes(result.bytes_freed); + + let mut output = format!( + "{} {} unused blob(s) ({} freed)", + action, result.blobs_removed, bytes_formatted + ); + + if dry_run && !result.removed_blobs.is_empty() { + output.push_str("\nUnused blobs:"); + for blob in &result.removed_blobs { + output.push_str(&format!("\n - {}", blob)); + } + } + + output +} + +/// Formats bytes into a human-readable string. +pub fn format_bytes(bytes: u64) -> String { + if bytes == 0 { + return "0 B".to_string(); + } + + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + + if bytes < KB { + format!("{} B", bytes) + } else if bytes < MB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else if bytes < GB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else { + format!("{:.2} GB", bytes as f64 / GB as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; + use std::collections::HashMap; + + const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111"; + const BEFORE_HASH_1: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111"; + const AFTER_HASH_1: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111"; + const BEFORE_HASH_2: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222"; + const AFTER_HASH_2: &str = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222"; + const ORPHAN_HASH: &str = + "oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo"; + + fn create_test_manifest() -> PatchManifest { + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_1.to_string(), + after_hash: AFTER_HASH_1.to_string(), + }, + ); + files.insert( + "package/lib/utils.js".to_string(), + PatchFileInfo { + before_hash: BEFORE_HASH_2.to_string(), + after_hash: AFTER_HASH_2.to_string(), + }, + ); + + let mut patches = HashMap::new(); + patches.insert( + "pkg:npm/pkg-a@1.0.0".to_string(), + PatchRecord { + uuid: TEST_UUID.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "Test patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + + PatchManifest { patches } + } + + #[tokio::test] + async fn test_cleanup_keeps_after_hash_removes_orphan() { + let dir = tempfile::tempdir().unwrap(); + let blobs_dir = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let manifest = create_test_manifest(); + + // Create blobs on disk + tokio::fs::write(blobs_dir.join(AFTER_HASH_1), "after content 1") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(AFTER_HASH_2), "after content 2") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(ORPHAN_HASH), "orphan content") + .await + .unwrap(); + + let result = cleanup_unused_blobs(&manifest, &blobs_dir, false) + .await + .unwrap(); + + // Should remove only the orphan blob + assert_eq!(result.blobs_removed, 1); + assert!(result.removed_blobs.contains(&ORPHAN_HASH.to_string())); + + // afterHash blobs should still exist + assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_1)) + .await + .is_ok()); + assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_2)) + .await + .is_ok()); + + // Orphan blob should be removed + assert!(tokio::fs::metadata(blobs_dir.join(ORPHAN_HASH)) + .await + .is_err()); + } + + #[tokio::test] + async fn test_cleanup_removes_before_hash_blobs() { + let dir = tempfile::tempdir().unwrap(); + let blobs_dir = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let manifest = create_test_manifest(); + + // Create both beforeHash and afterHash blobs + tokio::fs::write(blobs_dir.join(BEFORE_HASH_1), "before content 1") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(BEFORE_HASH_2), "before content 2") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(AFTER_HASH_1), "after content 1") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(AFTER_HASH_2), "after content 2") + .await + .unwrap(); + + let result = cleanup_unused_blobs(&manifest, &blobs_dir, false) + .await + .unwrap(); + + // Should remove the beforeHash blobs + assert_eq!(result.blobs_removed, 2); + assert!(result.removed_blobs.contains(&BEFORE_HASH_1.to_string())); + assert!(result.removed_blobs.contains(&BEFORE_HASH_2.to_string())); + + // afterHash blobs should still exist + assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_1)) + .await + .is_ok()); + assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_2)) + .await + .is_ok()); + + // beforeHash blobs should be removed + assert!(tokio::fs::metadata(blobs_dir.join(BEFORE_HASH_1)) + .await + .is_err()); + assert!(tokio::fs::metadata(blobs_dir.join(BEFORE_HASH_2)) + .await + .is_err()); + } + + #[tokio::test] + async fn test_cleanup_dry_run_does_not_delete() { + let dir = tempfile::tempdir().unwrap(); + let blobs_dir = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let manifest = create_test_manifest(); + + tokio::fs::write(blobs_dir.join(BEFORE_HASH_1), "before content 1") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(AFTER_HASH_1), "after content 1") + .await + .unwrap(); + + let result = cleanup_unused_blobs(&manifest, &blobs_dir, true) + .await + .unwrap(); + + // Should report beforeHash as would-be-removed + assert_eq!(result.blobs_removed, 1); + assert!(result.removed_blobs.contains(&BEFORE_HASH_1.to_string())); + + // But both blobs should still exist + assert!(tokio::fs::metadata(blobs_dir.join(BEFORE_HASH_1)) + .await + .is_ok()); + assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_1)) + .await + .is_ok()); + } + + #[tokio::test] + async fn test_cleanup_empty_manifest_removes_all() { + let dir = tempfile::tempdir().unwrap(); + let blobs_dir = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let manifest = PatchManifest::new(); + + tokio::fs::write(blobs_dir.join(AFTER_HASH_1), "content 1") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(BEFORE_HASH_1), "content 2") + .await + .unwrap(); + + let result = cleanup_unused_blobs(&manifest, &blobs_dir, false) + .await + .unwrap(); + + assert_eq!(result.blobs_removed, 2); + } + + #[tokio::test] + async fn test_cleanup_nonexistent_blobs_dir() { + let dir = tempfile::tempdir().unwrap(); + let non_existent = dir.path().join("non-existent"); + + let manifest = create_test_manifest(); + + let result = cleanup_unused_blobs(&manifest, &non_existent, false) + .await + .unwrap(); + + assert_eq!(result.blobs_checked, 0); + assert_eq!(result.blobs_removed, 0); + } + + #[test] + fn test_format_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(500), "500 B"); + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1024), "1.00 KB"); + assert_eq!(format_bytes(1536), "1.50 KB"); + assert_eq!(format_bytes(1048576), "1.00 MB"); + assert_eq!(format_bytes(1073741824), "1.00 GB"); + } + + #[test] + fn test_format_cleanup_result_no_blobs_dir() { + let result = CleanupResult { + blobs_checked: 0, + blobs_removed: 0, + bytes_freed: 0, + removed_blobs: vec![], + }; + assert_eq!( + format_cleanup_result(&result, false), + "No blobs directory found, nothing to clean up." + ); + } + + #[test] + fn test_format_cleanup_result_all_in_use() { + let result = CleanupResult { + blobs_checked: 5, + blobs_removed: 0, + bytes_freed: 0, + removed_blobs: vec![], + }; + assert_eq!( + format_cleanup_result(&result, false), + "Checked 5 blob(s), all are in use." + ); + } + + #[test] + fn test_format_cleanup_result_removed() { + let result = CleanupResult { + blobs_checked: 5, + blobs_removed: 2, + bytes_freed: 2048, + removed_blobs: vec!["aaa".to_string(), "bbb".to_string()], + }; + assert_eq!( + format_cleanup_result(&result, false), + "Removed 2 unused blob(s) (2.00 KB freed)" + ); + } + + #[test] + fn test_format_cleanup_result_dry_run_lists_blobs() { + let result = CleanupResult { + blobs_checked: 5, + blobs_removed: 2, + bytes_freed: 2048, + removed_blobs: vec!["aaa".to_string(), "bbb".to_string()], + }; + let formatted = format_cleanup_result(&result, true); + assert!(formatted.starts_with("Would remove 2 unused blob(s)")); + assert!(formatted.contains("Unused blobs:")); + assert!(formatted.contains(" - aaa")); + assert!(formatted.contains(" - bbb")); + } +} diff --git a/crates/socket-patch-core/src/utils/enumerate.rs b/crates/socket-patch-core/src/utils/enumerate.rs new file mode 100644 index 0000000..6535766 --- /dev/null +++ b/crates/socket-patch-core/src/utils/enumerate.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use crate::crawlers::types::{CrawledPackage, CrawlerOptions}; +use crate::crawlers::NpmCrawler; + +/// Type alias for backward compatibility with the TypeScript codebase. +pub type EnumeratedPackage = CrawledPackage; + +/// Enumerate all packages in a `node_modules` directory. +/// +/// This is a convenience wrapper around `NpmCrawler::crawl_all` that creates +/// a crawler with default options rooted at the given `cwd`. +pub async fn enumerate_node_modules(cwd: &Path) -> Vec { + let crawler = NpmCrawler::new(); + let options = CrawlerOptions { + cwd: cwd.to_path_buf(), + global: false, + global_prefix: None, + batch_size: 100, + }; + crawler.crawl_all(&options).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_enumerate_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let packages = enumerate_node_modules(dir.path()).await; + assert!(packages.is_empty()); + } + + #[tokio::test] + async fn test_enumerate_with_packages() { + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("node_modules"); + + // Create a simple package + let pkg_dir = nm.join("test-pkg"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::write( + pkg_dir.join("package.json"), + r#"{"name": "test-pkg", "version": "1.0.0"}"#, + ) + .await + .unwrap(); + + // Create a scoped package + let scoped_dir = nm.join("@scope").join("my-lib"); + tokio::fs::create_dir_all(&scoped_dir).await.unwrap(); + tokio::fs::write( + scoped_dir.join("package.json"), + r#"{"name": "@scope/my-lib", "version": "2.0.0"}"#, + ) + .await + .unwrap(); + + let packages = enumerate_node_modules(dir.path()).await; + assert_eq!(packages.len(), 2); + + let purls: Vec<&str> = packages.iter().map(|p| p.purl.as_str()).collect(); + assert!(purls.contains(&"pkg:npm/test-pkg@1.0.0")); + assert!(purls.contains(&"pkg:npm/@scope/my-lib@2.0.0")); + } + + #[tokio::test] + async fn test_enumerate_deduplicates() { + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("node_modules"); + + // Create package at top level + let pkg1 = nm.join("foo"); + tokio::fs::create_dir_all(&pkg1).await.unwrap(); + tokio::fs::write( + pkg1.join("package.json"), + r#"{"name": "foo", "version": "1.0.0"}"#, + ) + .await + .unwrap(); + + // Create same package nested inside another + let pkg2 = nm.join("bar"); + tokio::fs::create_dir_all(&pkg2).await.unwrap(); + tokio::fs::write( + pkg2.join("package.json"), + r#"{"name": "bar", "version": "2.0.0"}"#, + ) + .await + .unwrap(); + let nested_foo = pkg2.join("node_modules").join("foo"); + tokio::fs::create_dir_all(&nested_foo).await.unwrap(); + tokio::fs::write( + nested_foo.join("package.json"), + r#"{"name": "foo", "version": "1.0.0"}"#, + ) + .await + .unwrap(); + + let packages = enumerate_node_modules(dir.path()).await; + // foo@1.0.0 should be deduplicated + let foo_count = packages + .iter() + .filter(|p| p.purl == "pkg:npm/foo@1.0.0") + .count(); + assert_eq!(foo_count, 1); + } +} diff --git a/crates/socket-patch-core/src/utils/fuzzy_match.rs b/crates/socket-patch-core/src/utils/fuzzy_match.rs new file mode 100644 index 0000000..e508fa4 --- /dev/null +++ b/crates/socket-patch-core/src/utils/fuzzy_match.rs @@ -0,0 +1,266 @@ +use crate::crawlers::types::CrawledPackage; + +// --------------------------------------------------------------------------- +// MatchType enum +// --------------------------------------------------------------------------- + +/// Match type for sorting results by relevance. +/// +/// Lower numeric value = better match. The ordering is: +/// 1. Exact match on full name (including namespace) +/// 2. Exact match on package name only +/// 3. Prefix match on full name +/// 4. Prefix match on package name +/// 5. Contains match on full name +/// 6. Contains match on package name +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum MatchType { + /// Exact match on full name (including namespace). + ExactFull = 0, + /// Exact match on package name only. + ExactName = 1, + /// Query is a prefix of the full name. + PrefixFull = 2, + /// Query is a prefix of the package name. + PrefixName = 3, + /// Query is contained in the full name. + ContainsFull = 4, + /// Query is contained in the package name. + ContainsName = 5, +} + +// --------------------------------------------------------------------------- +// Internal match result +// --------------------------------------------------------------------------- + +struct MatchResult { + package: CrawledPackage, + match_type: MatchType, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Get the full display name for a package (including namespace if present). +fn get_full_name(pkg: &CrawledPackage) -> String { + match &pkg.namespace { + Some(ns) => format!("{ns}/{}", pkg.name), + None => pkg.name.clone(), + } +} + +/// Determine the match type for a package against a query. +/// Returns `None` if there is no match. +fn get_match_type(pkg: &CrawledPackage, query: &str) -> Option { + let lower_query = query.to_lowercase(); + let full_name = get_full_name(pkg).to_lowercase(); + let name = pkg.name.to_lowercase(); + + // Check exact matches + if full_name == lower_query { + return Some(MatchType::ExactFull); + } + if name == lower_query { + return Some(MatchType::ExactName); + } + + // Check prefix matches + if full_name.starts_with(&lower_query) { + return Some(MatchType::PrefixFull); + } + if name.starts_with(&lower_query) { + return Some(MatchType::PrefixName); + } + + // Check contains matches + if full_name.contains(&lower_query) { + return Some(MatchType::ContainsFull); + } + if name.contains(&lower_query) { + return Some(MatchType::ContainsName); + } + + None +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Fuzzy match packages against a query string. +/// +/// Matches are sorted by relevance: +/// 1. Exact match on full name (e.g., `"@types/node"` matches `"@types/node"`) +/// 2. Exact match on package name (e.g., `"node"` matches `"@types/node"`) +/// 3. Prefix match on full name +/// 4. Prefix match on package name +/// 5. Contains match on full name +/// 6. Contains match on package name +/// +/// Within the same match type, results are sorted alphabetically by full name. +pub fn fuzzy_match_packages( + query: &str, + packages: &[CrawledPackage], + limit: usize, +) -> Vec { + let trimmed = query.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + let mut matches: Vec = Vec::new(); + + for pkg in packages { + if let Some(match_type) = get_match_type(pkg, trimmed) { + matches.push(MatchResult { + package: pkg.clone(), + match_type, + }); + } + } + + // Sort by match type (lower is better), then alphabetically by full name + matches.sort_by(|a, b| { + let type_cmp = a.match_type.cmp(&b.match_type); + if type_cmp != std::cmp::Ordering::Equal { + return type_cmp; + } + get_full_name(&a.package).cmp(&get_full_name(&b.package)) + }); + + matches + .into_iter() + .take(limit) + .map(|m| m.package) + .collect() +} + +/// Check if a string looks like a PURL. +pub fn is_purl(s: &str) -> bool { + s.starts_with("pkg:") +} + +/// Check if a string looks like a scoped npm package name. +pub fn is_scoped_package(s: &str) -> bool { + s.starts_with('@') && s.contains('/') +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn make_pkg( + name: &str, + version: &str, + namespace: Option<&str>, + ) -> CrawledPackage { + let ns = namespace.map(|s| s.to_string()); + let purl = match &ns { + Some(n) => format!("pkg:npm/{n}/{name}@{version}"), + None => format!("pkg:npm/{name}@{version}"), + }; + CrawledPackage { + name: name.to_string(), + version: version.to_string(), + namespace: ns, + purl, + path: PathBuf::from("/fake"), + } + } + + #[test] + fn test_exact_full_name() { + let packages = vec![ + make_pkg("node", "20.0.0", Some("@types")), + make_pkg("node-fetch", "3.0.0", None), + ]; + + let results = fuzzy_match_packages("@types/node", &packages, 20); + // "node-fetch" does NOT contain "@types/node", so only 1 result + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "node"); // ExactFull + assert_eq!(results[0].namespace.as_deref(), Some("@types")); + } + + #[test] + fn test_exact_name_only() { + let packages = vec![ + make_pkg("node", "20.0.0", Some("@types")), + make_pkg("lodash", "4.17.21", None), + ]; + + let results = fuzzy_match_packages("node", &packages, 20); + assert_eq!(results[0].name, "node"); // ExactName + } + + #[test] + fn test_prefix_match() { + let packages = vec![ + make_pkg("lodash", "4.17.21", None), + make_pkg("lodash-es", "4.17.21", None), + ]; + + let results = fuzzy_match_packages("lodash", &packages, 20); + assert_eq!(results.len(), 2); + assert_eq!(results[0].name, "lodash"); // ExactName is better than PrefixName + } + + #[test] + fn test_contains_match() { + let packages = vec![make_pkg("string-width", "5.0.0", None)]; + + let results = fuzzy_match_packages("width", &packages, 20); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "string-width"); + } + + #[test] + fn test_no_match() { + let packages = vec![make_pkg("lodash", "4.17.21", None)]; + + let results = fuzzy_match_packages("zzzzz", &packages, 20); + assert!(results.is_empty()); + } + + #[test] + fn test_empty_query() { + let packages = vec![make_pkg("lodash", "4.17.21", None)]; + assert!(fuzzy_match_packages("", &packages, 20).is_empty()); + assert!(fuzzy_match_packages(" ", &packages, 20).is_empty()); + } + + #[test] + fn test_case_insensitive() { + let packages = vec![make_pkg("React", "18.0.0", None)]; + let results = fuzzy_match_packages("react", &packages, 20); + assert_eq!(results.len(), 1); + } + + #[test] + fn test_limit() { + let packages: Vec = (0..50) + .map(|i| make_pkg(&format!("pkg-{i}"), "1.0.0", None)) + .collect(); + + let results = fuzzy_match_packages("pkg", &packages, 10); + assert_eq!(results.len(), 10); + } + + #[test] + fn test_is_purl() { + assert!(is_purl("pkg:npm/lodash@4.17.21")); + assert!(is_purl("pkg:pypi/requests@2.28.0")); + assert!(!is_purl("lodash")); + assert!(!is_purl("@types/node")); + } + + #[test] + fn test_is_scoped_package() { + assert!(is_scoped_package("@types/node")); + assert!(is_scoped_package("@scope/pkg")); + assert!(!is_scoped_package("lodash")); + assert!(!is_scoped_package("@scope")); + } +} diff --git a/crates/socket-patch-core/src/utils/global_packages.rs b/crates/socket-patch-core/src/utils/global_packages.rs new file mode 100644 index 0000000..77653c3 --- /dev/null +++ b/crates/socket-patch-core/src/utils/global_packages.rs @@ -0,0 +1,186 @@ +use std::path::PathBuf; +use std::process::Command; + +// --------------------------------------------------------------------------- +// Individual package manager global prefix helpers +// --------------------------------------------------------------------------- + +/// Get the npm global `node_modules` path using `npm root -g`. +pub fn get_npm_global_prefix() -> Result { + let output = Command::new("npm") + .args(["root", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("Failed to run `npm root -g`: {e}"))?; + + if !output.status.success() { + return Err( + "Failed to determine npm global prefix. Ensure npm is installed and in PATH." + .to_string(), + ); + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return Err("npm root -g returned empty output".to_string()); + } + + Ok(path) +} + +/// Get the yarn global `node_modules` path via `yarn global dir`. +pub fn get_yarn_global_prefix() -> Option { + let output = Command::new("yarn") + .args(["global", "dir"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if dir.is_empty() { + return None; + } + + Some( + PathBuf::from(dir) + .join("node_modules") + .to_string_lossy() + .to_string(), + ) +} + +/// Get the pnpm global `node_modules` path via `pnpm root -g`. +pub fn get_pnpm_global_prefix() -> Option { + let output = Command::new("pnpm") + .args(["root", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return None; + } + + Some(path) +} + +/// Get the bun global `node_modules` path via `bun pm bin -g`. +pub fn get_bun_global_prefix() -> Option { + let output = Command::new("bun") + .args(["pm", "bin", "-g"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let bin_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if bin_path.is_empty() { + return None; + } + + let bun_root = PathBuf::from(&bin_path); + let parent = bun_root.parent()?; + + Some( + parent + .join("install") + .join("global") + .join("node_modules") + .to_string_lossy() + .to_string(), + ) +} + +// --------------------------------------------------------------------------- +// Aggregation helpers +// --------------------------------------------------------------------------- + +/// Get the global `node_modules` path, with support for a custom override. +/// +/// If `custom` is `Some`, that value is returned directly. Otherwise, falls +/// back to `get_npm_global_prefix()`. +pub fn get_global_prefix(custom: Option<&str>) -> Result { + if let Some(custom_path) = custom { + return Ok(custom_path.to_string()); + } + get_npm_global_prefix() +} + +/// Get all global `node_modules` paths for package lookup. +/// +/// Returns paths from all detected package managers (npm, pnpm, yarn, bun). +/// If `custom` is provided, only that path is returned. +pub fn get_global_node_modules_paths(custom: Option<&str>) -> Vec { + if let Some(custom_path) = custom { + return vec![custom_path.to_string()]; + } + + let mut paths = Vec::new(); + + if let Ok(npm_path) = get_npm_global_prefix() { + paths.push(npm_path); + } + + if let Some(pnpm_path) = get_pnpm_global_prefix() { + paths.push(pnpm_path); + } + + if let Some(yarn_path) = get_yarn_global_prefix() { + paths.push(yarn_path); + } + + if let Some(bun_path) = get_bun_global_prefix() { + paths.push(bun_path); + } + + paths +} + +/// Check if a path is within a global `node_modules` directory. +pub fn is_global_path(pkg_path: &str) -> bool { + let paths = get_global_node_modules_paths(None); + let normalized = PathBuf::from(pkg_path); + let normalized_str = normalized.to_string_lossy(); + + paths.iter().any(|global_path| { + let gp = PathBuf::from(global_path); + normalized_str.starts_with(&*gp.to_string_lossy()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_global_prefix_custom() { + let result = get_global_prefix(Some("/custom/node_modules")); + assert_eq!(result.unwrap(), "/custom/node_modules"); + } + + #[test] + fn test_get_global_node_modules_paths_custom() { + let paths = get_global_node_modules_paths(Some("/my/custom/path")); + assert_eq!(paths, vec!["/my/custom/path".to_string()]); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs new file mode 100644 index 0000000..482e134 --- /dev/null +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -0,0 +1,6 @@ +pub mod cleanup_blobs; +pub mod enumerate; +pub mod fuzzy_match; +pub mod global_packages; +pub mod purl; +pub mod telemetry; diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs new file mode 100644 index 0000000..b073981 --- /dev/null +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -0,0 +1,211 @@ +/// Strip query string qualifiers from a PURL. +/// +/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `"pkg:pypi/requests@2.28.0"` +pub fn strip_purl_qualifiers(purl: &str) -> &str { + match purl.find('?') { + Some(idx) => &purl[..idx], + None => purl, + } +} + +/// Check if a PURL is a PyPI package. +pub fn is_pypi_purl(purl: &str) -> bool { + purl.starts_with("pkg:pypi/") +} + +/// Check if a PURL is an npm package. +pub fn is_npm_purl(purl: &str) -> bool { + purl.starts_with("pkg:npm/") +} + +/// Parse a PyPI PURL to extract name and version. +/// +/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))` +pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> { + let base = strip_purl_qualifiers(purl); + let rest = base.strip_prefix("pkg:pypi/")?; + let at_idx = rest.rfind('@')?; + let name = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + if name.is_empty() || version.is_empty() { + return None; + } + Some((name, version)) +} + +/// Parse an npm PURL to extract namespace, name, and version. +/// +/// e.g., `"pkg:npm/@types/node@20.0.0"` -> `Some((Some("@types"), "node", "20.0.0"))` +/// e.g., `"pkg:npm/lodash@4.17.21"` -> `Some((None, "lodash", "4.17.21"))` +pub fn parse_npm_purl(purl: &str) -> Option<(Option<&str>, &str, &str)> { + let base = strip_purl_qualifiers(purl); + let rest = base.strip_prefix("pkg:npm/")?; + + // Find the last @ that separates name from version + let at_idx = rest.rfind('@')?; + let name_part = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + + if name_part.is_empty() || version.is_empty() { + return None; + } + + // Check for scoped package (@scope/name) + if name_part.starts_with('@') { + let slash_idx = name_part.find('/')?; + let namespace = &name_part[..slash_idx]; + let name = &name_part[slash_idx + 1..]; + if name.is_empty() { + return None; + } + Some((Some(namespace), name, version)) + } else { + Some((None, name_part, version)) + } +} + +/// Parse a PURL into ecosystem, package directory path, and version. +/// Supports both npm and pypi PURLs. +pub fn parse_purl(purl: &str) -> Option<(&str, String, &str)> { + let base = strip_purl_qualifiers(purl); + if let Some(rest) = base.strip_prefix("pkg:npm/") { + let at_idx = rest.rfind('@')?; + let pkg_dir = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + if pkg_dir.is_empty() || version.is_empty() { + return None; + } + Some(("npm", pkg_dir.to_string(), version)) + } else if let Some(rest) = base.strip_prefix("pkg:pypi/") { + let at_idx = rest.rfind('@')?; + let name = &rest[..at_idx]; + let version = &rest[at_idx + 1..]; + if name.is_empty() || version.is_empty() { + return None; + } + Some(("pypi", name.to_string(), version)) + } else { + None + } +} + +/// Check if a string looks like a PURL. +pub fn is_purl(s: &str) -> bool { + s.starts_with("pkg:") +} + +/// Build an npm PURL from components. +pub fn build_npm_purl(namespace: Option<&str>, name: &str, version: &str) -> String { + match namespace { + Some(ns) => format!("pkg:npm/{}/{name}@{version}", ns), + None => format!("pkg:npm/{name}@{version}"), + } +} + +/// Build a PyPI PURL from components. +pub fn build_pypi_purl(name: &str, version: &str) -> String { + format!("pkg:pypi/{name}@{version}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_qualifiers() { + assert_eq!( + strip_purl_qualifiers("pkg:pypi/requests@2.28.0?artifact_id=abc"), + "pkg:pypi/requests@2.28.0" + ); + assert_eq!( + strip_purl_qualifiers("pkg:npm/lodash@4.17.21"), + "pkg:npm/lodash@4.17.21" + ); + } + + #[test] + fn test_is_pypi_purl() { + assert!(is_pypi_purl("pkg:pypi/requests@2.28.0")); + assert!(!is_pypi_purl("pkg:npm/lodash@4.17.21")); + } + + #[test] + fn test_is_npm_purl() { + assert!(is_npm_purl("pkg:npm/lodash@4.17.21")); + assert!(!is_npm_purl("pkg:pypi/requests@2.28.0")); + } + + #[test] + fn test_parse_pypi_purl() { + assert_eq!( + parse_pypi_purl("pkg:pypi/requests@2.28.0"), + Some(("requests", "2.28.0")) + ); + assert_eq!( + parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc"), + Some(("requests", "2.28.0")) + ); + assert_eq!(parse_pypi_purl("pkg:npm/lodash@4.17.21"), None); + assert_eq!(parse_pypi_purl("pkg:pypi/@2.28.0"), None); + assert_eq!(parse_pypi_purl("pkg:pypi/requests@"), None); + } + + #[test] + fn test_parse_npm_purl() { + assert_eq!( + parse_npm_purl("pkg:npm/lodash@4.17.21"), + Some((None, "lodash", "4.17.21")) + ); + assert_eq!( + parse_npm_purl("pkg:npm/@types/node@20.0.0"), + Some((Some("@types"), "node", "20.0.0")) + ); + assert_eq!(parse_npm_purl("pkg:pypi/requests@2.28.0"), None); + } + + #[test] + fn test_parse_purl() { + let (eco, dir, ver) = parse_purl("pkg:npm/lodash@4.17.21").unwrap(); + assert_eq!(eco, "npm"); + assert_eq!(dir, "lodash"); + assert_eq!(ver, "4.17.21"); + + let (eco, dir, ver) = parse_purl("pkg:npm/@types/node@20.0.0").unwrap(); + assert_eq!(eco, "npm"); + assert_eq!(dir, "@types/node"); + assert_eq!(ver, "20.0.0"); + + let (eco, dir, ver) = parse_purl("pkg:pypi/requests@2.28.0").unwrap(); + assert_eq!(eco, "pypi"); + assert_eq!(dir, "requests"); + assert_eq!(ver, "2.28.0"); + } + + #[test] + fn test_is_purl() { + assert!(is_purl("pkg:npm/lodash@4.17.21")); + assert!(is_purl("pkg:pypi/requests@2.28.0")); + assert!(!is_purl("lodash")); + assert!(!is_purl("CVE-2024-1234")); + } + + #[test] + fn test_build_npm_purl() { + assert_eq!( + build_npm_purl(None, "lodash", "4.17.21"), + "pkg:npm/lodash@4.17.21" + ); + assert_eq!( + build_npm_purl(Some("@types"), "node", "20.0.0"), + "pkg:npm/@types/node@20.0.0" + ); + } + + #[test] + fn test_build_pypi_purl() { + assert_eq!( + build_pypi_purl("requests", "2.28.0"), + "pkg:pypi/requests@2.28.0" + ); + } +} diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs new file mode 100644 index 0000000..856d865 --- /dev/null +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -0,0 +1,632 @@ +use std::collections::HashMap; + +use once_cell::sync::Lazy; +use uuid::Uuid; + +use crate::constants::{DEFAULT_PATCH_API_PROXY_URL, DEFAULT_SOCKET_API_URL, USER_AGENT}; + +// --------------------------------------------------------------------------- +// Session ID — generated once per process invocation +// --------------------------------------------------------------------------- + +/// Unique session ID for the current CLI invocation. +/// Shared across all telemetry events in a single run. +static SESSION_ID: Lazy = Lazy::new(|| Uuid::new_v4().to_string()); + +/// Package version — updated during build. +const PACKAGE_VERSION: &str = "1.0.0"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Telemetry event types for the patch lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchTelemetryEventType { + PatchApplied, + PatchApplyFailed, + PatchRemoved, + PatchRemoveFailed, + PatchRolledBack, + PatchRollbackFailed, +} + +impl PatchTelemetryEventType { + /// Return the wire-format string for this event type. + pub fn as_str(&self) -> &'static str { + match self { + Self::PatchApplied => "patch_applied", + Self::PatchApplyFailed => "patch_apply_failed", + Self::PatchRemoved => "patch_removed", + Self::PatchRemoveFailed => "patch_remove_failed", + Self::PatchRolledBack => "patch_rolled_back", + Self::PatchRollbackFailed => "patch_rollback_failed", + } + } +} + +/// Telemetry context describing the execution environment. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PatchTelemetryContext { + pub version: String, + pub platform: String, + pub arch: String, + pub command: String, +} + +/// Error details for telemetry events. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PatchTelemetryError { + #[serde(rename = "type")] + pub error_type: String, + pub message: Option, +} + +/// Telemetry event structure for patch operations. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PatchTelemetryEvent { + pub event_sender_created_at: String, + pub event_type: String, + pub context: PatchTelemetryContext, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Options for tracking a patch event. +pub struct TrackPatchEventOptions { + /// The type of event being tracked. + pub event_type: PatchTelemetryEventType, + /// The CLI command being executed (e.g., "apply", "remove", "rollback"). + pub command: String, + /// Optional metadata to include with the event. + pub metadata: Option>, + /// Optional error information if the operation failed. + /// Tuple of (error_type, message). + pub error: Option<(String, String)>, + /// Optional API token for authenticated telemetry endpoint. + pub api_token: Option, + /// Optional organization slug for authenticated telemetry endpoint. + pub org_slug: Option, +} + +// --------------------------------------------------------------------------- +// Environment checks +// --------------------------------------------------------------------------- + +/// Check if telemetry is disabled via environment variables. +/// +/// Telemetry is disabled when: +/// - `SOCKET_PATCH_TELEMETRY_DISABLED` is `"1"` or `"true"` +/// - `VITEST` is `"true"` (test environment) +pub fn is_telemetry_disabled() -> bool { + matches!( + std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED") + .unwrap_or_default() + .as_str(), + "1" | "true" + ) || std::env::var("VITEST").unwrap_or_default() == "true" +} + +/// Check if debug mode is enabled. +fn is_debug_enabled() -> bool { + matches!( + std::env::var("SOCKET_PATCH_DEBUG") + .unwrap_or_default() + .as_str(), + "1" | "true" + ) +} + +/// Log debug messages when debug mode is enabled. +fn debug_log(message: &str) { + if is_debug_enabled() { + eprintln!("[socket-patch telemetry] {message}"); + } +} + +// --------------------------------------------------------------------------- +// Build event +// --------------------------------------------------------------------------- + +/// Build the telemetry context for the current environment. +fn build_telemetry_context(command: &str) -> PatchTelemetryContext { + PatchTelemetryContext { + version: PACKAGE_VERSION.to_string(), + platform: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + command: command.to_string(), + } +} + +/// Sanitize an error message for telemetry. +/// +/// Replaces the user's home directory path with `~` to avoid leaking +/// sensitive file system information. +pub fn sanitize_error_message(message: &str) -> String { + if let Some(home) = home_dir_string() { + if !home.is_empty() { + return message.replace(&home, "~"); + } + } + message.to_string() +} + +/// Get the home directory as a string. +fn home_dir_string() -> Option { + std::env::var("HOME") + .ok() + .or_else(|| std::env::var("USERPROFILE").ok()) +} + +/// Build a telemetry event from the given options. +fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent { + let error = options.error.as_ref().map(|(error_type, message)| { + PatchTelemetryError { + error_type: error_type.clone(), + message: Some(sanitize_error_message(message)), + } + }); + + PatchTelemetryEvent { + event_sender_created_at: chrono_now_iso(), + event_type: options.event_type.as_str().to_string(), + context: build_telemetry_context(&options.command), + session_id: SESSION_ID.clone(), + metadata: options.metadata.clone(), + error, + } +} + +/// Get the current time as an ISO 8601 string. +fn chrono_now_iso() -> String { + let now = std::time::SystemTime::now(); + let duration = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + + let days = secs / 86400; + let remaining = secs % 86400; + let hours = remaining / 3600; + let minutes = (remaining % 3600) / 60; + let seconds = remaining % 60; + let millis = duration.subsec_millis(); + + let (year, month, day) = days_to_ymd(days); + + format!( + "{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z" + ) +} + +/// Convert days since Unix epoch to (year, month, day). +fn days_to_ymd(days: u64) -> (u64, u64, u64) { + // Adapted from Howard Hinnant's civil_from_days algorithm + let z = days as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u64, m, d) +} + +// --------------------------------------------------------------------------- +// Send event +// --------------------------------------------------------------------------- + +/// Send a telemetry event to the API. +/// +/// This is fire-and-forget: errors are logged in debug mode but never +/// propagated. Uses `reqwest` with a 5-second timeout. +async fn send_telemetry_event( + event: &PatchTelemetryEvent, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let (url, use_auth) = match (api_token, org_slug) { + (Some(_token), Some(slug)) => { + let api_url = std::env::var("SOCKET_API_URL") + .unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string()); + (format!("{api_url}/v0/orgs/{slug}/telemetry"), true) + } + _ => { + let proxy_url = std::env::var("SOCKET_PATCH_PROXY_URL") + .unwrap_or_else(|_| DEFAULT_PATCH_API_PROXY_URL.to_string()); + (format!("{proxy_url}/patch/telemetry"), false) + } + }; + + debug_log(&format!("Sending telemetry to {url}")); + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(c) => c, + Err(e) => { + debug_log(&format!("Failed to build HTTP client: {e}")); + return; + } + }; + + let mut request = client + .post(&url) + .header("Content-Type", "application/json") + .header("User-Agent", USER_AGENT); + + if use_auth { + if let Some(token) = api_token { + request = request.header("Authorization", format!("Bearer {token}")); + } + } + + match request.json(event).send().await { + Ok(response) => { + let status = response.status(); + if status.is_success() { + debug_log("Telemetry sent successfully"); + } else { + debug_log(&format!("Telemetry request returned status {status}")); + } + } + Err(e) => { + debug_log(&format!("Telemetry request failed: {e}")); + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Track a patch lifecycle event. +/// +/// This function is non-blocking and will never return errors. Telemetry +/// failures are logged in debug mode but do not affect CLI operation. +/// +/// If telemetry is disabled (via environment variables), the function returns +/// immediately. +pub async fn track_patch_event(options: TrackPatchEventOptions) { + if is_telemetry_disabled() { + debug_log("Telemetry is disabled, skipping event"); + return; + } + + let event = build_telemetry_event(&options); + send_telemetry_event( + &event, + options.api_token.as_deref(), + options.org_slug.as_deref(), + ) + .await; +} + +/// Fire-and-forget version of `track_patch_event` that spawns the request +/// on a background task so it never blocks the caller. +pub fn track_patch_event_fire_and_forget(options: TrackPatchEventOptions) { + if is_telemetry_disabled() { + debug_log("Telemetry is disabled, skipping event"); + return; + } + + let event = build_telemetry_event(&options); + let api_token = options.api_token.clone(); + let org_slug = options.org_slug.clone(); + + tokio::spawn(async move { + send_telemetry_event(&event, api_token.as_deref(), org_slug.as_deref()).await; + }); +} + +// --------------------------------------------------------------------------- +// Convenience functions +// +// These accept `Option<&str>` for api_token/org_slug to make call sites +// convenient (callers typically have `Option` and call `.as_deref()`). +// --------------------------------------------------------------------------- + +/// Track a successful patch application. +pub async fn track_patch_applied( + patches_count: usize, + dry_run: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "patches_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(patches_count)), + ); + metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run)); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchApplied, + command: "apply".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed patch application. +/// +/// Accepts any `Display` type for the error (works with `&str`, `String`, +/// `anyhow::Error`, `std::io::Error`, etc.). +pub async fn track_patch_apply_failed( + error: impl std::fmt::Display, + dry_run: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run)); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchApplyFailed, + command: "apply".to_string(), + metadata: Some(metadata), + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful patch removal. +pub async fn track_patch_removed( + removed_count: usize, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "removed_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(removed_count)), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRemoved, + command: "remove".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed patch removal. +/// +/// Accepts any `Display` type for the error. +pub async fn track_patch_remove_failed( + error: impl std::fmt::Display, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRemoveFailed, + command: "remove".to_string(), + metadata: None, + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful patch rollback. +pub async fn track_patch_rolled_back( + rolled_back_count: usize, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "rolled_back_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(rolled_back_count)), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRolledBack, + command: "rollback".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed patch rollback. +/// +/// Accepts any `Display` type for the error. +pub async fn track_patch_rollback_failed( + error: impl std::fmt::Display, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRollbackFailed, + command: "rollback".to_string(), + metadata: None, + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_telemetry_disabled_default() { + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("VITEST"); + assert!(!is_telemetry_disabled()); + } + + #[test] + fn test_is_telemetry_disabled_when_set() { + std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1"); + assert!(is_telemetry_disabled()); + std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true"); + assert!(is_telemetry_disabled()); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + } + + #[test] + fn test_sanitize_error_message() { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/testuser".to_string()); + let msg = format!("Failed to read {home}/projects/secret/file.txt"); + let sanitized = sanitize_error_message(&msg); + assert!(sanitized.contains("~/projects/secret/file.txt")); + assert!(!sanitized.contains(&home)); + } + + #[test] + fn test_sanitize_error_message_no_home() { + let msg = "Some error without paths"; + assert_eq!(sanitize_error_message(msg), msg); + } + + #[test] + fn test_event_type_as_str() { + assert_eq!(PatchTelemetryEventType::PatchApplied.as_str(), "patch_applied"); + assert_eq!( + PatchTelemetryEventType::PatchApplyFailed.as_str(), + "patch_apply_failed" + ); + assert_eq!(PatchTelemetryEventType::PatchRemoved.as_str(), "patch_removed"); + assert_eq!( + PatchTelemetryEventType::PatchRemoveFailed.as_str(), + "patch_remove_failed" + ); + assert_eq!( + PatchTelemetryEventType::PatchRolledBack.as_str(), + "patch_rolled_back" + ); + assert_eq!( + PatchTelemetryEventType::PatchRollbackFailed.as_str(), + "patch_rollback_failed" + ); + } + + #[test] + fn test_build_telemetry_context() { + let ctx = build_telemetry_context("apply"); + assert_eq!(ctx.command, "apply"); + assert_eq!(ctx.version, PACKAGE_VERSION); + assert!(!ctx.platform.is_empty()); + assert!(!ctx.arch.is_empty()); + } + + #[test] + fn test_build_telemetry_event_basic() { + let options = TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchApplied, + command: "apply".to_string(), + metadata: None, + error: None, + api_token: None, + org_slug: None, + }; + + let event = build_telemetry_event(&options); + assert_eq!(event.event_type, "patch_applied"); + assert_eq!(event.context.command, "apply"); + assert!(!event.session_id.is_empty()); + assert!(!event.event_sender_created_at.is_empty()); + assert!(event.metadata.is_none()); + assert!(event.error.is_none()); + } + + #[test] + fn test_build_telemetry_event_with_metadata() { + let mut metadata = HashMap::new(); + metadata.insert( + "patches_count".to_string(), + serde_json::Value::Number(5.into()), + ); + + let options = TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchApplied, + command: "apply".to_string(), + metadata: Some(metadata), + error: None, + api_token: None, + org_slug: None, + }; + + let event = build_telemetry_event(&options); + assert!(event.metadata.is_some()); + let meta = event.metadata.unwrap(); + assert_eq!( + meta.get("patches_count").unwrap(), + &serde_json::Value::Number(5.into()) + ); + } + + #[test] + fn test_build_telemetry_event_with_error() { + let options = TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchApplyFailed, + command: "apply".to_string(), + metadata: None, + error: Some(("IoError".to_string(), "file not found".to_string())), + api_token: None, + org_slug: None, + }; + + let event = build_telemetry_event(&options); + assert!(event.error.is_some()); + let err = event.error.unwrap(); + assert_eq!(err.error_type, "IoError"); + assert_eq!(err.message.unwrap(), "file not found"); + } + + #[test] + fn test_session_id_is_consistent() { + let id1 = SESSION_ID.clone(); + let id2 = SESSION_ID.clone(); + assert_eq!(id1, id2); + // Should be a valid UUID v4 format + assert_eq!(id1.len(), 36); + assert!(id1.contains('-')); + } + + #[test] + fn test_chrono_now_iso_format() { + let ts = chrono_now_iso(); + // Should look like "2024-01-15T10:30:45.123Z" + assert!(ts.ends_with('Z')); + assert!(ts.contains('T')); + assert!(ts.contains('-')); + assert!(ts.contains(':')); + assert_eq!(ts.len(), 24); // YYYY-MM-DDTHH:MM:SS.mmmZ + } + + #[test] + fn test_days_to_ymd_epoch() { + let (y, m, d) = days_to_ymd(0); + assert_eq!((y, m, d), (1970, 1, 1)); + } + + #[test] + fn test_days_to_ymd_known_date() { + // 2024-01-01 is day 19723 + let (y, m, d) = days_to_ymd(19723); + assert_eq!((y, m, d), (2024, 1, 1)); + } +} diff --git a/npm/socket-patch-darwin-arm64/package.json b/npm/socket-patch-darwin-arm64/package.json new file mode 100644 index 0000000..a46ed6b --- /dev/null +++ b/npm/socket-patch-darwin-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@socketsecurity/socket-patch-darwin-arm64", + "version": "0.0.0", + "description": "socket-patch native binary for macOS ARM64", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "bin": { + "socket-patch": "bin/socket-patch" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + } +} diff --git a/npm/socket-patch-darwin-x64/package.json b/npm/socket-patch-darwin-x64/package.json new file mode 100644 index 0000000..3bac5f1 --- /dev/null +++ b/npm/socket-patch-darwin-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@socketsecurity/socket-patch-darwin-x64", + "version": "0.0.0", + "description": "socket-patch native binary for macOS x64", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "bin": { + "socket-patch": "bin/socket-patch" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + } +} diff --git a/npm/socket-patch-linux-arm64/package.json b/npm/socket-patch-linux-arm64/package.json new file mode 100644 index 0000000..59a2b07 --- /dev/null +++ b/npm/socket-patch-linux-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@socketsecurity/socket-patch-linux-arm64", + "version": "0.0.0", + "description": "socket-patch native binary for Linux ARM64", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "bin": { + "socket-patch": "bin/socket-patch" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + } +} diff --git a/npm/socket-patch-linux-x64/package.json b/npm/socket-patch-linux-x64/package.json new file mode 100644 index 0000000..1366623 --- /dev/null +++ b/npm/socket-patch-linux-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@socketsecurity/socket-patch-linux-x64", + "version": "0.0.0", + "description": "socket-patch native binary for Linux x64", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "bin": { + "socket-patch": "bin/socket-patch" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + } +} diff --git a/npm/socket-patch-win32-x64/package.json b/npm/socket-patch-win32-x64/package.json new file mode 100644 index 0000000..edc879e --- /dev/null +++ b/npm/socket-patch-win32-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@socketsecurity/socket-patch-win32-x64", + "version": "0.0.0", + "description": "socket-patch native binary for Windows x64", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "bin": { + "socket-patch": "bin/socket-patch.exe" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + } +} diff --git a/npm/socket-patch/bin/socket-patch b/npm/socket-patch/bin/socket-patch new file mode 100755 index 0000000..73527fc --- /dev/null +++ b/npm/socket-patch/bin/socket-patch @@ -0,0 +1,33 @@ +#!/usr/bin/env node +const path = require("path"); + +const PLATFORMS = { + "darwin arm64": "@socketsecurity/socket-patch-darwin-arm64", + "darwin x64": "@socketsecurity/socket-patch-darwin-x64", + "linux x64": "@socketsecurity/socket-patch-linux-x64", + "linux arm64": "@socketsecurity/socket-patch-linux-arm64", + "win32 x64": "@socketsecurity/socket-patch-win32-x64", +}; + +const key = `${process.platform} ${process.arch}`; +const pkg = PLATFORMS[key]; +if (!pkg) { + console.error(`Unsupported platform: ${key}`); + console.error("Install from GitHub Releases: https://github.com/SocketDev/socket-patch/releases"); + process.exit(1); +} + +const binName = process.platform === "win32" ? "socket-patch.exe" : "socket-patch"; +let binPath; +try { + binPath = path.join(path.dirname(require.resolve(`${pkg}/package.json`)), "bin", binName); +} catch { + console.error(`Could not find ${pkg}. Your platform may not be supported.`); + process.exit(1); +} + +const result = require("child_process").spawnSync(binPath, process.argv.slice(2), { + stdio: "inherit", + env: process.env, +}); +process.exit(result.status ?? 1); diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json new file mode 100644 index 0000000..ec1fbc3 --- /dev/null +++ b/npm/socket-patch/package.json @@ -0,0 +1,33 @@ +{ + "name": "@socketsecurity/socket-patch", + "version": "0.0.0", + "description": "CLI tool for applying security patches to dependencies", + "bin": { + "socket-patch": "bin/socket-patch" + }, + "optionalDependencies": { + "@socketsecurity/socket-patch-darwin-arm64": "0.0.0", + "@socketsecurity/socket-patch-darwin-x64": "0.0.0", + "@socketsecurity/socket-patch-linux-x64": "0.0.0", + "@socketsecurity/socket-patch-linux-arm64": "0.0.0", + "@socketsecurity/socket-patch-win32-x64": "0.0.0" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "security", + "patch", + "cli", + "dependencies" + ], + "author": "Socket Security", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/SocketDev/socket-patch" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh new file mode 100755 index 0000000..8ebd664 --- /dev/null +++ b/scripts/version-sync.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?Usage: version-sync.sh }" + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# Update workspace Cargo.toml version +sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$REPO_ROOT/Cargo.toml" +rm -f "$REPO_ROOT/Cargo.toml.bak" + +PLATFORM_PKGS=( + "socket-patch-darwin-arm64" + "socket-patch-darwin-x64" + "socket-patch-linux-x64" + "socket-patch-linux-arm64" + "socket-patch-win32-x64" +) + +# Update each platform package version +for pkg in "${PLATFORM_PKGS[@]}"; do + pkg_json="$REPO_ROOT/npm/$pkg/package.json" + tmp=$(mktemp) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$pkg_json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('$pkg_json', JSON.stringify(pkg, null, 2) + '\n'); + " +done + +# Update root wrapper package version + optionalDependencies versions +root_json="$REPO_ROOT/npm/socket-patch/package.json" +node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$root_json', 'utf8')); + pkg.version = '$VERSION'; + if (pkg.optionalDependencies) { + for (const dep of Object.keys(pkg.optionalDependencies)) { + pkg.optionalDependencies[dep] = '$VERSION'; + } + } + fs.writeFileSync('$root_json', JSON.stringify(pkg, null, 2) + '\n'); +" + +echo "Synced version to $VERSION" From 313ea79377e333a20d5b1273b7c1dc743d828d51 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 3 Mar 2026 18:09:29 -0500 Subject: [PATCH 02/10] fix: pin all GitHub Actions to full commit SHAs Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 4 ++-- .github/workflows/release.yml | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb320ee..aaca4e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: components: clippy - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ea680c..3f3fc65 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fd2bb8..6b0ef27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,10 +37,10 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: targets: ${{ matrix.target }} @@ -71,14 +71,14 @@ jobs: - name: Upload artifact (tar.gz) if: matrix.archive == 'tar.gz' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: socket-patch-${{ matrix.target }} path: socket-patch-${{ matrix.target }}.tar.gz - name: Upload artifact (zip) if: matrix.archive == 'zip' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: socket-patch-${{ matrix.target }} path: socket-patch-${{ matrix.target }}.zip @@ -88,7 +88,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts merge-multiple: true @@ -108,16 +108,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts merge-multiple: true - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' From 9030590825aabced9141236f8e5dbb347e9c7177 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 3 Mar 2026 18:14:44 -0500 Subject: [PATCH 03/10] fix: add explicit toolchain input for pinned rust-toolchain action When dtolnay/rust-toolchain is pinned to a SHA instead of a branch name, the toolchain version can't be inferred from the ref and must be passed explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaca4e4..631941b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: + toolchain: stable components: clippy - name: Cache cargo diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b0ef27..1a4600d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,7 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: + toolchain: stable targets: ${{ matrix.target }} - name: Install cross From 10dee6ea39a4924cdfe939adb98ea17cdaebbfa4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 3 Mar 2026 18:24:43 -0500 Subject: [PATCH 04/10] refactor: consolidate npm distribution into single package with bundled binaries Delete all TypeScript source, configs, and platform-specific npm packages. The single @socketsecurity/socket-patch package now ships all 5 platform binaries (~20MB total) instead of using optionalDependencies with 6 packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 12 +- .github/workflows/release.yml | 45 +- .gitignore | 6 +- .npmignore | 33 - .oxlintrc.json | 64 - EDGE_CASES.md | 464 ------ biome.json | 47 - npm/socket-patch-darwin-arm64/package.json | 22 - npm/socket-patch-darwin-x64/package.json | 22 - npm/socket-patch-linux-arm64/package.json | 22 - npm/socket-patch-linux-x64/package.json | 22 - npm/socket-patch-win32-x64/package.json | 22 - npm/socket-patch/bin/socket-patch | 30 +- npm/socket-patch/package.json | 9 +- package.json | 100 -- pnpm-lock.yaml | 365 ----- scripts/version-sync.sh | 33 +- src/cli.ts | 38 - src/commands/apply-python.test.ts | 240 --- src/commands/apply-qualifier-fallback.test.ts | 224 --- src/commands/apply.ts | 422 ----- src/commands/download.test.ts | 292 ---- src/commands/get.ts | 1396 ----------------- src/commands/list.ts | 151 -- src/commands/remove.test.ts | 275 ---- src/commands/remove.ts | 238 --- src/commands/repair.ts | 180 --- src/commands/rollback.test.ts | 551 ------- src/commands/rollback.ts | 763 --------- src/commands/scan-authenticated.test.ts | 944 ----------- src/commands/scan-python.test.ts | 277 ---- src/commands/scan.test.ts | 448 ------ src/commands/scan.ts | 489 ------ src/commands/setup.ts | 185 --- src/constants.ts | 16 - src/crawlers/index.ts | 14 - src/crawlers/npm-crawler.ts | 528 ------- src/crawlers/python-crawler.test.ts | 488 ------ src/crawlers/python-crawler.ts | 415 ----- src/crawlers/python-venv.test.ts | 182 --- src/crawlers/types.ts | 29 - src/hash/git-sha256.ts | 37 - src/index.ts | 21 - src/manifest/operations.test.ts | 188 --- src/manifest/operations.ts | 142 -- src/manifest/recovery.ts | 238 --- src/package-json/detect.test.ts | 618 -------- src/package-json/detect.ts | 156 -- src/package-json/find.ts | 326 ---- src/package-json/index.ts | 20 - src/package-json/update.ts | 88 -- src/patch/apply.ts | 314 ---- src/patch/file-hash.ts | 22 - src/patch/rollback.ts | 217 --- src/run.ts | 84 - src/schema/manifest-schema.ts | 38 - src/test-utils.ts | 342 ---- src/types.ts | 20 - src/utils.ts | 23 - src/utils/api-client.test.ts | 492 ------ src/utils/api-client.ts | 631 -------- src/utils/blob-fetcher.test.ts | 130 -- src/utils/blob-fetcher.ts | 311 ---- src/utils/cleanup-blobs.test.ts | 157 -- src/utils/cleanup-blobs.ts | 124 -- src/utils/enumerate-packages.ts | 200 --- src/utils/fuzzy-match.ts | 132 -- src/utils/global-packages.ts | 133 -- src/utils/purl-utils.test.ts | 66 - src/utils/purl-utils.ts | 35 - src/utils/spinner.ts | 175 --- src/utils/telemetry.ts | 410 ----- tsconfig.json | 27 - 73 files changed, 40 insertions(+), 15980 deletions(-) delete mode 100644 .npmignore delete mode 100644 .oxlintrc.json delete mode 100644 EDGE_CASES.md delete mode 100644 biome.json delete mode 100644 npm/socket-patch-darwin-arm64/package.json delete mode 100644 npm/socket-patch-darwin-x64/package.json delete mode 100644 npm/socket-patch-linux-arm64/package.json delete mode 100644 npm/socket-patch-linux-x64/package.json delete mode 100644 npm/socket-patch-win32-x64/package.json delete mode 100644 package.json delete mode 100644 pnpm-lock.yaml delete mode 100644 src/cli.ts delete mode 100644 src/commands/apply-python.test.ts delete mode 100644 src/commands/apply-qualifier-fallback.test.ts delete mode 100644 src/commands/apply.ts delete mode 100644 src/commands/download.test.ts delete mode 100644 src/commands/get.ts delete mode 100644 src/commands/list.ts delete mode 100644 src/commands/remove.test.ts delete mode 100644 src/commands/remove.ts delete mode 100644 src/commands/repair.ts delete mode 100644 src/commands/rollback.test.ts delete mode 100644 src/commands/rollback.ts delete mode 100644 src/commands/scan-authenticated.test.ts delete mode 100644 src/commands/scan-python.test.ts delete mode 100644 src/commands/scan.test.ts delete mode 100644 src/commands/scan.ts delete mode 100644 src/commands/setup.ts delete mode 100644 src/constants.ts delete mode 100644 src/crawlers/index.ts delete mode 100644 src/crawlers/npm-crawler.ts delete mode 100644 src/crawlers/python-crawler.test.ts delete mode 100644 src/crawlers/python-crawler.ts delete mode 100644 src/crawlers/python-venv.test.ts delete mode 100644 src/crawlers/types.ts delete mode 100644 src/hash/git-sha256.ts delete mode 100644 src/index.ts delete mode 100644 src/manifest/operations.test.ts delete mode 100644 src/manifest/operations.ts delete mode 100644 src/manifest/recovery.ts delete mode 100644 src/package-json/detect.test.ts delete mode 100644 src/package-json/detect.ts delete mode 100644 src/package-json/find.ts delete mode 100644 src/package-json/index.ts delete mode 100644 src/package-json/update.ts delete mode 100644 src/patch/apply.ts delete mode 100644 src/patch/file-hash.ts delete mode 100644 src/patch/rollback.ts delete mode 100644 src/run.ts delete mode 100644 src/schema/manifest-schema.ts delete mode 100644 src/test-utils.ts delete mode 100644 src/types.ts delete mode 100644 src/utils.ts delete mode 100644 src/utils/api-client.test.ts delete mode 100644 src/utils/api-client.ts delete mode 100644 src/utils/blob-fetcher.test.ts delete mode 100644 src/utils/blob-fetcher.ts delete mode 100644 src/utils/cleanup-blobs.test.ts delete mode 100644 src/utils/cleanup-blobs.ts delete mode 100644 src/utils/enumerate-packages.ts delete mode 100644 src/utils/fuzzy-match.ts delete mode 100644 src/utils/global-packages.ts delete mode 100644 src/utils/purl-utils.test.ts delete mode 100644 src/utils/purl-utils.ts delete mode 100644 src/utils/spinner.ts delete mode 100644 src/utils/telemetry.ts delete mode 100644 tsconfig.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f3fc65..ad5cefd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,10 +34,16 @@ jobs: - name: Bump version and sync run: | - npm version ${{ inputs.version-bump }} --no-git-tag-version - VERSION=$(node -p "require('./package.json').version") + CURRENT=$(node -p "require('./npm/socket-patch/package.json').version") + VERSION=$(node -e " + const [major, minor, patch] = '$CURRENT'.split('.').map(Number); + const bump = '${{ inputs.version-bump }}'; + if (bump === 'major') console.log((major+1)+'.0.0'); + else if (bump === 'minor') console.log(major+'.'+(minor+1)+'.0'); + else console.log(major+'.'+minor+'.'+(patch+1)); + ") bash scripts/version-sync.sh "$VERSION" - git add Cargo.toml package.json npm/ + git add Cargo.toml npm/ git commit -m "v$VERSION" git tag "v$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a4600d..8053cb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,41 +129,22 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_ENV" bash scripts/version-sync.sh "$VERSION" - - name: Stage darwin-arm64 binary + - name: Stage binaries run: | - tar xzf artifacts/socket-patch-aarch64-apple-darwin.tar.gz -C npm/socket-patch-darwin-arm64/bin/ - - - name: Stage darwin-x64 binary - run: | - tar xzf artifacts/socket-patch-x86_64-apple-darwin.tar.gz -C npm/socket-patch-darwin-x64/bin/ - - - name: Stage linux-x64 binary - run: | - tar xzf artifacts/socket-patch-x86_64-unknown-linux-musl.tar.gz -C npm/socket-patch-linux-x64/bin/ - - - name: Stage linux-arm64 binary - run: | - tar xzf artifacts/socket-patch-aarch64-unknown-linux-gnu.tar.gz -C npm/socket-patch-linux-arm64/bin/ - - - name: Stage win32-x64 binary - run: | - cd npm/socket-patch-win32-x64/bin + mkdir -p npm/socket-patch/bin + tar xzf artifacts/socket-patch-aarch64-apple-darwin.tar.gz -C npm/socket-patch/bin/ + mv npm/socket-patch/bin/socket-patch npm/socket-patch/bin/socket-patch-darwin-arm64 + tar xzf artifacts/socket-patch-x86_64-apple-darwin.tar.gz -C npm/socket-patch/bin/ + mv npm/socket-patch/bin/socket-patch npm/socket-patch/bin/socket-patch-darwin-x64 + tar xzf artifacts/socket-patch-x86_64-unknown-linux-musl.tar.gz -C npm/socket-patch/bin/ + mv npm/socket-patch/bin/socket-patch npm/socket-patch/bin/socket-patch-linux-x64 + tar xzf artifacts/socket-patch-aarch64-unknown-linux-gnu.tar.gz -C npm/socket-patch/bin/ + mv npm/socket-patch/bin/socket-patch npm/socket-patch/bin/socket-patch-linux-arm64 + cd npm/socket-patch/bin unzip ../../../artifacts/socket-patch-x86_64-pc-windows-msvc.zip + mv socket-patch.exe socket-patch-win32-x64.exe - - name: Publish platform packages - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for pkg in \ - npm/socket-patch-darwin-arm64 \ - npm/socket-patch-darwin-x64 \ - npm/socket-patch-linux-x64 \ - npm/socket-patch-linux-arm64 \ - npm/socket-patch-win32-x64; do - npm publish "$pkg" --provenance --access public - done - - - name: Publish root package + - name: Publish package env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish npm/socket-patch --provenance --access public diff --git a/.gitignore b/.gitignore index 727f410..c2fe737 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,5 @@ vite.config.ts.timestamp-* # Rust target/ -# npm platform binaries (populated at publish time) -npm/*/bin/socket-patch -npm/*/bin/socket-patch.exe -!npm/socket-patch/bin/socket-patch +# npm binaries (populated at publish time) +npm/socket-patch/bin/socket-patch-* diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ae7dfe3..0000000 --- a/.npmignore +++ /dev/null @@ -1,33 +0,0 @@ -# Source files (dist is published instead) -src/ - -# GitHub workflows and configs -.github/ - -# Linting and formatting configs -.oxlintrc.json -biome.json - -# TypeScript config -tsconfig.json - -# Lock files -pnpm-lock.yaml - -# Git files -.gitignore - -# Documentation (except README and LICENSE which npm includes by default) -EDGE_CASES.md - -# Build artifacts -*.tsbuildinfo - -# Environment files -.env* -!.env.example - -# Test files -*.test.ts -*.test.js -__tests__/ diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index 3415f15..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["**/dist/", "**/node_modules/", "**/.git/"], - "plugins": ["typescript", "oxc", "promise", "import"], - "categories": { - "correctness": "warn", - "perf": "warn", - "suspicious": "warn" - }, - "rules": { - "no-unused-vars": "allow", - "no-new-array": "allow", - "no-empty-file": "allow", - "no-await-in-loop": "allow", - "consistent-function-scoping": "allow", - "no-new": "allow", - "no-extraneous-class": "allow", - "no-array-index-key": "allow", - "no-unsafe-optional-chaining": "allow", - "no-promise-in-callback": "allow", - "no-callback-in-promise": "allow", - "consistent-type-imports": "deny", - "no-empty-named-blocks": "allow", - "no-unnecessary-parameter-property-assignment": "allow", - "no-unneeded-ternary": "allow", - "no-eq-null": "allow", - "max-lines-per-function": "allow", - "max-depth": "allow", - "no-magic-numbers": "allow", - "no-unassigned-import": "allow", - "promise/always-return": "allow", - "no-unassigned-vars": "deny", - "typescript/no-floating-promises": "deny", - "typescript/no-misused-promises": "deny", - "typescript/return-await": "allow", - "typescript/await-thenable": "allow", - "typescript/consistent-type-imports": "allow", - "typescript/no-base-to-string": "allow", - "typescript/no-duplicate-type-constituents": "allow", - "typescript/no-for-in-array": "allow", - "typescript/no-meaningless-void-operator": "allow", - "typescript/no-misused-spread": "allow", - "typescript/no-redundant-type-constituents": "allow", - "typescript/no-unnecessary-boolean-literal-compare": "allow", - "typescript/no-unnecessary-template-expression": "allow", - "typescript/no-unnecessary-type-arguments": "allow", - "typescript/no-unnecessary-type-assertion": "allow", - "typescript/no-unsafe-enum-comparison": "allow", - "typescript/no-unsafe-type-assertion": "allow", - "typescript/require-array-sort-compare": "allow", - "typescript/restrict-template-expressions": "allow", - "typescript/triple-slash-reference": "allow", - "typescript/unbound-method": "allow" - }, - "overrides": [ - { - "files": ["**/*.test.ts", "**/*.test.js", "**/*.spec.ts", "**/*.spec.js"], - "rules": { - "typescript/no-floating-promises": "allow", - "typescript/no-misused-promises": "allow" - } - } - ] -} diff --git a/EDGE_CASES.md b/EDGE_CASES.md deleted file mode 100644 index 4bda7ea..0000000 --- a/EDGE_CASES.md +++ /dev/null @@ -1,464 +0,0 @@ -# Socket-Patch Setup Command: Edge Case Analysis - -This document provides a comprehensive analysis of all edge cases handled by the `socket-patch setup` command. - -## Detection Logic - -The setup command detects if a postinstall script is already configured by checking if the string contains `'socket-patch apply'`. This substring match is intentionally lenient to recognize various valid formats. - -## Edge Cases - -### 1. No scripts field at all - -**Input:** -```json -{ - "name": "test", - "version": "1.0.0" -} -``` - -**Behavior:** ✅ Creates scripts field and adds postinstall - -**Output:** -```json -{ - "name": "test", - "version": "1.0.0", - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - ---- - -### 2. Scripts field exists but no postinstall - -**Input:** -```json -{ - "scripts": { - "test": "jest", - "build": "tsc" - } -} -``` - -**Behavior:** ✅ Adds postinstall to existing scripts object - -**Output:** -```json -{ - "scripts": { - "test": "jest", - "build": "tsc", - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - ---- - -### 2a. Postinstall is null - -**Input:** -```json -{ - "scripts": { - "postinstall": null - } -} -``` - -**Behavior:** ✅ Treats as missing, adds socket-patch command - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - ---- - -### 2b. Postinstall is empty string - -**Input:** -```json -{ - "scripts": { - "postinstall": "" - } -} -``` - -**Behavior:** ✅ Replaces empty string with socket-patch command - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - ---- - -### 2c. Postinstall is whitespace only - -**Input:** -```json -{ - "scripts": { - "postinstall": " \n\t " - } -} -``` - -**Behavior:** ✅ Treats as empty, adds socket-patch command - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - ---- - -### 3. Postinstall exists but missing socket-patch setup - -**Input:** -```json -{ - "scripts": { - "postinstall": "echo 'Running postinstall tasks'" - } -} -``` - -**Behavior:** ✅ Prepends socket-patch before existing script - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply && echo 'Running postinstall tasks'" - } -} -``` - -**Rationale:** Socket-patch runs first to apply security patches before other setup tasks. Uses `&&` to ensure existing script only runs if patching succeeds. - ---- - -### 4a. socket-patch apply without npx - -**Input:** -```json -{ - "scripts": { - "postinstall": "socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid if socket-patch is installed as a dependency. The substring `'socket-patch apply'` is present. - ---- - -### 4b. npx socket-patch apply (without @socketsecurity/) - -**Input:** -```json -{ - "scripts": { - "postinstall": "npx socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid format. The substring `'socket-patch apply'` is present. - ---- - -### 4c. Canonical format: npx @socketsecurity/socket-patch apply - -**Input:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** This is the recommended canonical format. - ---- - -### 4d. pnpm socket-patch apply - -**Input:** -```json -{ - "scripts": { - "postinstall": "pnpm socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid format for pnpm users. The substring `'socket-patch apply'` is present. - ---- - -### 4e. yarn socket-patch apply - -**Input:** -```json -{ - "scripts": { - "postinstall": "yarn socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid format for yarn users. The substring `'socket-patch apply'` is present. - ---- - -### 4f. node_modules/.bin/socket-patch apply (direct path) - -**Input:** -```json -{ - "scripts": { - "postinstall": "node_modules/.bin/socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid format using direct path. The substring `'socket-patch apply'` is present. - ---- - -### 4g. socket apply (main Socket CLI - DIFFERENT command) - -**Input:** -```json -{ - "scripts": { - "postinstall": "socket apply" - } -} -``` - -**Behavior:** ⚠️ NOT recognized as configured, adds socket-patch - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply && socket apply" - } -} -``` - -**Rationale:** `socket apply` is a DIFFERENT command from the main Socket CLI. The substring `'socket-patch apply'` is NOT present. Socket-patch should be added separately. - ---- - -### 4h. socket-patch list (wrong subcommand) - -**Input:** -```json -{ - "scripts": { - "postinstall": "socket-patch list" - } -} -``` - -**Behavior:** ⚠️ NOT recognized as configured, adds socket-patch apply - -**Output:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply && socket-patch list" - } -} -``` - -**Rationale:** `socket-patch list` is a different subcommand. The substring `'socket-patch apply'` is NOT present (missing "apply"). Socket-patch apply should be added. - ---- - -### 4i. socket-patch apply with flags - -**Input:** -```json -{ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Valid format with flags. The substring `'socket-patch apply'` is present. - ---- - -### 4j. socket-patch apply in middle of script chain - -**Input:** -```json -{ - "scripts": { - "postinstall": "echo start && socket-patch apply && echo done" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Socket-patch is already in the chain. The substring `'socket-patch apply'` is present. - ---- - -### 4k. socket-patch apply at end of chain - -**Input:** -```json -{ - "scripts": { - "postinstall": "npm run prepare && socket-patch apply" - } -} -``` - -**Behavior:** ✅ Recognized as configured, no changes - -**Rationale:** Socket-patch is already present. The substring `'socket-patch apply'` is present. - -**Note:** While this is recognized, it's not ideal since patches won't be applied before the prepare script runs. However, we don't modify it to avoid breaking existing setups. - ---- - -### 5. Postinstall with invalid data types - -#### 5a. Number instead of string - -**Input:** -```json -{ - "scripts": { - "postinstall": 123 - } -} -``` - -**Behavior:** ✅ Treated as not configured, adds socket-patch - -**Rationale:** Invalid type is coerced or ignored. Setup adds proper string command. - -#### 5b. Array instead of string - -**Input:** -```json -{ - "scripts": { - "postinstall": ["echo", "hello"] - } -} -``` - -**Behavior:** ✅ Treated as not configured, adds socket-patch - -**Rationale:** Invalid type. Setup adds proper string command. - -#### 5c. Object instead of string - -**Input:** -```json -{ - "scripts": { - "postinstall": { "command": "echo hello" } - } -} -``` - -**Behavior:** ✅ Treated as not configured, adds socket-patch - -**Rationale:** Invalid type. Setup adds proper string command. - ---- - -### 6. Malformed JSON - -**Input:** -``` -{ name: "test", invalid json } -``` - -**Behavior:** ❌ Throws error: "Invalid package.json: failed to parse JSON" - -**Rationale:** Cannot process malformed JSON. User must fix the JSON first. - ---- - -## Summary Table - -| Scenario | Contains `'socket-patch apply'`? | Behavior | -|----------|----------------------------------|----------| -| No scripts field | ❌ | Add scripts + postinstall | -| Scripts exists, no postinstall | ❌ | Add postinstall | -| Postinstall is null/undefined/empty | ❌ | Add socket-patch command | -| Postinstall has other command | ❌ | Prepend socket-patch | -| `socket-patch apply` | ✅ | Skip (already configured) | -| `npx socket-patch apply` | ✅ | Skip (already configured) | -| `npx @socketsecurity/socket-patch apply` | ✅ | Skip (already configured) | -| `pnpm/yarn socket-patch apply` | ✅ | Skip (already configured) | -| `node_modules/.bin/socket-patch apply` | ✅ | Skip (already configured) | -| `socket-patch apply --flags` | ✅ | Skip (already configured) | -| In script chain with `socket-patch apply` | ✅ | Skip (already configured) | -| `socket apply` (main CLI) | ❌ | Add socket-patch apply | -| `socket-patch list` (wrong subcommand) | ❌ | Add socket-patch apply | -| Invalid data types | ❌ | Add socket-patch command | -| Malformed JSON | N/A | Throw error | - -## Testing - -All edge cases are tested in: -- **Unit tests:** `submodules/socket-patch/src/package-json/detect.test.ts` -- **E2E tests:** `workspaces/api-v0/e2e-tests/tests/59_socket-patch-setup.js` - -Run tests: -```bash -# Unit tests -cd submodules/socket-patch -npm test - -# E2E tests -pnpm --filter @socketsecurity/api-v0 run test e2e-tests/tests/59_socket-patch-setup.js -``` diff --git a/biome.json b/biome.json deleted file mode 100644 index f6b3f5a..0000000 --- a/biome.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "files": { - "includes": ["**", "!.git", "!dist", "!node_modules"] - }, - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 80 - }, - "linter": { - "enabled": false, - "rules": { - "style": { - "noParameterAssign": "error", - "useAsConstAssertion": "error", - "useDefaultParameterLast": "error", - "useEnumInitializers": "error", - "useSelfClosingElements": "error", - "useSingleVarDeclarator": "error", - "noUnusedTemplateLiteral": "error", - "useNumberNamespace": "error", - "noInferrableTypes": "error", - "noUselessElse": "error" - } - } - }, - "javascript": { - "formatter": { - "arrowParentheses": "asNeeded", - "semicolons": "asNeeded", - "quoteStyle": "single", - "jsxQuoteStyle": "single", - "trailingCommas": "all" - } - }, - "json": { - "formatter": { - "trailingCommas": "none", - "indentStyle": "space", - "indentWidth": 2 - } - } -} diff --git a/npm/socket-patch-darwin-arm64/package.json b/npm/socket-patch-darwin-arm64/package.json deleted file mode 100644 index a46ed6b..0000000 --- a/npm/socket-patch-darwin-arm64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch-darwin-arm64", - "version": "0.0.0", - "description": "socket-patch native binary for macOS ARM64", - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "bin": { - "socket-patch": "bin/socket-patch" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - } -} diff --git a/npm/socket-patch-darwin-x64/package.json b/npm/socket-patch-darwin-x64/package.json deleted file mode 100644 index 3bac5f1..0000000 --- a/npm/socket-patch-darwin-x64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch-darwin-x64", - "version": "0.0.0", - "description": "socket-patch native binary for macOS x64", - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "bin": { - "socket-patch": "bin/socket-patch" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - } -} diff --git a/npm/socket-patch-linux-arm64/package.json b/npm/socket-patch-linux-arm64/package.json deleted file mode 100644 index 59a2b07..0000000 --- a/npm/socket-patch-linux-arm64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch-linux-arm64", - "version": "0.0.0", - "description": "socket-patch native binary for Linux ARM64", - "os": [ - "linux" - ], - "cpu": [ - "arm64" - ], - "bin": { - "socket-patch": "bin/socket-patch" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - } -} diff --git a/npm/socket-patch-linux-x64/package.json b/npm/socket-patch-linux-x64/package.json deleted file mode 100644 index 1366623..0000000 --- a/npm/socket-patch-linux-x64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch-linux-x64", - "version": "0.0.0", - "description": "socket-patch native binary for Linux x64", - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "bin": { - "socket-patch": "bin/socket-patch" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - } -} diff --git a/npm/socket-patch-win32-x64/package.json b/npm/socket-patch-win32-x64/package.json deleted file mode 100644 index edc879e..0000000 --- a/npm/socket-patch-win32-x64/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch-win32-x64", - "version": "0.0.0", - "description": "socket-patch native binary for Windows x64", - "os": [ - "win32" - ], - "cpu": [ - "x64" - ], - "bin": { - "socket-patch": "bin/socket-patch.exe" - }, - "publishConfig": { - "access": "public" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - } -} diff --git a/npm/socket-patch/bin/socket-patch b/npm/socket-patch/bin/socket-patch index 73527fc..9337d84 100755 --- a/npm/socket-patch/bin/socket-patch +++ b/npm/socket-patch/bin/socket-patch @@ -1,32 +1,24 @@ #!/usr/bin/env node +const { spawnSync } = require("child_process"); const path = require("path"); -const PLATFORMS = { - "darwin arm64": "@socketsecurity/socket-patch-darwin-arm64", - "darwin x64": "@socketsecurity/socket-patch-darwin-x64", - "linux x64": "@socketsecurity/socket-patch-linux-x64", - "linux arm64": "@socketsecurity/socket-patch-linux-arm64", - "win32 x64": "@socketsecurity/socket-patch-win32-x64", +const BINARIES = { + "darwin arm64": "socket-patch-darwin-arm64", + "darwin x64": "socket-patch-darwin-x64", + "linux x64": "socket-patch-linux-x64", + "linux arm64": "socket-patch-linux-arm64", + "win32 x64": "socket-patch-win32-x64.exe", }; const key = `${process.platform} ${process.arch}`; -const pkg = PLATFORMS[key]; -if (!pkg) { +const bin = BINARIES[key]; +if (!bin) { console.error(`Unsupported platform: ${key}`); - console.error("Install from GitHub Releases: https://github.com/SocketDev/socket-patch/releases"); process.exit(1); } -const binName = process.platform === "win32" ? "socket-patch.exe" : "socket-patch"; -let binPath; -try { - binPath = path.join(path.dirname(require.resolve(`${pkg}/package.json`)), "bin", binName); -} catch { - console.error(`Could not find ${pkg}. Your platform may not be supported.`); - process.exit(1); -} - -const result = require("child_process").spawnSync(binPath, process.argv.slice(2), { +const binPath = path.join(__dirname, bin); +const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit", env: process.env, }); diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index ec1fbc3..9c2c956 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -1,17 +1,10 @@ { "name": "@socketsecurity/socket-patch", - "version": "0.0.0", + "version": "1.2.0", "description": "CLI tool for applying security patches to dependencies", "bin": { "socket-patch": "bin/socket-patch" }, - "optionalDependencies": { - "@socketsecurity/socket-patch-darwin-arm64": "0.0.0", - "@socketsecurity/socket-patch-darwin-x64": "0.0.0", - "@socketsecurity/socket-patch-linux-x64": "0.0.0", - "@socketsecurity/socket-patch-linux-arm64": "0.0.0", - "@socketsecurity/socket-patch-win32-x64": "0.0.0" - }, "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json deleted file mode 100644 index 62cc681..0000000 --- a/package.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "name": "@socketsecurity/socket-patch", - "version": "1.2.0", - "packageManager": "pnpm@10.16.1", - "description": "CLI tool for applying security patches to dependencies", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "socket-patch": "dist/cli.js" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "import": "./dist/index.js" - }, - "./schema": { - "types": "./dist/schema/manifest-schema.d.ts", - "require": "./dist/schema/manifest-schema.js", - "import": "./dist/schema/manifest-schema.js" - }, - "./hash": { - "types": "./dist/hash/git-sha256.d.ts", - "require": "./dist/hash/git-sha256.js", - "import": "./dist/hash/git-sha256.js" - }, - "./patch": { - "types": "./dist/patch/apply.d.ts", - "require": "./dist/patch/apply.js", - "import": "./dist/patch/apply.js" - }, - "./manifest/operations": { - "types": "./dist/manifest/operations.d.ts", - "require": "./dist/manifest/operations.js", - "import": "./dist/manifest/operations.js" - }, - "./manifest/recovery": { - "types": "./dist/manifest/recovery.d.ts", - "require": "./dist/manifest/recovery.js", - "import": "./dist/manifest/recovery.js" - }, - "./constants": { - "types": "./dist/constants.d.ts", - "require": "./dist/constants.js", - "import": "./dist/constants.js" - }, - "./package-json": { - "types": "./dist/package-json/index.d.ts", - "require": "./dist/package-json/index.js", - "import": "./dist/package-json/index.js" - }, - "./run": { - "types": "./dist/run.d.ts", - "require": "./dist/run.js", - "import": "./dist/run.js" - } - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch", - "patch": "node dist/cli.js", - "lint": "oxlint -c ./.oxlintrc.json --tsconfig ./tsconfig.json --deny-warnings", - "lint:fix": "pnpm run lint --fix && pnpm run lint:fix:fast", - "lint:fix:fast": "biome format --write", - "test": "pnpm run build && node --test dist/**/*.test.js", - "test:unit": "pnpm run build && node --test --test-reporter=spec dist/**/*.test.js", - "prepublishOnly": "tsc", - "publish:ci": "npm publish --provenance --access public" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "keywords": [ - "security", - "patch", - "cli", - "dependencies" - ], - "author": "Socket Security", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/SocketDev/socket-patch" - }, - "dependencies": { - "yargs": "^17.7.2", - "zod": "^3.24.4" - }, - "devDependencies": { - "@biomejs/biome": "^2.1.2", - "@types/node": "^20.0.0", - "@types/yargs": "^17.0.32", - "oxlint": "^1.15.0", - "typescript": "^5.3.0" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 4108d26..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,365 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - yargs: - specifier: ^17.7.2 - version: 17.7.2 - zod: - specifier: ^3.24.4 - version: 3.25.76 - devDependencies: - '@biomejs/biome': - specifier: ^2.1.2 - version: 2.3.5 - '@types/node': - specifier: ^20.0.0 - version: 20.19.25 - '@types/yargs': - specifier: ^17.0.32 - version: 17.0.35 - oxlint: - specifier: ^1.15.0 - version: 1.28.0 - typescript: - specifier: ^5.3.0 - version: 5.9.3 - -packages: - - '@biomejs/biome@2.3.5': - resolution: {integrity: sha512-HvLhNlIlBIbAV77VysRIBEwp55oM/QAjQEin74QQX9Xb259/XP/D5AGGnZMOyF1el4zcvlNYYR3AyTMUV3ILhg==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.3.5': - resolution: {integrity: sha512-fLdTur8cJU33HxHUUsii3GLx/TR0BsfQx8FkeqIiW33cGMtUD56fAtrh+2Fx1uhiCsVZlFh6iLKUU3pniZREQw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.3.5': - resolution: {integrity: sha512-qpT8XDqeUlzrOW8zb4k3tjhT7rmvVRumhi2657I2aGcY4B+Ft5fNwDdZGACzn8zj7/K1fdWjgwYE3i2mSZ+vOA==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.3.5': - resolution: {integrity: sha512-eGUG7+hcLgGnMNl1KHVZUYxahYAhC462jF/wQolqu4qso2MSk32Q+QrpN7eN4jAHAg7FUMIo897muIhK4hXhqg==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-arm64@2.3.5': - resolution: {integrity: sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - - '@biomejs/cli-linux-x64-musl@2.3.5': - resolution: {integrity: sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-linux-x64@2.3.5': - resolution: {integrity: sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - - '@biomejs/cli-win32-arm64@2.3.5': - resolution: {integrity: sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.3.5': - resolution: {integrity: sha512-nUmR8gb6yvrKhtRgzwo/gDimPwnO5a4sCydf8ZS2kHIJhEmSmk+STsusr1LHTuM//wXppBawvSQi2xFXJCdgKQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@oxlint/darwin-arm64@1.28.0': - resolution: {integrity: sha512-H7J41/iKbgm7tTpdSnA/AtjEAhxyzNzCMKWtKU5wDuP2v39jrc3fasQEJruk6hj1YXPbJY4N+1nK/jE27GMGDQ==} - cpu: [arm64] - os: [darwin] - - '@oxlint/darwin-x64@1.28.0': - resolution: {integrity: sha512-bGsSDEwpyYzNc6FIwhTmbhSK7piREUjMlmWBt7eoR3ract0+RfhZYYG4se1Ngs+4WOFC0B3gbv23fyF+cnbGGQ==} - cpu: [x64] - os: [darwin] - - '@oxlint/linux-arm64-gnu@1.28.0': - resolution: {integrity: sha512-eNH/evMpV3xAA4jIS8dMLcGkM/LK0WEHM0RO9bxrHPAwfS72jhyPJtd0R7nZhvhG6U1bhn5jhoXbk1dn27XIAQ==} - cpu: [arm64] - os: [linux] - - '@oxlint/linux-arm64-musl@1.28.0': - resolution: {integrity: sha512-ickvpcekNeRLND3llndiZOtJBb6LDZqNnZICIDkovURkOIWPGJGmAxsHUOI6yW6iny9gLmIEIGl/c1b5nFk6Ag==} - cpu: [arm64] - os: [linux] - - '@oxlint/linux-x64-gnu@1.28.0': - resolution: {integrity: sha512-DkgAh4LQ8NR3DwTT7/LGMhaMau0RtZkih91Ez5Usk7H7SOxo1GDi84beE7it2Q+22cAzgY4hbw3c6svonQTjxg==} - cpu: [x64] - os: [linux] - - '@oxlint/linux-x64-musl@1.28.0': - resolution: {integrity: sha512-VBnMi3AJ2w5p/kgeyrjcGOKNY8RzZWWvlGHjCJwzqPgob4MXu6T+5Yrdi7EVJyIlouL8E3LYPYjmzB9NBi9gZw==} - cpu: [x64] - os: [linux] - - '@oxlint/win32-arm64@1.28.0': - resolution: {integrity: sha512-tomhIks+4dKs8axB+s4GXHy+ZWXhUgptf1XnG5cZg8CzRfX4JFX9k8l2fPUgFwytWnyyvZaaXLRPWGzoZ6yoHQ==} - cpu: [arm64] - os: [win32] - - '@oxlint/win32-x64@1.28.0': - resolution: {integrity: sha512-4+VO5P/UJ2nq9sj6kQToJxFy5cKs7dGIN2DiUSQ7cqyUi7EKYNQKe+98HFcDOjtm33jQOQnc4kw8Igya5KPozg==} - cpu: [x64] - os: [win32] - - '@types/node@20.19.25': - resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - oxlint@1.28.0: - resolution: {integrity: sha512-gE97d0BcIlTTSJrim395B49mIbQ9VO8ZVoHdWai7Svl+lEeUAyCLTN4d7piw1kcB8VfgTp1JFVlAvMPD9GewMA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.4.0' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@biomejs/biome@2.3.5': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.5 - '@biomejs/cli-darwin-x64': 2.3.5 - '@biomejs/cli-linux-arm64': 2.3.5 - '@biomejs/cli-linux-arm64-musl': 2.3.5 - '@biomejs/cli-linux-x64': 2.3.5 - '@biomejs/cli-linux-x64-musl': 2.3.5 - '@biomejs/cli-win32-arm64': 2.3.5 - '@biomejs/cli-win32-x64': 2.3.5 - - '@biomejs/cli-darwin-arm64@2.3.5': - optional: true - - '@biomejs/cli-darwin-x64@2.3.5': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.3.5': - optional: true - - '@biomejs/cli-linux-arm64@2.3.5': - optional: true - - '@biomejs/cli-linux-x64-musl@2.3.5': - optional: true - - '@biomejs/cli-linux-x64@2.3.5': - optional: true - - '@biomejs/cli-win32-arm64@2.3.5': - optional: true - - '@biomejs/cli-win32-x64@2.3.5': - optional: true - - '@oxlint/darwin-arm64@1.28.0': - optional: true - - '@oxlint/darwin-x64@1.28.0': - optional: true - - '@oxlint/linux-arm64-gnu@1.28.0': - optional: true - - '@oxlint/linux-arm64-musl@1.28.0': - optional: true - - '@oxlint/linux-x64-gnu@1.28.0': - optional: true - - '@oxlint/linux-x64-musl@1.28.0': - optional: true - - '@oxlint/win32-arm64@1.28.0': - optional: true - - '@oxlint/win32-x64@1.28.0': - optional: true - - '@types/node@20.19.25': - dependencies: - undici-types: 6.21.0 - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - emoji-regex@8.0.0: {} - - escalade@3.2.0: {} - - get-caller-file@2.0.5: {} - - is-fullwidth-code-point@3.0.0: {} - - oxlint@1.28.0: - optionalDependencies: - '@oxlint/darwin-arm64': 1.28.0 - '@oxlint/darwin-x64': 1.28.0 - '@oxlint/linux-arm64-gnu': 1.28.0 - '@oxlint/linux-arm64-musl': 1.28.0 - '@oxlint/linux-x64-gnu': 1.28.0 - '@oxlint/linux-x64-musl': 1.28.0 - '@oxlint/win32-arm64': 1.28.0 - '@oxlint/win32-x64': 1.28.0 - - require-directory@2.1.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - zod@3.25.76: {} diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 8ebd664..7369ea2 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -9,38 +9,13 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$REPO_ROOT/Cargo.toml" rm -f "$REPO_ROOT/Cargo.toml.bak" -PLATFORM_PKGS=( - "socket-patch-darwin-arm64" - "socket-patch-darwin-x64" - "socket-patch-linux-x64" - "socket-patch-linux-arm64" - "socket-patch-win32-x64" -) - -# Update each platform package version -for pkg in "${PLATFORM_PKGS[@]}"; do - pkg_json="$REPO_ROOT/npm/$pkg/package.json" - tmp=$(mktemp) - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('$pkg_json', 'utf8')); - pkg.version = '$VERSION'; - fs.writeFileSync('$pkg_json', JSON.stringify(pkg, null, 2) + '\n'); - " -done - -# Update root wrapper package version + optionalDependencies versions -root_json="$REPO_ROOT/npm/socket-patch/package.json" +# Update npm package version +pkg_json="$REPO_ROOT/npm/socket-patch/package.json" node -e " const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('$root_json', 'utf8')); + const pkg = JSON.parse(fs.readFileSync('$pkg_json', 'utf8')); pkg.version = '$VERSION'; - if (pkg.optionalDependencies) { - for (const dep of Object.keys(pkg.optionalDependencies)) { - pkg.optionalDependencies[dep] = '$VERSION'; - } - } - fs.writeFileSync('$root_json', JSON.stringify(pkg, null, 2) + '\n'); + fs.writeFileSync('$pkg_json', JSON.stringify(pkg, null, 2) + '\n'); " echo "Synced version to $VERSION" diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 04f82d1..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node - -import yargs from 'yargs' -import { hideBin } from 'yargs/helpers' -import { applyCommand } from './commands/apply.js' -import { getCommand } from './commands/get.js' -import { listCommand } from './commands/list.js' -import { removeCommand } from './commands/remove.js' -import { rollbackCommand } from './commands/rollback.js' -import { repairCommand } from './commands/repair.js' -import { scanCommand } from './commands/scan.js' -import { setupCommand } from './commands/setup.js' - -async function main(): Promise { - await yargs(hideBin(process.argv)) - .scriptName('socket-patch') - .usage('$0 [options]') - .command(getCommand) - .command(applyCommand) - .command(rollbackCommand) - .command(removeCommand) - .command(listCommand) - .command(scanCommand) - .command(setupCommand) - .command(repairCommand) - .demandCommand(1, 'You must specify a command') - .help() - .alias('h', 'help') - .version() - .alias('v', 'version') - .strict() - .parse() -} - -main().catch((error: Error) => { - console.error('Error:', error.message) - process.exit(1) -}) diff --git a/src/commands/apply-python.test.ts b/src/commands/apply-python.test.ts deleted file mode 100644 index fe81fa6..0000000 --- a/src/commands/apply-python.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - setupTestEnvironment, - computeTestHash, -} from '../test-utils.js' -import { applyPackagePatch } from '../patch/apply.js' -import * as fs from 'fs/promises' - -// Valid UUIDs for testing -const TEST_UUID_PY1 = 'aaaa1111-1111-4111-8111-111111111111' -const TEST_UUID_PY2 = 'aaaa2222-2222-4222-8222-222222222222' -const TEST_UUID_NPM1 = 'bbbb1111-1111-4111-8111-111111111111' - -describe('apply command - Python packages', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('apply-python-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should apply patch to pypi package', async () => { - const beforeContent = 'import os\nprint("vulnerable")\n' - const afterContent = 'import os\nprint("patched")\n' - - const { blobsDir, sitePackagesDir } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test-apply'), - patches: [ - { - purl: 'pkg:pypi/requests@2.28.0', - uuid: TEST_UUID_PY1, - files: { - 'requests/__init__.py': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'before', - }) - - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const result = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0', - sitePackagesDir, - { 'requests/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesPatched.length, 1) - - // Verify file was changed - const content = await fs.readFile( - path.join(sitePackagesDir, 'requests/__init__.py'), - 'utf-8', - ) - assert.equal(content, afterContent) - }) - - it('should apply patches to both pypi and npm packages', async () => { - const pyBefore = 'py_before' - const pyAfter = 'py_after' - const npmBefore = 'npm_before' - const npmAfter = 'npm_after' - - const { blobsDir, nodeModulesDir, sitePackagesDir } = - await setupTestEnvironment({ - testDir: path.join(testDir, 'test-mixed'), - patches: [ - { - purl: 'pkg:pypi/flask@2.3.0', - uuid: TEST_UUID_PY2, - files: { - 'flask/__init__.py': { - beforeContent: pyBefore, - afterContent: pyAfter, - }, - }, - }, - { - purl: 'pkg:npm/lodash@4.17.21', - uuid: TEST_UUID_NPM1, - files: { - 'package/index.js': { - beforeContent: npmBefore, - afterContent: npmAfter, - }, - }, - }, - ], - initialState: 'before', - }) - - // Apply Python patch - const pyResult = await applyPackagePatch( - 'pkg:pypi/flask@2.3.0', - sitePackagesDir, - { - 'flask/__init__.py': { - beforeHash: computeTestHash(pyBefore), - afterHash: computeTestHash(pyAfter), - }, - }, - blobsDir, - false, - ) - assert.equal(pyResult.success, true) - - // Apply npm patch - const npmPkgDir = path.join(nodeModulesDir, 'lodash') - const npmResult = await applyPackagePatch( - 'pkg:npm/lodash@4.17.21', - npmPkgDir, - { - 'package/index.js': { - beforeHash: computeTestHash(npmBefore), - afterHash: computeTestHash(npmAfter), - }, - }, - blobsDir, - false, - ) - assert.equal(npmResult.success, true) - }) - - it('should skip uninstalled pypi package with not-found status', async () => { - const beforeContent = 'original' - const afterContent = 'patched' - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const { blobsDir, sitePackagesDir } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test-uninstalled'), - patches: [], - initialState: 'before', - }) - - // Apply to a file that doesn't exist in site-packages - const result = await applyPackagePatch( - 'pkg:pypi/nonexistent@1.0.0', - sitePackagesDir, - { 'nonexistent/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, false) - assert.ok(result.error?.includes('not-found') || result.error?.includes('File not found')) - }) - - it('should not modify pypi files in dry-run mode', async () => { - const beforeContent = 'import six\noriginal = True\n' - const afterContent = 'import six\noriginal = False\n' - - const { blobsDir, sitePackagesDir } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test-dryrun'), - patches: [ - { - purl: 'pkg:pypi/six@1.16.0', - uuid: TEST_UUID_PY1, - files: { - 'six.py': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'before', - }) - - const result = await applyPackagePatch( - 'pkg:pypi/six@1.16.0', - sitePackagesDir, - { - 'six.py': { - beforeHash: computeTestHash(beforeContent), - afterHash: computeTestHash(afterContent), - }, - }, - blobsDir, - true, // dry-run - ) - - assert.equal(result.success, true) - assert.equal(result.filesPatched.length, 0) - - // File should be unchanged - const content = await fs.readFile( - path.join(sitePackagesDir, 'six.py'), - 'utf-8', - ) - assert.equal(content, beforeContent) - }) - - it('should skip already-patched pypi package', async () => { - const beforeContent = 'original_code' - const afterContent = 'patched_code' - - const { blobsDir, sitePackagesDir } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test-already'), - patches: [ - { - purl: 'pkg:pypi/requests@2.28.0', - uuid: TEST_UUID_PY1, - files: { - 'requests/__init__.py': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'after', // Start in patched state - }) - - const result = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0', - sitePackagesDir, - { - 'requests/__init__.py': { - beforeHash: computeTestHash(beforeContent), - afterHash: computeTestHash(afterContent), - }, - }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesPatched.length, 0, 'No files should be patched') - assert.equal( - result.filesVerified[0].status, - 'already-patched', - ) - }) -}) diff --git a/src/commands/apply-qualifier-fallback.test.ts b/src/commands/apply-qualifier-fallback.test.ts deleted file mode 100644 index e326818..0000000 --- a/src/commands/apply-qualifier-fallback.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - createTestPythonPackage, - writeTestBlobs, - computeTestHash, -} from '../test-utils.js' -import { applyPackagePatch, verifyFilePatch } from '../patch/apply.js' - -describe('apply command - qualifier variant fallback', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('apply-qualifier-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should apply first matching variant when hash matches', async () => { - const fileContent = 'original content v1' - const patchedContent = 'patched content v1' - const beforeHash = computeTestHash(fileContent) - const afterHash = computeTestHash(patchedContent) - - // Setup: file on disk matches variant 1 - const dir = path.join(testDir, 'test-first') - const sp = path.join(dir, 'site-packages') - await createTestPythonPackage(sp, 'requests', '2.28.0', { - 'requests/__init__.py': fileContent, - }) - - const blobsDir = path.join(dir, 'blobs') - await writeTestBlobs(blobsDir, { - [beforeHash]: fileContent, - [afterHash]: patchedContent, - }) - - const result = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0?artifact_id=aaa', - sp, - { 'requests/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesPatched.length, 1) - - const content = await fs.readFile( - path.join(sp, 'requests/__init__.py'), - 'utf-8', - ) - assert.equal(content, patchedContent) - }) - - it('should try second variant when first hash mismatches', async () => { - const variant1Before = 'variant 1 before' - const variant2Before = 'variant 2 before - actual file content' - const variant2After = 'variant 2 after' - - // The file on disk matches variant 2, not variant 1 - const dir = path.join(testDir, 'test-second') - const sp = path.join(dir, 'site-packages') - await createTestPythonPackage(sp, 'requests', '2.28.0', { - 'requests/__init__.py': variant2Before, - }) - - const blobsDir = path.join(dir, 'blobs') - const v2BeforeHash = computeTestHash(variant2Before) - const v2AfterHash = computeTestHash(variant2After) - await writeTestBlobs(blobsDir, { - [computeTestHash(variant1Before)]: variant1Before, - [v2BeforeHash]: variant2Before, - [v2AfterHash]: variant2After, - }) - - // First check variant 1 - should mismatch - const verify1 = await verifyFilePatch( - sp, - 'requests/__init__.py', - { - beforeHash: computeTestHash(variant1Before), - afterHash: computeTestHash('variant 1 after'), - }, - ) - assert.equal(verify1.status, 'hash-mismatch', 'Variant 1 should mismatch') - - // Then variant 2 - should be ready - const verify2 = await verifyFilePatch( - sp, - 'requests/__init__.py', - { beforeHash: v2BeforeHash, afterHash: v2AfterHash }, - ) - assert.equal(verify2.status, 'ready', 'Variant 2 should be ready') - - // Apply variant 2 - const result = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0?artifact_id=bbb', - sp, - { 'requests/__init__.py': { beforeHash: v2BeforeHash, afterHash: v2AfterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - const content = await fs.readFile( - path.join(sp, 'requests/__init__.py'), - 'utf-8', - ) - assert.equal(content, variant2After) - }) - - it('should fail when no variant matches', async () => { - const fileContent = 'completely different content' - const dir = path.join(testDir, 'test-nomatch') - const sp = path.join(dir, 'site-packages') - await createTestPythonPackage(sp, 'requests', '2.28.0', { - 'requests/__init__.py': fileContent, - }) - - const blobsDir = path.join(dir, 'blobs') - await fs.mkdir(blobsDir, { recursive: true }) - - // Both variants have mismatching beforeHash - const verify = await verifyFilePatch( - sp, - 'requests/__init__.py', - { - beforeHash: computeTestHash('wrong before 1'), - afterHash: computeTestHash('wrong after 1'), - }, - ) - - assert.equal(verify.status, 'hash-mismatch', 'No variant should match') - }) - - it('should skip second variant after first succeeds (appliedBasePurls)', async () => { - const beforeContent = 'original' - const afterContent = 'patched' - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const dir = path.join(testDir, 'test-dedup') - const sp = path.join(dir, 'site-packages') - await createTestPythonPackage(sp, 'requests', '2.28.0', { - 'requests/__init__.py': beforeContent, - }) - - const blobsDir = path.join(dir, 'blobs') - await writeTestBlobs(blobsDir, { - [beforeHash]: beforeContent, - [afterHash]: afterContent, - }) - - // Apply first variant - const result1 = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0?artifact_id=aaa', - sp, - { 'requests/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - assert.equal(result1.success, true) - - // After first variant succeeds, applying same base PURL should see already-patched - const result2 = await applyPackagePatch( - 'pkg:pypi/requests@2.28.0?artifact_id=bbb', - sp, - { 'requests/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - assert.equal(result2.success, true) - assert.equal(result2.filesPatched.length, 0, 'Should not re-patch') - assert.equal(result2.filesVerified[0].status, 'already-patched') - }) - - it('should find package via base PURL for rollback', async () => { - // This tests that the rollback command correctly maps - // qualified PURL back to the base PURL for package lookup - const beforeContent = 'rollback_before' - const afterContent = 'rollback_after' - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const dir = path.join(testDir, 'test-rollback-map') - const sp = path.join(dir, 'site-packages') - await createTestPythonPackage(sp, 'requests', '2.28.0', { - 'requests/__init__.py': afterContent, // Start in patched state - }) - - const blobsDir = path.join(dir, 'blobs') - await writeTestBlobs(blobsDir, { - [beforeHash]: beforeContent, - [afterHash]: afterContent, - }) - - // Import rollback function - const { rollbackPackagePatch } = await import('../patch/rollback.js') - - const result = await rollbackPackagePatch( - 'pkg:pypi/requests@2.28.0?artifact_id=aaa', - sp, - { 'requests/__init__.py': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesRolledBack.length, 1) - - const content = await fs.readFile( - path.join(sp, 'requests/__init__.py'), - 'utf-8', - ) - assert.equal(content, beforeContent) - }) -}) diff --git a/src/commands/apply.ts b/src/commands/apply.ts deleted file mode 100644 index 7e47c09..0000000 --- a/src/commands/apply.ts +++ /dev/null @@ -1,422 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { CommandModule } from 'yargs' -import { - PatchManifestSchema, - DEFAULT_PATCH_MANIFEST_PATH, -} from '../schema/manifest-schema.js' -import { applyPackagePatch } from '../patch/apply.js' -import type { ApplyResult } from '../patch/apply.js' -import { - cleanupUnusedBlobs, - formatCleanupResult, -} from '../utils/cleanup-blobs.js' -import { - getMissingBlobs, - fetchMissingBlobs, - formatFetchResult, -} from '../utils/blob-fetcher.js' -import { NpmCrawler, PythonCrawler } from '../crawlers/index.js' -import { - isPyPIPurl, - isNpmPurl, - stripPurlQualifiers, -} from '../utils/purl-utils.js' -import { verifyFilePatch } from '../patch/apply.js' -import { - trackPatchApplied, - trackPatchApplyFailed, -} from '../utils/telemetry.js' - -interface ApplyArgs { - cwd: string - 'dry-run': boolean - silent: boolean - 'manifest-path': string - offline: boolean - global: boolean - 'global-prefix'?: string - ecosystems?: string[] -} - -async function applyPatches( - cwd: string, - manifestPath: string, - dryRun: boolean, - silent: boolean, - offline: boolean, - useGlobal: boolean, - globalPrefix?: string, - ecosystems?: string[], -): Promise<{ success: boolean; results: ApplyResult[] }> { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - // Find .socket directory (contains blobs) - const socketDir = path.dirname(manifestPath) - const blobsPath = path.join(socketDir, 'blobs') - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - // Check for and download missing blobs (unless offline) - const missingBlobs = await getMissingBlobs(manifest, blobsPath) - if (missingBlobs.size > 0) { - if (offline) { - if (!silent) { - console.error( - `Error: ${missingBlobs.size} blob(s) are missing and --offline mode is enabled.`, - ) - console.error('Run "socket-patch repair" to download missing blobs.') - } - return { success: false, results: [] } - } - - if (!silent) { - console.log(`Downloading ${missingBlobs.size} missing blob(s)...`) - } - - const fetchResult = await fetchMissingBlobs(manifest, blobsPath, undefined, { - onProgress: silent - ? undefined - : (hash, index, total) => { - process.stdout.write( - `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), - ) - }, - }) - - if (!silent) { - // Clear progress line - process.stdout.write('\r' + ' '.repeat(60) + '\r') - console.log(formatFetchResult(fetchResult)) - } - - if (fetchResult.failed > 0) { - if (!silent) { - console.error('Some blobs could not be downloaded. Cannot apply patches.') - } - return { success: false, results: [] } - } - } - - // Partition manifest PURLs by ecosystem - const manifestPurls = Object.keys(manifest.patches) - let npmPurls = manifestPurls.filter(p => isNpmPurl(p)) - let pypiPurls = manifestPurls.filter(p => isPyPIPurl(p)) - - // Filter by ecosystem if specified - if (ecosystems && ecosystems.length > 0) { - if (!ecosystems.includes('npm')) npmPurls = [] - if (!ecosystems.includes('pypi')) pypiPurls = [] - } - - const crawlerOptions = { - cwd, - global: useGlobal, - globalPrefix, - } - - // allPackages maps purl -> package path on disk - const allPackages = new Map() - - // Find npm packages - if (npmPurls.length > 0) { - const npmCrawler = new NpmCrawler() - try { - const nodeModulesPaths = await npmCrawler.getNodeModulesPaths(crawlerOptions) - if ((useGlobal || globalPrefix) && !silent && nodeModulesPaths.length > 0) { - console.log(`Using global npm packages at: ${nodeModulesPaths[0]}`) - } - for (const nmPath of nodeModulesPaths) { - const packages = await npmCrawler.findByPurls(nmPath, npmPurls) - for (const [purl, location] of packages) { - if (!allPackages.has(purl)) { - allPackages.set(purl, location.path) - } - } - } - } catch (error) { - if (!silent) { - console.error('Failed to find npm packages:', error instanceof Error ? error.message : String(error)) - } - } - } - - // Find Python packages - if (pypiPurls.length > 0) { - const pythonCrawler = new PythonCrawler() - try { - // Strip qualifiers for on-disk lookup - const basePypiPurls = [...new Set(pypiPurls.map(stripPurlQualifiers))] - const sitePackagesPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - for (const spPath of sitePackagesPaths) { - const packages = await pythonCrawler.findByPurls(spPath, basePypiPurls) - for (const [purl, location] of packages) { - if (!allPackages.has(purl)) { - allPackages.set(purl, location.path) - } - } - } - } catch (error) { - if (!silent) { - console.error('Failed to find Python packages:', error instanceof Error ? error.message : String(error)) - } - } - } - - if (allPackages.size === 0 && npmPurls.length === 0 && pypiPurls.length === 0) { - if (!silent) { - console.error(useGlobal || globalPrefix ? 'No global packages found' : 'No package directories found') - } - return { success: false, results: [] } - } - - if (allPackages.size === 0) { - if (!silent) { - console.log('No packages found that match available patches') - } - return { success: true, results: [] } - } - - // Apply patches to each package - const results: ApplyResult[] = [] - let hasErrors = false - - // Group pypi manifest PURLs by their base (qualifier-stripped) PURL - const pypiQualifiedGroups = new Map() - for (const purl of pypiPurls) { - const base = stripPurlQualifiers(purl) - const group = pypiQualifiedGroups.get(base) - if (group) { - group.push(purl) - } else { - pypiQualifiedGroups.set(base, [purl]) - } - } - - // Track which base pypi PURLs have been successfully patched - const appliedBasePurls = new Set() - - for (const [purl, pkgPath] of allPackages) { - if (isPyPIPurl(purl)) { - // For pypi PURLs, try each qualified variant and use hash verification - const basePurl = stripPurlQualifiers(purl) - if (appliedBasePurls.has(basePurl)) continue - - const variants = pypiQualifiedGroups.get(basePurl) ?? [basePurl] - let applied = false - - for (const variantPurl of variants) { - const patch = manifest.patches[variantPurl] - if (!patch) continue - - // Check if this variant's beforeHash matches the file on disk - const firstFile = Object.entries(patch.files)[0] - if (firstFile) { - const [fileName, fileInfo] = firstFile - const verify = await verifyFilePatch(pkgPath, fileName, fileInfo) - if (verify.status === 'hash-mismatch') { - // This variant doesn't match, try next - continue - } - } - - const result = await applyPackagePatch( - variantPurl, - pkgPath, - patch.files, - blobsPath, - dryRun, - ) - results.push(result) - - if (result.success) { - applied = true - appliedBasePurls.add(basePurl) - break - } - } - - if (!applied) { - hasErrors = true - if (!silent) { - console.error(`Failed to patch ${basePurl}: no matching variant found`) - } - } - } else { - // npm PURLs: direct lookup - const patch = manifest.patches[purl] - if (!patch) continue - - const result = await applyPackagePatch( - purl, - pkgPath, - patch.files, - blobsPath, - dryRun, - ) - - results.push(result) - - if (!result.success) { - hasErrors = true - if (!silent) { - console.error(`Failed to patch ${purl}: ${result.error}`) - } - } - } - } - - // Clean up unused blobs after applying patches - if (!silent) { - const cleanupResult = await cleanupUnusedBlobs(manifest, blobsPath, dryRun) - if (cleanupResult.blobsRemoved > 0) { - console.log(`\n${formatCleanupResult(cleanupResult, dryRun)}`) - } - } - - return { success: !hasErrors, results } -} - -export const applyCommand: CommandModule<{}, ApplyArgs> = { - command: 'apply', - describe: 'Apply security patches to dependencies', - builder: yargs => { - return yargs - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('dry-run', { - alias: 'd', - describe: 'Verify patches can be applied without modifying files', - type: 'boolean', - default: false, - }) - .option('silent', { - alias: 's', - describe: 'Only output errors', - type: 'boolean', - default: false, - }) - .option('manifest-path', { - alias: 'm', - describe: 'Path to patch manifest file', - type: 'string', - default: DEFAULT_PATCH_MANIFEST_PATH, - }) - .option('offline', { - describe: 'Do not download missing blobs, fail if any are missing', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Apply patches to globally installed npm packages', - type: 'boolean', - default: false, - }) - .option('global-prefix', { - describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', - type: 'string', - }) - .option('ecosystems', { - describe: 'Restrict patching to specific ecosystems (comma-separated)', - type: 'array', - choices: ['npm', 'pypi'], - }) - .example('$0 apply', 'Apply all patches to local packages') - .example('$0 apply --global', 'Apply patches to global npm packages') - .example('$0 apply --global-prefix /custom/path', 'Apply patches to custom global location') - .example('$0 apply --dry-run', 'Preview patches without applying') - }, - handler: async argv => { - // Get API credentials for authenticated telemetry (optional). - const apiToken = process.env['SOCKET_API_TOKEN'] - const orgSlug = process.env['SOCKET_ORG_SLUG'] - - try { - const manifestPath = path.isAbsolute(argv['manifest-path']) - ? argv['manifest-path'] - : path.join(argv.cwd, argv['manifest-path']) - - // Check if manifest exists - exit successfully if no .socket folder is set up - try { - await fs.access(manifestPath) - } catch { - // No manifest means no patches to apply - this is a successful no-op - if (!argv.silent) { - console.log('No .socket folder found, skipping patch application.') - } - process.exit(0) - } - - const { success, results } = await applyPatches( - argv.cwd, - manifestPath, - argv['dry-run'], - argv.silent, - argv.offline, - argv.global, - argv['global-prefix'], - argv.ecosystems, - ) - - // Print results if not silent - if (!argv.silent && results.length > 0) { - const patched = results.filter(r => r.success) - const alreadyPatched = results.filter(r => - r.filesVerified.every(f => f.status === 'already-patched'), - ) - - if (argv['dry-run']) { - console.log(`\nPatch verification complete:`) - console.log(` ${patched.length} package(s) can be patched`) - if (alreadyPatched.length > 0) { - console.log(` ${alreadyPatched.length} package(s) already patched`) - } - } else { - console.log(`\nPatched packages:`) - for (const result of patched) { - if (result.filesPatched.length > 0) { - console.log(` ${result.packageKey}`) - } else if ( - result.filesVerified.every(f => f.status === 'already-patched') - ) { - console.log(` ${result.packageKey} (already patched)`) - } - } - } - } - - // Track telemetry event. - const patchedCount = results.filter(r => r.success && r.filesPatched.length > 0).length - if (success) { - await trackPatchApplied(patchedCount, argv['dry-run'], apiToken, orgSlug) - } else { - await trackPatchApplyFailed( - new Error('One or more patches failed to apply'), - argv['dry-run'], - apiToken, - orgSlug, - ) - } - - process.exit(success ? 0 : 1) - } catch (err) { - // Track telemetry for unexpected errors. - const error = err instanceof Error ? err : new Error(String(err)) - await trackPatchApplyFailed(error, argv['dry-run'], apiToken, orgSlug) - - if (!argv.silent) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - } - process.exit(1) - } - }, -} diff --git a/src/commands/download.test.ts b/src/commands/download.test.ts deleted file mode 100644 index 18b70d2..0000000 --- a/src/commands/download.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - computeTestHash, -} from '../test-utils.js' -import type { PatchResponse } from '../utils/api-client.js' - -/** - * Simulates the savePatch function behavior to test blob saving logic - * This mirrors the logic in download.ts - * NOTE: Only saves afterHash blobs - beforeHash blobs are downloaded on-demand during rollback - */ -async function simulateSavePatch( - patch: PatchResponse, - blobsDir: string, -): Promise> { - const files: Record = {} - - for (const [filePath, fileInfo] of Object.entries(patch.files)) { - if (fileInfo.afterHash) { - files[filePath] = { - beforeHash: fileInfo.beforeHash, - afterHash: fileInfo.afterHash, - } - } - - // Save after blob content if provided - // Note: beforeHash blobs are NOT saved here - they are downloaded on-demand during rollback - if (fileInfo.blobContent && fileInfo.afterHash) { - const blobPath = path.join(blobsDir, fileInfo.afterHash) - const blobBuffer = Buffer.from(fileInfo.blobContent, 'base64') - await fs.writeFile(blobPath, blobBuffer) - } - } - - return files -} - -describe('download command', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('download-test-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - describe('savePatch blob storage', () => { - it('should only save after blobs (before blobs are downloaded on-demand)', async () => { - const blobsDir = path.join(testDir, 'blobs1') - await fs.mkdir(blobsDir, { recursive: true }) - - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const patch: PatchResponse = { - uuid: 'test-uuid-1', - purl: 'pkg:npm/test@1.0.0', - publishedAt: new Date().toISOString(), - files: { - 'package/index.js': { - beforeHash, - afterHash, - blobContent: Buffer.from(afterContent).toString('base64'), - beforeBlobContent: Buffer.from(beforeContent).toString('base64'), - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - } - - await simulateSavePatch(patch, blobsDir) - - // Verify only after blob is saved (before blobs are downloaded on-demand during rollback) - const beforeBlobPath = path.join(blobsDir, beforeHash) - const afterBlobPath = path.join(blobsDir, afterHash) - - const afterBlobContent = await fs.readFile(afterBlobPath, 'utf-8') - assert.equal(afterBlobContent, afterContent) - - // Before blob should NOT exist (downloaded on-demand during rollback) - await assert.rejects( - async () => fs.access(beforeBlobPath), - /ENOENT/, - ) - }) - - it('should only save after blob when before blob content is not provided', async () => { - const blobsDir = path.join(testDir, 'blobs2') - await fs.mkdir(blobsDir, { recursive: true }) - - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const patch: PatchResponse = { - uuid: 'test-uuid-2', - purl: 'pkg:npm/test@1.0.0', - publishedAt: new Date().toISOString(), - files: { - 'package/index.js': { - beforeHash, - afterHash, - blobContent: Buffer.from(afterContent).toString('base64'), - // beforeBlobContent is NOT provided - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - } - - await simulateSavePatch(patch, blobsDir) - - // Verify only after blob is saved - const afterBlobPath = path.join(blobsDir, afterHash) - const afterBlobContent = await fs.readFile(afterBlobPath, 'utf-8') - assert.equal(afterBlobContent, afterContent) - - // Before blob should not exist - const beforeBlobPath = path.join(blobsDir, beforeHash) - await assert.rejects( - async () => fs.access(beforeBlobPath), - /ENOENT/, - ) - }) - - it('should handle multiple files with blobs (only after blobs saved)', async () => { - const blobsDir = path.join(testDir, 'blobs3') - await fs.mkdir(blobsDir, { recursive: true }) - - const files = { - 'package/index.js': { - before: 'index-before', - after: 'index-after', - }, - 'package/lib/utils.js': { - before: 'utils-before', - after: 'utils-after', - }, - } - - const patch: PatchResponse = { - uuid: 'test-uuid-3', - purl: 'pkg:npm/test@1.0.0', - publishedAt: new Date().toISOString(), - files: {}, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - } - - for (const [filePath, { before, after }] of Object.entries(files)) { - patch.files[filePath] = { - beforeHash: computeTestHash(before), - afterHash: computeTestHash(after), - blobContent: Buffer.from(after).toString('base64'), - beforeBlobContent: Buffer.from(before).toString('base64'), - } - } - - await simulateSavePatch(patch, blobsDir) - - // Verify only after blobs are saved (before blobs are downloaded on-demand) - for (const [, { before, after }] of Object.entries(files)) { - const beforeHash = computeTestHash(before) - const afterHash = computeTestHash(after) - - // After blob should exist - const afterBlobContent = await fs.readFile( - path.join(blobsDir, afterHash), - 'utf-8', - ) - assert.equal(afterBlobContent, after) - - // Before blob should NOT exist - await assert.rejects( - async () => fs.access(path.join(blobsDir, beforeHash)), - /ENOENT/, - ) - } - }) - - it('should handle binary file content (only after blob saved)', async () => { - const blobsDir = path.join(testDir, 'blobs4') - await fs.mkdir(blobsDir, { recursive: true }) - - // Create binary content - const beforeContent = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]) - const afterContent = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xfe]) - - const beforeHash = computeTestHash(beforeContent.toString('binary')) - const afterHash = computeTestHash(afterContent.toString('binary')) - - const patch: PatchResponse = { - uuid: 'test-uuid-4', - purl: 'pkg:npm/test@1.0.0', - publishedAt: new Date().toISOString(), - files: { - 'package/binary.bin': { - beforeHash, - afterHash, - blobContent: afterContent.toString('base64'), - beforeBlobContent: beforeContent.toString('base64'), - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - } - - await simulateSavePatch(patch, blobsDir) - - // Verify only after binary blob is saved - const afterBlobBuffer = await fs.readFile(path.join(blobsDir, afterHash)) - assert.deepEqual(afterBlobBuffer, afterContent) - - // Before blob should NOT exist - await assert.rejects( - async () => fs.access(path.join(blobsDir, beforeHash)), - /ENOENT/, - ) - }) - - it('should deduplicate after blobs with same content', async () => { - const blobsDir = path.join(testDir, 'blobs5') - await fs.mkdir(blobsDir, { recursive: true }) - - // Same after content for two different files (to test deduplication) - const sharedAfterContent = 'shared after content' - const beforeContent1 = 'before1' - const beforeContent2 = 'before2' - - const sharedAfterHash = computeTestHash(sharedAfterContent) - - const patch: PatchResponse = { - uuid: 'test-uuid-5', - purl: 'pkg:npm/test@1.0.0', - publishedAt: new Date().toISOString(), - files: { - 'package/file1.js': { - beforeHash: computeTestHash(beforeContent1), - afterHash: sharedAfterHash, // Same after hash - blobContent: Buffer.from(sharedAfterContent).toString('base64'), - beforeBlobContent: Buffer.from(beforeContent1).toString('base64'), - }, - 'package/file2.js': { - beforeHash: computeTestHash(beforeContent2), - afterHash: sharedAfterHash, // Same after hash - blobContent: Buffer.from(sharedAfterContent).toString('base64'), - beforeBlobContent: Buffer.from(beforeContent2).toString('base64'), - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - } - - await simulateSavePatch(patch, blobsDir) - - // Shared after blob should exist only once (content-addressable) - const blobFiles = await fs.readdir(blobsDir) - const sharedBlobCount = blobFiles.filter(f => f === sharedAfterHash).length - assert.equal(sharedBlobCount, 1) - - // Only 1 blob should be saved (the shared after blob) - // Before blobs are NOT saved - assert.equal(blobFiles.length, 1) - - // Content should be correct - const blobContent = await fs.readFile(path.join(blobsDir, sharedAfterHash), 'utf-8') - assert.equal(blobContent, sharedAfterContent) - }) - }) -}) diff --git a/src/commands/get.ts b/src/commands/get.ts deleted file mode 100644 index 10f7468..0000000 --- a/src/commands/get.ts +++ /dev/null @@ -1,1396 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import * as readline from 'readline' -import * as os from 'os' -import type { CommandModule } from 'yargs' -import { PatchManifestSchema } from '../schema/manifest-schema.js' -import { - getAPIClientFromEnv, - type APIClient, - type PatchResponse, - type PatchSearchResult, - type SearchResponse, -} from '../utils/api-client.js' -import { - cleanupUnusedBlobs, - formatCleanupResult, -} from '../utils/cleanup-blobs.js' -import { NpmCrawler, PythonCrawler, type CrawledPackage } from '../crawlers/index.js' -import { fuzzyMatchPackages, isPurl } from '../utils/fuzzy-match.js' -import { applyPackagePatch, verifyFilePatch } from '../patch/apply.js' -import { rollbackPackagePatch } from '../patch/rollback.js' -import { - getMissingBlobs, - fetchMissingBlobs, - formatFetchResult, -} from '../utils/blob-fetcher.js' -import { - isPyPIPurl, - isNpmPurl, - stripPurlQualifiers, -} from '../utils/purl-utils.js' - -/** - * Represents a package that has available patches with CVE information - */ -interface PackageWithPatchInfo extends CrawledPackage { - /** Available patches for this package */ - patches: PatchSearchResult[] - /** Whether user can access paid patches */ - canAccessPaidPatches: boolean - /** CVE IDs that this package's patches address */ - cveIds: string[] - /** GHSA IDs that this package's patches address */ - ghsaIds: string[] -} - -// Identifier type patterns -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i -const CVE_PATTERN = /^CVE-\d{4}-\d+$/i -const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i - -// Maximum number of packages to check for patches (to limit API queries) -const MAX_PACKAGES_TO_CHECK = 15 - -type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'purl' | 'package' - -/** - * Parse a PURL to extract the package directory path, version, and ecosystem. - * Supports both npm and pypi PURLs. - * @example parsePurl('pkg:npm/lodash@4.17.21') => { packageDir: 'lodash', version: '4.17.21', ecosystem: 'npm' } - * @example parsePurl('pkg:npm/@types/node@20.0.0') => { packageDir: '@types/node', version: '20.0.0', ecosystem: 'npm' } - * @example parsePurl('pkg:pypi/requests@2.28.0') => { packageDir: 'requests', version: '2.28.0', ecosystem: 'pypi' } - */ -function parsePurl(purl: string): { packageDir: string; version: string; ecosystem: 'npm' | 'pypi' } | null { - // Strip qualifiers for parsing - const base = stripPurlQualifiers(purl) - const npmMatch = base.match(/^pkg:npm\/(.+)@([^@]+)$/) - if (npmMatch) return { packageDir: npmMatch[1], version: npmMatch[2], ecosystem: 'npm' } - const pypiMatch = base.match(/^pkg:pypi\/(.+)@([^@]+)$/) - if (pypiMatch) return { packageDir: pypiMatch[1], version: pypiMatch[2], ecosystem: 'pypi' } - return null -} - -/** - * Check which PURLs from search results are actually installed. - * Supports both npm (node_modules) and pypi (site-packages) packages. - * This is O(n) where n = number of unique packages in search results, - * NOT O(m) where m = total packages in node_modules/site-packages. - */ -async function findInstalledPurls( - cwd: string, - purls: string[], - useGlobal: boolean, - globalPrefix?: string, -): Promise> { - const installedPurls = new Set() - - // Partition PURLs by ecosystem - const npmPurls = purls.filter(p => isNpmPurl(p)) - const pypiPurls = purls.filter(p => isPyPIPurl(p)) - - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - - // Check npm PURLs - if (npmPurls.length > 0) { - const npmCrawler = new NpmCrawler() - try { - const nmPaths = await npmCrawler.getNodeModulesPaths(crawlerOptions) - for (const nmPath of nmPaths) { - const packages = await npmCrawler.findByPurls(nmPath, npmPurls) - for (const purl of packages.keys()) { - installedPurls.add(purl) - } - } - } catch { - // npm not available - } - } - - // Check pypi PURLs - if (pypiPurls.length > 0) { - const pythonCrawler = new PythonCrawler() - try { - const basePurls = [...new Set(pypiPurls.map(stripPurlQualifiers))] - const spPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - for (const spPath of spPaths) { - const packages = await pythonCrawler.findByPurls(spPath, basePurls) - for (const basePurl of packages.keys()) { - // Mark all qualified variants of this base PURL as installed - for (const originalPurl of pypiPurls) { - if (stripPurlQualifiers(originalPurl) === basePurl) { - installedPurls.add(originalPurl) - } - } - } - } - } catch { - // python not available - } - } - - return installedPurls -} - -/** - * Check which packages have available patches with CVE fixes. - * Queries the API for each package and returns only those with patches. - * - * @param apiClient - API client to use for queries - * @param orgSlug - Organization slug (or null for public proxy) - * @param packages - Packages to check - * @param onProgress - Optional callback for progress updates - * @returns Packages that have available patches with CVE info - */ -async function findPackagesWithPatches( - apiClient: APIClient, - orgSlug: string | null, - packages: CrawledPackage[], - onProgress?: (checked: number, total: number, current: string) => void, -): Promise { - const packagesWithPatches: PackageWithPatchInfo[] = [] - - for (let i = 0; i < packages.length; i++) { - const pkg = packages[i] - const displayName = pkg.namespace - ? `${pkg.namespace}/${pkg.name}` - : pkg.name - - if (onProgress) { - onProgress(i + 1, packages.length, displayName) - } - - try { - const searchResponse = await apiClient.searchPatchesByPackage( - orgSlug, - pkg.purl, - ) - - const { patches, canAccessPaidPatches } = searchResponse - - // Include all patches (free and paid) - we'll show upgrade CTA for paid patches - if (patches.length === 0) { - continue - } - - // Extract CVE and GHSA IDs from all patches - const cveIds = new Set() - const ghsaIds = new Set() - - for (const patch of patches) { - for (const [vulnId, vulnInfo] of Object.entries(patch.vulnerabilities)) { - // Check if the vulnId itself is a GHSA - if (GHSA_PATTERN.test(vulnId)) { - ghsaIds.add(vulnId) - } - // Add all CVEs associated with this vulnerability - for (const cve of vulnInfo.cves) { - cveIds.add(cve) - } - } - } - - // Only include packages that have CVE fixes - if (cveIds.size === 0 && ghsaIds.size === 0) { - continue - } - - packagesWithPatches.push({ - ...pkg, - patches, - canAccessPaidPatches, - cveIds: Array.from(cveIds).sort(), - ghsaIds: Array.from(ghsaIds).sort(), - }) - } catch { - // Skip packages that fail API lookup (likely network issues) - continue - } - } - - return packagesWithPatches -} - -interface GetArgs { - identifier: string - org?: string - cwd: string - id?: boolean - cve?: boolean - ghsa?: boolean - package?: boolean - yes?: boolean - 'api-url'?: string - 'api-token'?: string - 'no-apply'?: boolean - global?: boolean - 'global-prefix'?: string - 'one-off'?: boolean -} - -/** - * Detect the type of identifier based on its format - */ -function detectIdentifierType(identifier: string): IdentifierType | null { - if (UUID_PATTERN.test(identifier)) { - return 'uuid' - } - if (CVE_PATTERN.test(identifier)) { - return 'cve' - } - if (GHSA_PATTERN.test(identifier)) { - return 'ghsa' - } - if (isPurl(identifier)) { - return 'purl' - } - return null -} - -/** - * Prompt user for confirmation - */ -async function promptConfirmation(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise(resolve => { - rl.question(`${message} [y/N] `, answer => { - rl.close() - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') - }) - }) -} - -/** - * Display packages with available patches and CVE info, prompt user to select one - */ -async function promptSelectPackageWithPatches( - packages: PackageWithPatchInfo[], -): Promise { - console.log('\nPackages with available security patches:\n') - - for (let i = 0; i < packages.length; i++) { - const pkg = packages[i] - const displayName = pkg.namespace - ? `${pkg.namespace}/${pkg.name}` - : pkg.name - - // Build vulnerability summary - const vulnIds = [...pkg.cveIds, ...pkg.ghsaIds] - const vulnSummary = vulnIds.length > 3 - ? `${vulnIds.slice(0, 3).join(', ')} (+${vulnIds.length - 3} more)` - : vulnIds.join(', ') - - // Count free vs paid patches - const freePatches = pkg.patches.filter(p => p.tier === 'free').length - const paidPatches = pkg.patches.filter(p => p.tier === 'paid').length - - // Count patches and show severity info - const severities = new Set() - for (const patch of pkg.patches) { - for (const vuln of Object.values(patch.vulnerabilities)) { - severities.add(vuln.severity) - } - } - const severityList = Array.from(severities).sort((a, b) => { - const order = ['critical', 'high', 'medium', 'low'] - return order.indexOf(a.toLowerCase()) - order.indexOf(b.toLowerCase()) - }) - - // Build patch count string - let patchCountStr = String(freePatches) - if (paidPatches > 0) { - if (pkg.canAccessPaidPatches) { - patchCountStr += `+${paidPatches}` - } else { - patchCountStr += `+\x1b[33m${paidPatches} paid\x1b[0m` - } - } - - console.log(` ${i + 1}. ${displayName}@${pkg.version}`) - console.log(` Patches: ${patchCountStr} | Severity: ${severityList.join(', ')}`) - console.log(` Fixes: ${vulnSummary}`) - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise(resolve => { - rl.question( - `\nSelect a package (1-${packages.length}) or 0 to cancel: `, - answer => { - rl.close() - const selection = parseInt(answer, 10) - if (isNaN(selection) || selection < 1 || selection > packages.length) { - resolve(null) - } else { - resolve(packages[selection - 1]) - } - }, - ) - }) -} - -/** - * Display search results to the user - */ -function displaySearchResults( - patches: PatchSearchResult[], - canAccessPaidPatches: boolean, -): void { - console.log('\nFound patches:\n') - - for (let i = 0; i < patches.length; i++) { - const patch = patches[i] - const tierLabel = patch.tier === 'paid' ? ' [PAID]' : ' [FREE]' - const accessLabel = - patch.tier === 'paid' && !canAccessPaidPatches ? ' (no access)' : '' - - console.log(` ${i + 1}. ${patch.purl}${tierLabel}${accessLabel}`) - console.log(` UUID: ${patch.uuid}`) - if (patch.description) { - const desc = - patch.description.length > 80 - ? patch.description.slice(0, 77) + '...' - : patch.description - console.log(` Description: ${desc}`) - } - - // Show vulnerabilities - const vulnIds = Object.keys(patch.vulnerabilities) - if (vulnIds.length > 0) { - const vulnSummary = vulnIds - .map(id => { - const vuln = patch.vulnerabilities[id] - const cves = vuln.cves.length > 0 ? vuln.cves.join(', ') : id - return `${cves} (${vuln.severity})` - }) - .join(', ') - console.log(` Fixes: ${vulnSummary}`) - } - console.log() - } -} - -/** - * Save a patch to the manifest and blobs directory - * Only saves afterHash blobs - beforeHash blobs are downloaded on-demand during rollback - */ -async function savePatch( - patch: PatchResponse, - manifest: any, - blobsDir: string, -): Promise { - // Check if patch already exists with same UUID - if (manifest.patches[patch.purl]?.uuid === patch.uuid) { - console.log(` [skip] ${patch.purl} (already in manifest)`) - return false - } - - // Save blob contents (only afterHash blobs to save disk space) - const files: Record = {} - for (const [filePath, fileInfo] of Object.entries(patch.files)) { - if (fileInfo.afterHash) { - files[filePath] = { - beforeHash: fileInfo.beforeHash, - afterHash: fileInfo.afterHash, - } - } - - // Save after blob content if provided - // Note: beforeHash blobs are NOT saved here - they are downloaded on-demand during rollback - if (fileInfo.blobContent && fileInfo.afterHash) { - const blobPath = path.join(blobsDir, fileInfo.afterHash) - const blobBuffer = Buffer.from(fileInfo.blobContent, 'base64') - await fs.writeFile(blobPath, blobBuffer) - } - } - - // Add/update patch in manifest - manifest.patches[patch.purl] = { - uuid: patch.uuid, - exportedAt: patch.publishedAt, - files, - vulnerabilities: patch.vulnerabilities, - description: patch.description, - license: patch.license, - tier: patch.tier, - } - - console.log(` [add] ${patch.purl}`) - return true -} - -/** - * Apply patches after downloading - */ -async function applyDownloadedPatches( - cwd: string, - manifestPath: string, - silent: boolean, - useGlobal: boolean, - globalPrefix?: string, -): Promise { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - // Find .socket directory (contains blobs) - const socketDir = path.dirname(manifestPath) - const blobsPath = path.join(socketDir, 'blobs') - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - // Check for and download missing blobs - const missingBlobs = await getMissingBlobs(manifest, blobsPath) - if (missingBlobs.size > 0) { - if (!silent) { - console.log(`Downloading ${missingBlobs.size} missing blob(s)...`) - } - - const fetchResult = await fetchMissingBlobs(manifest, blobsPath, undefined, { - onProgress: silent - ? undefined - : (hash, index, total) => { - process.stdout.write( - `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), - ) - }, - }) - - if (!silent) { - // Clear progress line - process.stdout.write('\r' + ' '.repeat(60) + '\r') - console.log(formatFetchResult(fetchResult)) - } - - if (fetchResult.failed > 0) { - if (!silent) { - console.error('Some blobs could not be downloaded. Cannot apply patches.') - } - return false - } - } - - // Partition manifest PURLs by ecosystem - const manifestPurls = Object.keys(manifest.patches) - const npmPurls = manifestPurls.filter(p => isNpmPurl(p)) - const pypiPurls = manifestPurls.filter(p => isPyPIPurl(p)) - - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - const allPackages = new Map() - - // Find npm packages - if (npmPurls.length > 0) { - const npmCrawler = new NpmCrawler() - try { - const nodeModulesPaths = await npmCrawler.getNodeModulesPaths(crawlerOptions) - for (const nmPath of nodeModulesPaths) { - const packages = await npmCrawler.findByPurls(nmPath, npmPurls) - for (const [purl, location] of packages) { - if (!allPackages.has(purl)) { - allPackages.set(purl, location.path) - } - } - } - } catch (error) { - if (!silent) { - console.error('Failed to find npm packages:', error instanceof Error ? error.message : String(error)) - } - } - } - - // Find Python packages - if (pypiPurls.length > 0) { - const pythonCrawler = new PythonCrawler() - try { - const basePypiPurls = [...new Set(pypiPurls.map(stripPurlQualifiers))] - const sitePackagesPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - for (const spPath of sitePackagesPaths) { - const packages = await pythonCrawler.findByPurls(spPath, basePypiPurls) - for (const [purl, location] of packages) { - if (!allPackages.has(purl)) { - allPackages.set(purl, location.path) - } - } - } - } catch (error) { - if (!silent) { - console.error('Failed to find Python packages:', error instanceof Error ? error.message : String(error)) - } - } - } - - if (allPackages.size === 0) { - if (!silent) { - console.log('No packages found that match available patches') - } - return true - } - - // Group pypi manifest PURLs by base PURL for qualifier fallback - const pypiQualifiedGroups = new Map() - for (const purl of pypiPurls) { - const base = stripPurlQualifiers(purl) - const group = pypiQualifiedGroups.get(base) - if (group) { - group.push(purl) - } else { - pypiQualifiedGroups.set(base, [purl]) - } - } - - // Apply patches to each package - let hasErrors = false - const patchedPackages: string[] = [] - const alreadyPatched: string[] = [] - const appliedBasePurls = new Set() - - for (const [purl, pkgPath] of allPackages) { - if (isPyPIPurl(purl)) { - const basePurl = stripPurlQualifiers(purl) - if (appliedBasePurls.has(basePurl)) continue - - const variants = pypiQualifiedGroups.get(basePurl) ?? [basePurl] - let applied = false - - for (const variantPurl of variants) { - const patch = manifest.patches[variantPurl] - if (!patch) continue - - // Check if this variant's beforeHash matches the file on disk - const firstFile = Object.entries(patch.files)[0] - if (firstFile) { - const [fileName, fileInfo] = firstFile - const verify = await verifyFilePatch(pkgPath, fileName, fileInfo) - if (verify.status === 'hash-mismatch') continue - } - - const result = await applyPackagePatch( - variantPurl, - pkgPath, - patch.files, - blobsPath, - false, - ) - - if (result.success) { - applied = true - appliedBasePurls.add(basePurl) - if (result.filesPatched.length > 0) { - patchedPackages.push(variantPurl) - } else if (result.filesVerified.every(f => f.status === 'already-patched')) { - alreadyPatched.push(variantPurl) - } - break - } - } - - if (!applied) { - hasErrors = true - if (!silent) { - console.error(`Failed to patch ${basePurl}: no matching variant found`) - } - } - } else { - const patch = manifest.patches[purl] - if (!patch) continue - - const result = await applyPackagePatch( - purl, - pkgPath, - patch.files, - blobsPath, - false, - ) - - if (!result.success) { - hasErrors = true - if (!silent) { - console.error(`Failed to patch ${purl}: ${result.error}`) - } - } else if (result.filesPatched.length > 0) { - patchedPackages.push(purl) - } else if (result.filesVerified.every(f => f.status === 'already-patched')) { - alreadyPatched.push(purl) - } - } - } - - // Print results - if (!silent) { - if (patchedPackages.length > 0) { - console.log(`\nPatched packages:`) - for (const pkg of patchedPackages) { - console.log(` ${pkg}`) - } - } - if (alreadyPatched.length > 0) { - console.log(`\nAlready patched:`) - for (const pkg of alreadyPatched) { - console.log(` ${pkg}`) - } - } - } - - return !hasErrors -} - -/** - * Handle one-off patch application (no manifest storage) - */ -async function applyOneOffPatch( - patch: PatchResponse, - useGlobal: boolean, - cwd: string, - silent: boolean, - globalPrefix?: string, -): Promise<{ success: boolean; rollback?: () => Promise }> { - const parsed = parsePurl(patch.purl) - if (!parsed) { - if (!silent) { - console.error(`Invalid PURL format: ${patch.purl}`) - } - return { success: false } - } - - let pkgPath: string - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - - if (parsed.ecosystem === 'pypi') { - // Find the package in Python site-packages - const pythonCrawler = new PythonCrawler() - try { - const basePurl = stripPurlQualifiers(patch.purl) - const spPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - let found = false - pkgPath = '' // Will be set if found - - for (const spPath of spPaths) { - const packages = await pythonCrawler.findByPurls(spPath, [basePurl]) - const pkg = packages.get(basePurl) - if (pkg) { - pkgPath = pkg.path - found = true - break - } - } - - if (!found) { - if (!silent) { - console.error(`Python package not found: ${parsed.packageDir}@${parsed.version}`) - } - return { success: false } - } - } catch (error) { - if (!silent) { - console.error('Failed to find Python packages:', error instanceof Error ? error.message : String(error)) - } - return { success: false } - } - } else { - // npm: Find the package in node_modules - const npmCrawler = new NpmCrawler() - let nodeModulesPath: string - try { - const paths = await npmCrawler.getNodeModulesPaths(crawlerOptions) - nodeModulesPath = paths[0] ?? path.join(cwd, 'node_modules') - } catch (error) { - if (!silent) { - console.error('Failed to find npm packages:', error instanceof Error ? error.message : String(error)) - } - return { success: false } - } - - pkgPath = path.join(nodeModulesPath, parsed.packageDir) - - // Verify npm package exists - try { - const pkgJsonPath = path.join(pkgPath, 'package.json') - const pkgJsonContent = await fs.readFile(pkgJsonPath, 'utf-8') - const pkgJson = JSON.parse(pkgJsonContent) - if (pkgJson.version !== parsed.version) { - if (!silent) { - console.error(`Version mismatch: installed ${pkgJson.version}, patch is for ${parsed.version}`) - } - return { success: false } - } - } catch { - if (!silent) { - console.error(`Package not found: ${parsed.packageDir}`) - } - return { success: false } - } - } - - // Create temporary directory for blobs - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-')) - const tempBlobsDir = path.join(tempDir, 'blobs') - await fs.mkdir(tempBlobsDir, { recursive: true }) - - // Store beforeHash blobs in temp directory for rollback - const beforeBlobs = new Map() - for (const [, fileInfo] of Object.entries(patch.files)) { - if (fileInfo.beforeBlobContent && fileInfo.beforeHash) { - const blobBuffer = Buffer.from(fileInfo.beforeBlobContent, 'base64') - beforeBlobs.set(fileInfo.beforeHash, blobBuffer) - await fs.writeFile(path.join(tempBlobsDir, fileInfo.beforeHash), blobBuffer) - } - if (fileInfo.blobContent && fileInfo.afterHash) { - const blobBuffer = Buffer.from(fileInfo.blobContent, 'base64') - await fs.writeFile(path.join(tempBlobsDir, fileInfo.afterHash), blobBuffer) - } - } - - // Build files record for applyPackagePatch - const files: Record = {} - for (const [filePath, fileInfo] of Object.entries(patch.files)) { - if (fileInfo.beforeHash && fileInfo.afterHash) { - files[filePath] = { - beforeHash: fileInfo.beforeHash, - afterHash: fileInfo.afterHash, - } - } - } - - // Apply the patch - const result = await applyPackagePatch( - patch.purl, - pkgPath, - files, - tempBlobsDir, - false, - ) - - if (!result.success) { - if (!silent) { - console.error(`Failed to patch ${patch.purl}: ${result.error}`) - } - // Clean up temp directory - await fs.rm(tempDir, { recursive: true, force: true }) - return { success: false } - } - - if (!silent) { - if (result.filesPatched.length > 0) { - console.log(`\nPatched ${patch.purl}`) - } else if (result.filesVerified.every(f => f.status === 'already-patched')) { - console.log(`\n${patch.purl} is already patched`) - } - } - - // Return rollback function - const rollback = async () => { - if (!silent) { - console.log(`Rolling back ${patch.purl}...`) - } - const rollbackResult = await rollbackPackagePatch( - patch.purl, - pkgPath, - files, - tempBlobsDir, - false, - ) - if (rollbackResult.success) { - if (!silent) { - console.log(`Rolled back ${patch.purl}`) - } - } else { - if (!silent) { - console.error(`Failed to rollback: ${rollbackResult.error}`) - } - } - // Clean up temp directory - await fs.rm(tempDir, { recursive: true, force: true }) - } - - return { success: true, rollback } -} - -async function getPatches(args: GetArgs): Promise { - const { - identifier, - org: orgSlug, - cwd, - id: forceId, - cve: forceCve, - ghsa: forceGhsa, - package: forcePackage, - yes: skipConfirmation, - 'api-url': apiUrl, - 'api-token': apiToken, - 'no-apply': noApply, - global: useGlobal, - 'global-prefix': globalPrefix, - 'one-off': oneOff, - } = args - - // Override environment variables if CLI options are provided - if (apiUrl) { - process.env.SOCKET_API_URL = apiUrl - } - if (apiToken) { - process.env.SOCKET_API_TOKEN = apiToken - } - - // Get API client (will use public proxy if no token is set) - const { client: apiClient, usePublicProxy } = getAPIClientFromEnv() - - // Validate that org is provided when using authenticated API - if (!usePublicProxy && !orgSlug) { - throw new Error( - '--org is required when using SOCKET_API_TOKEN. Provide an organization slug.', - ) - } - - // The org slug to use (null when using public proxy) - const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null - - // Set up crawlers for package lookups - const npmCrawler = new NpmCrawler() - const pythonCrawler = new PythonCrawler() - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - - // Determine identifier type - let idType: IdentifierType - if (forceId) { - idType = 'uuid' - } else if (forceCve) { - idType = 'cve' - } else if (forceGhsa) { - idType = 'ghsa' - } else if (forcePackage) { - // --package flag forces package search (fuzzy match against node_modules) - idType = 'package' - } else { - const detectedType = detectIdentifierType(identifier) - if (!detectedType) { - // If not recognized as UUID/CVE/GHSA/PURL, assume it's a package name search - idType = 'package' - console.log(`Treating "${identifier}" as a package name search`) - } else { - idType = detectedType - console.log(`Detected identifier type: ${idType}`) - } - } - - // For UUID, directly fetch and download the patch - if (idType === 'uuid') { - console.log(`Fetching patch by UUID: ${identifier}`) - const patch = await apiClient.fetchPatch(effectiveOrgSlug, identifier) - if (!patch) { - console.log(`No patch found with UUID: ${identifier}`) - return true - } - - // Check if patch is paid and user doesn't have access - if (patch.tier === 'paid' && usePublicProxy) { - console.log(`\n\x1b[33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`) - console.log(`\x1b[33m This patch requires a paid subscription to download.\x1b[0m`) - console.log(`\x1b[33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`) - console.log(`\n Patch: ${patch.purl}`) - console.log(` Tier: \x1b[33mpaid\x1b[0m`) - console.log(`\n Upgrade to Socket's paid plan to access this patch and many more:`) - console.log(` \x1b[36mhttps://socket.dev/pricing\x1b[0m\n`) - return true - } - - // Handle one-off mode - if (oneOff) { - const { success, rollback } = await applyOneOffPatch(patch, useGlobal ?? false, cwd, false, globalPrefix) - if (success && rollback) { - console.log('\nPatch applied (one-off mode). The patch will persist until you reinstall the package.') - console.log('To rollback, use: socket-patch rollback --one-off ' + identifier + (useGlobal ? ' --global' : '')) - } - return success - } - - // Prepare .socket directory - const socketDir = path.join(cwd, '.socket') - const blobsDir = path.join(socketDir, 'blobs') - const manifestPath = path.join(socketDir, 'manifest.json') - - await fs.mkdir(socketDir, { recursive: true }) - await fs.mkdir(blobsDir, { recursive: true }) - - let manifest: any - try { - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - manifest = PatchManifestSchema.parse(JSON.parse(manifestContent)) - } catch { - manifest = { patches: {} } - } - - const added = await savePatch(patch, manifest, blobsDir) - - await fs.writeFile( - manifestPath, - JSON.stringify(manifest, null, 2) + '\n', - 'utf-8', - ) - - console.log(`\nPatch saved to ${manifestPath}`) - if (added) { - console.log(` Added: 1`) - } else { - console.log(` Skipped: 1 (already exists)`) - } - - // Auto-apply unless --no-apply is specified - if (!noApply) { - console.log('\nApplying patches...') - const applySuccess = await applyDownloadedPatches(cwd, manifestPath, false, useGlobal ?? false, globalPrefix) - if (!applySuccess) { - console.error('\nSome patches could not be applied.') - } - } - - return true - } - - // For CVE/GHSA/PURL/package, first search then download - let searchResponse: SearchResponse - - switch (idType) { - case 'cve': { - console.log(`Searching patches for CVE: ${identifier}`) - searchResponse = await apiClient.searchPatchesByCVE(effectiveOrgSlug, identifier) - break - } - case 'ghsa': { - console.log(`Searching patches for GHSA: ${identifier}`) - searchResponse = await apiClient.searchPatchesByGHSA(effectiveOrgSlug, identifier) - break - } - case 'purl': { - console.log(`Searching patches for PURL: ${identifier}`) - searchResponse = await apiClient.searchPatchesByPackage(effectiveOrgSlug, identifier) - break - } - case 'package': { - // Enumerate packages from both npm and Python ecosystems, then fuzzy match - console.log(`Enumerating packages...`) - const npmPackages = await npmCrawler.crawlAll(crawlerOptions) - const pythonPackages = await pythonCrawler.crawlAll(crawlerOptions) - const packages: CrawledPackage[] = [...npmPackages, ...pythonPackages] - - if (packages.length === 0) { - console.log(useGlobal - ? 'No global packages found.' - : 'No packages found. Run npm/yarn/pnpm/pip install first.') - return true - } - - console.log(`Found ${packages.length} packages`) - - // Fuzzy match against the identifier - let matches = fuzzyMatchPackages(identifier, packages) - - if (matches.length === 0) { - console.log(`No packages matching "${identifier}" found.`) - return true - } - - // Sort by package name length (shorter names are typically more relevant/common) - // and truncate to limit API queries - let truncatedCount = 0 - if (matches.length > MAX_PACKAGES_TO_CHECK) { - // Sort by full name length (namespace/name) - shorter = more relevant - matches = matches.sort((a, b) => { - const aFullName = a.namespace ? `${a.namespace}/${a.name}` : a.name - const bFullName = b.namespace ? `${b.namespace}/${b.name}` : b.name - return aFullName.length - bFullName.length - }) - truncatedCount = matches.length - MAX_PACKAGES_TO_CHECK - matches = matches.slice(0, MAX_PACKAGES_TO_CHECK) - console.log(`Found ${matches.length + truncatedCount} matching package(s), checking top ${MAX_PACKAGES_TO_CHECK} by name length...`) - } else { - console.log(`Found ${matches.length} matching package(s), checking for available patches...`) - } - - // Check which packages have available patches with CVE fixes - const packagesWithPatches = await findPackagesWithPatches( - apiClient, - effectiveOrgSlug, - matches, - (checked, total, current) => { - // Clear line and show progress - process.stdout.write(`\r Checking ${checked}/${total}: ${current}`.padEnd(80)) - }, - ) - // Clear the progress line - process.stdout.write('\r' + ' '.repeat(80) + '\r') - - if (packagesWithPatches.length === 0) { - console.log(`No patches with CVE fixes found for packages matching "${identifier}".`) - const checkedCount = matches.length - if (checkedCount > 0) { - console.log(` (${checkedCount} package(s) checked but none have available patches)`) - } - if (truncatedCount > 0) { - console.log(` (${truncatedCount} additional match(es) not checked - try a more specific search)`) - } - return true - } - - const skippedCount = matches.length - packagesWithPatches.length - if (skippedCount > 0 || truncatedCount > 0) { - let note = `Found ${packagesWithPatches.length} package(s) with available patches` - if (skippedCount > 0) { - note += ` (${skippedCount} without patches hidden)` - } - if (truncatedCount > 0) { - note += ` (${truncatedCount} additional match(es) not checked)` - } - console.log(note) - } - - let selectedPackage: PackageWithPatchInfo - - if (packagesWithPatches.length === 1) { - // Single match with patches, use it directly - selectedPackage = packagesWithPatches[0] - const displayName = selectedPackage.namespace - ? `${selectedPackage.namespace}/${selectedPackage.name}` - : selectedPackage.name - console.log(`Found: ${displayName}@${selectedPackage.version}`) - console.log(` Patches: ${selectedPackage.patches.length}`) - console.log(` Fixes: ${[...selectedPackage.cveIds, ...selectedPackage.ghsaIds].join(', ')}`) - } else { - // Multiple matches with patches, prompt user to select - if (skipConfirmation) { - // With --yes, use the first result (best match with patches) - selectedPackage = packagesWithPatches[0] - console.log(`Using best match: ${selectedPackage.purl}`) - } else { - const selected = await promptSelectPackageWithPatches(packagesWithPatches) - if (!selected) { - console.log('No package selected. Download cancelled.') - return true - } - selectedPackage = selected - } - } - - // Use pre-fetched patch info directly - searchResponse = { - patches: selectedPackage.patches, - canAccessPaidPatches: selectedPackage.canAccessPaidPatches, - } - break - } - default: - throw new Error(`Unknown identifier type: ${idType}`) - } - - const { patches: searchResults, canAccessPaidPatches } = searchResponse - - if (searchResults.length === 0) { - console.log(`No patches found for ${idType}: ${identifier}`) - return true - } - - // For CVE/GHSA searches, filter to only show patches for installed packages - // Uses O(n) filesystem operations where n = unique packages in results, - // NOT O(m) where m = all packages in node_modules - let filteredResults = searchResults - let notInstalledCount = 0 - - if (idType === 'cve' || idType === 'ghsa') { - console.log(`Checking which packages are installed...`) - const searchPurls = searchResults.map(patch => patch.purl) - const installedPurls = await findInstalledPurls(cwd, searchPurls, useGlobal ?? false, globalPrefix) - - filteredResults = searchResults.filter(patch => installedPurls.has(patch.purl)) - notInstalledCount = searchResults.length - filteredResults.length - - if (filteredResults.length === 0) { - console.log(`No patches found for installed packages.`) - if (notInstalledCount > 0) { - console.log(` (${notInstalledCount} patch(es) exist for packages not installed in this project)`) - } - return true - } - } - - // Filter patches based on tier access - const accessiblePatches = filteredResults.filter( - patch => patch.tier === 'free' || canAccessPaidPatches, - ) - const inaccessibleCount = filteredResults.length - accessiblePatches.length - - // Display search results - displaySearchResults(filteredResults, canAccessPaidPatches) - - if (notInstalledCount > 0) { - console.log(`Note: ${notInstalledCount} patch(es) for packages not installed in this project were hidden.`) - } - - if (inaccessibleCount > 0 && !canAccessPaidPatches) { - console.log( - `\x1b[33mNote: ${inaccessibleCount} patch(es) require a paid subscription and will be skipped.\x1b[0m`, - ) - } - - if (accessiblePatches.length === 0) { - console.log(`\n\x1b[33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`) - console.log(`\x1b[33m All available patches require a paid subscription.\x1b[0m`) - console.log(`\x1b[33m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`) - console.log(`\n Found ${inaccessibleCount} paid patch(es) that you cannot currently access.`) - console.log(`\n Upgrade to Socket's paid plan to access these patches:`) - console.log(` \x1b[36mhttps://socket.dev/pricing\x1b[0m\n`) - return true - } - - // Prompt for confirmation if multiple patches and not using --yes - if (accessiblePatches.length > 1 && !skipConfirmation) { - const confirmed = await promptConfirmation( - `Download ${accessiblePatches.length} patch(es)?`, - ) - if (!confirmed) { - console.log('Download cancelled.') - return true - } - } - - // Handle one-off mode for search results - if (oneOff) { - // For one-off mode with multiple patches, apply the first one - const patchToApply = accessiblePatches[0] - console.log(`\nFetching and applying patch for ${patchToApply.purl}...`) - - const fullPatch = await apiClient.fetchPatch(effectiveOrgSlug, patchToApply.uuid) - if (!fullPatch) { - console.error(`Could not fetch patch details for ${patchToApply.uuid}`) - return false - } - - const { success, rollback } = await applyOneOffPatch(fullPatch, useGlobal ?? false, cwd, false, globalPrefix) - if (success && rollback) { - console.log('\nPatch applied (one-off mode). The patch will persist until you reinstall the package.') - console.log('To rollback, use: socket-patch rollback --one-off ' + patchToApply.uuid + (useGlobal ? ' --global' : '')) - } - return success - } - - // Prepare .socket directory - const socketDir = path.join(cwd, '.socket') - const blobsDir = path.join(socketDir, 'blobs') - const manifestPath = path.join(socketDir, 'manifest.json') - - await fs.mkdir(socketDir, { recursive: true }) - await fs.mkdir(blobsDir, { recursive: true }) - - let manifest: any - try { - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - manifest = PatchManifestSchema.parse(JSON.parse(manifestContent)) - } catch { - manifest = { patches: {} } - } - - // Download and save each accessible patch - console.log(`\nDownloading ${accessiblePatches.length} patch(es)...`) - - let patchesAdded = 0 - let patchesSkipped = 0 - let patchesFailed = 0 - - for (const searchResult of accessiblePatches) { - // Fetch full patch details with blob content - const patch = await apiClient.fetchPatch(effectiveOrgSlug, searchResult.uuid) - if (!patch) { - console.log(` [fail] ${searchResult.purl} (could not fetch details)`) - patchesFailed++ - continue - } - - const added = await savePatch(patch, manifest, blobsDir) - if (added) { - patchesAdded++ - } else { - patchesSkipped++ - } - } - - // Write updated manifest - await fs.writeFile( - manifestPath, - JSON.stringify(manifest, null, 2) + '\n', - 'utf-8', - ) - - console.log(`\nPatches saved to ${manifestPath}`) - console.log(` Added: ${patchesAdded}`) - if (patchesSkipped > 0) { - console.log(` Skipped: ${patchesSkipped}`) - } - if (patchesFailed > 0) { - console.log(` Failed: ${patchesFailed}`) - } - - // Clean up unused blobs - const cleanupResult = await cleanupUnusedBlobs(manifest, blobsDir, false) - if (cleanupResult.blobsRemoved > 0) { - console.log(`\n${formatCleanupResult(cleanupResult, false)}`) - } - - // Auto-apply unless --no-apply is specified - if (!noApply && patchesAdded > 0) { - console.log('\nApplying patches...') - const applySuccess = await applyDownloadedPatches(cwd, manifestPath, false, useGlobal ?? false, globalPrefix) - if (!applySuccess) { - console.error('\nSome patches could not be applied.') - } - } - - return true -} - -export const getCommand: CommandModule<{}, GetArgs> = { - command: 'get ', - aliases: ['download'], - describe: 'Get security patches from Socket API and apply them', - builder: yargs => { - return yargs - .positional('identifier', { - describe: - 'Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name)', - type: 'string', - demandOption: true, - }) - .option('org', { - describe: 'Organization slug (required when using SOCKET_API_TOKEN, optional for public proxy)', - type: 'string', - demandOption: false, - }) - .option('id', { - describe: 'Force identifier to be treated as a patch UUID', - type: 'boolean', - default: false, - }) - .option('cve', { - describe: 'Force identifier to be treated as a CVE ID', - type: 'boolean', - default: false, - }) - .option('ghsa', { - describe: 'Force identifier to be treated as a GHSA ID', - type: 'boolean', - default: false, - }) - .option('package', { - alias: 'p', - describe: 'Force identifier to be treated as a package name (fuzzy matches against node_modules)', - type: 'boolean', - default: false, - }) - .option('yes', { - alias: 'y', - describe: 'Skip confirmation prompt for multiple patches', - type: 'boolean', - default: false, - }) - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('api-url', { - describe: 'Socket API URL (overrides SOCKET_API_URL env var)', - type: 'string', - }) - .option('api-token', { - describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)', - type: 'string', - }) - .option('no-apply', { - describe: 'Download patch without applying it', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Apply patch to globally installed npm packages', - type: 'boolean', - default: false, - }) - .option('global-prefix', { - describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', - type: 'string', - }) - .option('one-off', { - describe: 'Apply patch immediately without saving to .socket folder (ephemeral)', - type: 'boolean', - default: false, - }) - .example( - '$0 get CVE-2021-44228', - 'Get and apply free patches for a CVE', - ) - .example( - '$0 get GHSA-jfhm-5ghh-2f97', - 'Get and apply free patches for a GHSA', - ) - .example( - '$0 get pkg:npm/lodash@4.17.21', - 'Get and apply patches for a specific package version by PURL', - ) - .example( - '$0 get lodash --package', - 'Search for patches by package name (fuzzy matches node_modules)', - ) - .example( - '$0 get CVE-2021-44228 --no-apply', - 'Download patches without applying them', - ) - .example( - '$0 get lodash --global', - 'Get and apply patches to globally installed package', - ) - .example( - '$0 get CVE-2021-44228 --one-off', - 'Apply patch immediately without saving to .socket folder', - ) - .example( - '$0 get lodash --global --one-off', - 'Apply patch to global package without saving', - ) - .check(argv => { - // Ensure only one type flag is set - const typeFlags = [argv.id, argv.cve, argv.ghsa, argv.package].filter( - Boolean, - ) - if (typeFlags.length > 1) { - throw new Error( - 'Only one of --id, --cve, --ghsa, or --package can be specified', - ) - } - // --one-off implies apply, so --no-apply doesn't make sense - if (argv['one-off'] && argv['no-apply']) { - throw new Error( - '--one-off and --no-apply cannot be used together', - ) - } - return true - }) - }, - handler: async argv => { - try { - const success = await getPatches(argv) - process.exit(success ? 0 : 1) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - process.exit(1) - } - }, -} diff --git a/src/commands/list.ts b/src/commands/list.ts deleted file mode 100644 index 0f876bc..0000000 --- a/src/commands/list.ts +++ /dev/null @@ -1,151 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { CommandModule } from 'yargs' -import { - PatchManifestSchema, - DEFAULT_PATCH_MANIFEST_PATH, -} from '../schema/manifest-schema.js' - -interface ListArgs { - cwd: string - 'manifest-path': string - json: boolean -} - -async function listPatches( - manifestPath: string, - outputJson: boolean, -): Promise { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - const patchEntries = Object.entries(manifest.patches) - - if (patchEntries.length === 0) { - if (outputJson) { - console.log(JSON.stringify({ patches: [] }, null, 2)) - } else { - console.log('No patches found in manifest.') - } - return - } - - if (outputJson) { - // Output as JSON for machine consumption - const jsonOutput = { - patches: patchEntries.map(([purl, patch]) => ({ - purl, - uuid: patch.uuid, - exportedAt: patch.exportedAt, - tier: patch.tier, - license: patch.license, - description: patch.description, - files: Object.keys(patch.files), - vulnerabilities: Object.entries(patch.vulnerabilities).map( - ([id, vuln]) => ({ - id, - cves: vuln.cves, - summary: vuln.summary, - severity: vuln.severity, - description: vuln.description, - }), - ), - })), - } - console.log(JSON.stringify(jsonOutput, null, 2)) - } else { - // Human-readable output - console.log(`Found ${patchEntries.length} patch(es):\n`) - - for (const [purl, patch] of patchEntries) { - console.log(`Package: ${purl}`) - console.log(` UUID: ${patch.uuid}`) - console.log(` Tier: ${patch.tier}`) - console.log(` License: ${patch.license}`) - console.log(` Exported: ${patch.exportedAt}`) - - if (patch.description) { - console.log(` Description: ${patch.description}`) - } - - // List vulnerabilities - const vulnEntries = Object.entries(patch.vulnerabilities) - if (vulnEntries.length > 0) { - console.log(` Vulnerabilities (${vulnEntries.length}):`) - for (const [id, vuln] of vulnEntries) { - const cveList = vuln.cves.length > 0 ? ` (${vuln.cves.join(', ')})` : '' - console.log(` - ${id}${cveList}`) - console.log(` Severity: ${vuln.severity}`) - console.log(` Summary: ${vuln.summary}`) - } - } - - // List files being patched - const fileList = Object.keys(patch.files) - if (fileList.length > 0) { - console.log(` Files patched (${fileList.length}):`) - for (const filePath of fileList) { - console.log(` - ${filePath}`) - } - } - - console.log('') // Empty line between patches - } - } -} - -export const listCommand: CommandModule<{}, ListArgs> = { - command: 'list', - describe: 'List all patches in the local manifest', - builder: yargs => { - return yargs - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('manifest-path', { - alias: 'm', - describe: 'Path to patch manifest file', - type: 'string', - default: DEFAULT_PATCH_MANIFEST_PATH, - }) - .option('json', { - describe: 'Output as JSON', - type: 'boolean', - default: false, - }) - }, - handler: async argv => { - try { - const manifestPath = path.isAbsolute(argv['manifest-path']) - ? argv['manifest-path'] - : path.join(argv.cwd, argv['manifest-path']) - - // Check if manifest exists - try { - await fs.access(manifestPath) - } catch { - if (argv.json) { - console.log(JSON.stringify({ error: 'Manifest not found', path: manifestPath }, null, 2)) - } else { - console.error(`Manifest not found at ${manifestPath}`) - } - process.exit(1) - } - - await listPatches(manifestPath, argv.json) - process.exit(0) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - if (argv.json) { - console.log(JSON.stringify({ error: errorMessage }, null, 2)) - } else { - console.error(`Error: ${errorMessage}`) - } - process.exit(1) - } - }, -} diff --git a/src/commands/remove.test.ts b/src/commands/remove.test.ts deleted file mode 100644 index c5fc29c..0000000 --- a/src/commands/remove.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - setupTestEnvironment, - readPackageFile, -} from '../test-utils.js' -import { PatchManifestSchema } from '../schema/manifest-schema.js' - -// Valid UUIDs for testing -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' -const TEST_UUID_2 = '22222222-2222-4222-8222-222222222222' -const TEST_UUID_3 = '33333333-3333-4333-8333-333333333333' -const TEST_UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -const TEST_UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const TEST_UUID_SPECIFIC = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' - -describe('remove command with rollback', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('remove-test-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should rollback files before removing from manifest', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'rollback1'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_1, - files: { - 'package/index.js': { - beforeContent: 'original content', - afterContent: 'patched content', - }, - }, - }, - ], - initialState: 'after', // Start in patched state - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - - // Verify file is in patched state before remove - assert.equal(await readPackageFile(pkgDir, 'index.js'), 'patched content') - - // Simulate the remove command behavior with rollback - // First, import and call rollbackPatches - const { rollbackPatches } = await import('./rollback.js') - - const cwd = path.dirname(manifestPath).replace('/.socket', '') - - const { success: rollbackSuccess } = await rollbackPatches( - cwd, - manifestPath, - 'pkg:npm/test-pkg@1.0.0', - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(rollbackSuccess, true) - - // Verify file is restored to original state - assert.equal(await readPackageFile(pkgDir, 'index.js'), 'original content') - - // Now remove from manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifest = PatchManifestSchema.parse(JSON.parse(manifestContent)) - - delete manifest.patches['pkg:npm/test-pkg@1.0.0'] - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') - - // Verify manifest is updated - const updatedManifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) - assert.equal(updatedManifest.patches['pkg:npm/test-pkg@1.0.0'], undefined) - }) - - it('should allow removal without rollback using --skip-rollback flag', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'norollback1'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_2, - files: { - 'package/index.js': { - beforeContent: 'original content', - afterContent: 'patched content', - }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - - // Simulate --skip-rollback: only remove from manifest without rollback - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifest = PatchManifestSchema.parse(JSON.parse(manifestContent)) - - delete manifest.patches['pkg:npm/test-pkg@1.0.0'] - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') - - // File should still be in patched state (no rollback performed) - assert.equal(await readPackageFile(pkgDir, 'index.js'), 'patched content') - - // Manifest should be updated - const updatedManifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) - assert.equal(updatedManifest.patches['pkg:npm/test-pkg@1.0.0'], undefined) - }) - - it('should fail if rollback fails (file modified)', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'fail1'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_3, - files: { - 'package/index.js': { - beforeContent: 'original content', - afterContent: 'patched content', - }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - - // Modify the file to simulate user changes - await fs.writeFile(path.join(pkgDir, 'index.js'), 'user modified content') - - const { rollbackPatches } = await import('./rollback.js') - - const cwd = path.dirname(manifestPath).replace('/.socket', '') - - const { success } = await rollbackPatches( - cwd, - manifestPath, - 'pkg:npm/test-pkg@1.0.0', - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - // Rollback should fail due to hash mismatch - assert.equal(success, false) - - // Manifest should still have the patch - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) - assert.ok(manifest.patches['pkg:npm/test-pkg@1.0.0']) - }) - - it('should remove by UUID and rollback', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'uuid1'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_SPECIFIC, - files: { - 'package/index.js': { - beforeContent: 'original by uuid', - afterContent: 'patched by uuid', - }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - - const { rollbackPatches } = await import('./rollback.js') - - const cwd = path.dirname(manifestPath).replace('/.socket', '') - - // Rollback by UUID - const { success } = await rollbackPatches( - cwd, - manifestPath, - TEST_UUID_SPECIFIC, - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - assert.equal(await readPackageFile(pkgDir, 'index.js'), 'original by uuid') - }) - - it('should handle removing one of multiple patches', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'multi1'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-original', - afterContent: 'a-patched', - }, - }, - }, - { - purl: 'pkg:npm/pkg-b@2.0.0', - uuid: TEST_UUID_B, - files: { - 'package/index.js': { - beforeContent: 'b-original', - afterContent: 'b-patched', - }, - }, - }, - ], - initialState: 'after', - }) - - const pkgADir = packageDirs.get('pkg:npm/pkg-a@1.0.0')! - const pkgBDir = packageDirs.get('pkg:npm/pkg-b@2.0.0')! - - const { rollbackPatches } = await import('./rollback.js') - - const cwd = path.dirname(manifestPath).replace('/.socket', '') - - // Rollback only pkg-a - const { success } = await rollbackPatches( - cwd, - manifestPath, - 'pkg:npm/pkg-a@1.0.0', - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - - // pkg-a should be rolled back, pkg-b should remain patched - assert.equal(await readPackageFile(pkgADir, 'index.js'), 'a-original') - assert.equal(await readPackageFile(pkgBDir, 'index.js'), 'b-patched') - - // Remove pkg-a from manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifest = PatchManifestSchema.parse(JSON.parse(manifestContent)) - - delete manifest.patches['pkg:npm/pkg-a@1.0.0'] - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') - - // Verify manifest still has pkg-b - const updatedManifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) - assert.equal(updatedManifest.patches['pkg:npm/pkg-a@1.0.0'], undefined) - assert.ok(updatedManifest.patches['pkg:npm/pkg-b@2.0.0']) - }) -}) diff --git a/src/commands/remove.ts b/src/commands/remove.ts deleted file mode 100644 index defac49..0000000 --- a/src/commands/remove.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { CommandModule } from 'yargs' -import { - PatchManifestSchema, - DEFAULT_PATCH_MANIFEST_PATH, - type PatchManifest, -} from '../schema/manifest-schema.js' -import { - cleanupUnusedBlobs, - formatCleanupResult, -} from '../utils/cleanup-blobs.js' -import { rollbackPatches } from './rollback.js' -import { - trackPatchRemoved, - trackPatchRemoveFailed, -} from '../utils/telemetry.js' - -interface RemoveArgs { - identifier: string - cwd: string - 'manifest-path': string - 'skip-rollback': boolean - global: boolean - 'global-prefix'?: string -} - -async function removePatch( - identifier: string, - manifestPath: string, -): Promise<{ removed: string[]; notFound: boolean; manifest: PatchManifest }> { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - const removed: string[] = [] - let foundMatch = false - - // Check if identifier is a PURL (contains "pkg:") - if (identifier.startsWith('pkg:')) { - // Remove by PURL - if (manifest.patches[identifier]) { - removed.push(identifier) - delete manifest.patches[identifier] - foundMatch = true - } - } else { - // Remove by UUID - search through all patches - for (const [purl, patch] of Object.entries(manifest.patches)) { - if (patch.uuid === identifier) { - removed.push(purl) - delete manifest.patches[purl] - foundMatch = true - } - } - } - - if (foundMatch) { - // Write updated manifest - await fs.writeFile( - manifestPath, - JSON.stringify(manifest, null, 2) + '\n', - 'utf-8', - ) - } - - return { removed, notFound: !foundMatch, manifest } -} - -export const removeCommand: CommandModule<{}, RemoveArgs> = { - command: 'remove ', - describe: 'Remove a patch from the manifest by PURL or UUID (rolls back files first)', - builder: yargs => { - return yargs - .positional('identifier', { - describe: 'Package PURL (e.g., pkg:npm/package@version) or patch UUID', - type: 'string', - demandOption: true, - }) - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('manifest-path', { - alias: 'm', - describe: 'Path to patch manifest file', - type: 'string', - default: DEFAULT_PATCH_MANIFEST_PATH, - }) - .option('skip-rollback', { - describe: 'Skip rolling back files before removing (only update manifest)', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Remove patches from globally installed npm packages', - type: 'boolean', - default: false, - }) - .option('global-prefix', { - describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', - type: 'string', - }) - .example( - '$0 remove pkg:npm/lodash@4.17.21', - 'Rollback and remove a patch by PURL', - ) - .example( - '$0 remove 12345678-1234-1234-1234-123456789abc', - 'Rollback and remove a patch by UUID', - ) - .example( - '$0 remove pkg:npm/lodash@4.17.21 --skip-rollback', - 'Remove from manifest without rolling back files', - ) - .example( - '$0 remove pkg:npm/lodash@4.17.21 --global', - 'Remove and rollback from global npm packages', - ) - }, - handler: async argv => { - // Get API credentials for authenticated telemetry (optional). - const apiToken = process.env['SOCKET_API_TOKEN'] - const orgSlug = process.env['SOCKET_ORG_SLUG'] - - try { - const manifestPath = path.isAbsolute(argv['manifest-path']) - ? argv['manifest-path'] - : path.join(argv.cwd, argv['manifest-path']) - - // Check if manifest exists - try { - await fs.access(manifestPath) - } catch { - console.error(`Manifest not found at ${manifestPath}`) - process.exit(1) - } - - // First, rollback the patch if not skipped - if (!argv['skip-rollback']) { - console.log(`Rolling back patch before removal...`) - const { success: rollbackSuccess, results: rollbackResults } = - await rollbackPatches( - argv.cwd, - manifestPath, - argv.identifier, - false, // not dry run - false, // not silent - false, // not offline - argv.global, - argv['global-prefix'], - ) - - if (!rollbackSuccess) { - await trackPatchRemoveFailed( - new Error('Rollback failed during patch removal'), - apiToken, - orgSlug, - ) - console.error( - '\nRollback failed. Use --skip-rollback to remove from manifest without restoring files.', - ) - process.exit(1) - } - - // Report rollback results - const rolledBack = rollbackResults.filter( - r => r.success && r.filesRolledBack.length > 0, - ) - const alreadyOriginal = rollbackResults.filter( - r => - r.success && - r.filesVerified.every(f => f.status === 'already-original'), - ) - - if (rolledBack.length > 0) { - console.log(`Rolled back ${rolledBack.length} package(s)`) - } - if (alreadyOriginal.length > 0) { - console.log( - `${alreadyOriginal.length} package(s) already in original state`, - ) - } - if (rollbackResults.length === 0) { - console.log('No packages found to rollback (not installed)') - } - console.log() - } - - // Now remove from manifest - const { removed, notFound, manifest } = await removePatch( - argv.identifier, - manifestPath, - ) - - if (notFound) { - await trackPatchRemoveFailed( - new Error(`No patch found matching identifier: ${argv.identifier}`), - apiToken, - orgSlug, - ) - console.error(`No patch found matching identifier: ${argv.identifier}`) - process.exit(1) - } - - console.log(`Removed ${removed.length} patch(es) from manifest:`) - for (const purl of removed) { - console.log(` - ${purl}`) - } - - console.log(`\nManifest updated at ${manifestPath}`) - - // Clean up unused blobs after removing patches - const socketDir = path.dirname(manifestPath) - const blobsPath = path.join(socketDir, 'blobs') - const cleanupResult = await cleanupUnusedBlobs(manifest, blobsPath, false) - if (cleanupResult.blobsRemoved > 0) { - console.log(`\n${formatCleanupResult(cleanupResult, false)}`) - } - - // Track successful removal. - await trackPatchRemoved(removed.length, apiToken, orgSlug) - - process.exit(0) - } catch (err) { - // Track telemetry for unexpected errors. - const error = err instanceof Error ? err : new Error(String(err)) - await trackPatchRemoveFailed(error, apiToken, orgSlug) - - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - process.exit(1) - } - }, -} diff --git a/src/commands/repair.ts b/src/commands/repair.ts deleted file mode 100644 index 8e7d6fd..0000000 --- a/src/commands/repair.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { CommandModule } from 'yargs' -import { - PatchManifestSchema, - DEFAULT_PATCH_MANIFEST_PATH, -} from '../schema/manifest-schema.js' -import { - cleanupUnusedBlobs, - formatCleanupResult, -} from '../utils/cleanup-blobs.js' -import { - fetchMissingBlobs, - formatFetchResult, - getMissingBlobs, -} from '../utils/blob-fetcher.js' - -interface RepairArgs { - cwd: string - 'manifest-path': string - 'dry-run': boolean - offline: boolean - 'download-only': boolean -} - -async function repair( - manifestPath: string, - dryRun: boolean, - offline: boolean, - downloadOnly: boolean, -): Promise { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - // Find .socket directory (contains blobs) - const socketDir = path.dirname(manifestPath) - const blobsPath = path.join(socketDir, 'blobs') - - // Step 1: Check for and download missing blobs (unless offline) - if (!offline) { - const missingBlobs = await getMissingBlobs(manifest, blobsPath) - - if (missingBlobs.size > 0) { - console.log(`Found ${missingBlobs.size} missing blob(s)`) - - if (dryRun) { - console.log('\nDry run - would download:') - for (const hash of Array.from(missingBlobs).slice(0, 10)) { - console.log(` - ${hash.slice(0, 12)}...`) - } - if (missingBlobs.size > 10) { - console.log(` ... and ${missingBlobs.size - 10} more`) - } - } else { - console.log('\nDownloading missing blobs...') - - const fetchResult = await fetchMissingBlobs(manifest, blobsPath, undefined, { - onProgress: (hash, index, total) => { - process.stdout.write( - `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), - ) - }, - }) - - // Clear progress line - process.stdout.write('\r' + ' '.repeat(60) + '\r') - - console.log(formatFetchResult(fetchResult)) - } - } else { - console.log('All blobs are present locally.') - } - } else { - // Offline mode - just check for missing blobs - const missingBlobs = await getMissingBlobs(manifest, blobsPath) - if (missingBlobs.size > 0) { - console.log( - `Warning: ${missingBlobs.size} blob(s) are missing (offline mode - not downloading)`, - ) - for (const hash of Array.from(missingBlobs).slice(0, 5)) { - console.log(` - ${hash.slice(0, 12)}...`) - } - if (missingBlobs.size > 5) { - console.log(` ... and ${missingBlobs.size - 5} more`) - } - } else { - console.log('All blobs are present locally.') - } - } - - // Step 2: Clean up unused blobs (unless download-only) - if (!downloadOnly) { - console.log('') - const cleanupResult = await cleanupUnusedBlobs(manifest, blobsPath, dryRun) - - if (cleanupResult.blobsChecked === 0) { - console.log('No blobs directory found, nothing to clean up.') - } else if (cleanupResult.blobsRemoved === 0) { - console.log( - `Checked ${cleanupResult.blobsChecked} blob(s), all are in use.`, - ) - } else { - console.log(formatCleanupResult(cleanupResult, dryRun)) - } - } - - if (!dryRun) { - console.log('\nRepair complete.') - } -} - -export const repairCommand: CommandModule<{}, RepairArgs> = { - command: 'repair', - aliases: ['gc'], - describe: 'Download missing blobs and clean up unused blobs', - builder: yargs => { - return yargs - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('manifest-path', { - alias: 'm', - describe: 'Path to patch manifest file', - type: 'string', - default: DEFAULT_PATCH_MANIFEST_PATH, - }) - .option('dry-run', { - alias: 'd', - describe: 'Show what would be done without actually doing it', - type: 'boolean', - default: false, - }) - .option('offline', { - describe: 'Skip network operations (cleanup only)', - type: 'boolean', - default: false, - }) - .option('download-only', { - describe: 'Only download missing blobs, do not clean up', - type: 'boolean', - default: false, - }) - .example('$0 repair', 'Download missing blobs and clean up unused ones') - .example('$0 repair --dry-run', 'Show what would be done without doing it') - .example('$0 repair --offline', 'Only clean up unused blobs (no network)') - .example('$0 repair --download-only', 'Only download missing blobs') - .example('$0 gc', 'Alias for repair command') - }, - handler: async argv => { - try { - const manifestPath = path.isAbsolute(argv['manifest-path']) - ? argv['manifest-path'] - : path.join(argv.cwd, argv['manifest-path']) - - // Check if manifest exists - try { - await fs.access(manifestPath) - } catch { - console.error(`Manifest not found at ${manifestPath}`) - process.exit(1) - } - - await repair( - manifestPath, - argv['dry-run'], - argv['offline'], - argv['download-only'], - ) - process.exit(0) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - process.exit(1) - } - }, -} diff --git a/src/commands/rollback.test.ts b/src/commands/rollback.test.ts deleted file mode 100644 index db13127..0000000 --- a/src/commands/rollback.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - setupTestEnvironment, - readPackageFile, - computeTestHash, -} from '../test-utils.js' -import { rollbackPackagePatch } from '../patch/rollback.js' -import { rollbackPatches } from './rollback.js' - -// Valid UUIDs for testing -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' -const TEST_UUID_2 = '22222222-2222-4222-8222-222222222222' -const TEST_UUID_3 = '33333333-3333-4333-8333-333333333333' -const TEST_UUID_4 = '44444444-4444-4444-8444-444444444444' -const TEST_UUID_5 = '55555555-5555-4555-8555-555555555555' -const TEST_UUID_6 = '66666666-6666-4666-8666-666666666666' -const TEST_UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -const TEST_UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const TEST_UUID_SCOPED = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' - -describe('rollback command', () => { - describe('rollbackPackagePatch', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('rollback-pkg-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should restore files to original state when in patched state', async () => { - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test1'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_1, - files: { - 'package/index.js': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'after', // Package starts in patched state - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - { 'package/index.js': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesRolledBack.length, 1) - assert.equal(result.filesRolledBack[0], 'package/index.js') - - const fileContent = await readPackageFile(pkgDir, 'index.js') - assert.equal(fileContent, beforeContent) - }) - - it('should skip files already in original state', async () => { - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test2'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_2, - files: { - 'package/index.js': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'before', // Package starts in original state - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - { 'package/index.js': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesRolledBack.length, 0) - assert.equal(result.filesVerified[0].status, 'already-original') - }) - - it('should fail if file has been modified after patching', async () => { - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - const modifiedContent = 'console.log("user modified");' - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test3'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_3, - files: { - 'package/index.js': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - - // Modify the file to simulate user changes - await fs.writeFile(path.join(pkgDir, 'index.js'), modifiedContent) - - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - { 'package/index.js': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, false) - assert.ok(result.error?.includes('modified after patching')) - }) - - it('should fail if before blob is missing', async () => { - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test4'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_4, - files: { - 'package/index.js': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - // Delete the before blob - await fs.unlink(path.join(blobsDir, beforeHash)) - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - { 'package/index.js': { beforeHash, afterHash } }, - blobsDir, - false, - ) - - assert.equal(result.success, false) - assert.ok(result.error?.includes('Before blob not found')) - }) - - it('should not modify files in dry-run mode', async () => { - const beforeContent = 'console.log("original");' - const afterContent = 'console.log("patched");' - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test5'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_5, - files: { - 'package/index.js': { beforeContent, afterContent }, - }, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - { 'package/index.js': { beforeHash, afterHash } }, - blobsDir, - true, // dry-run - ) - - assert.equal(result.success, true) - assert.equal(result.filesRolledBack.length, 0) - - // File should still be in patched state - const fileContent = await readPackageFile(pkgDir, 'index.js') - assert.equal(fileContent, afterContent) - }) - - it('should handle multiple files in a package', async () => { - const files = { - 'package/index.js': { - beforeContent: 'export default 1;', - afterContent: 'export default 2;', - }, - 'package/lib/utils.js': { - beforeContent: 'const a = 1;', - afterContent: 'const a = 2;', - }, - } - - const { blobsDir, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'test6'), - patches: [ - { - purl: 'pkg:npm/test-pkg@1.0.0', - uuid: TEST_UUID_6, - files, - }, - ], - initialState: 'after', - }) - - const pkgDir = packageDirs.get('pkg:npm/test-pkg@1.0.0')! - const patchFiles: Record = {} - - for (const [filePath, { beforeContent, afterContent }] of Object.entries(files)) { - patchFiles[filePath] = { - beforeHash: computeTestHash(beforeContent), - afterHash: computeTestHash(afterContent), - } - } - - const result = await rollbackPackagePatch( - 'pkg:npm/test-pkg@1.0.0', - pkgDir, - patchFiles, - blobsDir, - false, - ) - - assert.equal(result.success, true) - assert.equal(result.filesRolledBack.length, 2) - - // Verify all files are restored - assert.equal( - await readPackageFile(pkgDir, 'index.js'), - files['package/index.js'].beforeContent, - ) - assert.equal( - await readPackageFile(pkgDir, 'lib/utils.js'), - files['package/lib/utils.js'].beforeContent, - ) - }) - }) - - describe('rollbackPatches', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('rollback-patches-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should rollback all patches when no identifier provided', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'all1'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-before', - afterContent: 'a-after', - }, - }, - }, - { - purl: 'pkg:npm/pkg-b@2.0.0', - uuid: TEST_UUID_B, - files: { - 'package/index.js': { - beforeContent: 'b-before', - afterContent: 'b-after', - }, - }, - }, - ], - initialState: 'after', - }) - - const { success, results } = await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - undefined, // no identifier = all patches - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - assert.equal(results.length, 2) - - // Verify both packages are rolled back - const pkgADir = packageDirs.get('pkg:npm/pkg-a@1.0.0')! - const pkgBDir = packageDirs.get('pkg:npm/pkg-b@2.0.0')! - - assert.equal(await readPackageFile(pkgADir, 'index.js'), 'a-before') - assert.equal(await readPackageFile(pkgBDir, 'index.js'), 'b-before') - }) - - it('should rollback only specified patch by PURL', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'purl1'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-before', - afterContent: 'a-after', - }, - }, - }, - { - purl: 'pkg:npm/pkg-b@2.0.0', - uuid: TEST_UUID_B, - files: { - 'package/index.js': { - beforeContent: 'b-before', - afterContent: 'b-after', - }, - }, - }, - ], - initialState: 'after', - }) - - const { success, results } = await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - 'pkg:npm/pkg-a@1.0.0', // Only rollback pkg-a - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - assert.equal(results.length, 1) - assert.equal(results[0].packageKey, 'pkg:npm/pkg-a@1.0.0') - - // Verify only pkg-a is rolled back - const pkgADir = packageDirs.get('pkg:npm/pkg-a@1.0.0')! - const pkgBDir = packageDirs.get('pkg:npm/pkg-b@2.0.0')! - - assert.equal(await readPackageFile(pkgADir, 'index.js'), 'a-before') - assert.equal(await readPackageFile(pkgBDir, 'index.js'), 'b-after') // Still patched - }) - - it('should rollback only specified patch by UUID', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'uuid1'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-before', - afterContent: 'a-after', - }, - }, - }, - { - purl: 'pkg:npm/pkg-b@2.0.0', - uuid: TEST_UUID_B, - files: { - 'package/index.js': { - beforeContent: 'b-before', - afterContent: 'b-after', - }, - }, - }, - ], - initialState: 'after', - }) - - const { success, results } = await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - TEST_UUID_B, // Rollback by UUID - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - assert.equal(results.length, 1) - assert.equal(results[0].packageKey, 'pkg:npm/pkg-b@2.0.0') - - // Verify only pkg-b is rolled back - const pkgADir = packageDirs.get('pkg:npm/pkg-a@1.0.0')! - const pkgBDir = packageDirs.get('pkg:npm/pkg-b@2.0.0')! - - assert.equal(await readPackageFile(pkgADir, 'index.js'), 'a-after') // Still patched - assert.equal(await readPackageFile(pkgBDir, 'index.js'), 'b-before') - }) - - it('should error if patch not found by PURL', async () => { - const { manifestPath } = await setupTestEnvironment({ - testDir: path.join(testDir, 'notfound1'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-before', - afterContent: 'a-after', - }, - }, - }, - ], - initialState: 'after', - }) - - await assert.rejects( - async () => { - await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - 'pkg:npm/nonexistent@1.0.0', - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - }, - /No patch found matching identifier/, - ) - }) - - it('should error if patch not found by UUID', async () => { - const { manifestPath } = await setupTestEnvironment({ - testDir: path.join(testDir, 'notfound2'), - patches: [ - { - purl: 'pkg:npm/pkg-a@1.0.0', - uuid: TEST_UUID_A, - files: { - 'package/index.js': { - beforeContent: 'a-before', - afterContent: 'a-after', - }, - }, - }, - ], - initialState: 'after', - }) - - await assert.rejects( - async () => { - await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', // nonexistent UUID - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - }, - /No patch found matching identifier/, - ) - }) - - it('should handle scoped packages', async () => { - const { manifestPath, packageDirs } = await setupTestEnvironment({ - testDir: path.join(testDir, 'scoped1'), - patches: [ - { - purl: 'pkg:npm/@scope/pkg-a@1.0.0', - uuid: TEST_UUID_SCOPED, - files: { - 'package/index.js': { - beforeContent: 'scoped-before', - afterContent: 'scoped-after', - }, - }, - }, - ], - initialState: 'after', - }) - - const { success, results } = await rollbackPatches( - path.dirname(manifestPath).replace('/.socket', ''), - manifestPath, - 'pkg:npm/@scope/pkg-a@1.0.0', - false, - true, - false, // not offline - false, // not global - undefined, // no global prefix - ) - - assert.equal(success, true) - assert.equal(results.length, 1) - - const pkgDir = packageDirs.get('pkg:npm/@scope/pkg-a@1.0.0')! - assert.equal(await readPackageFile(pkgDir, 'index.js'), 'scoped-before') - }) - }) -}) diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts deleted file mode 100644 index e6ccbfb..0000000 --- a/src/commands/rollback.ts +++ /dev/null @@ -1,763 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import * as os from 'os' -import type { CommandModule } from 'yargs' -import { - PatchManifestSchema, - DEFAULT_PATCH_MANIFEST_PATH, - type PatchManifest, - type PatchRecord, -} from '../schema/manifest-schema.js' -import { - findNodeModules, - findPackagesForPatches, -} from '../patch/apply.js' -import { rollbackPackagePatch } from '../patch/rollback.js' -import type { RollbackResult } from '../patch/rollback.js' -import { - fetchBlobsByHash, - formatFetchResult, -} from '../utils/blob-fetcher.js' -import { getGlobalPrefix } from '../utils/global-packages.js' -import { getAPIClientFromEnv } from '../utils/api-client.js' -import { PythonCrawler } from '../crawlers/index.js' -import { - isPyPIPurl, - stripPurlQualifiers, -} from '../utils/purl-utils.js' -import { - trackPatchRolledBack, - trackPatchRollbackFailed, -} from '../utils/telemetry.js' - -interface RollbackArgs { - identifier?: string - cwd: string - 'dry-run': boolean - silent: boolean - 'manifest-path': string - offline: boolean - global: boolean - 'global-prefix'?: string - 'one-off': boolean - org?: string - 'api-url'?: string - 'api-token'?: string - ecosystems?: string[] -} - -interface PatchToRollback { - purl: string - patch: PatchRecord -} - -/** - * Find patches to rollback based on identifier - * - If identifier starts with 'pkg:' -> treat as PURL - * - Otherwise -> treat as UUID - * - If no identifier -> return all patches - */ -function findPatchesToRollback( - manifest: PatchManifest, - identifier?: string, -): PatchToRollback[] { - if (!identifier) { - // Rollback all patches - return Object.entries(manifest.patches).map(([purl, patch]) => ({ - purl, - patch, - })) - } - - const patches: PatchToRollback[] = [] - - if (identifier.startsWith('pkg:')) { - // Search by PURL - exact match - const patch = manifest.patches[identifier] - if (patch) { - patches.push({ purl: identifier, patch }) - } - } else { - // Search by UUID - search through all patches - for (const [purl, patch] of Object.entries(manifest.patches)) { - if (patch.uuid === identifier) { - patches.push({ purl, patch }) - } - } - } - - return patches -} - -/** - * Get the set of beforeHash blobs needed for rollback. - * These are different from the afterHash blobs needed for apply. - */ -function getBeforeHashBlobs(manifest: PatchManifest): Set { - const blobs = new Set() - for (const patchRecord of Object.values(manifest.patches)) { - const record = patchRecord as PatchRecord - for (const fileInfo of Object.values(record.files)) { - blobs.add(fileInfo.beforeHash) - } - } - return blobs -} - -/** - * Check which beforeHash blobs are missing from disk. - */ -async function getMissingBeforeBlobs( - manifest: PatchManifest, - blobsPath: string, -): Promise> { - const beforeBlobs = getBeforeHashBlobs(manifest) - const missingBlobs = new Set() - - for (const hash of beforeBlobs) { - const blobPath = path.join(blobsPath, hash) - try { - await fs.access(blobPath) - } catch { - missingBlobs.add(hash) - } - } - - return missingBlobs -} - -async function rollbackPatches( - cwd: string, - manifestPath: string, - identifier: string | undefined, - dryRun: boolean, - silent: boolean, - offline: boolean, - useGlobal: boolean, - globalPrefix?: string, - ecosystems?: string[], -): Promise<{ success: boolean; results: RollbackResult[] }> { - // Read and parse manifest - const manifestContent = await fs.readFile(manifestPath, 'utf-8') - const manifestData = JSON.parse(manifestContent) - const manifest = PatchManifestSchema.parse(manifestData) - - // Find .socket directory (contains blobs) - const socketDir = path.dirname(manifestPath) - const blobsPath = path.join(socketDir, 'blobs') - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - // Find patches to rollback - const patchesToRollback = findPatchesToRollback(manifest, identifier) - - if (patchesToRollback.length === 0) { - if (identifier) { - throw new Error(`No patch found matching identifier: ${identifier}`) - } - if (!silent) { - console.log('No patches found in manifest') - } - return { success: true, results: [] } - } - - // Create a filtered manifest containing only the patches we want to rollback - const filteredManifest: PatchManifest = { - patches: Object.fromEntries( - patchesToRollback.map(p => [p.purl, p.patch]), - ), - } - - // Check for and download missing beforeHash blobs (unless offline) - // Rollback needs the original (beforeHash) blobs, not the patched (afterHash) blobs - const missingBlobs = await getMissingBeforeBlobs(filteredManifest, blobsPath) - if (missingBlobs.size > 0) { - if (offline) { - if (!silent) { - console.error( - `Error: ${missingBlobs.size} blob(s) are missing and --offline mode is enabled.`, - ) - console.error('Run "socket-patch repair" to download missing blobs.') - } - return { success: false, results: [] } - } - - if (!silent) { - console.log(`Downloading ${missingBlobs.size} missing blob(s)...`) - } - - // Use fetchBlobsByHash to download the specific beforeHash blobs - const fetchResult = await fetchBlobsByHash(missingBlobs, blobsPath, undefined, { - onProgress: silent - ? undefined - : (hash, index, total) => { - process.stdout.write( - `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), - ) - }, - }) - - if (!silent) { - // Clear progress line - process.stdout.write('\r' + ' '.repeat(60) + '\r') - console.log(formatFetchResult(fetchResult)) - } - - // Re-check which blobs are still missing after download - const stillMissing = await getMissingBeforeBlobs(filteredManifest, blobsPath) - if (stillMissing.size > 0) { - if (!silent) { - console.error(`${stillMissing.size} blob(s) could not be downloaded. Cannot rollback.`) - } - return { success: false, results: [] } - } - } - - // Partition PURLs by ecosystem - const rollbackPurls = patchesToRollback.map(p => p.purl) - let npmPurls = rollbackPurls.filter(p => !isPyPIPurl(p)) - let pypiPurls = rollbackPurls.filter(p => isPyPIPurl(p)) - - // Filter by ecosystem if specified - if (ecosystems && ecosystems.length > 0) { - if (!ecosystems.includes('npm')) npmPurls = [] - if (!ecosystems.includes('pypi')) pypiPurls = [] - } - - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - const allPackages = new Map() - - // Find npm packages - if (npmPurls.length > 0) { - let nodeModulesPaths: string[] - if (useGlobal || globalPrefix) { - try { - nodeModulesPaths = [getGlobalPrefix(globalPrefix)] - if (!silent) { - console.log(`Using global npm packages at: ${nodeModulesPaths[0]}`) - } - } catch (error) { - if (!silent) { - console.error('Failed to find global npm packages:', error instanceof Error ? error.message : String(error)) - } - return { success: false, results: [] } - } - } else { - nodeModulesPaths = await findNodeModules(cwd) - } - - for (const nmPath of nodeModulesPaths) { - const packages = await findPackagesForPatches(nmPath, filteredManifest) - for (const [purl, location] of packages) { - if (!allPackages.has(purl)) { - allPackages.set(purl, location.path) - } - } - } - } - - // Find Python packages - if (pypiPurls.length > 0) { - const pythonCrawler = new PythonCrawler() - try { - const basePypiPurls = [...new Set(pypiPurls.map(stripPurlQualifiers))] - const sitePackagesPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - for (const spPath of sitePackagesPaths) { - const packages = await pythonCrawler.findByPurls(spPath, basePypiPurls) - for (const [basePurl, location] of packages) { - // Map back to the qualified PURL(s) in the manifest - for (const qualifiedPurl of pypiPurls) { - if (stripPurlQualifiers(qualifiedPurl) === basePurl && !allPackages.has(qualifiedPurl)) { - allPackages.set(qualifiedPurl, location.path) - } - } - } - } - } catch (error) { - if (!silent) { - console.error('Failed to find Python packages:', error instanceof Error ? error.message : String(error)) - } - } - } - - if (allPackages.size === 0) { - if (!silent) { - console.log('No packages found that match patches to rollback') - } - return { success: true, results: [] } - } - - // Rollback patches for each package - const results: RollbackResult[] = [] - let hasErrors = false - - for (const [purl, pkgPath] of allPackages) { - const patch = filteredManifest.patches[purl] - if (!patch) continue - - const result = await rollbackPackagePatch( - purl, - pkgPath, - patch.files, - blobsPath, - dryRun, - ) - - results.push(result) - - if (!result.success) { - hasErrors = true - if (!silent) { - console.error(`Failed to rollback ${purl}: ${result.error}`) - } - } - } - - return { success: !hasErrors, results } -} - -export const rollbackCommand: CommandModule<{}, RollbackArgs> = { - command: 'rollback [identifier]', - describe: 'Rollback patches to restore original files', - builder: yargs => { - return yargs - .positional('identifier', { - describe: - 'Package PURL (e.g., pkg:npm/package@version) or patch UUID to rollback. Omit to rollback all patches.', - type: 'string', - }) - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('dry-run', { - alias: 'd', - describe: 'Verify rollback can be performed without modifying files', - type: 'boolean', - default: false, - }) - .option('silent', { - alias: 's', - describe: 'Only output errors', - type: 'boolean', - default: false, - }) - .option('manifest-path', { - alias: 'm', - describe: 'Path to patch manifest file', - type: 'string', - default: DEFAULT_PATCH_MANIFEST_PATH, - }) - .option('offline', { - describe: 'Do not download missing blobs, fail if any are missing', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Rollback patches from globally installed npm packages', - type: 'boolean', - default: false, - }) - .option('global-prefix', { - describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', - type: 'string', - }) - .option('one-off', { - describe: 'Rollback a patch by fetching beforeHash blobs from API (no manifest required)', - type: 'boolean', - default: false, - }) - .option('org', { - describe: 'Organization slug (required for --one-off when using SOCKET_API_TOKEN)', - type: 'string', - }) - .option('api-url', { - describe: 'Socket API URL (overrides SOCKET_API_URL env var)', - type: 'string', - }) - .option('api-token', { - describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)', - type: 'string', - }) - .option('ecosystems', { - describe: 'Restrict rollback to specific ecosystems (comma-separated)', - type: 'array', - choices: ['npm', 'pypi'], - }) - .example('$0 rollback', 'Rollback all patches') - .example( - '$0 rollback pkg:npm/lodash@4.17.21', - 'Rollback patches for a specific package', - ) - .example( - '$0 rollback 12345678-1234-1234-1234-123456789abc', - 'Rollback a patch by UUID', - ) - .example('$0 rollback --dry-run', 'Preview what would be rolled back') - .example('$0 rollback --global', 'Rollback patches from global npm packages') - .example( - '$0 rollback pkg:npm/lodash@4.17.21 --one-off --global', - 'Rollback global package by fetching blobs from API', - ) - .check(argv => { - if (argv['one-off'] && !argv.identifier) { - throw new Error('--one-off requires an identifier (UUID or PURL)') - } - return true - }) - }, - handler: async argv => { - // Get API credentials for authenticated telemetry (optional). - const apiToken = argv['api-token'] || process.env['SOCKET_API_TOKEN'] - const orgSlug = argv.org || process.env['SOCKET_ORG_SLUG'] - - try { - // Handle one-off mode (no manifest required) - if (argv['one-off']) { - const success = await rollbackOneOff( - argv.identifier!, - argv.cwd, - argv.global, - argv['global-prefix'], - argv['dry-run'], - argv.silent, - argv.org, - argv['api-url'], - argv['api-token'], - ) - - // Track telemetry for one-off rollback. - if (success) { - await trackPatchRolledBack(1, apiToken, orgSlug) - } else { - await trackPatchRollbackFailed( - new Error('One-off rollback failed'), - apiToken, - orgSlug, - ) - } - - process.exit(success ? 0 : 1) - } - - const manifestPath = path.isAbsolute(argv['manifest-path']) - ? argv['manifest-path'] - : path.join(argv.cwd, argv['manifest-path']) - - // Check if manifest exists - try { - await fs.access(manifestPath) - } catch { - if (!argv.silent) { - console.error(`Manifest not found at ${manifestPath}`) - } - process.exit(1) - } - - const { success, results } = await rollbackPatches( - argv.cwd, - manifestPath, - argv.identifier, - argv['dry-run'], - argv.silent, - argv.offline, - argv.global, - argv['global-prefix'], - argv.ecosystems, - ) - - // Print results if not silent - if (!argv.silent && results.length > 0) { - const rolledBack = results.filter(r => r.success && r.filesRolledBack.length > 0) - const alreadyOriginal = results.filter(r => - r.success && r.filesVerified.every(f => f.status === 'already-original'), - ) - const failed = results.filter(r => !r.success) - - if (argv['dry-run']) { - console.log('\nRollback verification complete:') - const canRollback = results.filter(r => r.success) - console.log(` ${canRollback.length} package(s) can be rolled back`) - if (alreadyOriginal.length > 0) { - console.log( - ` ${alreadyOriginal.length} package(s) already in original state`, - ) - } - if (failed.length > 0) { - console.log(` ${failed.length} package(s) cannot be rolled back`) - } - } else { - if (rolledBack.length > 0 || alreadyOriginal.length > 0) { - console.log('\nRolled back packages:') - for (const result of rolledBack) { - console.log(` ${result.packageKey}`) - } - for (const result of alreadyOriginal) { - console.log(` ${result.packageKey} (already original)`) - } - } - if (failed.length > 0) { - console.log('\nFailed to rollback:') - for (const result of failed) { - console.log(` ${result.packageKey}: ${result.error}`) - } - } - } - } - - // Track telemetry event. - const rolledBackCount = results.filter(r => r.success && r.filesRolledBack.length > 0).length - if (success) { - await trackPatchRolledBack(rolledBackCount, apiToken, orgSlug) - } else { - await trackPatchRollbackFailed( - new Error('One or more rollbacks failed'), - apiToken, - orgSlug, - ) - } - - process.exit(success ? 0 : 1) - } catch (err) { - // Track telemetry for unexpected errors. - const error = err instanceof Error ? err : new Error(String(err)) - await trackPatchRollbackFailed(error, apiToken, orgSlug) - - if (!argv.silent) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - } - process.exit(1) - } - }, -} - -/** - * Parse a PURL to extract the package directory path, version, and ecosystem. - * Supports both npm and pypi PURLs. - */ -function parsePurl(purl: string): { packageDir: string; version: string; ecosystem: 'npm' | 'pypi' } | null { - const base = stripPurlQualifiers(purl) - const npmMatch = base.match(/^pkg:npm\/(.+)@([^@]+)$/) - if (npmMatch) return { packageDir: npmMatch[1], version: npmMatch[2], ecosystem: 'npm' } - const pypiMatch = base.match(/^pkg:pypi\/(.+)@([^@]+)$/) - if (pypiMatch) return { packageDir: pypiMatch[1], version: pypiMatch[2], ecosystem: 'pypi' } - return null -} - -/** - * Rollback a patch without using the manifest (one-off mode) - * Downloads beforeHash blobs from API on demand - */ -async function rollbackOneOff( - identifier: string, - cwd: string, - useGlobal: boolean, - globalPrefix: string | undefined, - dryRun: boolean, - silent: boolean, - orgSlug: string | undefined, - apiUrl: string | undefined, - apiToken: string | undefined, -): Promise { - // Override environment variables if CLI options are provided - if (apiUrl) { - process.env.SOCKET_API_URL = apiUrl - } - if (apiToken) { - process.env.SOCKET_API_TOKEN = apiToken - } - - // Get API client - const { client: apiClient, usePublicProxy } = getAPIClientFromEnv() - - // Validate that org is provided when using authenticated API - if (!usePublicProxy && !orgSlug) { - throw new Error( - '--org is required when using SOCKET_API_TOKEN. Provide an organization slug.', - ) - } - - const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null - - if (!silent) { - console.log(`Fetching patch data for: ${identifier}`) - } - - // Fetch the patch (can be UUID or PURL) - let patch - if (identifier.startsWith('pkg:')) { - // Search by PURL - const searchResponse = await apiClient.searchPatchesByPackage(effectiveOrgSlug, identifier) - if (searchResponse.patches.length === 0) { - throw new Error(`No patch found for PURL: ${identifier}`) - } - patch = await apiClient.fetchPatch(effectiveOrgSlug, searchResponse.patches[0].uuid) - } else { - // Assume UUID - patch = await apiClient.fetchPatch(effectiveOrgSlug, identifier) - } - - if (!patch) { - throw new Error(`Could not fetch patch: ${identifier}`) - } - - // Parse PURL to get package directory - const parsed = parsePurl(patch.purl) - if (!parsed) { - throw new Error(`Invalid PURL format: ${patch.purl}`) - } - - let pkgPath: string - const crawlerOptions = { cwd, global: useGlobal, globalPrefix } - - if (parsed.ecosystem === 'pypi') { - // Find the Python package in site-packages - const pythonCrawler = new PythonCrawler() - const basePurl = stripPurlQualifiers(patch.purl) - const spPaths = await pythonCrawler.getSitePackagesPaths(crawlerOptions) - let found = false - pkgPath = '' - - for (const spPath of spPaths) { - const packages = await pythonCrawler.findByPurls(spPath, [basePurl]) - const pkg = packages.get(basePurl) - if (pkg) { - pkgPath = pkg.path - found = true - break - } - } - - if (!found) { - throw new Error(`Python package not found: ${parsed.packageDir}@${parsed.version}`) - } - } else { - // npm: Determine node_modules path - let nodeModulesPath: string - if (useGlobal || globalPrefix) { - try { - nodeModulesPath = getGlobalPrefix(globalPrefix) - if (!silent) { - console.log(`Using global npm packages at: ${nodeModulesPath}`) - } - } catch (error) { - throw new Error( - `Failed to find global npm packages: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } else { - nodeModulesPath = path.join(cwd, 'node_modules') - } - - pkgPath = path.join(nodeModulesPath, parsed.packageDir) - - // Verify npm package exists - try { - const pkgJsonPath = path.join(pkgPath, 'package.json') - const pkgJsonContent = await fs.readFile(pkgJsonPath, 'utf-8') - const pkgJson = JSON.parse(pkgJsonContent) - if (pkgJson.version !== parsed.version) { - if (!silent) { - console.log(`Note: Installed version ${pkgJson.version} differs from patch version ${parsed.version}`) - } - } - } catch { - throw new Error(`Package not found: ${parsed.packageDir}`) - } - } - - // Create temporary directory for blobs - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-')) - const tempBlobsDir = path.join(tempDir, 'blobs') - await fs.mkdir(tempBlobsDir, { recursive: true }) - - try { - // Download beforeHash blobs - const beforeHashes = new Set() - for (const fileInfo of Object.values(patch.files)) { - if (fileInfo.beforeHash) { - beforeHashes.add(fileInfo.beforeHash) - } - // Also save beforeBlobContent if available - if (fileInfo.beforeBlobContent && fileInfo.beforeHash) { - const blobBuffer = Buffer.from(fileInfo.beforeBlobContent, 'base64') - await fs.writeFile(path.join(tempBlobsDir, fileInfo.beforeHash), blobBuffer) - beforeHashes.delete(fileInfo.beforeHash) - } - } - - // Fetch any missing beforeHash blobs - if (beforeHashes.size > 0) { - if (!silent) { - console.log(`Downloading ${beforeHashes.size} blob(s) for rollback...`) - } - const fetchResult = await fetchBlobsByHash(beforeHashes, tempBlobsDir, undefined, { - onProgress: silent - ? undefined - : (hash, index, total) => { - process.stdout.write( - `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), - ) - }, - }) - if (!silent) { - process.stdout.write('\r' + ' '.repeat(60) + '\r') - console.log(formatFetchResult(fetchResult)) - } - if (fetchResult.failed > 0) { - throw new Error('Some blobs could not be downloaded. Cannot rollback.') - } - } - - // Build files record - const files: Record = {} - for (const [filePath, fileInfo] of Object.entries(patch.files)) { - if (fileInfo.beforeHash && fileInfo.afterHash) { - files[filePath] = { - beforeHash: fileInfo.beforeHash, - afterHash: fileInfo.afterHash, - } - } - } - - if (dryRun) { - if (!silent) { - console.log(`\nDry run: Would rollback ${patch.purl}`) - console.log(` Files: ${Object.keys(files).length}`) - } - return true - } - - // Perform rollback - const result = await rollbackPackagePatch( - patch.purl, - pkgPath, - files, - tempBlobsDir, - false, - ) - - if (result.success) { - if (!silent) { - if (result.filesRolledBack.length > 0) { - console.log(`\nRolled back ${patch.purl}`) - } else if (result.filesVerified.every(f => f.status === 'already-original')) { - console.log(`\n${patch.purl} is already in original state`) - } - } - return true - } else { - throw new Error(result.error || 'Unknown rollback error') - } - } finally { - // Clean up temp directory - await fs.rm(tempDir, { recursive: true, force: true }) - } -} - -// Export the rollback function for use by other commands (e.g., remove) -export { rollbackPatches, findPatchesToRollback } diff --git a/src/commands/scan-authenticated.test.ts b/src/commands/scan-authenticated.test.ts deleted file mode 100644 index b36ab3a..0000000 --- a/src/commands/scan-authenticated.test.ts +++ /dev/null @@ -1,944 +0,0 @@ -import { describe, it, before, after, beforeEach, afterEach } from 'node:test' -import assert from 'node:assert/strict' -import * as path from 'path' -import * as http from 'node:http' -import type { AddressInfo } from 'node:net' -import { - createTestDir, - removeTestDir, - createTestPackage, -} from '../test-utils.js' -import { APIClient, type BatchSearchResponse } from '../utils/api-client.js' -import { NpmCrawler } from '../crawlers/index.js' - -// Test UUIDs -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' -const TEST_UUID_2 = '22222222-2222-4222-8222-222222222222' -const TEST_UUID_3 = '33333333-3333-4333-8333-333333333333' - -/** - * Create a mock HTTP server that simulates the Socket API - */ -function createMockServer(options: { - requireAuth?: boolean - canAccessPaidPatches?: boolean - patchResponses?: Map -}): http.Server { - const { - requireAuth = false, - canAccessPaidPatches = false, - patchResponses = new Map(), - } = options - - return http.createServer((req, res) => { - // Check authorization if required - if (requireAuth) { - const authHeader = req.headers['authorization'] - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.writeHead(401, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Unauthorized' })) - return - } - } - - // Handle batch endpoint (authenticated API) - if (req.method === 'POST' && req.url?.includes('/patches/batch')) { - let body = '' - req.on('data', chunk => { - body += chunk - }) - req.on('end', () => { - try { - const { components } = JSON.parse(body) as { components: Array<{ purl: string }> } - const purls = components.map(c => c.purl) - - // Build response from configured patch responses - const packages: BatchSearchResponse['packages'] = [] - for (const purl of purls) { - // Check each configured response - for (const [, response] of patchResponses) { - const pkg = response.packages.find((p: BatchSearchResponse['packages'][0]) => p.purl === purl) - if (pkg) { - packages.push(pkg) - } - } - } - - const response: BatchSearchResponse = { - packages, - canAccessPaidPatches, - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(response)) - } catch { - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Invalid request' })) - } - }) - return - } - - // Handle individual package endpoint (public proxy) - if (req.method === 'GET' && req.url?.includes('/patch/by-package/')) { - const purl = decodeURIComponent(req.url.split('/patch/by-package/')[1]) - - // Find matching package in configured responses - for (const [, response] of patchResponses) { - const pkg = response.packages.find((p: BatchSearchResponse['packages'][0]) => p.purl === purl) - if (pkg) { - // Convert batch format to search response format - const searchResponse = { - patches: pkg.patches.map((patch: BatchSearchResponse['packages'][0]['patches'][0]) => ({ - uuid: patch.uuid, - purl: patch.purl, - publishedAt: new Date().toISOString(), - description: patch.title, - license: 'MIT', - tier: patch.tier, - vulnerabilities: Object.fromEntries( - patch.ghsaIds.map((ghsaId: string, i: number) => [ - ghsaId, - { - cves: patch.cveIds.slice(i, i + 1), - summary: patch.title, - severity: patch.severity || 'unknown', - description: patch.title, - }, - ]), - ), - })), - canAccessPaidPatches, - } - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(searchResponse)) - return - } - } - - // No patches found for this package - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ patches: [], canAccessPaidPatches: false })) - return - } - - // 404 for unknown endpoints - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Not found' })) - }) -} - -describe('scan command authenticated API', () => { - describe('APIClient with paid tier token', () => { - let server: http.Server - let serverUrl: string - let testDir: string - let nodeModulesDir: string - - const patchResponses = new Map() - - before(async () => { - // Create test directory with packages - testDir = await createTestDir('scan-auth-paid-') - nodeModulesDir = path.join(testDir, 'node_modules') - - // Create test packages - await createTestPackage(nodeModulesDir, 'vulnerable-pkg', '1.0.0', { - 'index.js': 'module.exports = "vulnerable"', - }) - await createTestPackage(nodeModulesDir, 'another-pkg', '2.0.0', { - 'index.js': 'module.exports = "another"', - }) - - // Configure mock responses - patchResponses.set('vulnerable-pkg', { - packages: [ - { - purl: 'pkg:npm/vulnerable-pkg@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/vulnerable-pkg@1.0.0', - tier: 'free', - cveIds: ['CVE-2024-1234'], - ghsaIds: ['GHSA-xxxx-xxxx-xxxx'], - severity: 'high', - title: 'Prototype pollution vulnerability', - }, - { - uuid: TEST_UUID_2, - purl: 'pkg:npm/vulnerable-pkg@1.0.0', - tier: 'paid', - cveIds: ['CVE-2024-5678'], - ghsaIds: ['GHSA-yyyy-yyyy-yyyy'], - severity: 'critical', - title: 'Remote code execution', - }, - ], - }, - ], - canAccessPaidPatches: true, - }) - - // Start mock server (requires auth, can access paid patches) - server = createMockServer({ - requireAuth: true, - canAccessPaidPatches: true, - patchResponses, - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - after(async () => { - await removeTestDir(testDir) - await new Promise(resolve => server.close(() => resolve())) - }) - - it('should authenticate with Bearer token', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-paid-token', - orgSlug: 'test-org', - }) - - const response = await client.searchPatchesBatch('test-org', [ - 'pkg:npm/vulnerable-pkg@1.0.0', - ]) - - assert.ok(response, 'Should get response') - assert.equal(response.canAccessPaidPatches, true, 'Should have paid access') - assert.equal(response.packages.length, 1, 'Should find 1 package with patches') - }) - - it('should fail without auth token when server requires auth', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - // No token - }) - - await assert.rejects( - async () => client.searchPatchesBatch('test-org', ['pkg:npm/vulnerable-pkg@1.0.0']), - /Unauthorized/, - 'Should reject with unauthorized error', - ) - }) - - it('should return both free and paid patches with paid token', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-paid-token', - orgSlug: 'test-org', - }) - - const response = await client.searchPatchesBatch('test-org', [ - 'pkg:npm/vulnerable-pkg@1.0.0', - ]) - - assert.equal(response.packages.length, 1) - const pkg = response.packages[0] - assert.equal(pkg.patches.length, 2, 'Should have 2 patches (free + paid)') - - const freePatch = pkg.patches.find(p => p.tier === 'free') - const paidPatch = pkg.patches.find(p => p.tier === 'paid') - - assert.ok(freePatch, 'Should have free patch') - assert.ok(paidPatch, 'Should have paid patch') - assert.deepEqual(freePatch.cveIds, ['CVE-2024-1234']) - assert.deepEqual(paidPatch.cveIds, ['CVE-2024-5678']) - }) - - it('should indicate canAccessPaidPatches: true for paid tier', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-paid-token', - orgSlug: 'test-org', - }) - - const response = await client.searchPatchesBatch('test-org', [ - 'pkg:npm/vulnerable-pkg@1.0.0', - ]) - - assert.equal(response.canAccessPaidPatches, true) - }) - }) - - describe('APIClient with free tier (public proxy)', () => { - let server: http.Server - let serverUrl: string - let testDir: string - let nodeModulesDir: string - - const patchResponses = new Map() - - before(async () => { - // Create test directory with packages - testDir = await createTestDir('scan-auth-free-') - nodeModulesDir = path.join(testDir, 'node_modules') - - await createTestPackage(nodeModulesDir, 'free-vuln-pkg', '1.0.0', { - 'index.js': 'module.exports = "free"', - }) - - // Configure mock responses for free tier - patchResponses.set('free-vuln-pkg', { - packages: [ - { - purl: 'pkg:npm/free-vuln-pkg@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/free-vuln-pkg@1.0.0', - tier: 'free', - cveIds: ['CVE-2024-1111'], - ghsaIds: ['GHSA-free-free-free'], - severity: 'medium', - title: 'XSS vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: false, - }) - - // Start mock server (no auth required, no paid access) - server = createMockServer({ - requireAuth: false, - canAccessPaidPatches: false, - patchResponses, - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - after(async () => { - await removeTestDir(testDir) - await new Promise(resolve => server.close(() => resolve())) - }) - - it('should work without authentication token (public proxy)', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - // Public proxy uses individual GET requests, not batch POST - const response = await client.searchPatchesBatch(null, [ - 'pkg:npm/free-vuln-pkg@1.0.0', - ]) - - assert.ok(response, 'Should get response') - assert.equal(response.packages.length, 1, 'Should find 1 package') - }) - - it('should indicate canAccessPaidPatches: false for free tier', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - const response = await client.searchPatchesBatch(null, [ - 'pkg:npm/free-vuln-pkg@1.0.0', - ]) - - assert.equal(response.canAccessPaidPatches, false) - }) - - it('should only return free patches for free tier', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - const response = await client.searchPatchesBatch(null, [ - 'pkg:npm/free-vuln-pkg@1.0.0', - ]) - - assert.equal(response.packages.length, 1) - const pkg = response.packages[0] - - // All patches should be free tier - for (const patch of pkg.patches) { - assert.equal(patch.tier, 'free', 'All patches should be free tier') - } - }) - - it('should not require org slug for public proxy', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - // Should work with null org slug - const response = await client.searchPatchesBatch(null, [ - 'pkg:npm/free-vuln-pkg@1.0.0', - ]) - - assert.ok(response, 'Should work without org slug') - }) - }) - - describe('Batch mode scanning', () => { - let server: http.Server - let serverUrl: string - let testDir: string - let nodeModulesDir: string - let requestCount: number - let requestedPurls: string[][] - - const patchResponses = new Map() - - before(async () => { - // Create test directory with many packages - testDir = await createTestDir('scan-batch-') - nodeModulesDir = path.join(testDir, 'node_modules') - - // Create 10 test packages - for (let i = 1; i <= 10; i++) { - await createTestPackage(nodeModulesDir, `batch-pkg-${i}`, '1.0.0', { - 'index.js': `module.exports = "pkg${i}"`, - }) - } - - // Configure mock responses for some packages - patchResponses.set('batch-pkg-1', { - packages: [ - { - purl: 'pkg:npm/batch-pkg-1@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/batch-pkg-1@1.0.0', - tier: 'free', - cveIds: ['CVE-2024-0001'], - ghsaIds: ['GHSA-0001-0001-0001'], - severity: 'high', - title: 'Batch pkg 1 vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: true, - }) - - patchResponses.set('batch-pkg-5', { - packages: [ - { - purl: 'pkg:npm/batch-pkg-5@1.0.0', - patches: [ - { - uuid: TEST_UUID_2, - purl: 'pkg:npm/batch-pkg-5@1.0.0', - tier: 'paid', - cveIds: ['CVE-2024-0005'], - ghsaIds: ['GHSA-0005-0005-0005'], - severity: 'critical', - title: 'Batch pkg 5 vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: true, - }) - - patchResponses.set('batch-pkg-10', { - packages: [ - { - purl: 'pkg:npm/batch-pkg-10@1.0.0', - patches: [ - { - uuid: TEST_UUID_3, - purl: 'pkg:npm/batch-pkg-10@1.0.0', - tier: 'free', - cveIds: ['CVE-2024-0010'], - ghsaIds: ['GHSA-0010-0010-0010'], - severity: 'medium', - title: 'Batch pkg 10 vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: true, - }) - }) - - beforeEach(async () => { - requestCount = 0 - requestedPurls = [] - - // Create a new server for each test to track requests - server = http.createServer((req, res) => { - if (req.method === 'POST' && req.url?.includes('/patches/batch')) { - requestCount++ - let body = '' - req.on('data', chunk => { - body += chunk - }) - req.on('end', () => { - const { components } = JSON.parse(body) as { components: Array<{ purl: string }> } - const purls = components.map(c => c.purl) - requestedPurls.push(purls) - - // Build response - const packages: BatchSearchResponse['packages'] = [] - for (const purl of purls) { - for (const [, response] of patchResponses) { - const pkg = response.packages.find(p => p.purl === purl) - if (pkg) { - packages.push(pkg) - } - } - } - - const response: BatchSearchResponse = { - packages, - canAccessPaidPatches: true, - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(response)) - }) - return - } - - res.writeHead(404) - res.end() - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - afterEach(async () => { - await new Promise(resolve => server.close(() => resolve())) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should send packages in batches', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - // Crawl all packages - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - const purls = packages.map(p => p.purl) - - assert.equal(purls.length, 10, 'Should have 10 packages') - - // Make a single batch request with all packages - const response = await client.searchPatchesBatch('test-org', purls) - - assert.ok(response, 'Should get a response') - assert.equal(requestCount, 1, 'Should make 1 batch request') - assert.equal(requestedPurls[0].length, 10, 'Batch should contain all 10 purls') - }) - - it('should split large requests into multiple batches', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - // Create a large list of purls (simulating many packages) - const purls: string[] = [] - for (let i = 1; i <= 10; i++) { - purls.push(`pkg:npm/batch-pkg-${i}@1.0.0`) - } - - // The batch endpoint accepts all at once, but the scan command - // splits them based on batch-size option - // Here we're testing the APIClient directly which sends all in one request - const response = await client.searchPatchesBatch('test-org', purls) - - assert.ok(response, 'Should get response') - assert.equal(response.packages.length, 3, 'Should find 3 packages with patches') - }) - - it('should handle batch response correctly', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - const purls = [ - 'pkg:npm/batch-pkg-1@1.0.0', - 'pkg:npm/batch-pkg-5@1.0.0', - 'pkg:npm/batch-pkg-10@1.0.0', - ] - - const response = await client.searchPatchesBatch('test-org', purls) - - assert.equal(response.packages.length, 3, 'Should return 3 packages') - assert.equal(response.canAccessPaidPatches, true) - - // Verify each package - const pkg1 = response.packages.find(p => p.purl === 'pkg:npm/batch-pkg-1@1.0.0') - const pkg5 = response.packages.find(p => p.purl === 'pkg:npm/batch-pkg-5@1.0.0') - const pkg10 = response.packages.find(p => p.purl === 'pkg:npm/batch-pkg-10@1.0.0') - - assert.ok(pkg1, 'Should have batch-pkg-1') - assert.ok(pkg5, 'Should have batch-pkg-5') - assert.ok(pkg10, 'Should have batch-pkg-10') - - assert.equal(pkg1.patches[0].tier, 'free') - assert.equal(pkg5.patches[0].tier, 'paid') - assert.equal(pkg10.patches[0].tier, 'free') - }) - - it('should return empty packages array for packages without patches', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - // Request packages that don't have patches configured - const purls = [ - 'pkg:npm/batch-pkg-2@1.0.0', - 'pkg:npm/batch-pkg-3@1.0.0', - ] - - const response = await client.searchPatchesBatch('test-org', purls) - - assert.equal(response.packages.length, 0, 'Should return empty packages array') - }) - - it('should aggregate results across multiple batches', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - // First batch: packages 1-5 - const response1 = await client.searchPatchesBatch('test-org', [ - 'pkg:npm/batch-pkg-1@1.0.0', - 'pkg:npm/batch-pkg-2@1.0.0', - 'pkg:npm/batch-pkg-3@1.0.0', - 'pkg:npm/batch-pkg-4@1.0.0', - 'pkg:npm/batch-pkg-5@1.0.0', - ]) - - // Second batch: packages 6-10 - const response2 = await client.searchPatchesBatch('test-org', [ - 'pkg:npm/batch-pkg-6@1.0.0', - 'pkg:npm/batch-pkg-7@1.0.0', - 'pkg:npm/batch-pkg-8@1.0.0', - 'pkg:npm/batch-pkg-9@1.0.0', - 'pkg:npm/batch-pkg-10@1.0.0', - ]) - - // Aggregate results (as the scan command does) - const allPackages = [...response1.packages, ...response2.packages] - const canAccessPaid = response1.canAccessPaidPatches || response2.canAccessPaidPatches - - assert.equal(allPackages.length, 3, 'Should find 3 packages with patches total') - assert.equal(canAccessPaid, true) - - // Verify we found packages from both batches - const foundPurls = allPackages.map(p => p.purl).sort() - assert.deepEqual(foundPurls, [ - 'pkg:npm/batch-pkg-10@1.0.0', - 'pkg:npm/batch-pkg-1@1.0.0', - 'pkg:npm/batch-pkg-5@1.0.0', - ].sort()) - }) - }) - - describe('Public proxy fallback to individual requests', () => { - let server: http.Server - let serverUrl: string - let testDir: string - let nodeModulesDir: string - let getRequestCount: number - let getRequestedPurls: string[] - - const patchResponses = new Map() - - before(async () => { - testDir = await createTestDir('scan-proxy-fallback-') - nodeModulesDir = path.join(testDir, 'node_modules') - - await createTestPackage(nodeModulesDir, 'proxy-pkg-1', '1.0.0', { - 'index.js': 'module.exports = "pkg1"', - }) - await createTestPackage(nodeModulesDir, 'proxy-pkg-2', '1.0.0', { - 'index.js': 'module.exports = "pkg2"', - }) - - patchResponses.set('proxy-pkg-1', { - packages: [ - { - purl: 'pkg:npm/proxy-pkg-1@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/proxy-pkg-1@1.0.0', - tier: 'free', - cveIds: ['CVE-2024-PROX'], - ghsaIds: ['GHSA-prox-prox-prox'], - severity: 'low', - title: 'Proxy pkg 1 vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: false, - }) - }) - - beforeEach(async () => { - getRequestCount = 0 - getRequestedPurls = [] - - server = http.createServer((req, res) => { - // Public proxy uses GET requests for individual packages - if (req.method === 'GET' && req.url?.includes('/patch/by-package/')) { - getRequestCount++ - const purl = decodeURIComponent(req.url.split('/patch/by-package/')[1]) - getRequestedPurls.push(purl) - - // Find matching package - for (const [, response] of patchResponses) { - const pkg = response.packages.find(p => p.purl === purl) - if (pkg) { - const searchResponse = { - patches: pkg.patches.map(patch => ({ - uuid: patch.uuid, - purl: patch.purl, - publishedAt: new Date().toISOString(), - description: patch.title, - license: 'MIT', - tier: patch.tier, - vulnerabilities: { - [patch.ghsaIds[0]]: { - cves: patch.cveIds, - summary: patch.title, - severity: patch.severity || 'unknown', - description: patch.title, - }, - }, - })), - canAccessPaidPatches: false, - } - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(searchResponse)) - return - } - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ patches: [], canAccessPaidPatches: false })) - return - } - - res.writeHead(404) - res.end() - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - afterEach(async () => { - await new Promise(resolve => server.close(() => resolve())) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should use individual GET requests for public proxy batch search', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - const purls = [ - 'pkg:npm/proxy-pkg-1@1.0.0', - 'pkg:npm/proxy-pkg-2@1.0.0', - ] - - await client.searchPatchesBatch(null, purls) - - // Public proxy falls back to individual GET requests - assert.equal(getRequestCount, 2, 'Should make 2 individual GET requests') - assert.ok(getRequestedPurls.includes('pkg:npm/proxy-pkg-1@1.0.0')) - assert.ok(getRequestedPurls.includes('pkg:npm/proxy-pkg-2@1.0.0')) - }) - - it('should aggregate results from individual requests', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - usePublicProxy: true, - }) - - const purls = [ - 'pkg:npm/proxy-pkg-1@1.0.0', - 'pkg:npm/proxy-pkg-2@1.0.0', - ] - - const response = await client.searchPatchesBatch(null, purls) - - // Only proxy-pkg-1 has patches configured - assert.equal(response.packages.length, 1) - assert.equal(response.packages[0].purl, 'pkg:npm/proxy-pkg-1@1.0.0') - }) - }) - - describe('Error handling', () => { - let server: http.Server - let serverUrl: string - - beforeEach(async () => { - server = http.createServer((req, res) => { - if (req.url?.includes('/error-401')) { - res.writeHead(401, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Unauthorized' })) - return - } - if (req.url?.includes('/error-403')) { - res.writeHead(403, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Forbidden' })) - return - } - if (req.url?.includes('/error-429')) { - res.writeHead(429, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Rate limited' })) - return - } - if (req.url?.includes('/error-500')) { - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Server error' })) - return - } - - res.writeHead(404) - res.end() - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - afterEach(async () => { - await new Promise(resolve => server.close(() => resolve())) - }) - - it('should throw on 401 Unauthorized', async () => { - const client = new APIClient({ - apiUrl: `${serverUrl}/error-401`, - apiToken: 'invalid-token', - }) - - await assert.rejects( - async () => client.searchPatchesBatch('test-org', ['pkg:npm/test@1.0.0']), - /Unauthorized/, - ) - }) - - it('should throw on 403 Forbidden', async () => { - const client = new APIClient({ - apiUrl: `${serverUrl}/error-403`, - apiToken: 'test-token', - }) - - await assert.rejects( - async () => client.searchPatchesBatch('test-org', ['pkg:npm/test@1.0.0']), - /Forbidden/, - ) - }) - - it('should throw on 429 Rate Limit', async () => { - const client = new APIClient({ - apiUrl: `${serverUrl}/error-429`, - apiToken: 'test-token', - }) - - await assert.rejects( - async () => client.searchPatchesBatch('test-org', ['pkg:npm/test@1.0.0']), - /Rate limit/, - ) - }) - - it('should throw on 500 Server Error', async () => { - const client = new APIClient({ - apiUrl: `${serverUrl}/error-500`, - apiToken: 'test-token', - }) - - await assert.rejects( - async () => client.searchPatchesBatch('test-org', ['pkg:npm/test@1.0.0']), - /API request failed/, - ) - }) - }) - - describe('getAPIClientFromEnv behavior', () => { - let originalEnv: NodeJS.ProcessEnv - - beforeEach(() => { - originalEnv = { ...process.env } - }) - - afterEach(() => { - process.env = originalEnv - }) - - it('should use public proxy when no SOCKET_API_TOKEN is set', async () => { - delete process.env.SOCKET_API_TOKEN - delete process.env.SOCKET_API_URL - - const { getAPIClientFromEnv } = await import('../utils/api-client.js') - const { usePublicProxy } = getAPIClientFromEnv() - - assert.equal(usePublicProxy, true, 'Should use public proxy without token') - }) - - it('should use authenticated API when SOCKET_API_TOKEN is set', async () => { - process.env.SOCKET_API_TOKEN = 'test-token' - process.env.SOCKET_API_URL = 'https://api.socket.dev' - - // Re-import to get fresh state - const apiClientModule = await import('../utils/api-client.js') - const { usePublicProxy } = apiClientModule.getAPIClientFromEnv() - - assert.equal(usePublicProxy, false, 'Should use authenticated API with token') - }) - }) -}) diff --git a/src/commands/scan-python.test.ts b/src/commands/scan-python.test.ts deleted file mode 100644 index 17bd146..0000000 --- a/src/commands/scan-python.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, it, before, after, beforeEach, afterEach } from 'node:test' -import assert from 'node:assert/strict' -import * as path from 'path' -import * as http from 'node:http' -import type { AddressInfo } from 'node:net' -import { - createTestDir, - removeTestDir, - createTestPackage, - createTestPythonPackage, -} from '../test-utils.js' -import { APIClient, type BatchSearchResponse } from '../utils/api-client.js' -import { NpmCrawler, PythonCrawler } from '../crawlers/index.js' - -// Test UUIDs -const TEST_UUID_1 = 'ee111111-1111-4111-8111-111111111111' - -describe('scan command - Python packages', () => { - describe('combined npm + python scanning', () => { - let testDir: string - let nodeModulesDir: string - let sitePackagesDir: string - - before(async () => { - testDir = await createTestDir('scan-python-combined-') - nodeModulesDir = path.join(testDir, 'node_modules') - sitePackagesDir = path.join( - testDir, - '.venv', - 'lib', - 'python3.11', - 'site-packages', - ) - - // Create npm packages - await createTestPackage(nodeModulesDir, 'npm-pkg-a', '1.0.0', { - 'index.js': 'module.exports = "a"', - }) - await createTestPackage(nodeModulesDir, 'npm-pkg-b', '2.0.0', { - 'index.js': 'module.exports = "b"', - }) - - // Create python packages - await createTestPythonPackage(sitePackagesDir, 'requests', '2.28.0', {}) - await createTestPythonPackage(sitePackagesDir, 'flask', '2.3.0', {}) - await createTestPythonPackage(sitePackagesDir, 'six', '1.16.0', {}) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should find both npm and python packages', async () => { - const npmCrawler = new NpmCrawler() - const pythonCrawler = new PythonCrawler() - - const npmPackages = await npmCrawler.crawlAll({ cwd: testDir }) - const pythonPackages = await pythonCrawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - assert.ok(npmPackages.length >= 2, `Should find at least 2 npm packages, got ${npmPackages.length}`) - assert.equal(pythonPackages.length, 3, 'Should find 3 python packages') - - // Verify combined count - const totalCount = npmPackages.length + pythonPackages.length - assert.ok(totalCount >= 5, `Should find at least 5 total packages, got ${totalCount}`) - }) - }) - - describe('API receives pypi PURLs', () => { - let server: http.Server - let serverUrl: string - let testDir: string - let sitePackagesDir: string - let receivedPurls: string[] - - const patchResponses = new Map() - - before(async () => { - testDir = await createTestDir('scan-python-api-') - sitePackagesDir = path.join(testDir, 'site-packages') - - await createTestPythonPackage(sitePackagesDir, 'requests', '2.28.0', {}) - await createTestPythonPackage(sitePackagesDir, 'flask', '2.3.0', {}) - - patchResponses.set('requests', { - packages: [ - { - purl: 'pkg:pypi/requests@2.28.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:pypi/requests@2.28.0', - tier: 'free', - cveIds: ['CVE-2024-9999'], - ghsaIds: ['GHSA-pypi-pypi-pypi'], - severity: 'high', - title: 'Python requests vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: false, - }) - }) - - beforeEach(async () => { - receivedPurls = [] - - server = http.createServer((req, res) => { - if (req.method === 'POST' && req.url?.includes('/patches/batch')) { - let body = '' - req.on('data', (chunk: Buffer) => { - body += chunk - }) - req.on('end', () => { - const { components } = JSON.parse(body) as { - components: Array<{ purl: string }> - } - const purls = components.map(c => c.purl) - receivedPurls.push(...purls) - - // Build response - const packages: BatchSearchResponse['packages'] = [] - for (const purl of purls) { - for (const [, response] of patchResponses) { - const pkg = response.packages.find(p => p.purl === purl) - if (pkg) packages.push(pkg) - } - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ packages, canAccessPaidPatches: false }), - ) - }) - return - } - - res.writeHead(404) - res.end() - }) - - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const address = server.address() as AddressInfo - serverUrl = `http://127.0.0.1:${address.port}` - resolve() - }) - }) - }) - - afterEach(async () => { - await new Promise(resolve => server.close(() => resolve())) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should send pypi PURLs in batch request', async () => { - const client = new APIClient({ - apiUrl: serverUrl, - apiToken: 'test-token', - orgSlug: 'test-org', - }) - - // Crawl python packages - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - const purls = packages.map(p => p.purl) - - const response = await client.searchPatchesBatch('test-org', purls) - - // Verify pypi PURLs were sent - assert.ok( - receivedPurls.some(p => p.startsWith('pkg:pypi/')), - 'Should send pypi PURLs to API', - ) - assert.ok( - receivedPurls.includes('pkg:pypi/requests@2.28.0'), - 'Should include requests PURL', - ) - assert.ok( - receivedPurls.includes('pkg:pypi/flask@2.3.0'), - 'Should include flask PURL', - ) - - // Verify response - assert.equal(response.packages.length, 1) - assert.equal(response.packages[0].purl, 'pkg:pypi/requests@2.28.0') - }) - }) - - describe('ecosystem summary', () => { - it('should build ecosystem summary with both npm and python counts', () => { - // This tests the summary formatting logic from scan.ts - const npmCount = 2 - const pythonCount = 3 - - const ecosystemParts: string[] = [] - if (npmCount > 0) ecosystemParts.push(`${npmCount} npm`) - if (pythonCount > 0) ecosystemParts.push(`${pythonCount} python`) - const ecosystemSummary = - ecosystemParts.length > 0 - ? ` (${ecosystemParts.join(', ')})` - : '' - - assert.equal(ecosystemSummary, ' (2 npm, 3 python)') - }) - }) - - describe('scan with only python packages', () => { - let testDir: string - let sitePackagesDir: string - - before(async () => { - testDir = await createTestDir('scan-python-only-') - sitePackagesDir = path.join(testDir, 'site-packages') - - await createTestPythonPackage(sitePackagesDir, 'requests', '2.28.0', {}) - await createTestPythonPackage(sitePackagesDir, 'flask', '2.3.0', {}) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should work with only python packages (no node_modules)', async () => { - const npmCrawler = new NpmCrawler() - const pythonCrawler = new PythonCrawler() - - const npmPackages = await npmCrawler.crawlAll({ cwd: testDir }) - const pythonPackages = await pythonCrawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - assert.equal(npmPackages.length, 0, 'Should find no npm packages') - assert.equal(pythonPackages.length, 2, 'Should find 2 python packages') - - const allPurls = [ - ...npmPackages.map(p => p.purl), - ...pythonPackages.map(p => p.purl), - ] - assert.equal(allPurls.length, 2) - assert.ok(allPurls.every(p => p.startsWith('pkg:pypi/'))) - }) - }) - - describe('scan with no packages found', () => { - it('should handle empty dirs gracefully', async () => { - const tempDir = await createTestDir('scan-python-empty-') - - const npmCrawler = new NpmCrawler() - const pythonCrawler = new PythonCrawler() - - const npmPackages = await npmCrawler.crawlAll({ cwd: tempDir }) - // Use globalPrefix pointing to non-existent dir to avoid finding system packages - const pythonPackages = await pythonCrawler.crawlAll({ - cwd: tempDir, - globalPrefix: path.join(tempDir, 'nonexistent-site-packages'), - }) - - const packageCount = npmPackages.length + pythonPackages.length - assert.equal(packageCount, 0, 'Should find no packages') - - await removeTestDir(tempDir) - }) - }) -}) diff --git a/src/commands/scan.test.ts b/src/commands/scan.test.ts deleted file mode 100644 index 067d131..0000000 --- a/src/commands/scan.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - createTestPackage, -} from '../test-utils.js' -import { NpmCrawler } from '../crawlers/index.js' - -// Test UUID -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' - -describe('scan command', () => { - describe('NpmCrawler', () => { - let testDir: string - let nodeModulesDir: string - - before(async () => { - testDir = await createTestDir('scan-crawler-') - nodeModulesDir = path.join(testDir, 'node_modules') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should crawl packages in node_modules', async () => { - // Create test packages - await createTestPackage(nodeModulesDir, 'test-pkg-a', '1.0.0', { - 'index.js': 'module.exports = "a"', - }) - await createTestPackage(nodeModulesDir, 'test-pkg-b', '2.0.0', { - 'index.js': 'module.exports = "b"', - }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - assert.ok(packages.length >= 2, 'Should find at least 2 packages') - - const pkgA = packages.find(p => p.name === 'test-pkg-a') - const pkgB = packages.find(p => p.name === 'test-pkg-b') - - assert.ok(pkgA, 'Should find test-pkg-a') - assert.ok(pkgB, 'Should find test-pkg-b') - - assert.equal(pkgA.version, '1.0.0') - assert.equal(pkgA.purl, 'pkg:npm/test-pkg-a@1.0.0') - - assert.equal(pkgB.version, '2.0.0') - assert.equal(pkgB.purl, 'pkg:npm/test-pkg-b@2.0.0') - }) - - it('should handle scoped packages', async () => { - await createTestPackage(nodeModulesDir, '@scope/test-pkg', '3.0.0', { - 'index.js': 'module.exports = "scoped"', - }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const scopedPkg = packages.find(p => p.namespace === '@scope' && p.name === 'test-pkg') - assert.ok(scopedPkg, 'Should find scoped package') - assert.equal(scopedPkg.version, '3.0.0') - assert.equal(scopedPkg.purl, 'pkg:npm/@scope/test-pkg@3.0.0') - }) - - it('should yield packages in batches', async () => { - const crawler = new NpmCrawler() - const batches: number[] = [] - - for await (const batch of crawler.crawlBatches({ cwd: testDir, batchSize: 2 })) { - batches.push(batch.length) - } - - assert.ok(batches.length > 0, 'Should yield at least one batch') - // With batch size 2 and 3+ packages, should have multiple batches - if (batches.length > 1) { - assert.ok(batches[0] <= 2, 'Batch size should be respected') - } - }) - - it('should find packages by PURL', async () => { - const crawler = new NpmCrawler() - - const purls = [ - 'pkg:npm/test-pkg-a@1.0.0', - 'pkg:npm/@scope/test-pkg@3.0.0', - 'pkg:npm/nonexistent@1.0.0', // Should not be found - ] - - const found = await crawler.findByPurls(nodeModulesDir, purls) - - assert.equal(found.size, 2, 'Should find 2 packages') - assert.ok(found.has('pkg:npm/test-pkg-a@1.0.0'), 'Should find test-pkg-a') - assert.ok(found.has('pkg:npm/@scope/test-pkg@3.0.0'), 'Should find scoped package') - assert.ok(!found.has('pkg:npm/nonexistent@1.0.0'), 'Should not find nonexistent') - }) - - it('should deduplicate packages by PURL', async () => { - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const purls = new Set(packages.map(p => p.purl)) - assert.equal(purls.size, packages.length, 'All PURLs should be unique') - }) - }) - - describe('NpmCrawler with nested node_modules', () => { - let testDir: string - let nodeModulesDir: string - - before(async () => { - testDir = await createTestDir('scan-nested-') - nodeModulesDir = path.join(testDir, 'node_modules') - - // Create parent package - await createTestPackage(nodeModulesDir, 'parent-pkg', '1.0.0', { - 'index.js': 'module.exports = "parent"', - }) - - // Create nested node_modules inside parent-pkg - const nestedNodeModules = path.join(nodeModulesDir, 'parent-pkg', 'node_modules') - await createTestPackage(nestedNodeModules, 'nested-pkg', '1.0.0', { - 'index.js': 'module.exports = "nested"', - }) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should find nested packages', async () => { - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const parentPkg = packages.find(p => p.name === 'parent-pkg') - const nestedPkg = packages.find(p => p.name === 'nested-pkg') - - assert.ok(parentPkg, 'Should find parent package') - assert.ok(nestedPkg, 'Should find nested package') - }) - }) - - describe('NpmCrawler edge cases', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('scan-edge-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should handle empty node_modules', async () => { - const emptyDir = path.join(testDir, 'empty-project') - const { mkdir } = await import('fs/promises') - await mkdir(path.join(emptyDir, 'node_modules'), { recursive: true }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: emptyDir }) - - assert.equal(packages.length, 0, 'Should return empty array for empty node_modules') - }) - - it('should handle missing node_modules', async () => { - const noNodeModulesDir = path.join(testDir, 'no-node-modules') - const { mkdir } = await import('fs/promises') - await mkdir(noNodeModulesDir, { recursive: true }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: noNodeModulesDir }) - - assert.equal(packages.length, 0, 'Should return empty array when no node_modules') - }) - - it('should skip packages with invalid package.json', async () => { - const invalidDir = path.join(testDir, 'invalid-pkgjson') - const nodeModulesDir = path.join(invalidDir, 'node_modules') - const { mkdir, writeFile } = await import('fs/promises') - - // Create package with invalid package.json - const badPkgDir = path.join(nodeModulesDir, 'bad-pkg') - await mkdir(badPkgDir, { recursive: true }) - await writeFile(path.join(badPkgDir, 'package.json'), 'not valid json') - - // Create valid package - await createTestPackage(nodeModulesDir, 'good-pkg', '1.0.0', { - 'index.js': 'module.exports = "good"', - }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: invalidDir }) - - assert.equal(packages.length, 1, 'Should only find valid package') - assert.equal(packages[0].name, 'good-pkg') - }) - - it('should skip packages missing name or version', async () => { - const incompleteDir = path.join(testDir, 'incomplete-pkgjson') - const nodeModulesDir = path.join(incompleteDir, 'node_modules') - const { mkdir, writeFile } = await import('fs/promises') - - // Create package without version - const noVersionDir = path.join(nodeModulesDir, 'no-version') - await mkdir(noVersionDir, { recursive: true }) - await writeFile( - path.join(noVersionDir, 'package.json'), - JSON.stringify({ name: 'no-version' }), - ) - - // Create package without name - const noNameDir = path.join(nodeModulesDir, 'no-name') - await mkdir(noNameDir, { recursive: true }) - await writeFile( - path.join(noNameDir, 'package.json'), - JSON.stringify({ version: '1.0.0' }), - ) - - // Create valid package - await createTestPackage(nodeModulesDir, 'complete-pkg', '1.0.0', { - 'index.js': 'module.exports = "complete"', - }) - - const crawler = new NpmCrawler() - const packages = await crawler.crawlAll({ cwd: incompleteDir }) - - assert.equal(packages.length, 1, 'Should only find complete package') - assert.equal(packages[0].name, 'complete-pkg') - }) - }) - - describe('Batch search result structure', () => { - // These tests verify the expected response structure from the batch API - // They use mock data since actual API calls require authentication - - it('should have correct BatchPatchInfo structure', () => { - // Verify the expected structure matches what scan.ts expects - const mockPatchInfo = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/test@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1234'], - ghsaIds: ['GHSA-xxxx-xxxx-xxxx'], - severity: 'high', - title: 'Test vulnerability', - } - - assert.equal(typeof mockPatchInfo.uuid, 'string') - assert.equal(typeof mockPatchInfo.purl, 'string') - assert.ok(['free', 'paid'].includes(mockPatchInfo.tier)) - assert.ok(Array.isArray(mockPatchInfo.cveIds)) - assert.ok(Array.isArray(mockPatchInfo.ghsaIds)) - assert.ok(mockPatchInfo.severity === null || typeof mockPatchInfo.severity === 'string') - assert.equal(typeof mockPatchInfo.title, 'string') - }) - - it('should have correct BatchSearchResponse structure', () => { - const mockResponse = { - packages: [ - { - purl: 'pkg:npm/test@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/test@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1234'], - ghsaIds: [], - severity: 'high', - title: 'Test vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: false, - } - - assert.ok(Array.isArray(mockResponse.packages)) - assert.equal(typeof mockResponse.canAccessPaidPatches, 'boolean') - - for (const pkg of mockResponse.packages) { - assert.equal(typeof pkg.purl, 'string') - assert.ok(Array.isArray(pkg.patches)) - } - }) - }) - - describe('Scan output formatting', () => { - // Test the output formatting logic used in scan command - - it('should sort packages by severity', () => { - const packages = [ - { purl: 'low', patches: [{ severity: 'low' }] }, - { purl: 'critical', patches: [{ severity: 'critical' }] }, - { purl: 'medium', patches: [{ severity: 'medium' }] }, - { purl: 'high', patches: [{ severity: 'high' }] }, - ] - - const severityOrder: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, - } - - const sorted = packages.sort((a, b) => { - const aMaxSeverity = Math.min( - ...a.patches.map(p => severityOrder[p.severity ?? 'unknown'] ?? 4), - ) - const bMaxSeverity = Math.min( - ...b.patches.map(p => severityOrder[p.severity ?? 'unknown'] ?? 4), - ) - return aMaxSeverity - bMaxSeverity - }) - - assert.equal(sorted[0].purl, 'critical') - assert.equal(sorted[1].purl, 'high') - assert.equal(sorted[2].purl, 'medium') - assert.equal(sorted[3].purl, 'low') - }) - - it('should handle packages with multiple patches of different severities', () => { - const packages = [ - { - purl: 'mixed-low', - patches: [{ severity: 'low' }, { severity: 'medium' }], - }, - { - purl: 'has-critical', - patches: [{ severity: 'low' }, { severity: 'critical' }], - }, - ] - - const severityOrder: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, - } - - const sorted = packages.sort((a, b) => { - const aMaxSeverity = Math.min( - ...a.patches.map(p => severityOrder[p.severity ?? 'unknown'] ?? 4), - ) - const bMaxSeverity = Math.min( - ...b.patches.map(p => severityOrder[p.severity ?? 'unknown'] ?? 4), - ) - return aMaxSeverity - bMaxSeverity - }) - - // Package with critical should come first - assert.equal(sorted[0].purl, 'has-critical') - }) - - it('should handle null severity', () => { - const severityOrder: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, - } - - const getSeverityOrder = (severity: string | null): number => { - if (!severity) return 4 - return severityOrder[severity.toLowerCase()] ?? 4 - } - - assert.equal(getSeverityOrder(null), 4) - assert.equal(getSeverityOrder('unknown'), 4) - assert.equal(getSeverityOrder('critical'), 0) - }) - }) - - describe('JSON output structure', () => { - it('should produce valid ScanResult JSON', () => { - const result = { - scannedPackages: 100, - packagesWithPatches: 5, - totalPatches: 8, - canAccessPaidPatches: false, - packages: [ - { - purl: 'pkg:npm/test@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/test@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1234'], - ghsaIds: ['GHSA-xxxx-xxxx-xxxx'], - severity: 'high', - title: 'Test vulnerability', - }, - ], - }, - ], - } - - // Should be valid JSON - const jsonStr = JSON.stringify(result, null, 2) - const parsed = JSON.parse(jsonStr) - - assert.equal(parsed.scannedPackages, 100) - assert.equal(parsed.packagesWithPatches, 5) - assert.equal(parsed.totalPatches, 8) - assert.equal(parsed.canAccessPaidPatches, false) - assert.ok(Array.isArray(parsed.packages)) - assert.equal(parsed.packages.length, 1) - assert.equal(parsed.packages[0].patches[0].uuid, TEST_UUID_1) - }) - }) -}) - -describe('crawlers module', () => { - describe('exports', () => { - it('should export NpmCrawler', async () => { - const { NpmCrawler } = await import('../crawlers/index.js') - assert.ok(NpmCrawler, 'NpmCrawler should be exported') - assert.equal(typeof NpmCrawler, 'function', 'NpmCrawler should be a constructor') - }) - - it('should export CrawledPackage type via module', async () => { - const { NpmCrawler } = await import('../crawlers/index.js') - const crawler = new NpmCrawler() - assert.ok(crawler, 'Should be able to instantiate NpmCrawler') - }) - - it('should export global prefix functions', async () => { - const { - getNpmGlobalPrefix, - getYarnGlobalPrefix, - getPnpmGlobalPrefix, - getBunGlobalPrefix, - } = await import('../crawlers/index.js') - - assert.equal(typeof getNpmGlobalPrefix, 'function') - assert.equal(typeof getYarnGlobalPrefix, 'function') - assert.equal(typeof getPnpmGlobalPrefix, 'function') - assert.equal(typeof getBunGlobalPrefix, 'function') - }) - }) -}) diff --git a/src/commands/scan.ts b/src/commands/scan.ts deleted file mode 100644 index 772c04b..0000000 --- a/src/commands/scan.ts +++ /dev/null @@ -1,489 +0,0 @@ -import type { CommandModule } from 'yargs' -import { - getAPIClientFromEnv, - type BatchPackagePatches, -} from '../utils/api-client.js' -import { NpmCrawler, PythonCrawler } from '../crawlers/index.js' -import { createSpinner } from '../utils/spinner.js' - -// Default batch size for API queries -const DEFAULT_BATCH_SIZE = 100 - -// Severity order for sorting (most severe first) -const SEVERITY_ORDER: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, -} - -interface ScanArgs { - cwd: string - org?: string - json?: boolean - global?: boolean - 'global-prefix'?: string - 'batch-size'?: number - 'api-url'?: string - 'api-token'?: string -} - -/** - * Result structure for JSON output - */ -interface ScanResult { - scannedPackages: number - packagesWithPatches: number - totalPatches: number - freePatches: number - paidPatches: number - canAccessPaidPatches: boolean - packages: Array<{ - purl: string - patches: Array<{ - uuid: string - purl: string - tier: 'free' | 'paid' - cveIds: string[] - ghsaIds: string[] - severity: string | null - title: string - }> - }> -} - -/** - * Format severity with color codes for terminal output - */ -function formatSeverity(severity: string | null): string { - if (!severity) return 'unknown' - const s = severity.toLowerCase() - switch (s) { - case 'critical': - return '\x1b[31mcritical\x1b[0m' // red - case 'high': - return '\x1b[91mhigh\x1b[0m' // bright red - case 'medium': - return '\x1b[33mmedium\x1b[0m' // yellow - case 'low': - return '\x1b[36mlow\x1b[0m' // cyan - default: - return s - } -} - -/** - * Get numeric severity for sorting - */ -function getSeverityOrder(severity: string | null): number { - if (!severity) return 4 - return SEVERITY_ORDER[severity.toLowerCase()] ?? 4 -} - -/** - * Format a table row for console output - */ -function formatTableRow( - purl: string, - freeCount: number, - paidCount: number, - severity: string | null, - cveIds: string[], - ghsaIds: string[], - canAccessPaidPatches: boolean, -): string { - // Truncate PURL if too long - const maxPurlLen = 40 - const displayPurl = - purl.length > maxPurlLen ? purl.slice(0, maxPurlLen - 3) + '...' : purl - - // Format patch counts - let countStr = String(freeCount) - if (paidCount > 0) { - if (canAccessPaidPatches) { - countStr += `+${paidCount}` - } else { - countStr += `\x1b[33m+${paidCount}\x1b[0m` // yellow for locked paid patches - } - } - - // Format vulnerability IDs - const vulnIds = [...cveIds, ...ghsaIds] - const maxVulnLen = 30 - let vulnStr = - vulnIds.length > 0 - ? vulnIds.slice(0, 2).join(', ') - : '-' - if (vulnIds.length > 2) { - vulnStr += ` (+${vulnIds.length - 2})` - } - if (vulnStr.length > maxVulnLen) { - vulnStr = vulnStr.slice(0, maxVulnLen - 3) + '...' - } - - return `${displayPurl.padEnd(maxPurlLen)} ${countStr.padStart(8)} ${formatSeverity(severity).padEnd(16)} ${vulnStr}` -} - -/** - * Scan installed packages for available patches - */ -async function scanPatches(args: ScanArgs): Promise { - const { - cwd, - org: orgSlug, - json: outputJson, - global: useGlobal, - 'global-prefix': globalPrefix, - 'batch-size': batchSize = DEFAULT_BATCH_SIZE, - 'api-url': apiUrl, - 'api-token': apiToken, - } = args - - // Override environment variables if CLI options are provided - if (apiUrl) { - process.env.SOCKET_API_URL = apiUrl - } - if (apiToken) { - process.env.SOCKET_API_TOKEN = apiToken - } - - // Get API client (will use public proxy if no token is set) - const { client: apiClient, usePublicProxy } = getAPIClientFromEnv() - - // Validate that org is provided when using authenticated API - if (!usePublicProxy && !orgSlug) { - throw new Error( - '--org is required when using SOCKET_API_TOKEN. Provide an organization slug.', - ) - } - - // The org slug to use (null when using public proxy) - const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null - - // Initialize crawlers and spinner - const npmCrawler = new NpmCrawler() - const pythonCrawler = new PythonCrawler() - const spinner = createSpinner({ disabled: outputJson }) - - const scanTarget = useGlobal || globalPrefix - ? 'global packages' - : 'packages' - - spinner.start(`Scanning ${scanTarget}...`) - - // Collect all packages using batching to be memory-efficient - const allPurls: string[] = [] - let packageCount = 0 - let npmCount = 0 - let pythonCount = 0 - let lastPath = '' - - const crawlerOptions = { - cwd, - global: useGlobal, - globalPrefix, - batchSize, - } - - // Crawl npm packages - for await (const batch of npmCrawler.crawlBatches(crawlerOptions)) { - for (const pkg of batch) { - allPurls.push(pkg.purl) - packageCount++ - npmCount++ - lastPath = pkg.path - } - - const relativePath = lastPath.startsWith(cwd) - ? lastPath.slice(cwd.length + 1) - : lastPath - spinner.update(`Scanning npm... ${packageCount} pkgs | ${relativePath}`) - } - - // Crawl Python packages - for await (const batch of pythonCrawler.crawlBatches(crawlerOptions)) { - for (const pkg of batch) { - allPurls.push(pkg.purl) - packageCount++ - pythonCount++ - lastPath = pkg.path - } - - const relativePath = lastPath.startsWith(cwd) - ? lastPath.slice(cwd.length + 1) - : lastPath - spinner.update(`Scanning python... ${packageCount} pkgs | ${relativePath}`) - } - - if (packageCount === 0) { - spinner.stop() - if (outputJson) { - console.log( - JSON.stringify( - { - scannedPackages: 0, - packagesWithPatches: 0, - totalPatches: 0, - freePatches: 0, - paidPatches: 0, - canAccessPaidPatches: false, - packages: [], - } satisfies ScanResult, - null, - 2, - ), - ) - } else { - console.log( - useGlobal || globalPrefix - ? 'No global packages found.' - : 'No packages found. Run npm/yarn/pnpm/pip install first.', - ) - } - return true - } - - // Build a summary showing what ecosystems were found - const ecosystemParts: string[] = [] - if (npmCount > 0) ecosystemParts.push(`${npmCount} npm`) - if (pythonCount > 0) ecosystemParts.push(`${pythonCount} python`) - const ecosystemSummary = ecosystemParts.length > 0 - ? ` (${ecosystemParts.join(', ')})` - : '' - - spinner.succeed(`Found ${packageCount} packages${ecosystemSummary}`) - - // Query API in batches - const allPackagesWithPatches: BatchPackagePatches[] = [] - let canAccessPaidPatches = false - let batchIndex = 0 - const totalBatches = Math.ceil(allPurls.length / batchSize) - let totalPatchesFound = 0 - - spinner.start(`Querying API for patches... (batch 1/${totalBatches})`) - - for (let i = 0; i < allPurls.length; i += batchSize) { - batchIndex++ - const batch = allPurls.slice(i, i + batchSize) - - // Show progress with batch number and patches found so far - const patchInfo = totalPatchesFound > 0 ? `, ${totalPatchesFound} patches found` : '' - spinner.update(`Querying API for patches... (batch ${batchIndex}/${totalBatches}${patchInfo})`) - - try { - const response = await apiClient.searchPatchesBatch(effectiveOrgSlug, batch) - - // Merge results - if (response.canAccessPaidPatches) { - canAccessPaidPatches = true - } - - // Include ALL patches (free and paid) - we'll show paid as upgrade options - for (const pkg of response.packages) { - if (pkg.patches.length > 0) { - allPackagesWithPatches.push({ - purl: pkg.purl, - patches: pkg.patches, - }) - totalPatchesFound += pkg.patches.length - } - } - } catch (error) { - spinner.stop() - if (!outputJson) { - console.error( - `Error querying batch ${batchIndex}: ${error instanceof Error ? error.message : String(error)}`, - ) - } - // Restart spinner and continue with other batches - if (batchIndex < totalBatches) { - spinner.start(`Querying API for patches... (batch ${batchIndex + 1}/${totalBatches})`) - } - } - } - - if (totalPatchesFound > 0) { - spinner.succeed(`Found ${totalPatchesFound} patches for ${allPackagesWithPatches.length} packages`) - } else { - spinner.succeed('API query complete') - } - - // Calculate patch counts by tier - let freePatches = 0 - let paidPatches = 0 - for (const pkg of allPackagesWithPatches) { - for (const patch of pkg.patches) { - if (patch.tier === 'free') { - freePatches++ - } else { - paidPatches++ - } - } - } - const totalPatches = freePatches + paidPatches - - // Prepare result - const result: ScanResult = { - scannedPackages: packageCount, - packagesWithPatches: allPackagesWithPatches.length, - totalPatches, - freePatches, - paidPatches, - canAccessPaidPatches, - packages: allPackagesWithPatches, - } - - if (outputJson) { - console.log(JSON.stringify(result, null, 2)) - return true - } - - // Console table output - if (allPackagesWithPatches.length === 0) { - console.log('\nNo patches available for installed packages.') - return true - } - - // Sort packages by highest severity - const sortedPackages = allPackagesWithPatches.sort((a, b) => { - const aMaxSeverity = Math.min( - ...a.patches.map(p => getSeverityOrder(p.severity)), - ) - const bMaxSeverity = Math.min( - ...b.patches.map(p => getSeverityOrder(p.severity)), - ) - return aMaxSeverity - bMaxSeverity - }) - - // Print table header - console.log('\n' + '='.repeat(100)) - console.log('PACKAGE'.padEnd(40) + ' ' + 'PATCHES'.padStart(8) + ' ' + 'SEVERITY'.padEnd(16) + ' VULNERABILITIES') - console.log('='.repeat(100)) - - // Print each package - for (const pkg of sortedPackages) { - // Get highest severity among all patches - const highestSeverity = pkg.patches.reduce((acc, patch) => { - if (!acc) return patch.severity - if (!patch.severity) return acc - return getSeverityOrder(patch.severity) < getSeverityOrder(acc) - ? patch.severity - : acc - }, null) - - // Count free vs paid patches for this package - const pkgFreeCount = pkg.patches.filter(p => p.tier === 'free').length - const pkgPaidCount = pkg.patches.filter(p => p.tier === 'paid').length - - // Collect all CVE/GHSA IDs - const allCveIds = new Set() - const allGhsaIds = new Set() - for (const patch of pkg.patches) { - for (const cve of patch.cveIds) allCveIds.add(cve) - for (const ghsa of patch.ghsaIds) allGhsaIds.add(ghsa) - } - - console.log( - formatTableRow( - pkg.purl, - pkgFreeCount, - pkgPaidCount, - highestSeverity, - Array.from(allCveIds), - Array.from(allGhsaIds), - canAccessPaidPatches, - ), - ) - } - - console.log('='.repeat(100)) - - // Summary with breakdown - if (canAccessPaidPatches) { - console.log( - `\nSummary: ${allPackagesWithPatches.length} package(s) with ${totalPatches} available patch(es)`, - ) - } else { - console.log( - `\nSummary: ${allPackagesWithPatches.length} package(s) with ${freePatches} free patch(es)`, - ) - if (paidPatches > 0) { - console.log( - `\x1b[33m + ${paidPatches} additional patch(es) available with paid subscription\x1b[0m`, - ) - console.log( - '\nUpgrade to Socket\'s paid plan to access all patches: https://socket.dev/pricing', - ) - } - } - - console.log('\nTo apply a patch, run:') - console.log(' socket-patch get ') - console.log(' socket-patch get ') - - return true -} - -export const scanCommand: CommandModule<{}, ScanArgs> = { - command: 'scan', - describe: 'Scan installed packages for available security patches', - builder: yargs => { - return yargs - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('org', { - describe: 'Organization slug (required when using SOCKET_API_TOKEN, optional for public proxy)', - type: 'string', - demandOption: false, - }) - .option('json', { - describe: 'Output results as JSON', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Scan globally installed npm packages', - type: 'boolean', - default: false, - }) - .option('global-prefix', { - describe: 'Custom path to global node_modules (overrides auto-detection)', - type: 'string', - }) - .option('batch-size', { - describe: 'Number of packages to query per API request', - type: 'number', - default: DEFAULT_BATCH_SIZE, - }) - .option('api-url', { - describe: 'Socket API URL (overrides SOCKET_API_URL env var)', - type: 'string', - }) - .option('api-token', { - describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)', - type: 'string', - }) - .example('$0 scan', 'Scan local node_modules for available patches') - .example('$0 scan --json', 'Output scan results as JSON') - .example('$0 scan --global', 'Scan globally installed packages') - .example( - '$0 scan --batch-size 200', - 'Use larger batches for faster scanning', - ) - }, - handler: async argv => { - try { - const success = await scanPatches(argv) - process.exit(success ? 0 : 1) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - process.exit(1) - } - }, -} diff --git a/src/commands/setup.ts b/src/commands/setup.ts deleted file mode 100644 index b47a4d1..0000000 --- a/src/commands/setup.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as path from 'path' -import * as readline from 'readline/promises' -import type { CommandModule } from 'yargs' -import { - findPackageJsonFiles, - updateMultiplePackageJsons, - type UpdateResult, -} from '../package-json/index.js' - -interface SetupArgs { - cwd: string - 'dry-run': boolean - yes: boolean -} - -/** - * Display a preview table of changes - */ -function displayPreview(results: UpdateResult[], cwd: string): void { - console.log('\nPackage.json files to be updated:\n') - - const toUpdate = results.filter(r => r.status === 'updated') - const alreadyConfigured = results.filter( - r => r.status === 'already-configured', - ) - const errors = results.filter(r => r.status === 'error') - - if (toUpdate.length > 0) { - console.log('Will update:') - for (const result of toUpdate) { - const relativePath = path.relative(cwd, result.path) - console.log(` ✓ ${relativePath}`) - if (result.oldScript) { - console.log(` Current: "${result.oldScript}"`) - } else { - console.log(` Current: (no postinstall script)`) - } - console.log(` New: "${result.newScript}"`) - } - console.log() - } - - if (alreadyConfigured.length > 0) { - console.log('Already configured (will skip):') - for (const result of alreadyConfigured) { - const relativePath = path.relative(cwd, result.path) - console.log(` ⊘ ${relativePath}`) - } - console.log() - } - - if (errors.length > 0) { - console.log('Errors:') - for (const result of errors) { - const relativePath = path.relative(cwd, result.path) - console.log(` ✗ ${relativePath}: ${result.error}`) - } - console.log() - } -} - -/** - * Display summary of changes made - */ -function displaySummary(results: UpdateResult[], dryRun: boolean): void { - const updated = results.filter(r => r.status === 'updated') - const alreadyConfigured = results.filter( - r => r.status === 'already-configured', - ) - const errors = results.filter(r => r.status === 'error') - - console.log('\nSummary:') - console.log( - ` ${updated.length} file(s) ${dryRun ? 'would be updated' : 'updated'}`, - ) - console.log(` ${alreadyConfigured.length} file(s) already configured`) - if (errors.length > 0) { - console.log(` ${errors.length} error(s)`) - } -} - -/** - * Prompt user for confirmation - */ -async function promptConfirmation(): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - try { - const answer = await rl.question('Proceed with these changes? (y/N): ') - return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' - } finally { - rl.close() - } -} - -export const setupCommand: CommandModule<{}, SetupArgs> = { - command: 'setup', - describe: 'Configure package.json postinstall scripts to apply patches', - builder: yargs => { - return yargs - .option('cwd', { - describe: 'Working directory', - type: 'string', - default: process.cwd(), - }) - .option('dry-run', { - alias: 'd', - describe: 'Preview changes without modifying files', - type: 'boolean', - default: false, - }) - .option('yes', { - alias: 'y', - describe: 'Skip confirmation prompt', - type: 'boolean', - default: false, - }) - }, - handler: async argv => { - try { - // Find all package.json files - console.log('Searching for package.json files...') - const packageJsonFiles = await findPackageJsonFiles(argv.cwd) - - if (packageJsonFiles.length === 0) { - console.log('No package.json files found') - process.exit(0) - } - - console.log(`Found ${packageJsonFiles.length} package.json file(s)`) - - // Preview changes (dry run to see what would change) - const previewResults = await updateMultiplePackageJsons( - packageJsonFiles.map(p => p.path), - true, // Always preview first - ) - - // Display preview - displayPreview(previewResults, argv.cwd) - - const toUpdate = previewResults.filter(r => r.status === 'updated') - - if (toUpdate.length === 0) { - console.log( - 'All package.json files are already configured with socket-patch!', - ) - process.exit(0) - } - - // If not dry-run, ask for confirmation (unless --yes) - if (!argv['dry-run']) { - if (!argv.yes) { - const confirmed = await promptConfirmation() - if (!confirmed) { - console.log('Aborted') - process.exit(0) - } - } - - // Apply changes - console.log('\nApplying changes...') - const results = await updateMultiplePackageJsons( - packageJsonFiles.map(p => p.path), - false, - ) - - displaySummary(results, false) - - const errors = results.filter(r => r.status === 'error') - process.exit(errors.length > 0 ? 1 : 0) - } else { - // Dry run mode - displaySummary(previewResults, true) - process.exit(0) - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - console.error(`Error: ${errorMessage}`) - process.exit(1) - } - }, -} diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 0b5cc60..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Standard paths and constants used throughout the socket-patch system - */ - -// Re-export from schema for convenience -export { DEFAULT_PATCH_MANIFEST_PATH } from './schema/manifest-schema.js' - -/** - * Default folder for storing patched file blobs - */ -export const DEFAULT_BLOB_FOLDER = '.socket/blob' - -/** - * Default Socket directory - */ -export const DEFAULT_SOCKET_DIR = '.socket' diff --git a/src/crawlers/index.ts b/src/crawlers/index.ts deleted file mode 100644 index 7d3bb37..0000000 --- a/src/crawlers/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from './types.js' -export { - NpmCrawler, - getNpmGlobalPrefix, - getYarnGlobalPrefix, - getPnpmGlobalPrefix, - getBunGlobalPrefix, -} from './npm-crawler.js' -export { - PythonCrawler, - canonicalizePyPIName, - findPythonDirs, - findLocalVenvSitePackages, -} from './python-crawler.js' diff --git a/src/crawlers/npm-crawler.ts b/src/crawlers/npm-crawler.ts deleted file mode 100644 index 8c8573a..0000000 --- a/src/crawlers/npm-crawler.ts +++ /dev/null @@ -1,528 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import { execSync } from 'child_process' -import type { Dirent } from 'fs' -import type { CrawledPackage, CrawlerOptions } from './types.js' - -const DEFAULT_BATCH_SIZE = 100 - -/** - * Read and parse a package.json file - */ -async function readPackageJson( - pkgPath: string, -): Promise<{ name: string; version: string } | null> { - try { - const content = await fs.readFile(pkgPath, 'utf-8') - const pkg = JSON.parse(content) - if (typeof pkg.name === 'string' && typeof pkg.version === 'string') { - return { name: pkg.name, version: pkg.version } - } - return null - } catch { - return null - } -} - -/** - * Parse a package name into namespace and name components - */ -function parsePackageName(fullName: string): { - namespace?: string - name: string -} { - if (fullName.startsWith('@')) { - const slashIndex = fullName.indexOf('/') - if (slashIndex !== -1) { - return { - namespace: fullName.substring(0, slashIndex), - name: fullName.substring(slashIndex + 1), - } - } - } - return { name: fullName } -} - -/** - * Build a PURL string from package components - */ -function buildPurl( - namespace: string | undefined, - name: string, - version: string, -): string { - if (namespace) { - return `pkg:npm/${namespace}/${name}@${version}` - } - return `pkg:npm/${name}@${version}` -} - -/** - * Get the npm global node_modules path using 'npm root -g' - */ -function getNpmGlobalPrefix(): string { - try { - const result = execSync('npm root -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() - } catch { - throw new Error( - 'Failed to determine npm global prefix. Ensure npm is installed and in PATH.', - ) - } -} - -/** - * Get the yarn global node_modules path - */ -function getYarnGlobalPrefix(): string | null { - try { - const result = execSync('yarn global dir', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return path.join(result.trim(), 'node_modules') - } catch { - return null - } -} - -/** - * Get the pnpm global node_modules path - */ -function getPnpmGlobalPrefix(): string | null { - try { - const result = execSync('pnpm root -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() - } catch { - return null - } -} - -/** - * Get the bun global node_modules path - */ -function getBunGlobalPrefix(): string | null { - try { - const binPath = execSync('bun pm bin -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim() - const bunRoot = path.dirname(binPath) - return path.join(bunRoot, 'install', 'global', 'node_modules') - } catch { - return null - } -} - -/** - * NPM ecosystem crawler for discovering packages in node_modules - */ -export class NpmCrawler { - /** - * Get node_modules paths based on options - */ - async getNodeModulesPaths(options: CrawlerOptions): Promise { - if (options.global || options.globalPrefix) { - // Global mode: return well-known global paths - if (options.globalPrefix) { - return [options.globalPrefix] - } - return this.getGlobalNodeModulesPaths() - } - - // Local mode: find node_modules in cwd and workspace directories - return this.findLocalNodeModulesDirs(options.cwd) - } - - /** - * Get well-known global node_modules paths - * Only checks standard locations where global packages are installed - */ - private getGlobalNodeModulesPaths(): string[] { - const paths: string[] = [] - - // Try npm global path - try { - paths.push(getNpmGlobalPrefix()) - } catch { - // npm not available - } - - // Try pnpm global path - const pnpmPath = getPnpmGlobalPrefix() - if (pnpmPath) { - paths.push(pnpmPath) - } - - // Try yarn global path - const yarnPath = getYarnGlobalPrefix() - if (yarnPath) { - paths.push(yarnPath) - } - - // Try bun global path - const bunPath = getBunGlobalPrefix() - if (bunPath) { - paths.push(bunPath) - } - - return paths - } - - /** - * Find node_modules directories within the project root. - * Recursively searches for workspace node_modules but stays within the project. - */ - private async findLocalNodeModulesDirs(startPath: string): Promise { - const results: string[] = [] - - // Check for node_modules directly in startPath - const directNodeModules = path.join(startPath, 'node_modules') - try { - const stat = await fs.stat(directNodeModules) - if (stat.isDirectory()) { - results.push(directNodeModules) - } - } catch { - // No direct node_modules - } - - // Recursively search for workspace node_modules - await this.findWorkspaceNodeModules(startPath, startPath, results) - - return results - } - - /** - * Recursively find node_modules in subdirectories (for monorepos/workspaces). - * Stays within the project by not crossing into other projects or system directories. - * Skips symlinks to avoid duplicates and potential infinite loops. - */ - private async findWorkspaceNodeModules( - dir: string, - rootPath: string, - results: string[], - ): Promise { - let entries - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - return - } - - for (const entry of entries) { - // Skip non-directories and symlinks (avoid duplicates and infinite loops) - if (!entry.isDirectory()) continue - - const fullPath = path.join(dir, entry.name) - - // Skip node_modules - we handle these separately when found - if (entry.name === 'node_modules') continue - - // Skip hidden directories - if (entry.name.startsWith('.')) continue - - // Skip common build/output directories that won't have workspace node_modules - if ( - entry.name === 'dist' || - entry.name === 'build' || - entry.name === 'coverage' || - entry.name === 'tmp' || - entry.name === 'temp' || - entry.name === '__pycache__' || - entry.name === 'vendor' - ) { - continue - } - - // Check if this subdirectory has its own node_modules - const subNodeModules = path.join(fullPath, 'node_modules') - try { - const stat = await fs.stat(subNodeModules) - if (stat.isDirectory()) { - results.push(subNodeModules) - } - } catch { - // No node_modules here - } - - // Recurse into subdirectory - await this.findWorkspaceNodeModules(fullPath, rootPath, results) - } - } - - /** - * Yield packages in batches (memory efficient for large codebases) - */ - async *crawlBatches( - options: CrawlerOptions, - ): AsyncGenerator { - const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE - const seen = new Set() - let batch: CrawledPackage[] = [] - - const nodeModulesPaths = await this.getNodeModulesPaths(options) - - for (const nodeModulesPath of nodeModulesPaths) { - for await (const pkg of this.scanNodeModules(nodeModulesPath, seen)) { - batch.push(pkg) - if (batch.length >= batchSize) { - yield batch - batch = [] - } - } - } - - // Yield remaining packages - if (batch.length > 0) { - yield batch - } - } - - /** - * Return all packages at once (convenience method) - */ - async crawlAll(options: CrawlerOptions): Promise { - const packages: CrawledPackage[] = [] - for await (const batch of this.crawlBatches(options)) { - packages.push(...batch) - } - return packages - } - - /** - * Find specific packages by PURL - * Efficient O(n) lookup where n = number of PURLs to find - */ - async findByPurls( - nodeModulesPath: string, - purls: string[], - ): Promise> { - const result = new Map() - - // Parse PURLs to extract package info for targeted lookup - const purlSet = new Set(purls) - const packageTargets = new Map< - string, - { namespace?: string; name: string; version: string; purl: string } - >() - - for (const purl of purls) { - const parsed = this.parsePurl(purl) - if (parsed) { - // Key by directory path pattern: @scope/name or name - const dirKey = parsed.namespace - ? `${parsed.namespace}/${parsed.name}` - : parsed.name - packageTargets.set(dirKey, { ...parsed, purl }) - } - } - - // Check each target package directory directly - for (const [dirKey, target] of packageTargets) { - const pkgPath = path.join(nodeModulesPath, dirKey) - const pkgJsonPath = path.join(pkgPath, 'package.json') - - const pkgInfo = await readPackageJson(pkgJsonPath) - if (pkgInfo && pkgInfo.version === target.version) { - const purl = buildPurl(target.namespace, target.name, pkgInfo.version) - if (purlSet.has(purl)) { - result.set(purl, { - name: target.name, - version: pkgInfo.version, - namespace: target.namespace, - purl, - path: pkgPath, - }) - } - } - } - - return result - } - - /** - * Scan a node_modules directory and yield packages - */ - private async *scanNodeModules( - nodeModulesPath: string, - seen: Set, - ): AsyncGenerator { - let entries: Dirent[] - try { - entries = await fs.readdir(nodeModulesPath, { withFileTypes: true }) - } catch { - return - } - - for (const entry of entries) { - // Skip hidden files and special directories - if (entry.name.startsWith('.') || entry.name === 'node_modules') { - continue - } - - // Allow both directories and symlinks (pnpm uses symlinks) - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - - const entryPath = path.join(nodeModulesPath, entry.name) - - // Handle scoped packages (@scope/package) - if (entry.name.startsWith('@')) { - yield* this.scanScopedPackages(entryPath, entry.name, seen) - } else { - // Regular package - const pkg = await this.checkPackage(entryPath, seen) - if (pkg) { - yield pkg - } - - // Check for nested node_modules only for real directories (not symlinks) - // Symlinked packages (pnpm) have their deps managed separately - if (entry.isDirectory()) { - yield* this.scanNestedNodeModules(entryPath, seen) - } - } - } - } - - /** - * Scan scoped packages directory (@scope/) - */ - private async *scanScopedPackages( - scopePath: string, - _scope: string, - seen: Set, - ): AsyncGenerator { - let scopeEntries: Dirent[] - try { - scopeEntries = await fs.readdir(scopePath, { withFileTypes: true }) - } catch { - return - } - - for (const scopeEntry of scopeEntries) { - if (scopeEntry.name.startsWith('.')) continue - - // Allow both directories and symlinks (pnpm uses symlinks) - if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) { - continue - } - - const pkgPath = path.join(scopePath, scopeEntry.name) - const pkg = await this.checkPackage(pkgPath, seen) - if (pkg) { - yield pkg - } - - // Check for nested node_modules only for real directories (not symlinks) - if (scopeEntry.isDirectory()) { - yield* this.scanNestedNodeModules(pkgPath, seen) - } - } - } - - /** - * Scan nested node_modules inside a package (if it exists) - */ - private async *scanNestedNodeModules( - pkgPath: string, - seen: Set, - ): AsyncGenerator { - const nestedNodeModules = path.join(pkgPath, 'node_modules') - try { - // Try to read the directory - this checks existence and gets entries in one call - const entries = await fs.readdir(nestedNodeModules, { withFileTypes: true }) - // If we got here, the directory exists and we have its entries - // Yield packages from this nested node_modules - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules') { - continue - } - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - - const entryPath = path.join(nestedNodeModules, entry.name) - - if (entry.name.startsWith('@')) { - yield* this.scanScopedPackages(entryPath, entry.name, seen) - } else { - const pkg = await this.checkPackage(entryPath, seen) - if (pkg) { - yield pkg - } - // Recursively check for deeper nested node_modules - yield* this.scanNestedNodeModules(entryPath, seen) - } - } - } catch { - // No nested node_modules or can't read it - this is the common case - } - } - - /** - * Check a package directory and return CrawledPackage if valid - */ - private async checkPackage( - pkgPath: string, - seen: Set, - ): Promise { - const packageJsonPath = path.join(pkgPath, 'package.json') - const pkgInfo = await readPackageJson(packageJsonPath) - - if (!pkgInfo) { - return null - } - - const { namespace, name } = parsePackageName(pkgInfo.name) - const purl = buildPurl(namespace, name, pkgInfo.version) - - // Deduplicate by PURL - if (seen.has(purl)) { - return null - } - seen.add(purl) - - return { - name, - version: pkgInfo.version, - namespace, - purl, - path: pkgPath, - } - } - - /** - * Parse a PURL string to extract components - */ - private parsePurl( - purl: string, - ): { namespace?: string; name: string; version: string } | null { - // Format: pkg:npm/name@version or pkg:npm/@scope/name@version - const match = purl.match(/^pkg:npm\/(?:(@[^/]+)\/)?([^@]+)@(.+)$/) - if (!match) { - return null - } - return { - namespace: match[1] || undefined, - name: match[2], - version: match[3], - } - } -} - -// Re-export global prefix functions for backward compatibility -export { - getNpmGlobalPrefix, - getYarnGlobalPrefix, - getPnpmGlobalPrefix, - getBunGlobalPrefix, -} diff --git a/src/crawlers/python-crawler.test.ts b/src/crawlers/python-crawler.test.ts deleted file mode 100644 index 6e61ac7..0000000 --- a/src/crawlers/python-crawler.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { - createTestDir, - removeTestDir, - createTestPythonPackage, -} from '../test-utils.js' -import { - PythonCrawler, - canonicalizePyPIName, - findPythonDirs, - findLocalVenvSitePackages, -} from './index.js' - -describe('PythonCrawler', () => { - describe('canonicalizePyPIName', () => { - it('should lowercase names', () => { - assert.equal(canonicalizePyPIName('Requests'), 'requests') - }) - - it('should replace underscores with hyphens', () => { - assert.equal(canonicalizePyPIName('my_package'), 'my-package') - }) - - it('should replace dots with hyphens', () => { - assert.equal(canonicalizePyPIName('My.Package'), 'my-package') - }) - - it('should collapse runs of separators', () => { - assert.equal(canonicalizePyPIName('a___b---c...d'), 'a-b-c-d') - }) - - it('should trim whitespace', () => { - assert.equal(canonicalizePyPIName(' requests '), 'requests') - }) - }) - - describe('crawlAll', () => { - let testDir: string - let sitePackagesDir: string - - before(async () => { - testDir = await createTestDir('python-crawler-crawl-') - sitePackagesDir = path.join(testDir, 'lib', 'python3.11', 'site-packages') - - await createTestPythonPackage(sitePackagesDir, 'requests', '2.28.0', {}) - await createTestPythonPackage(sitePackagesDir, 'flask', '2.3.0', {}) - await createTestPythonPackage(sitePackagesDir, 'six', '1.16.0', {}) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should discover all packages', async () => { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - assert.equal(packages.length, 3, 'Should find 3 packages') - - const purls = packages.map(p => p.purl).sort() - assert.deepEqual(purls, [ - 'pkg:pypi/flask@2.3.0', - 'pkg:pypi/requests@2.28.0', - 'pkg:pypi/six@1.16.0', - ]) - }) - - it('should read METADATA correctly', async () => { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - const requests = packages.find(p => p.name === 'requests') - assert.ok(requests, 'Should find requests') - assert.equal(requests.version, '2.28.0') - assert.equal(requests.purl, 'pkg:pypi/requests@2.28.0') - }) - - it('should skip dist-info without METADATA file', async () => { - const tempDir = await createTestDir('python-crawler-no-meta-') - const sp = path.join(tempDir, 'site-packages') - await fs.mkdir(sp, { recursive: true }) - - // Create dist-info without METADATA - const distInfoDir = path.join(sp, 'bad_pkg-1.0.0.dist-info') - await fs.mkdir(distInfoDir, { recursive: true }) - // No METADATA file - - // Create a valid package - await createTestPythonPackage(sp, 'good-pkg', '1.0.0', {}) - - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: tempDir, - globalPrefix: sp, - }) - - assert.equal(packages.length, 1) - assert.equal(packages[0].name, 'good-pkg') - - await removeTestDir(tempDir) - }) - - it('should skip malformed METADATA', async () => { - const tempDir = await createTestDir('python-crawler-bad-meta-') - const sp = path.join(tempDir, 'site-packages') - await fs.mkdir(sp, { recursive: true }) - - // Create dist-info with malformed METADATA (no Name/Version) - const distInfoDir = path.join(sp, 'bad_pkg-1.0.0.dist-info') - await fs.mkdir(distInfoDir, { recursive: true }) - await fs.writeFile( - path.join(distInfoDir, 'METADATA'), - 'Summary: A package with no name or version\n', - ) - - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: tempDir, - globalPrefix: sp, - }) - - assert.equal(packages.length, 0) - - await removeTestDir(tempDir) - }) - - it('should handle METADATA with extra headers before Name/Version', async () => { - const tempDir = await createTestDir('python-crawler-extra-') - const sp = path.join(tempDir, 'site-packages') - await fs.mkdir(sp, { recursive: true }) - - const distInfoDir = path.join(sp, 'extra_pkg-2.0.0.dist-info') - await fs.mkdir(distInfoDir, { recursive: true }) - await fs.writeFile( - path.join(distInfoDir, 'METADATA'), - 'Metadata-Version: 2.1\nSummary: Has extra headers\nName: extra-pkg\nVersion: 2.0.0\n', - ) - - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: tempDir, - globalPrefix: sp, - }) - - assert.equal(packages.length, 1) - assert.equal(packages[0].name, 'extra-pkg') - assert.equal(packages[0].version, '2.0.0') - - await removeTestDir(tempDir) - }) - - it('should return empty for empty dir', async () => { - const tempDir = await createTestDir('python-crawler-empty-') - const sp = path.join(tempDir, 'empty-site-packages') - await fs.mkdir(sp, { recursive: true }) - - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: tempDir, - globalPrefix: sp, - }) - - assert.equal(packages.length, 0) - - await removeTestDir(tempDir) - }) - - it('should return empty for non-existent dir', async () => { - const tempDir = await createTestDir('python-crawler-noexist-') - const sp = path.join(tempDir, 'nonexistent-dir') - - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: tempDir, - globalPrefix: sp, - }) - - assert.equal(packages.length, 0) - - await removeTestDir(tempDir) - }) - - it('should set path to site-packages directory', async () => { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ - cwd: testDir, - globalPrefix: sitePackagesDir, - }) - - for (const pkg of packages) { - assert.equal(pkg.path, sitePackagesDir, 'Package path should be site-packages dir') - } - }) - }) - - describe('crawlBatches', () => { - let testDir: string - let sitePackagesDir: string - - before(async () => { - testDir = await createTestDir('python-crawler-batch-') - sitePackagesDir = path.join(testDir, 'site-packages') - - for (let i = 1; i <= 5; i++) { - await createTestPythonPackage(sitePackagesDir, `pkg${i}`, '1.0.0', {}) - } - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should respect batch size', async () => { - const crawler = new PythonCrawler() - const batches: number[] = [] - - for await (const batch of crawler.crawlBatches({ - cwd: testDir, - globalPrefix: sitePackagesDir, - batchSize: 2, - })) { - batches.push(batch.length) - } - - // 5 packages with batchSize=2 → batches of [2, 2, 1] - assert.equal(batches.length, 3, 'Should have 3 batches') - assert.equal(batches[0], 2) - assert.equal(batches[1], 2) - assert.equal(batches[2], 1) - }) - }) - - describe('deduplication across paths', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('python-crawler-dedup-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should deduplicate packages across site-packages dirs', async () => { - // Create the same package in two directories - const sp1 = path.join(testDir, 'sp1') - const sp2 = path.join(testDir, 'sp2') - - await createTestPythonPackage(sp1, 'duped', '1.0.0', {}) - await createTestPythonPackage(sp2, 'duped', '1.0.0', {}) - - // Use a custom crawler that reports from multiple paths - const crawler = new PythonCrawler() - const seen = new Set() - const packages: Array<{ purl: string }> = [] - - // Manually crawl both using the internal pattern - for await (const batch of crawler.crawlBatches({ - cwd: testDir, - globalPrefix: sp1, - })) { - packages.push(...batch) - } - // Crawl second path - packages already seen should be skipped by PURL - for await (const batch of crawler.crawlBatches({ - cwd: testDir, - globalPrefix: sp2, - })) { - for (const pkg of batch) { - if (!seen.has(pkg.purl)) { - seen.add(pkg.purl) - packages.push(pkg) - } - } - } - - // The first crawl finds 1, the second crawl also finds 1 (separate calls) - // but within a single crawlAll call, dedup works automatically - // Test dedup within a single crawlAll is implicitly tested by crawlAll above - const allPurls = packages.map(p => p.purl) - assert.ok(allPurls.includes('pkg:pypi/duped@1.0.0')) - }) - }) - - describe('findByPurls', () => { - let testDir: string - let sitePackagesDir: string - - before(async () => { - testDir = await createTestDir('python-crawler-find-') - sitePackagesDir = path.join(testDir, 'site-packages') - - await createTestPythonPackage(sitePackagesDir, 'requests', '2.28.0', {}) - await createTestPythonPackage(sitePackagesDir, 'flask', '2.3.0', {}) - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should find matching packages', async () => { - const crawler = new PythonCrawler() - const found = await crawler.findByPurls(sitePackagesDir, [ - 'pkg:pypi/requests@2.28.0', - 'pkg:pypi/nonexistent@1.0.0', - ]) - - assert.equal(found.size, 1) - assert.ok(found.has('pkg:pypi/requests@2.28.0')) - assert.ok(!found.has('pkg:pypi/nonexistent@1.0.0')) - }) - - it('should handle PEP 503 name normalization', async () => { - const tempDir = await createTestDir('python-crawler-pep503-') - const sp = path.join(tempDir, 'site-packages') - - // Dist-info uses the original name format (underscores) - await createTestPythonPackage(sp, 'My_Package', '1.0.0', {}) - - const crawler = new PythonCrawler() - // Search using normalized name (hyphens, lowercase) - const found = await crawler.findByPurls(sp, [ - 'pkg:pypi/my-package@1.0.0', - ]) - - assert.equal(found.size, 1, 'Should match via PEP 503 normalization') - - await removeTestDir(tempDir) - }) - - it('should return empty map for empty purls list', async () => { - const crawler = new PythonCrawler() - const found = await crawler.findByPurls(sitePackagesDir, []) - - assert.equal(found.size, 0) - }) - }) - - describe('findPythonDirs', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('python-dirs-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should match python3.* wildcard', async () => { - // Create two python version directories - const base = path.join(testDir, 'lib') - await fs.mkdir(path.join(base, 'python3.10', 'site-packages'), { - recursive: true, - }) - await fs.mkdir(path.join(base, 'python3.11', 'site-packages'), { - recursive: true, - }) - - const results = await findPythonDirs( - base, - 'python3.*', - 'site-packages', - ) - - assert.equal(results.length, 2, 'Should find both python versions') - const sorted = results.sort() - assert.ok(sorted[0].includes('python3.10')) - assert.ok(sorted[1].includes('python3.11')) - }) - }) - - describe('findLocalVenvSitePackages', () => { - let testDir: string - - before(async () => { - testDir = await createTestDir('python-venv-find-') - }) - - after(async () => { - await removeTestDir(testDir) - }) - - it('should find .venv directory', async () => { - const venvSp = path.join( - testDir, - '.venv', - 'lib', - 'python3.11', - 'site-packages', - ) - await fs.mkdir(venvSp, { recursive: true }) - - // Temporarily unset VIRTUAL_ENV to test .venv detection - const origVirtualEnv = process.env['VIRTUAL_ENV'] - delete process.env['VIRTUAL_ENV'] - - try { - const results = await findLocalVenvSitePackages(testDir) - assert.ok(results.length > 0, 'Should find .venv site-packages') - assert.ok( - results.some(r => r.includes('.venv')), - 'Should include .venv path', - ) - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - } - }) - - it('should find venv directory', async () => { - const tempDir = await createTestDir('python-venv-plain-') - const venvSp = path.join( - tempDir, - 'venv', - 'lib', - 'python3.11', - 'site-packages', - ) - await fs.mkdir(venvSp, { recursive: true }) - - const origVirtualEnv = process.env['VIRTUAL_ENV'] - delete process.env['VIRTUAL_ENV'] - - try { - const results = await findLocalVenvSitePackages(tempDir) - assert.ok(results.length > 0, 'Should find venv site-packages') - assert.ok( - results.some(r => r.includes('venv')), - 'Should include venv path', - ) - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - await removeTestDir(tempDir) - } - }) - }) - - describe('getSitePackagesPaths', () => { - it('should honor VIRTUAL_ENV env var', async () => { - const tempDir = await createTestDir('python-virtual-env-') - const venvSp = path.join( - tempDir, - 'custom-venv', - 'lib', - 'python3.11', - 'site-packages', - ) - await fs.mkdir(venvSp, { recursive: true }) - - const origVirtualEnv = process.env['VIRTUAL_ENV'] - process.env['VIRTUAL_ENV'] = path.join(tempDir, 'custom-venv') - - try { - const crawler = new PythonCrawler() - const paths = await crawler.getSitePackagesPaths({ cwd: tempDir }) - assert.ok(paths.length > 0, 'Should find VIRTUAL_ENV site-packages') - assert.ok( - paths.some(p => p.includes('custom-venv')), - 'Should use VIRTUAL_ENV path', - ) - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - await removeTestDir(tempDir) - } - }) - }) -}) diff --git a/src/crawlers/python-crawler.ts b/src/crawlers/python-crawler.ts deleted file mode 100644 index 047cef4..0000000 --- a/src/crawlers/python-crawler.ts +++ /dev/null @@ -1,415 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import { execSync } from 'child_process' -import type { CrawledPackage, CrawlerOptions } from './types.js' - -const DEFAULT_BATCH_SIZE = 100 - -/** - * Canonicalize a Python package name per PEP 503. - * Lowercases, trims, and replaces runs of [-_.] with a single '-'. - */ -export function canonicalizePyPIName(name: string): string { - return name - .trim() - .toLowerCase() - .replaceAll(/[-_.]+/gi, '-') -} - -/** - * Read Name and Version from a dist-info METADATA file. - */ -async function readPythonMetadata( - distInfoPath: string, -): Promise<{ name: string; version: string } | null> { - try { - const metadataPath = path.join(distInfoPath, 'METADATA') - const content = await fs.readFile(metadataPath, 'utf-8') - - let name: string | undefined - let version: string | undefined - - for (const line of content.split('\n')) { - if (name && version) break - if (line.startsWith('Name:')) { - name = line.slice('Name:'.length).trim() - } else if (line.startsWith('Version:')) { - version = line.slice('Version:'.length).trim() - } - // Stop at first empty line (end of headers) - if (line.trim() === '' && (name || version)) break - } - - if (name && version) { - return { name, version } - } - return null - } catch { - return null - } -} - -/** - * Find directories matching a pattern with a single `python3.*` wildcard segment. - * E.g., given "/path/to/lib/python3.*\/site-packages", find all matching paths. - * This replaces the glob dependency. - */ -export async function findPythonDirs(basePath: string, ...segments: string[]): Promise { - const results: string[] = [] - - try { - const stat = await fs.stat(basePath) - if (!stat.isDirectory()) return results - } catch { - return results - } - - if (segments.length === 0) { - results.push(basePath) - return results - } - - const [first, ...rest] = segments - - if (first === 'python3.*') { - // Wildcard segment: list directory and match python3.X entries - try { - const entries = await fs.readdir(basePath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('python3.')) { - const subResults = await findPythonDirs( - path.join(basePath, entry.name), - ...rest, - ) - results.push(...subResults) - } - } - } catch { - // directory not readable - } - } else if (first === '*') { - // Generic wildcard: match any directory entry - try { - const entries = await fs.readdir(basePath, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - const subResults = await findPythonDirs( - path.join(basePath, entry.name), - ...rest, - ) - results.push(...subResults) - } - } - } catch { - // directory not readable - } - } else { - // Literal segment: just check if it exists - const subResults = await findPythonDirs( - path.join(basePath, first), - ...rest, - ) - results.push(...subResults) - } - - return results -} - -/** - * Find site-packages directories under a given lib directory using python version wildcard. - * Handles both Unix (lib/python3.X/site-packages) and Windows (Lib/site-packages) layouts. - */ -async function findSitePackagesUnder( - baseDir: string, - subDirType: 'dist-packages' | 'site-packages' = 'site-packages', -): Promise { - if (process.platform === 'win32') { - return findPythonDirs(baseDir, 'Lib', subDirType) - } - return findPythonDirs(baseDir, 'lib', 'python3.*', subDirType) -} - -/** - * Find local virtual environment site-packages directories. - */ -export async function findLocalVenvSitePackages(cwd: string): Promise { - const results: string[] = [] - - // 1. Check VIRTUAL_ENV env var - const virtualEnv = process.env['VIRTUAL_ENV'] - if (virtualEnv) { - const matches = await findSitePackagesUnder(virtualEnv) - results.push(...matches) - if (results.length > 0) return results - } - - // 2. Check .venv and venv in cwd - for (const venvDir of ['.venv', 'venv']) { - const venvPath = path.join(cwd, venvDir) - const matches = await findSitePackagesUnder(venvPath) - results.push(...matches) - } - - return results -} - -/** - * Get global/system Python site-packages directories. - */ -async function getGlobalPythonSitePackages(): Promise { - const results: string[] = [] - const seen = new Set() - - function addPath(p: string): void { - const resolved = path.resolve(p) - if (!seen.has(resolved)) { - seen.add(resolved) - results.push(resolved) - } - } - - // 1. Ask Python for site-packages - try { - const output = execSync( - 'python3 -c "import site; print(\'\\n\'.join(site.getsitepackages())); print(site.getusersitepackages())"', - { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - for (const line of output.trim().split('\n')) { - const p = line.trim() - if (p) addPath(p) - } - } catch { - // python3 not available - } - - // 2. Well-known system paths - const homeDir = process.env['HOME'] ?? '~' - - // Helper to scan a base/lib/python3.*/[dist|site]-packages pattern - async function scanWellKnown(base: string, pkgType: 'dist-packages' | 'site-packages'): Promise { - const matches = await findPythonDirs(base, 'lib', 'python3.*', pkgType) - for (const m of matches) addPath(m) - } - - // Debian/Ubuntu - await scanWellKnown('/usr', 'dist-packages') - await scanWellKnown('/usr', 'site-packages') - // Debian pip / most distros / macOS - await scanWellKnown('/usr/local', 'dist-packages') - await scanWellKnown('/usr/local', 'site-packages') - // pip --user - await scanWellKnown(`${homeDir}/.local`, 'site-packages') - - // macOS-specific - if (process.platform === 'darwin') { - await scanWellKnown('/opt/homebrew', 'site-packages') - // Python.org framework: /Library/Frameworks/Python.framework/Versions/3.*/lib/python3.*/site-packages - const fwMatches = await findPythonDirs( - '/Library/Frameworks/Python.framework/Versions', - 'python3.*', - 'lib', - 'python3.*', - 'site-packages', - ) - for (const m of fwMatches) addPath(m) - // Also try just 3.* version dirs - const fwMatches2 = await findPythonDirs( - '/Library/Frameworks/Python.framework', - 'Versions', - '*', - 'lib', - 'python3.*', - 'site-packages', - ) - for (const m of fwMatches2) addPath(m) - } - - // Conda - await scanWellKnown(`${homeDir}/anaconda3`, 'site-packages') - await scanWellKnown(`${homeDir}/miniconda3`, 'site-packages') - - // uv tools - if (process.platform === 'darwin') { - const uvMatches = await findPythonDirs( - `${homeDir}/Library/Application Support/uv/tools`, - '*', - 'lib', - 'python3.*', - 'site-packages', - ) - for (const m of uvMatches) addPath(m) - } else { - const uvMatches = await findPythonDirs( - `${homeDir}/.local/share/uv/tools`, - '*', - 'lib', - 'python3.*', - 'site-packages', - ) - for (const m of uvMatches) addPath(m) - } - - return results -} - -/** - * Python ecosystem crawler for discovering packages in site-packages - */ -export class PythonCrawler { - /** - * Get site-packages paths based on options - */ - async getSitePackagesPaths(options: CrawlerOptions): Promise { - if (options.global || options.globalPrefix) { - if (options.globalPrefix) { - return [options.globalPrefix] - } - return getGlobalPythonSitePackages() - } - return findLocalVenvSitePackages(options.cwd) - } - - /** - * Yield packages in batches (memory efficient for large codebases) - */ - async *crawlBatches( - options: CrawlerOptions, - ): AsyncGenerator { - const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE - const seen = new Set() - let batch: CrawledPackage[] = [] - - const sitePackagesPaths = await this.getSitePackagesPaths(options) - - for (const spPath of sitePackagesPaths) { - for await (const pkg of this.scanSitePackages(spPath, seen)) { - batch.push(pkg) - if (batch.length >= batchSize) { - yield batch - batch = [] - } - } - } - - if (batch.length > 0) { - yield batch - } - } - - /** - * Return all packages at once (convenience method) - */ - async crawlAll(options: CrawlerOptions): Promise { - const packages: CrawledPackage[] = [] - for await (const batch of this.crawlBatches(options)) { - packages.push(...batch) - } - return packages - } - - /** - * Find specific packages by PURL. - * Accepts base PURLs (no qualifiers) - caller strips qualifiers before calling. - */ - async findByPurls( - sitePackagesPath: string, - purls: string[], - ): Promise> { - const result = new Map() - - // Build a lookup map from canonicalized-name@version -> purl - const purlLookup = new Map() - for (const purl of purls) { - const parsed = this.parsePurl(purl) - if (parsed) { - const key = `${canonicalizePyPIName(parsed.name)}@${parsed.version}` - purlLookup.set(key, purl) - } - } - - if (purlLookup.size === 0) return result - - // Scan all dist-info dirs once, check against requested purls - let entries: string[] - try { - const allEntries = await fs.readdir(sitePackagesPath) - entries = allEntries.filter(e => e.endsWith('.dist-info')) - } catch { - return result - } - - for (const entry of entries) { - const distInfoPath = path.join(sitePackagesPath, entry) - const metadata = await readPythonMetadata(distInfoPath) - if (!metadata) continue - - const canonName = canonicalizePyPIName(metadata.name) - const key = `${canonName}@${metadata.version}` - const matchedPurl = purlLookup.get(key) - - if (matchedPurl) { - result.set(matchedPurl, { - name: canonName, - version: metadata.version, - purl: matchedPurl, - path: sitePackagesPath, - }) - } - } - - return result - } - - /** - * Scan a site-packages directory and yield packages - */ - private async *scanSitePackages( - sitePackagesPath: string, - seen: Set, - ): AsyncGenerator { - let entries: string[] - try { - const allEntries = await fs.readdir(sitePackagesPath) - entries = allEntries.filter(e => e.endsWith('.dist-info')) - } catch { - return - } - - for (const entry of entries) { - const distInfoPath = path.join(sitePackagesPath, entry) - const metadata = await readPythonMetadata(distInfoPath) - if (!metadata) continue - - const canonName = canonicalizePyPIName(metadata.name) - const purl = `pkg:pypi/${canonName}@${metadata.version}` - - if (seen.has(purl)) continue - seen.add(purl) - - yield { - name: canonName, - version: metadata.version, - purl, - path: sitePackagesPath, - } - } - } - - /** - * Parse a PyPI PURL string to extract name and version. - * Strips qualifiers before parsing. - */ - private parsePurl( - purl: string, - ): { name: string; version: string } | null { - // Strip qualifiers - const qIdx = purl.indexOf('?') - const base = qIdx === -1 ? purl : purl.slice(0, qIdx) - const match = base.match(/^pkg:pypi\/([^@]+)@(.+)$/) - if (!match) return null - return { name: match[1], version: match[2] } - } -} diff --git a/src/crawlers/python-venv.test.ts b/src/crawlers/python-venv.test.ts deleted file mode 100644 index 19085cb..0000000 --- a/src/crawlers/python-venv.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import { execSync } from 'child_process' -import { - createTestDir, - removeTestDir, -} from '../test-utils.js' -import { PythonCrawler } from './index.js' - -// Check if python3 is available -let hasPython = false -try { - execSync('python3 --version', { stdio: 'pipe' }) - hasPython = true -} catch { - // python3 not available -} - -describe('PythonCrawler - real venv tests', () => { - it('should crawl packages in a real venv', { skip: !hasPython }, async () => { - const testDir = await createTestDir('python-venv-real-') - - try { - // Create a real venv and install a tiny package - execSync('python3 -m venv venv', { - cwd: testDir, - stdio: 'pipe', - }) - execSync( - `${path.join(testDir, 'venv', 'bin', 'pip')} install --quiet six==1.16.0`, - { cwd: testDir, stdio: 'pipe', timeout: 60000 }, - ) - - // Set VIRTUAL_ENV to point to the venv - const origVirtualEnv = process.env['VIRTUAL_ENV'] - process.env['VIRTUAL_ENV'] = path.join(testDir, 'venv') - - try { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const six = packages.find(p => p.name === 'six') - assert.ok(six, 'Should find six package') - assert.equal(six.version, '1.16.0') - assert.equal(six.purl, 'pkg:pypi/six@1.16.0') - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - } - } finally { - await removeTestDir(testDir) - } - }) - - it('should find .venv automatically without VIRTUAL_ENV', { skip: !hasPython }, async () => { - const testDir = await createTestDir('python-dotvenv-auto-') - - try { - // Create a .venv (dotted name) - execSync('python3 -m venv .venv', { - cwd: testDir, - stdio: 'pipe', - }) - execSync( - `${path.join(testDir, '.venv', 'bin', 'pip')} install --quiet six==1.16.0`, - { cwd: testDir, stdio: 'pipe', timeout: 60000 }, - ) - - // Ensure VIRTUAL_ENV is NOT set - const origVirtualEnv = process.env['VIRTUAL_ENV'] - delete process.env['VIRTUAL_ENV'] - - try { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const six = packages.find(p => p.name === 'six') - assert.ok(six, 'Should find six in .venv without VIRTUAL_ENV') - assert.equal(six.purl, 'pkg:pypi/six@1.16.0') - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - } - } finally { - await removeTestDir(testDir) - } - }) - - it('should honor VIRTUAL_ENV env var', { skip: !hasPython }, async () => { - const testDir = await createTestDir('python-virtualenv-var-') - - try { - // Create venv in a custom location - const customVenvPath = path.join(testDir, 'custom-env') - execSync(`python3 -m venv ${customVenvPath}`, { - cwd: testDir, - stdio: 'pipe', - }) - execSync( - `${path.join(customVenvPath, 'bin', 'pip')} install --quiet six==1.16.0`, - { cwd: testDir, stdio: 'pipe', timeout: 60000 }, - ) - - const origVirtualEnv = process.env['VIRTUAL_ENV'] - process.env['VIRTUAL_ENV'] = customVenvPath - - try { - const crawler = new PythonCrawler() - const packages = await crawler.crawlAll({ cwd: testDir }) - - const six = packages.find(p => p.name === 'six') - assert.ok(six, 'Should find six via VIRTUAL_ENV') - assert.ok( - six.path.includes('custom-env'), - 'Path should reference the custom venv', - ) - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - } - } finally { - await removeTestDir(testDir) - } - }) - - it('should return paths from getSitePackagesPaths with global option', { skip: !hasPython }, async () => { - const crawler = new PythonCrawler() - const paths = await crawler.getSitePackagesPaths({ - cwd: process.cwd(), - global: true, - }) - - // Global site-packages should return at least one path - assert.ok(paths.length > 0, 'Should return at least one global site-packages path') - - // Verify paths exist (at least some of them) - let existingCount = 0 - for (const p of paths) { - try { - await fs.access(p) - existingCount++ - } catch { - // Some paths may not exist on all systems - } - } - assert.ok(existingCount >= 0, 'Some global site-packages paths may exist') - }) - - it('should return empty when no venv exists in local mode', { skip: !hasPython }, async () => { - // Create a temp dir with no venv - const testDir = await createTestDir('python-no-venv-') - const origVirtualEnv = process.env['VIRTUAL_ENV'] - delete process.env['VIRTUAL_ENV'] - - try { - const crawler = new PythonCrawler() - const paths = await crawler.getSitePackagesPaths({ cwd: testDir }) - - // Without VIRTUAL_ENV and no .venv/venv dir, local mode should return empty - // (system packages are only returned in global mode) - assert.equal(paths.length, 0, 'Local mode with no venv should return empty') - } finally { - if (origVirtualEnv !== undefined) { - process.env['VIRTUAL_ENV'] = origVirtualEnv - } else { - delete process.env['VIRTUAL_ENV'] - } - await removeTestDir(testDir) - } - }) -}) diff --git a/src/crawlers/types.ts b/src/crawlers/types.ts deleted file mode 100644 index c47d461..0000000 --- a/src/crawlers/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Represents a package discovered during crawling - */ -export interface CrawledPackage { - /** Package name (without scope) */ - name: string - /** Package version */ - version: string - /** Package scope/namespace (e.g., @types) - undefined for unscoped packages */ - namespace?: string - /** Full PURL string (e.g., pkg:npm/@types/node@20.0.0) */ - purl: string - /** Absolute path to the package directory */ - path: string -} - -/** - * Options for package crawling - */ -export interface CrawlerOptions { - /** Working directory to start from */ - cwd: string - /** Use global packages instead of local node_modules */ - global?: boolean - /** Custom path to global node_modules (overrides auto-detection) */ - globalPrefix?: string - /** Batch size for yielding packages (default: 100) */ - batchSize?: number -} diff --git a/src/hash/git-sha256.ts b/src/hash/git-sha256.ts deleted file mode 100644 index 729734f..0000000 --- a/src/hash/git-sha256.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as crypto from 'crypto' - -/** - * Compute Git-compatible SHA256 hash for a buffer - * @param buffer - Buffer or Uint8Array to hash - * @returns Git-compatible SHA256 hash (hex string) - */ -export function computeGitSHA256FromBuffer( - buffer: Buffer | Uint8Array, -): string { - const gitHash = crypto.createHash('sha256') - const header = `blob ${buffer.length}\0` - gitHash.update(header) - gitHash.update(buffer) - return gitHash.digest('hex') -} - -/** - * Compute Git-compatible SHA256 hash from an async iterable of chunks - * @param size - Total size of the file in bytes - * @param chunks - Async iterable of Buffer or Uint8Array chunks - * @returns Git-compatible SHA256 hash (hex string) - */ -export async function computeGitSHA256FromChunks( - size: number, - chunks: AsyncIterable, -): Promise { - const gitHash = crypto.createHash('sha256') - const header = `blob ${size}\0` - gitHash.update(header) - - for await (const chunk of chunks) { - gitHash.update(chunk) - } - - return gitHash.digest('hex') -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 2354ab3..0000000 --- a/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type { PatchInfo, ApplyOptions, PatchResult } from './types.js' -export { formatPatchResult, log, error } from './utils.js' - -// Re-export schema and hash modules -export * from './schema/manifest-schema.js' -export * from './hash/git-sha256.js' - -// Re-export patch application utilities -export * from './patch/file-hash.js' -export * from './patch/apply.js' - -// Re-export manifest utilities -export * from './manifest/operations.js' -export * from './manifest/recovery.js' - -// Re-export constants -export * from './constants.js' - -// Re-export programmatic API -export { runPatch } from './run.js' -export type { PatchOptions } from './run.js' diff --git a/src/manifest/operations.test.ts b/src/manifest/operations.test.ts deleted file mode 100644 index 435cc90..0000000 --- a/src/manifest/operations.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { - getReferencedBlobs, - getAfterHashBlobs, - getBeforeHashBlobs, -} from './operations.js' -import type { PatchManifest } from '../schema/manifest-schema.js' - -// Valid UUIDs for testing -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' -const TEST_UUID_2 = '22222222-2222-4222-8222-222222222222' - -// Sample hashes for testing -const BEFORE_HASH_1 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111' -const AFTER_HASH_1 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111' -const BEFORE_HASH_2 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222' -const AFTER_HASH_2 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222' -const BEFORE_HASH_3 = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3333' -const AFTER_HASH_3 = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3333' - -function createTestManifest(): PatchManifest { - return { - patches: { - 'pkg:npm/pkg-a@1.0.0': { - uuid: TEST_UUID_1, - exportedAt: '2024-01-01T00:00:00Z', - files: { - 'package/index.js': { - beforeHash: BEFORE_HASH_1, - afterHash: AFTER_HASH_1, - }, - 'package/lib/utils.js': { - beforeHash: BEFORE_HASH_2, - afterHash: AFTER_HASH_2, - }, - }, - vulnerabilities: {}, - description: 'Test patch 1', - license: 'MIT', - tier: 'free', - }, - 'pkg:npm/pkg-b@2.0.0': { - uuid: TEST_UUID_2, - exportedAt: '2024-01-01T00:00:00Z', - files: { - 'package/main.js': { - beforeHash: BEFORE_HASH_3, - afterHash: AFTER_HASH_3, - }, - }, - vulnerabilities: {}, - description: 'Test patch 2', - license: 'MIT', - tier: 'free', - }, - }, - } -} - -describe('manifest operations', () => { - describe('getReferencedBlobs', () => { - it('should return all blobs (both beforeHash and afterHash)', () => { - const manifest = createTestManifest() - const blobs = getReferencedBlobs(manifest) - - // Should contain all 6 hashes (3 before + 3 after) - assert.equal(blobs.size, 6) - assert.ok(blobs.has(BEFORE_HASH_1)) - assert.ok(blobs.has(AFTER_HASH_1)) - assert.ok(blobs.has(BEFORE_HASH_2)) - assert.ok(blobs.has(AFTER_HASH_2)) - assert.ok(blobs.has(BEFORE_HASH_3)) - assert.ok(blobs.has(AFTER_HASH_3)) - }) - - it('should return empty set for empty manifest', () => { - const manifest: PatchManifest = { patches: {} } - const blobs = getReferencedBlobs(manifest) - assert.equal(blobs.size, 0) - }) - - it('should deduplicate blobs with same hash', () => { - // Create a manifest where two files have the same beforeHash - const manifest: PatchManifest = { - patches: { - 'pkg:npm/pkg-a@1.0.0': { - uuid: TEST_UUID_1, - exportedAt: '2024-01-01T00:00:00Z', - files: { - 'package/file1.js': { - beforeHash: BEFORE_HASH_1, - afterHash: AFTER_HASH_1, - }, - 'package/file2.js': { - beforeHash: BEFORE_HASH_1, // Same beforeHash as file1 - afterHash: AFTER_HASH_2, - }, - }, - vulnerabilities: {}, - description: 'Test', - license: 'MIT', - tier: 'free', - }, - }, - } - - const blobs = getReferencedBlobs(manifest) - // Should be 3 unique hashes, not 4 - assert.equal(blobs.size, 3) - }) - }) - - describe('getAfterHashBlobs', () => { - it('should return only afterHash blobs', () => { - const manifest = createTestManifest() - const blobs = getAfterHashBlobs(manifest) - - // Should contain only 3 afterHash blobs - assert.equal(blobs.size, 3) - assert.ok(blobs.has(AFTER_HASH_1)) - assert.ok(blobs.has(AFTER_HASH_2)) - assert.ok(blobs.has(AFTER_HASH_3)) - - // Should NOT contain beforeHash blobs - assert.ok(!blobs.has(BEFORE_HASH_1)) - assert.ok(!blobs.has(BEFORE_HASH_2)) - assert.ok(!blobs.has(BEFORE_HASH_3)) - }) - - it('should return empty set for empty manifest', () => { - const manifest: PatchManifest = { patches: {} } - const blobs = getAfterHashBlobs(manifest) - assert.equal(blobs.size, 0) - }) - }) - - describe('getBeforeHashBlobs', () => { - it('should return only beforeHash blobs', () => { - const manifest = createTestManifest() - const blobs = getBeforeHashBlobs(manifest) - - // Should contain only 3 beforeHash blobs - assert.equal(blobs.size, 3) - assert.ok(blobs.has(BEFORE_HASH_1)) - assert.ok(blobs.has(BEFORE_HASH_2)) - assert.ok(blobs.has(BEFORE_HASH_3)) - - // Should NOT contain afterHash blobs - assert.ok(!blobs.has(AFTER_HASH_1)) - assert.ok(!blobs.has(AFTER_HASH_2)) - assert.ok(!blobs.has(AFTER_HASH_3)) - }) - - it('should return empty set for empty manifest', () => { - const manifest: PatchManifest = { patches: {} } - const blobs = getBeforeHashBlobs(manifest) - assert.equal(blobs.size, 0) - }) - }) - - describe('relationship between functions', () => { - it('afterHash + beforeHash should equal all referenced blobs', () => { - const manifest = createTestManifest() - const allBlobs = getReferencedBlobs(manifest) - const afterBlobs = getAfterHashBlobs(manifest) - const beforeBlobs = getBeforeHashBlobs(manifest) - - // Union of afterBlobs and beforeBlobs should equal allBlobs - const union = new Set([...afterBlobs, ...beforeBlobs]) - assert.equal(union.size, allBlobs.size) - for (const blob of allBlobs) { - assert.ok(union.has(blob)) - } - }) - - it('afterHash and beforeHash should be disjoint (no overlap) in typical cases', () => { - const manifest = createTestManifest() - const afterBlobs = getAfterHashBlobs(manifest) - const beforeBlobs = getBeforeHashBlobs(manifest) - - // Check no overlap - for (const blob of afterBlobs) { - assert.ok(!beforeBlobs.has(blob), `${blob} appears in both sets`) - } - }) - }) -}) diff --git a/src/manifest/operations.ts b/src/manifest/operations.ts deleted file mode 100644 index ed650dc..0000000 --- a/src/manifest/operations.ts +++ /dev/null @@ -1,142 +0,0 @@ -import * as fs from 'fs/promises' -import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js' -import { PatchManifestSchema } from '../schema/manifest-schema.js' - -/** - * Get all blob hashes referenced by a manifest (both beforeHash and afterHash) - * Used for garbage collection and validation - */ -export function getReferencedBlobs(manifest: PatchManifest): Set { - const blobs = new Set() - - for (const patchRecord of Object.values(manifest.patches)) { - const record = patchRecord as PatchRecord - for (const fileInfo of Object.values(record.files)) { - blobs.add(fileInfo.beforeHash) - blobs.add(fileInfo.afterHash) - } - } - - return blobs -} - -/** - * Get only afterHash blobs referenced by a manifest - * Used for apply operations - we only need the patched file content, not the original - * This saves disk space since beforeHash blobs are not needed for applying patches - */ -export function getAfterHashBlobs(manifest: PatchManifest): Set { - const blobs = new Set() - - for (const patchRecord of Object.values(manifest.patches)) { - const record = patchRecord as PatchRecord - for (const fileInfo of Object.values(record.files)) { - blobs.add(fileInfo.afterHash) - } - } - - return blobs -} - -/** - * Get only beforeHash blobs referenced by a manifest - * Used for rollback operations - we need the original file content to restore - */ -export function getBeforeHashBlobs(manifest: PatchManifest): Set { - const blobs = new Set() - - for (const patchRecord of Object.values(manifest.patches)) { - const record = patchRecord as PatchRecord - for (const fileInfo of Object.values(record.files)) { - blobs.add(fileInfo.beforeHash) - } - } - - return blobs -} - -/** - * Calculate differences between two manifests - */ -export interface ManifestDiff { - added: Set // PURLs - removed: Set - modified: Set -} - -export function diffManifests( - oldManifest: PatchManifest, - newManifest: PatchManifest, -): ManifestDiff { - const oldPurls = new Set(Object.keys(oldManifest.patches)) - const newPurls = new Set(Object.keys(newManifest.patches)) - - const added = new Set() - const removed = new Set() - const modified = new Set() - - // Find added and modified - for (const purl of newPurls) { - if (!oldPurls.has(purl)) { - added.add(purl) - } else { - const oldPatch = oldManifest.patches[purl] as PatchRecord - const newPatch = newManifest.patches[purl] as PatchRecord - if (oldPatch.uuid !== newPatch.uuid) { - modified.add(purl) - } - } - } - - // Find removed - for (const purl of oldPurls) { - if (!newPurls.has(purl)) { - removed.add(purl) - } - } - - return { added, removed, modified } -} - -/** - * Validate a parsed manifest object - */ -export function validateManifest(parsed: unknown): { - success: boolean - manifest?: PatchManifest - error?: string -} { - const result = PatchManifestSchema.safeParse(parsed) - if (result.success) { - return { success: true, manifest: result.data } - } - return { - success: false, - error: result.error.message, - } -} - -/** - * Read and parse a manifest from the filesystem - */ -export async function readManifest(path: string): Promise { - try { - const content = await fs.readFile(path, 'utf-8') - const parsed = JSON.parse(content) - const result = validateManifest(parsed) - return result.success ? result.manifest! : null - } catch { - return null - } -} - -/** - * Write a manifest to the filesystem - */ -export async function writeManifest( - path: string, - manifest: PatchManifest, -): Promise { - const content = JSON.stringify(manifest, null, 2) - await fs.writeFile(path, content, 'utf-8') -} diff --git a/src/manifest/recovery.ts b/src/manifest/recovery.ts deleted file mode 100644 index af5a894..0000000 --- a/src/manifest/recovery.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { PatchManifest, PatchRecord } from '../schema/manifest-schema.js' -import { PatchManifestSchema, PatchRecordSchema } from '../schema/manifest-schema.js' - -/** - * Result of manifest recovery operation - */ -export interface RecoveryResult { - manifest: PatchManifest - repairNeeded: boolean - invalidPatches: string[] - recoveredPatches: string[] - discardedPatches: string[] -} - -/** - * Options for manifest recovery - */ -export interface RecoveryOptions { - /** - * Optional function to refetch patch data from external source (e.g., database) - * Should return patch data or null if not found - * @param uuid - The patch UUID - * @param purl - The package URL (for context/validation) - */ - refetchPatch?: (uuid: string, purl?: string) => Promise - - /** - * Optional callback for logging recovery events - */ - onRecoveryEvent?: (event: RecoveryEvent) => void -} - -/** - * Patch data returned from external source - */ -export interface PatchData { - uuid: string - purl: string - publishedAt: string - files: Record< - string, - { - beforeHash?: string - afterHash?: string - } - > - vulnerabilities: Record< - string, - { - cves: string[] - summary: string - severity: string - description: string - } - > - description: string - license: string - tier: string -} - -/** - * Events emitted during recovery - */ -export type RecoveryEvent = - | { type: 'corrupted_manifest' } - | { type: 'invalid_patch'; purl: string; uuid: string | null } - | { type: 'recovered_patch'; purl: string; uuid: string } - | { type: 'discarded_patch_not_found'; purl: string; uuid: string } - | { type: 'discarded_patch_purl_mismatch'; purl: string; uuid: string; dbPurl: string } - | { type: 'discarded_patch_no_uuid'; purl: string } - | { type: 'recovery_error'; purl: string; uuid: string; error: string } - -/** - * Recover and validate manifest with automatic repair of invalid patches - * - * This function attempts to parse and validate a manifest. If the manifest - * contains invalid patches, it will attempt to recover them using the provided - * refetch function. Patches that cannot be recovered are discarded. - * - * @param parsed - The parsed manifest object (may be invalid) - * @param options - Recovery options including refetch function and event callback - * @returns Recovery result with repaired manifest and statistics - */ -export async function recoverManifest( - parsed: unknown, - options: RecoveryOptions = {}, -): Promise { - const { refetchPatch, onRecoveryEvent } = options - - // Try strict parse first (fast path for valid manifests) - const strictResult = PatchManifestSchema.safeParse(parsed) - if (strictResult.success) { - return { - manifest: strictResult.data, - repairNeeded: false, - invalidPatches: [], - recoveredPatches: [], - discardedPatches: [], - } - } - - // Extract patches object with safety checks - const patchesObj = - parsed && - typeof parsed === 'object' && - 'patches' in parsed && - parsed.patches && - typeof parsed.patches === 'object' - ? (parsed.patches as Record) - : null - - if (!patchesObj) { - // Completely corrupted manifest - onRecoveryEvent?.({ type: 'corrupted_manifest' }) - return { - manifest: { patches: {} }, - repairNeeded: true, - invalidPatches: [], - recoveredPatches: [], - discardedPatches: [], - } - } - - // Try to recover individual patches - const recoveredPatchesMap: Record = {} - const invalidPatches: string[] = [] - const recoveredPatches: string[] = [] - const discardedPatches: string[] = [] - - for (const [purl, patchData] of Object.entries(patchesObj)) { - // Try to parse this individual patch - const patchResult = PatchRecordSchema.safeParse(patchData) - - if (patchResult.success) { - // Valid patch, keep it as-is - recoveredPatchesMap[purl] = patchResult.data - } else { - // Invalid patch, try to recover from external source - const uuid = - patchData && - typeof patchData === 'object' && - 'uuid' in patchData && - typeof patchData.uuid === 'string' - ? patchData.uuid - : null - - invalidPatches.push(purl) - onRecoveryEvent?.({ type: 'invalid_patch', purl, uuid }) - - if (uuid && refetchPatch) { - try { - // Try to refetch from external source - const patchFromSource = await refetchPatch(uuid, purl) - - if (patchFromSource && patchFromSource.purl === purl) { - // Successfully recovered, reconstruct patch record - const manifestFiles: Record< - string, - { beforeHash: string; afterHash: string } - > = {} - for (const [filePath, fileInfo] of Object.entries( - patchFromSource.files, - )) { - if (fileInfo.beforeHash && fileInfo.afterHash) { - manifestFiles[filePath] = { - beforeHash: fileInfo.beforeHash, - afterHash: fileInfo.afterHash, - } - } - } - - recoveredPatchesMap[purl] = { - uuid: patchFromSource.uuid, - exportedAt: patchFromSource.publishedAt, - files: manifestFiles, - vulnerabilities: patchFromSource.vulnerabilities, - description: patchFromSource.description, - license: patchFromSource.license, - tier: patchFromSource.tier, - } - - recoveredPatches.push(purl) - onRecoveryEvent?.({ type: 'recovered_patch', purl, uuid }) - } else if (patchFromSource && patchFromSource.purl !== purl) { - // PURL mismatch - wrong package! - discardedPatches.push(purl) - onRecoveryEvent?.({ - type: 'discarded_patch_purl_mismatch', - purl, - uuid, - dbPurl: patchFromSource.purl, - }) - } else { - // Not found in external source (might be unpublished) - discardedPatches.push(purl) - onRecoveryEvent?.({ - type: 'discarded_patch_not_found', - purl, - uuid, - }) - } - } catch (error: unknown) { - // Error during recovery - discardedPatches.push(purl) - const errorMessage = error instanceof Error ? error.message : String(error) - onRecoveryEvent?.({ - type: 'recovery_error', - purl, - uuid, - error: errorMessage, - }) - } - } else { - // No UUID or no refetch function, can't recover - discardedPatches.push(purl) - if (!uuid) { - onRecoveryEvent?.({ type: 'discarded_patch_no_uuid', purl }) - } else { - onRecoveryEvent?.({ - type: 'discarded_patch_not_found', - purl, - uuid, - }) - } - } - } - } - - const repairNeeded = invalidPatches.length > 0 - - return { - manifest: { patches: recoveredPatchesMap }, - repairNeeded, - invalidPatches, - recoveredPatches, - discardedPatches, - } -} diff --git a/src/package-json/detect.test.ts b/src/package-json/detect.test.ts deleted file mode 100644 index e0e000b..0000000 --- a/src/package-json/detect.test.ts +++ /dev/null @@ -1,618 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { - isPostinstallConfigured, - generateUpdatedPostinstall, - updatePackageJsonContent, -} from './detect.js' - -describe('isPostinstallConfigured', () => { - describe('Edge Case 1: No scripts field at all', () => { - it('should detect as not configured when scripts field is missing', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - }) - - describe('Edge Case 2: Scripts field exists but no postinstall', () => { - it('should detect as not configured when postinstall is missing', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - test: 'jest', - build: 'tsc', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - - it('should detect as not configured when postinstall is null', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: null, - }, - } - - const result = isPostinstallConfigured(packageJson as any) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - - it('should detect as not configured when postinstall is undefined', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: undefined, - }, - } - - const result = isPostinstallConfigured(packageJson as any) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - - it('should detect as not configured when postinstall is empty string', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: '', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - - it('should detect as not configured when postinstall is whitespace only', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: ' \t\n ', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, ' \t\n ') - }) - }) - - describe('Edge Case 3: Postinstall exists but missing socket-patch setup', () => { - it('should detect as not configured when postinstall has different command', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'echo "Running postinstall"', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, 'echo "Running postinstall"') - }) - - it('should detect as not configured with complex existing script', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'npm run build && npm run prepare && echo done', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - }) - }) - - describe('Edge Case 4: Postinstall has socket-patch but not exact format', () => { - it('should detect socket-patch apply without npx as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - true, - 'socket-patch apply should be recognized', - ) - assert.equal(result.needsUpdate, false) - }) - - it('should detect npx socket-patch apply (without @socketsecurity/) as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'npx socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - true, - 'npx socket-patch apply should be recognized', - ) - assert.equal(result.needsUpdate, false) - }) - - it('should detect canonical format npx @socketsecurity/socket-patch apply', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'npx @socketsecurity/socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, true) - assert.equal(result.needsUpdate, false) - }) - - it('should detect pnpm socket-patch apply as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'pnpm socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - true, - 'pnpm socket-patch apply should be recognized', - ) - assert.equal(result.needsUpdate, false) - }) - - it('should detect yarn socket-patch apply as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'yarn socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - true, - 'yarn socket-patch apply should be recognized', - ) - assert.equal(result.needsUpdate, false) - }) - - it('should detect node_modules/.bin/socket-patch apply as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'node_modules/.bin/socket-patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, true) - assert.equal(result.needsUpdate, false) - }) - - it('should NOT detect socket apply (main Socket CLI) as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - false, - 'socket apply (main CLI) should NOT be recognized', - ) - assert.equal(result.needsUpdate, true) - }) - - it('should NOT detect socket-patch without apply subcommand', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket-patch list', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - false, - 'socket-patch list should NOT be recognized', - ) - assert.equal(result.needsUpdate, true) - }) - - it('should detect socket-patch apply with additional flags', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'npx @socketsecurity/socket-patch apply --silent', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, true) - assert.equal(result.needsUpdate, false) - }) - - it('should detect socket-patch apply in complex script chain', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: - 'echo "Starting" && socket-patch apply && echo "Complete"', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, true) - assert.equal(result.needsUpdate, false) - }) - - it('should detect socket patch apply (Socket CLI subcommand) as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket patch apply', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal( - result.configured, - true, - 'socket patch apply (CLI subcommand) should be recognized', - ) - assert.equal(result.needsUpdate, false) - }) - - it('should detect socket patch apply with --silent flag as configured', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket patch apply --silent', - }, - } - - const result = isPostinstallConfigured(packageJson) - - assert.equal(result.configured, true) - assert.equal(result.needsUpdate, false) - }) - }) - - describe('Edge Case 5: Invalid or malformed data', () => { - it('should handle malformed JSON gracefully', () => { - const malformedJson = '{ name: "test", invalid }' - - const result = isPostinstallConfigured(malformedJson) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - assert.equal(result.currentScript, '') - }) - - it('should handle non-string postinstall value', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 123, - }, - } - - const result = isPostinstallConfigured(packageJson as any) - - // Should coerce to string or handle gracefully - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - }) - - it('should handle array postinstall value', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: ['echo', 'hello'], - }, - } - - const result = isPostinstallConfigured(packageJson as any) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - }) - - it('should handle object postinstall value', () => { - const packageJson = { - name: 'test', - version: '1.0.0', - scripts: { - postinstall: { command: 'echo hello' }, - }, - } - - const result = isPostinstallConfigured(packageJson as any) - - assert.equal(result.configured, false) - assert.equal(result.needsUpdate, true) - }) - }) -}) - -describe('generateUpdatedPostinstall', () => { - it('should create command for empty string', () => { - const result = generateUpdatedPostinstall('') - assert.equal(result, 'socket patch apply --silent --ecosystems npm') - }) - - it('should create command for whitespace-only string', () => { - const result = generateUpdatedPostinstall(' \n\t ') - assert.equal(result, 'socket patch apply --silent --ecosystems npm') - }) - - it('should prepend to existing script', () => { - const result = generateUpdatedPostinstall('echo "Hello"') - assert.equal( - result, - 'socket patch apply --silent --ecosystems npm && echo "Hello"', - ) - }) - - it('should preserve existing script with socket-patch', () => { - const existing = 'socket-patch apply && echo "Done"' - const result = generateUpdatedPostinstall(existing) - assert.equal(result, existing, 'Should not modify if already present') - }) - - it('should preserve npx @socketsecurity/socket-patch apply', () => { - const existing = 'npx @socketsecurity/socket-patch apply' - const result = generateUpdatedPostinstall(existing) - assert.equal(result, existing) - }) - - it('should preserve socket patch apply (CLI subcommand)', () => { - const existing = 'socket patch apply' - const result = generateUpdatedPostinstall(existing) - assert.equal(result, existing) - }) - - it('should preserve socket patch apply --silent', () => { - const existing = 'socket patch apply --silent' - const result = generateUpdatedPostinstall(existing) - assert.equal(result, existing) - }) - - it('should prepend to script with socket apply (non-patch command)', () => { - const existing = 'socket apply' - const result = generateUpdatedPostinstall(existing) - assert.equal( - result, - 'socket patch apply --silent --ecosystems npm && socket apply', - 'Should add socket patch apply even if socket apply is present', - ) - }) -}) - -describe('updatePackageJsonContent', () => { - it('should add scripts field when missing', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - const updated = JSON.parse(result.content) - assert.ok(updated.scripts) - assert.equal( - updated.scripts.postinstall, - 'socket patch apply --silent --ecosystems npm', - ) - }) - - it('should add postinstall to existing scripts', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - test: 'jest', - build: 'tsc', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - const updated = JSON.parse(result.content) - assert.equal( - updated.scripts.postinstall, - 'socket patch apply --silent --ecosystems npm', - ) - assert.equal(updated.scripts.test, 'jest', 'Should preserve other scripts') - assert.equal(updated.scripts.build, 'tsc', 'Should preserve other scripts') - }) - - it('should prepend to existing postinstall', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'echo "Setup complete"', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - assert.equal(result.oldScript, 'echo "Setup complete"') - assert.equal( - result.newScript, - 'socket patch apply --silent --ecosystems npm && echo "Setup complete"', - ) - }) - - it('should not modify when already configured with legacy format', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'npx @socketsecurity/socket-patch apply', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, false) - assert.equal(result.content, content) - }) - - it('should not modify when already configured with socket patch apply', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - postinstall: 'socket patch apply --silent', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, false) - assert.equal(result.content, content) - }) - - it('should throw error for invalid JSON', () => { - const content = '{ invalid json }' - - assert.throws( - () => updatePackageJsonContent(content), - /Invalid package\.json/, - ) - }) - - it('should handle empty postinstall by replacing it', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - postinstall: '', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - const updated = JSON.parse(result.content) - assert.equal( - updated.scripts.postinstall, - 'socket patch apply --silent --ecosystems npm', - ) - }) - - it('should handle whitespace-only postinstall', () => { - const content = JSON.stringify({ - name: 'test', - version: '1.0.0', - scripts: { - postinstall: ' \n\t ', - }, - }) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - const updated = JSON.parse(result.content) - assert.equal( - updated.scripts.postinstall, - 'socket patch apply --silent --ecosystems npm', - ) - }) - - it('should preserve JSON formatting', () => { - const content = JSON.stringify( - { - name: 'test', - version: '1.0.0', - }, - null, - 2, - ) - - const result = updatePackageJsonContent(content) - - assert.equal(result.modified, true) - // Check that formatting is preserved (2 space indent) - assert.ok(result.content.includes(' "name"')) - assert.ok(result.content.includes(' "scripts"')) - }) -}) diff --git a/src/package-json/detect.ts b/src/package-json/detect.ts deleted file mode 100644 index 79a4364..0000000 --- a/src/package-json/detect.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Shared logic for detecting and generating postinstall scripts. - * Used by both CLI and GitHub bot. - */ - -// The command to run for applying patches via socket CLI. -const SOCKET_PATCH_COMMAND = 'socket patch apply --silent --ecosystems npm' - -// Legacy command patterns to detect existing configurations. -const LEGACY_PATCH_PATTERNS = [ - 'socket-patch apply', - 'npx @socketsecurity/socket-patch apply', - 'socket patch apply', -] - -export interface PostinstallStatus { - configured: boolean - currentScript: string - needsUpdate: boolean -} - -/** - * Check if a postinstall script is properly configured for socket-patch - */ -export function isPostinstallConfigured( - packageJsonContent: string | Record, -): PostinstallStatus { - let packageJson: Record - - if (typeof packageJsonContent === 'string') { - try { - packageJson = JSON.parse(packageJsonContent) - } catch { - return { - configured: false, - currentScript: '', - needsUpdate: true, - } - } - } else { - packageJson = packageJsonContent - } - - const rawPostinstall = packageJson.scripts?.postinstall - // Handle non-string values (null, object, array) by treating as empty string. - const currentScript = typeof rawPostinstall === 'string' ? rawPostinstall : '' - - // Check if any socket-patch apply variant is already present. - const configured = LEGACY_PATCH_PATTERNS.some(pattern => - currentScript.includes(pattern), - ) - - return { - configured, - currentScript, - needsUpdate: !configured, - } -} - -/** - * Generate an updated postinstall script that includes socket-patch. - */ -export function generateUpdatedPostinstall( - currentPostinstall: string, -): string { - const trimmed = currentPostinstall.trim() - - // If empty, just add the socket-patch command. - if (!trimmed) { - return SOCKET_PATCH_COMMAND - } - - // If any socket-patch variant is already present, return unchanged. - const alreadyConfigured = LEGACY_PATCH_PATTERNS.some(pattern => - trimmed.includes(pattern), - ) - if (alreadyConfigured) { - return trimmed - } - - // Prepend socket-patch command so it runs first, then existing script. - // Using && ensures existing script only runs if patching succeeds. - return `${SOCKET_PATCH_COMMAND} && ${trimmed}` -} - -/** - * Update a package.json object with the new postinstall script - * Returns the modified package.json and whether it was changed - */ -export function updatePackageJsonObject( - packageJson: Record, -): { modified: boolean; packageJson: Record } { - const status = isPostinstallConfigured(packageJson) - - if (!status.needsUpdate) { - return { modified: false, packageJson } - } - - // Ensure scripts object exists - if (!packageJson.scripts) { - packageJson.scripts = {} - } - - // Update postinstall script - const newPostinstall = generateUpdatedPostinstall(status.currentScript) - packageJson.scripts.postinstall = newPostinstall - - return { modified: true, packageJson } -} - -/** - * Parse package.json content and update it with socket-patch postinstall - * Returns the updated JSON string and metadata about the change - */ -export function updatePackageJsonContent( - content: string, -): { - modified: boolean - content: string - oldScript: string - newScript: string -} { - let packageJson: Record - - try { - packageJson = JSON.parse(content) - } catch { - throw new Error('Invalid package.json: failed to parse JSON') - } - - const status = isPostinstallConfigured(packageJson) - - if (!status.needsUpdate) { - return { - modified: false, - content, - oldScript: status.currentScript, - newScript: status.currentScript, - } - } - - // Update the package.json object - const { packageJson: updatedPackageJson } = - updatePackageJsonObject(packageJson) - - // Stringify with formatting - const newContent = JSON.stringify(updatedPackageJson, null, 2) + '\n' - const newScript = updatedPackageJson.scripts.postinstall - - return { - modified: true, - content: newContent, - oldScript: status.currentScript, - newScript, - } -} diff --git a/src/package-json/find.ts b/src/package-json/find.ts deleted file mode 100644 index 3b9a258..0000000 --- a/src/package-json/find.ts +++ /dev/null @@ -1,326 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' - -export interface WorkspaceConfig { - type: 'npm' | 'yarn' | 'pnpm' | 'none' - patterns: string[] -} - -export interface PackageJsonLocation { - path: string - isRoot: boolean - isWorkspace: boolean - workspacePattern?: string -} - -/** - * Find all package.json files recursively, respecting workspace configurations - */ -export async function findPackageJsonFiles( - startPath: string, -): Promise { - const results: PackageJsonLocation[] = [] - const rootPackageJsonPath = path.join(startPath, 'package.json') - - // Check if root package.json exists - let rootExists = false - let workspaceConfig: WorkspaceConfig = { type: 'none', patterns: [] } - - try { - await fs.access(rootPackageJsonPath) - rootExists = true - - // Detect workspace configuration - workspaceConfig = await detectWorkspaces(rootPackageJsonPath) - - // Add root package.json - results.push({ - path: rootPackageJsonPath, - isRoot: true, - isWorkspace: false, - }) - } catch { - // No root package.json - } - - // If workspaces are configured, find all workspace package.json files - if (workspaceConfig.type !== 'none') { - const workspacePackages = await findWorkspacePackages( - startPath, - workspaceConfig, - ) - results.push(...workspacePackages) - } else if (rootExists) { - // No workspaces, just search for nested package.json files - const nestedPackages = await findNestedPackageJsonFiles(startPath) - results.push(...nestedPackages) - } - - return results -} - -/** - * Detect workspace configuration from package.json - */ -export async function detectWorkspaces( - packageJsonPath: string, -): Promise { - try { - const content = await fs.readFile(packageJsonPath, 'utf-8') - const packageJson = JSON.parse(content) - - // Check for npm/yarn workspaces - if (packageJson.workspaces) { - const patterns = Array.isArray(packageJson.workspaces) - ? packageJson.workspaces - : packageJson.workspaces.packages || [] - - return { - type: 'npm', // npm and yarn use same format - patterns, - } - } - - // Check for pnpm workspaces (pnpm-workspace.yaml) - const dir = path.dirname(packageJsonPath) - const pnpmWorkspacePath = path.join(dir, 'pnpm-workspace.yaml') - - try { - await fs.access(pnpmWorkspacePath) - // Parse pnpm-workspace.yaml (simple YAML parsing for packages field) - const yamlContent = await fs.readFile(pnpmWorkspacePath, 'utf-8') - const patterns = parsePnpmWorkspacePatterns(yamlContent) - - return { - type: 'pnpm', - patterns, - } - } catch { - // No pnpm workspace file - } - - return { type: 'none', patterns: [] } - } catch { - return { type: 'none', patterns: [] } - } -} - -/** - * Simple parser for pnpm-workspace.yaml packages field - */ -function parsePnpmWorkspacePatterns(yamlContent: string): string[] { - const patterns: string[] = [] - const lines = yamlContent.split('\n') - let inPackages = false - - for (const line of lines) { - const trimmed = line.trim() - - if (trimmed === 'packages:') { - inPackages = true - continue - } - - if (inPackages) { - // Stop at next top-level key - if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('#')) { - break - } - - // Parse list item - const match = trimmed.match(/^-\s*['"]?([^'"]+)['"]?/) - if (match) { - patterns.push(match[1]) - } - } - } - - return patterns -} - -/** - * Find workspace packages based on workspace patterns - */ -async function findWorkspacePackages( - rootPath: string, - workspaceConfig: WorkspaceConfig, -): Promise { - const results: PackageJsonLocation[] = [] - - for (const pattern of workspaceConfig.patterns) { - // Handle glob patterns like "packages/*" or "apps/**" - const packages = await findPackagesMatchingPattern(rootPath, pattern) - results.push( - ...packages.map(p => ({ - path: p, - isRoot: false, - isWorkspace: true, - workspacePattern: pattern, - })), - ) - } - - return results -} - -/** - * Find packages matching a workspace pattern - * Supports basic glob patterns: *, ** - */ -async function findPackagesMatchingPattern( - rootPath: string, - pattern: string, -): Promise { - const results: string[] = [] - - // Convert glob pattern to regex-like logic - const parts = pattern.split('/') - const searchPath = path.join(rootPath, parts[0]) - - // If pattern is like "packages/*", search one level deep - if (parts.length === 2 && parts[1] === '*') { - await searchOneLevel(searchPath, results) - } - // If pattern is like "packages/**", search recursively - else if (parts.length === 2 && parts[1] === '**') { - await searchRecursive(searchPath, results) - } - // If pattern is just a directory name, check if it has package.json - else { - const packageJsonPath = path.join(rootPath, pattern, 'package.json') - try { - await fs.access(packageJsonPath) - results.push(packageJsonPath) - } catch { - // Not a valid package - } - } - - return results -} - -/** - * Search one level deep for package.json files - */ -async function searchOneLevel( - dir: string, - results: string[], -): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isDirectory()) continue - - const packageJsonPath = path.join(dir, entry.name, 'package.json') - try { - await fs.access(packageJsonPath) - results.push(packageJsonPath) - } catch { - // No package.json in this directory - } - } - } catch { - // Ignore permission errors or missing directories - } -} - -/** - * Search recursively for package.json files - */ -async function searchRecursive( - dir: string, - results: string[], -): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isDirectory()) continue - - const fullPath = path.join(dir, entry.name) - - // Skip hidden directories, node_modules, dist, build - if ( - entry.name.startsWith('.') || - entry.name === 'node_modules' || - entry.name === 'dist' || - entry.name === 'build' - ) { - continue - } - - // Check for package.json at this level - const packageJsonPath = path.join(fullPath, 'package.json') - try { - await fs.access(packageJsonPath) - results.push(packageJsonPath) - } catch { - // No package.json at this level - } - - // Recurse into subdirectories - await searchRecursive(fullPath, results) - } - } catch { - // Ignore permission errors - } -} - -/** - * Find nested package.json files without workspace configuration - */ -async function findNestedPackageJsonFiles( - startPath: string, -): Promise { - const results: PackageJsonLocation[] = [] - - async function search(dir: string, depth: number): Promise { - // Limit depth to avoid searching too deep - if (depth > 5) return - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isDirectory()) continue - - const fullPath = path.join(dir, entry.name) - - // Skip hidden directories, node_modules, dist, build - if ( - entry.name.startsWith('.') || - entry.name === 'node_modules' || - entry.name === 'dist' || - entry.name === 'build' - ) { - continue - } - - // Check for package.json at this level - const packageJsonPath = path.join(fullPath, 'package.json') - try { - await fs.access(packageJsonPath) - // Don't include the root package.json (already added) - if (packageJsonPath !== path.join(startPath, 'package.json')) { - results.push({ - path: packageJsonPath, - isRoot: false, - isWorkspace: false, - }) - } - } catch { - // No package.json at this level - } - - // Recurse into subdirectories - await search(fullPath, depth + 1) - } - } catch { - // Ignore permission errors - } - } - - await search(startPath, 0) - return results -} diff --git a/src/package-json/index.ts b/src/package-json/index.ts deleted file mode 100644 index 34815b0..0000000 --- a/src/package-json/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { - findPackageJsonFiles, - detectWorkspaces, - type WorkspaceConfig, - type PackageJsonLocation, -} from './find.js' - -export { - isPostinstallConfigured, - generateUpdatedPostinstall, - updatePackageJsonObject, - updatePackageJsonContent, - type PostinstallStatus, -} from './detect.js' - -export { - updatePackageJson, - updateMultiplePackageJsons, - type UpdateResult, -} from './update.js' diff --git a/src/package-json/update.ts b/src/package-json/update.ts deleted file mode 100644 index 033b7de..0000000 --- a/src/package-json/update.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as fs from 'fs/promises' -import { - isPostinstallConfigured, - updatePackageJsonContent, -} from './detect.js' - -export interface UpdateResult { - path: string - status: 'updated' | 'already-configured' | 'error' - oldScript: string - newScript: string - error?: string -} - -/** - * Update a single package.json file with socket-patch postinstall script - */ -export async function updatePackageJson( - packageJsonPath: string, - dryRun: boolean = false, -): Promise { - try { - // Read current package.json - const content = await fs.readFile(packageJsonPath, 'utf-8') - - // Check current status - const status = isPostinstallConfigured(content) - - if (!status.needsUpdate) { - return { - path: packageJsonPath, - status: 'already-configured', - oldScript: status.currentScript, - newScript: status.currentScript, - } - } - - // Generate updated content - const { modified, content: newContent, oldScript, newScript } = - updatePackageJsonContent(content) - - if (!modified) { - return { - path: packageJsonPath, - status: 'already-configured', - oldScript, - newScript, - } - } - - // Write updated content (unless dry run) - if (!dryRun) { - await fs.writeFile(packageJsonPath, newContent, 'utf-8') - } - - return { - path: packageJsonPath, - status: 'updated', - oldScript, - newScript, - } - } catch (error) { - return { - path: packageJsonPath, - status: 'error', - oldScript: '', - newScript: '', - error: error instanceof Error ? error.message : String(error), - } - } -} - -/** - * Update multiple package.json files - */ -export async function updateMultiplePackageJsons( - paths: string[], - dryRun: boolean = false, -): Promise { - const results: UpdateResult[] = [] - - for (const path of paths) { - const result = await updatePackageJson(path, dryRun) - results.push(result) - } - - return results -} diff --git a/src/patch/apply.ts b/src/patch/apply.ts deleted file mode 100644 index a749d9f..0000000 --- a/src/patch/apply.ts +++ /dev/null @@ -1,314 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import { computeFileGitSHA256 } from './file-hash.js' -import type { PatchManifest } from '../schema/manifest-schema.js' - -export interface PatchFileInfo { - beforeHash: string - afterHash: string -} - -export interface PackageLocation { - name: string - version: string - path: string -} - -export interface VerifyResult { - file: string - status: 'ready' | 'already-patched' | 'hash-mismatch' | 'not-found' - message?: string - currentHash?: string - expectedHash?: string - targetHash?: string -} - -export interface ApplyResult { - packageKey: string - packagePath: string - success: boolean - filesVerified: VerifyResult[] - filesPatched: string[] - error?: string -} - -/** - * Normalize file path by removing the 'package/' prefix if present - * Patch files come from the API with paths like 'package/lib/file.js' - * but we need relative paths like 'lib/file.js' for the actual package directory - */ -function normalizeFilePath(fileName: string): string { - const packagePrefix = 'package/' - if (fileName.startsWith(packagePrefix)) { - return fileName.slice(packagePrefix.length) - } - return fileName -} - -/** - * Verify a single file can be patched - */ -export async function verifyFilePatch( - packagePath: string, - fileName: string, - fileInfo: PatchFileInfo, -): Promise { - const normalizedFileName = normalizeFilePath(fileName) - const filepath = path.join(packagePath, normalizedFileName) - - // Check if file exists - try { - await fs.access(filepath) - } catch { - return { - file: fileName, - status: 'not-found', - message: 'File not found', - } - } - - // Compute current hash - const currentHash = await computeFileGitSHA256(filepath) - - // Check if already patched - if (currentHash === fileInfo.afterHash) { - return { - file: fileName, - status: 'already-patched', - currentHash, - } - } - - // Check if matches expected before hash - if (currentHash !== fileInfo.beforeHash) { - return { - file: fileName, - status: 'hash-mismatch', - message: 'File hash does not match expected value', - currentHash, - expectedHash: fileInfo.beforeHash, - targetHash: fileInfo.afterHash, - } - } - - return { - file: fileName, - status: 'ready', - currentHash, - targetHash: fileInfo.afterHash, - } -} - -/** - * Apply a patch to a single file - */ -export async function applyFilePatch( - packagePath: string, - fileName: string, - patchedContent: Buffer, - expectedHash: string, -): Promise { - const normalizedFileName = normalizeFilePath(fileName) - const filepath = path.join(packagePath, normalizedFileName) - - // Write the patched content - await fs.writeFile(filepath, patchedContent) - - // Verify the hash after writing - const verifyHash = await computeFileGitSHA256(filepath) - if (verifyHash !== expectedHash) { - throw new Error( - `Hash verification failed after patch. Expected: ${expectedHash}, Got: ${verifyHash}`, - ) - } -} - -/** - * Verify and apply patches for a single package - */ -export async function applyPackagePatch( - packageKey: string, - packagePath: string, - files: Record, - blobsPath: string, - dryRun: boolean = false, -): Promise { - const result: ApplyResult = { - packageKey, - packagePath, - success: false, - filesVerified: [], - filesPatched: [], - } - - try { - // First, verify all files - for (const [fileName, fileInfo] of Object.entries(files)) { - const verifyResult = await verifyFilePatch( - packagePath, - fileName, - fileInfo, - ) - result.filesVerified.push(verifyResult) - - // If any file is not ready or already patched, we can't proceed - if ( - verifyResult.status !== 'ready' && - verifyResult.status !== 'already-patched' - ) { - result.error = `Cannot apply patch: ${verifyResult.file} - ${verifyResult.message || verifyResult.status}` - return result - } - } - - // Check if all files are already patched - const allPatched = result.filesVerified.every( - v => v.status === 'already-patched', - ) - if (allPatched) { - result.success = true - return result - } - - // If dry run, stop here - if (dryRun) { - result.success = true - return result - } - - // Apply patches to files that need it - for (const [fileName, fileInfo] of Object.entries(files)) { - const verifyResult = result.filesVerified.find(v => v.file === fileName) - if (verifyResult?.status === 'already-patched') { - continue - } - - // Read patched content from blobs - const blobPath = path.join(blobsPath, fileInfo.afterHash) - const patchedContent = await fs.readFile(blobPath) - - // Apply the patch - await applyFilePatch( - packagePath, - fileName, - patchedContent, - fileInfo.afterHash, - ) - result.filesPatched.push(fileName) - } - - result.success = true - } catch (error) { - result.error = error instanceof Error ? error.message : String(error) - } - - return result -} - -/** - * Find all node_modules directories recursively - */ -export async function findNodeModules(startPath: string): Promise { - const results: string[] = [] - - async function search(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isDirectory()) continue - - const fullPath = path.join(dir, entry.name) - - if (entry.name === 'node_modules') { - results.push(fullPath) - // Don't recurse into nested node_modules - continue - } - - // Skip hidden directories and common non-source directories - if ( - entry.name.startsWith('.') || - entry.name === 'dist' || - entry.name === 'build' - ) { - continue - } - - await search(fullPath) - } - } catch { - // Ignore permission errors - } - } - - await search(startPath) - return results -} - -/** - * Find packages in node_modules that match the manifest - */ -export async function findPackagesForPatches( - nodeModulesPath: string, - manifest: PatchManifest, -): Promise> { - const packages = new Map() - - try { - const entries = await fs.readdir(nodeModulesPath, { withFileTypes: true }) - - for (const entry of entries) { - // Allow both directories and symlinks (pnpm uses symlinks) - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue - - const isScoped = entry.name.startsWith('@') - const dirPath = path.join(nodeModulesPath, entry.name) - - if (isScoped) { - // Handle scoped packages - const scopedEntries = await fs.readdir(dirPath, { withFileTypes: true }) - for (const scopedEntry of scopedEntries) { - // Allow both directories and symlinks (pnpm uses symlinks) - if (!scopedEntry.isDirectory() && !scopedEntry.isSymbolicLink()) continue - - const pkgPath = path.join(dirPath, scopedEntry.name) - await checkPackage(pkgPath, manifest, packages) - } - } else { - // Handle non-scoped packages - await checkPackage(dirPath, manifest, packages) - } - } - } catch { - // Ignore errors reading node_modules - } - - return packages -} - -async function checkPackage( - pkgPath: string, - manifest: PatchManifest, - packages: Map, -): Promise { - try { - const pkgJsonPath = path.join(pkgPath, 'package.json') - const pkgJsonContent = await fs.readFile(pkgJsonPath, 'utf-8') - const pkgJson = JSON.parse(pkgJsonContent) - - if (!pkgJson.name || !pkgJson.version) return - - // Check if this package has a patch - const purl = `pkg:npm/${pkgJson.name}@${pkgJson.version}` - if (manifest.patches[purl]) { - packages.set(purl, { - name: pkgJson.name, - version: pkgJson.version, - path: pkgPath, - }) - } - } catch { - // Ignore invalid package.json - } -} diff --git a/src/patch/file-hash.ts b/src/patch/file-hash.ts deleted file mode 100644 index bae6a85..0000000 --- a/src/patch/file-hash.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as fs from 'fs' -import * as fsp from 'fs/promises' -import { computeGitSHA256FromChunks } from '../hash/git-sha256.js' - -/** - * Compute Git-compatible SHA256 hash of file contents using streaming - */ -export async function computeFileGitSHA256(filepath: string): Promise { - // Get file size first - const stats = await fsp.stat(filepath) - const fileSize = stats.size - - // Create async iterable from read stream - async function* readFileChunks() { - const stream = fs.createReadStream(filepath) - for await (const chunk of stream) { - yield chunk as Buffer - } - } - - return computeGitSHA256FromChunks(fileSize, readFileChunks()) -} diff --git a/src/patch/rollback.ts b/src/patch/rollback.ts deleted file mode 100644 index 06509de..0000000 --- a/src/patch/rollback.ts +++ /dev/null @@ -1,217 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import { computeFileGitSHA256 } from './file-hash.js' -import type { PatchFileInfo } from './apply.js' - -export interface VerifyRollbackResult { - file: string - status: - | 'ready' - | 'already-original' - | 'hash-mismatch' - | 'not-found' - | 'missing-blob' - message?: string - currentHash?: string - expectedHash?: string - targetHash?: string -} - -export interface RollbackResult { - packageKey: string - packagePath: string - success: boolean - filesVerified: VerifyRollbackResult[] - filesRolledBack: string[] - error?: string -} - -/** - * Normalize file path by removing the 'package/' prefix if present - * Patch files come from the API with paths like 'package/lib/file.js' - * but we need relative paths like 'lib/file.js' for the actual package directory - */ -function normalizeFilePath(fileName: string): string { - const packagePrefix = 'package/' - if (fileName.startsWith(packagePrefix)) { - return fileName.slice(packagePrefix.length) - } - return fileName -} - -/** - * Verify a single file can be rolled back - * A file is ready for rollback if its current hash matches the afterHash (patched state) - */ -export async function verifyFileRollback( - packagePath: string, - fileName: string, - fileInfo: PatchFileInfo, - blobsPath: string, -): Promise { - const normalizedFileName = normalizeFilePath(fileName) - const filepath = path.join(packagePath, normalizedFileName) - - // Check if file exists - try { - await fs.access(filepath) - } catch { - return { - file: fileName, - status: 'not-found', - message: 'File not found', - } - } - - // Check if before blob exists (required for rollback) - const beforeBlobPath = path.join(blobsPath, fileInfo.beforeHash) - try { - await fs.access(beforeBlobPath) - } catch { - return { - file: fileName, - status: 'missing-blob', - message: `Before blob not found: ${fileInfo.beforeHash}. Re-download the patch to enable rollback.`, - targetHash: fileInfo.beforeHash, - } - } - - // Compute current hash - const currentHash = await computeFileGitSHA256(filepath) - - // Check if already in original state - if (currentHash === fileInfo.beforeHash) { - return { - file: fileName, - status: 'already-original', - currentHash, - } - } - - // Check if matches expected patched hash (afterHash) - if (currentHash !== fileInfo.afterHash) { - return { - file: fileName, - status: 'hash-mismatch', - message: - 'File has been modified after patching. Cannot safely rollback.', - currentHash, - expectedHash: fileInfo.afterHash, - targetHash: fileInfo.beforeHash, - } - } - - return { - file: fileName, - status: 'ready', - currentHash, - targetHash: fileInfo.beforeHash, - } -} - -/** - * Rollback a single file to its original state - */ -export async function rollbackFilePatch( - packagePath: string, - fileName: string, - originalContent: Buffer, - expectedHash: string, -): Promise { - const normalizedFileName = normalizeFilePath(fileName) - const filepath = path.join(packagePath, normalizedFileName) - - // Write the original content - await fs.writeFile(filepath, originalContent) - - // Verify the hash after writing - const verifyHash = await computeFileGitSHA256(filepath) - if (verifyHash !== expectedHash) { - throw new Error( - `Hash verification failed after rollback. Expected: ${expectedHash}, Got: ${verifyHash}`, - ) - } -} - -/** - * Verify and rollback patches for a single package - */ -export async function rollbackPackagePatch( - packageKey: string, - packagePath: string, - files: Record, - blobsPath: string, - dryRun: boolean = false, -): Promise { - const result: RollbackResult = { - packageKey, - packagePath, - success: false, - filesVerified: [], - filesRolledBack: [], - } - - try { - // First, verify all files - for (const [fileName, fileInfo] of Object.entries(files)) { - const verifyResult = await verifyFileRollback( - packagePath, - fileName, - fileInfo, - blobsPath, - ) - result.filesVerified.push(verifyResult) - - // If any file has issues (not ready and not already original), we can't proceed - if ( - verifyResult.status !== 'ready' && - verifyResult.status !== 'already-original' - ) { - result.error = `Cannot rollback: ${verifyResult.file} - ${verifyResult.message || verifyResult.status}` - return result - } - } - - // Check if all files are already in original state - const allOriginal = result.filesVerified.every( - v => v.status === 'already-original', - ) - if (allOriginal) { - result.success = true - return result - } - - // If dry run, stop here - if (dryRun) { - result.success = true - return result - } - - // Rollback files that need it - for (const [fileName, fileInfo] of Object.entries(files)) { - const verifyResult = result.filesVerified.find(v => v.file === fileName) - if (verifyResult?.status === 'already-original') { - continue - } - - // Read original content from blobs - const blobPath = path.join(blobsPath, fileInfo.beforeHash) - const originalContent = await fs.readFile(blobPath) - - // Rollback the file - await rollbackFilePatch( - packagePath, - fileName, - originalContent, - fileInfo.beforeHash, - ) - result.filesRolledBack.push(fileName) - } - - result.success = true - } catch (error) { - result.error = error instanceof Error ? error.message : String(error) - } - - return result -} diff --git a/src/run.ts b/src/run.ts deleted file mode 100644 index e28a4a8..0000000 --- a/src/run.ts +++ /dev/null @@ -1,84 +0,0 @@ -import yargs from 'yargs' -import { applyCommand } from './commands/apply.js' -import { getCommand } from './commands/get.js' -import { listCommand } from './commands/list.js' -import { removeCommand } from './commands/remove.js' -import { rollbackCommand } from './commands/rollback.js' -import { repairCommand } from './commands/repair.js' -import { setupCommand } from './commands/setup.js' - -/** - * Configuration options for running socket-patch programmatically. - */ -export interface PatchOptions { - /** Socket API URL (e.g., https://api.socket.dev). */ - apiUrl?: string - /** Socket API token for authentication. */ - apiToken?: string - /** Organization slug. */ - orgSlug?: string - /** Public patch API proxy URL. */ - patchProxyUrl?: string - /** HTTP proxy URL for all requests. */ - httpProxy?: string - /** Enable debug logging. */ - debug?: boolean -} - -/** - * Run socket-patch programmatically with provided arguments and options. - * Maps options to environment variables before executing yargs commands. - * - * @param args - Command line arguments to pass to yargs (e.g., ['get', 'CVE-2021-44228']). - * @param options - Configuration options that override environment variables. - * @returns Exit code (0 for success, non-zero for failure). - */ -export async function runPatch( - args: string[], - options?: PatchOptions -): Promise { - // Map options to environment variables. - if (options?.apiUrl) { - process.env.SOCKET_API_URL = options.apiUrl - } - if (options?.apiToken) { - process.env.SOCKET_API_TOKEN = options.apiToken - } - if (options?.orgSlug) { - process.env.SOCKET_ORG_SLUG = options.orgSlug - } - if (options?.patchProxyUrl) { - process.env.SOCKET_PATCH_PROXY_URL = options.patchProxyUrl - } - if (options?.httpProxy) { - process.env.SOCKET_PATCH_HTTP_PROXY = options.httpProxy - } - if (options?.debug) { - process.env.SOCKET_PATCH_DEBUG = '1' - } - - try { - await yargs(args) - .scriptName('socket patch') - .usage('$0 [options]') - .command(getCommand) - .command(applyCommand) - .command(rollbackCommand) - .command(removeCommand) - .command(listCommand) - .command(setupCommand) - .command(repairCommand) - .demandCommand(1, 'You must specify a command') - .help() - .alias('h', 'help') - .strict() - .parse() - - return 0 - } catch (error) { - if (process.env.SOCKET_PATCH_DEBUG) { - console.error('socket-patch error:', error) - } - return 1 - } -} diff --git a/src/schema/manifest-schema.ts b/src/schema/manifest-schema.ts deleted file mode 100644 index 96f201b..0000000 --- a/src/schema/manifest-schema.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod' - -export const DEFAULT_PATCH_MANIFEST_PATH = '.socket/manifest.json' - -export const PatchRecordSchema = z.object({ - uuid: z.string().uuid(), - exportedAt: z.string(), - files: z.record( - z.string(), // File path - z.object({ - beforeHash: z.string(), - afterHash: z.string(), - }), - ), - vulnerabilities: z.record( - z.string(), // Vulnerability ID like "GHSA-jrhj-2j3q-xf3v" - z.object({ - cves: z.array(z.string()), - summary: z.string(), - severity: z.string(), - description: z.string(), - }), - ), - description: z.string(), - license: z.string(), - tier: z.string(), -}) - -export type PatchRecord = z.infer - -export const PatchManifestSchema = z.object({ - patches: z.record( - z.string(), // Package identifier like "npm:simplehttpserver@0.0.6" - PatchRecordSchema, - ), -}) - -export type PatchManifest = z.infer diff --git a/src/test-utils.ts b/src/test-utils.ts deleted file mode 100644 index fc9d031..0000000 --- a/src/test-utils.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Test utilities for socket-patch e2e tests - */ -import * as fs from 'fs/promises' -import * as path from 'path' -import * as os from 'os' -import { computeGitSHA256FromBuffer } from './hash/git-sha256.js' -import type { PatchManifest, PatchRecord } from './schema/manifest-schema.js' - -/** - * Create a temporary test directory - */ -export async function createTestDir(prefix: string = 'socket-patch-test-'): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), prefix)) -} - -/** - * Remove a directory recursively - */ -export async function removeTestDir(dir: string): Promise { - await fs.rm(dir, { recursive: true, force: true }) -} - -/** - * Compute git SHA256 hash of content - */ -export function computeTestHash(content: string): string { - return computeGitSHA256FromBuffer(Buffer.from(content, 'utf-8')) -} - -/** - * Create a mock manifest - */ -export function createTestManifest( - patches: Record, -): PatchManifest { - return { patches } -} - -/** - * Generate a valid UUID v4 for testing - * Uses a simple approach that produces valid UUIDs - */ -export function generateTestUUID(): string { - const hex = '0123456789abcdef' - let uuid = '' - - for (let i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) { - uuid += '-' - } else if (i === 14) { - uuid += '4' // Version 4 - } else if (i === 19) { - uuid += hex[Math.floor(Math.random() * 4) + 8] // Variant bits - } else { - uuid += hex[Math.floor(Math.random() * 16)] - } - } - - return uuid -} - -/** - * Create a patch entry for testing - */ -export function createTestPatchEntry(options: { - uuid?: string // If not provided, generates a valid UUID - files: Record - vulnerabilities?: Record< - string, - { - cves: string[] - summary: string - severity: string - description: string - } - > - description?: string - license?: string - tier?: 'free' | 'paid' -}): { entry: PatchRecord; blobs: Record } { - const files: Record = {} - const blobs: Record = {} - - for (const [filePath, { beforeContent, afterContent }] of Object.entries( - options.files, - )) { - const beforeHash = computeTestHash(beforeContent) - const afterHash = computeTestHash(afterContent) - - files[filePath] = { beforeHash, afterHash } - blobs[beforeHash] = beforeContent - blobs[afterHash] = afterContent - } - - return { - entry: { - uuid: options.uuid ?? generateTestUUID(), - exportedAt: new Date().toISOString(), - files, - vulnerabilities: options.vulnerabilities ?? {}, - description: options.description ?? 'Test patch', - license: options.license ?? 'MIT', - tier: options.tier ?? 'free', - }, - blobs, - } -} - -/** - * Write a manifest to disk - */ -export async function writeTestManifest( - socketDir: string, - manifest: PatchManifest, -): Promise { - await fs.mkdir(socketDir, { recursive: true }) - const manifestPath = path.join(socketDir, 'manifest.json') - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') - return manifestPath -} - -/** - * Create mock blob files - */ -export async function writeTestBlobs( - blobsDir: string, - blobs: Record, -): Promise { - await fs.mkdir(blobsDir, { recursive: true }) - - for (const [hash, content] of Object.entries(blobs)) { - const blobPath = path.join(blobsDir, hash) - await fs.writeFile(blobPath, content) - } -} - -/** - * Create a mock package in node_modules - */ -export async function createTestPackage( - nodeModulesDir: string, - name: string, - version: string, - files: Record, -): Promise { - // Handle scoped packages - const parts = name.split('/') - let pkgDir: string - - if (parts.length === 2 && parts[0].startsWith('@')) { - // Scoped package: @scope/name - const scopeDir = path.join(nodeModulesDir, parts[0]) - await fs.mkdir(scopeDir, { recursive: true }) - pkgDir = path.join(scopeDir, parts[1]) - } else { - // Regular package - pkgDir = path.join(nodeModulesDir, name) - } - - await fs.mkdir(pkgDir, { recursive: true }) - - // Write package.json - await fs.writeFile( - path.join(pkgDir, 'package.json'), - JSON.stringify({ name, version }, null, 2), - ) - - // Write other files - for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(pkgDir, filePath) - await fs.mkdir(path.dirname(fullPath), { recursive: true }) - await fs.writeFile(fullPath, content) - } - - return pkgDir -} - -/** - * Create a mock Python package in site-packages - * Creates /-.dist-info/METADATA - * and writes package files relative to sitePackagesDir - */ -export async function createTestPythonPackage( - sitePackagesDir: string, - name: string, - version: string, - files: Record, -): Promise { - await fs.mkdir(sitePackagesDir, { recursive: true }) - - // Create dist-info directory with METADATA - const distInfoDir = path.join( - sitePackagesDir, - `${name}-${version}.dist-info`, - ) - await fs.mkdir(distInfoDir, { recursive: true }) - await fs.writeFile( - path.join(distInfoDir, 'METADATA'), - `Metadata-Version: 2.1\nName: ${name}\nVersion: ${version}\nSummary: Test package\n`, - ) - - // Write package files relative to sitePackagesDir - for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(sitePackagesDir, filePath) - await fs.mkdir(path.dirname(fullPath), { recursive: true }) - await fs.writeFile(fullPath, content) - } - - return sitePackagesDir -} - -/** - * Read file content from a package - */ -export async function readPackageFile( - pkgDir: string, - filePath: string, -): Promise { - return fs.readFile(path.join(pkgDir, filePath), 'utf-8') -} - -/** - * Setup a complete test environment with manifest, blobs, and packages - */ -export async function setupTestEnvironment(options: { - testDir: string - patches: Array<{ - purl: string - uuid: string - files: Record - }> - initialState?: 'before' | 'after' // whether packages start in before or after state -}): Promise<{ - manifestPath: string - blobsDir: string - nodeModulesDir: string - sitePackagesDir: string - socketDir: string - packageDirs: Map -}> { - const { testDir, patches, initialState = 'before' } = options - - const socketDir = path.join(testDir, '.socket') - const blobsDir = path.join(socketDir, 'blobs') - const nodeModulesDir = path.join(testDir, 'node_modules') - const sitePackagesDir = path.join( - testDir, - '.venv', - 'lib', - 'python3.11', - 'site-packages', - ) - - await fs.mkdir(socketDir, { recursive: true }) - await fs.mkdir(blobsDir, { recursive: true }) - await fs.mkdir(nodeModulesDir, { recursive: true }) - - const allBlobs: Record = {} - const manifestPatches: Record = {} - const packageDirs = new Map() - - for (const patch of patches) { - const { entry, blobs } = createTestPatchEntry({ - uuid: patch.uuid, - files: patch.files, - }) - - manifestPatches[patch.purl] = entry - Object.assign(allBlobs, blobs) - - // Strip qualifiers for PURL matching - const qIdx = patch.purl.indexOf('?') - const basePurl = qIdx === -1 ? patch.purl : patch.purl.slice(0, qIdx) - - // Extract package name and version from PURL - // Format: pkg:npm/name@version or pkg:npm/@scope/name@version - const npmMatch = basePurl.match(/^pkg:npm\/(.+)@([^@]+)$/) - if (npmMatch) { - const [, name, version] = npmMatch - - // Prepare package files in initial state - const packageFiles: Record = {} - for (const [filePath, { beforeContent, afterContent }] of Object.entries( - patch.files, - )) { - // Remove 'package/' prefix if present - const normalizedPath = filePath.startsWith('package/') - ? filePath.slice('package/'.length) - : filePath - packageFiles[normalizedPath] = - initialState === 'before' ? beforeContent : afterContent - } - - const pkgDir = await createTestPackage( - nodeModulesDir, - name, - version, - packageFiles, - ) - packageDirs.set(patch.purl, pkgDir) - } - - // Handle pkg:pypi/ PURLs - const pypiMatch = basePurl.match(/^pkg:pypi\/([^@]+)@(.+)$/) - if (pypiMatch) { - const [, name, version] = pypiMatch - - // Prepare package files in initial state relative to site-packages - const packageFiles: Record = {} - for (const [filePath, { beforeContent, afterContent }] of Object.entries( - patch.files, - )) { - packageFiles[filePath] = - initialState === 'before' ? beforeContent : afterContent - } - - await createTestPythonPackage( - sitePackagesDir, - name, - version, - packageFiles, - ) - packageDirs.set(patch.purl, sitePackagesDir) - } - } - - await writeTestBlobs(blobsDir, allBlobs) - const manifestPath = await writeTestManifest( - socketDir, - createTestManifest(manifestPatches), - ) - - return { - manifestPath, - blobsDir, - nodeModulesDir, - sitePackagesDir, - socketDir, - packageDirs, - } -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index f7ccfa8..0000000 --- a/src/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface PatchInfo { - packageName: string - version: string - patchPath: string - description?: string -} - -export interface ApplyOptions { - dryRun: boolean - verbose: boolean - force: boolean -} - -export interface PatchResult { - success: boolean - packageName: string - version: string - error?: string - filesModified?: string[] -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 872a82e..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { PatchResult } from './types.js' - -export function formatPatchResult(result: PatchResult): string { - if (result.success) { - let message = `✓ Successfully patched ${result.packageName}@${result.version}` - if (result.filesModified && result.filesModified.length > 0) { - message += `\n Modified files: ${result.filesModified.join(', ')}` - } - return message - } else { - return `✗ Failed to patch ${result.packageName}@${result.version}: ${result.error || 'Unknown error'}` - } -} - -export function log(message: string, verbose: boolean = false): void { - if (verbose) { - console.log(`[socket-patch] ${message}`) - } -} - -export function error(message: string): void { - console.error(`[socket-patch] ERROR: ${message}`) -} diff --git a/src/utils/api-client.test.ts b/src/utils/api-client.test.ts deleted file mode 100644 index 5b39da4..0000000 --- a/src/utils/api-client.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' - -// Test UUID -const TEST_UUID_1 = '11111111-1111-4111-8111-111111111111' -const TEST_UUID_2 = '22222222-2222-4222-8222-222222222222' - -/** - * Tests for API client batch query logic - * - * These tests verify the response format conversion between individual - * search responses and batch search responses, as well as severity ordering. - */ -describe('API Client', () => { - describe('Severity ordering', () => { - // Replicate the severity order logic from api-client.ts - const SEVERITY_ORDER: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, - } - - function getSeverityOrder(severity: string | null): number { - if (!severity) return 4 - return SEVERITY_ORDER[severity.toLowerCase()] ?? 4 - } - - it('should return correct order for known severities', () => { - assert.equal(getSeverityOrder('critical'), 0) - assert.equal(getSeverityOrder('high'), 1) - assert.equal(getSeverityOrder('medium'), 2) - assert.equal(getSeverityOrder('low'), 3) - assert.equal(getSeverityOrder('unknown'), 4) - }) - - it('should handle case-insensitive severity', () => { - assert.equal(getSeverityOrder('CRITICAL'), 0) - assert.equal(getSeverityOrder('Critical'), 0) - assert.equal(getSeverityOrder('HIGH'), 1) - assert.equal(getSeverityOrder('High'), 1) - }) - - it('should return 4 (unknown) for null severity', () => { - assert.equal(getSeverityOrder(null), 4) - }) - - it('should return 4 (unknown) for unrecognized severity', () => { - assert.equal(getSeverityOrder('super-critical'), 4) - assert.equal(getSeverityOrder(''), 4) - assert.equal(getSeverityOrder('moderate'), 4) - }) - - it('should sort severities correctly', () => { - const severities = ['low', 'critical', 'medium', 'high', null, 'unknown'] - const sorted = severities.sort( - (a, b) => getSeverityOrder(a) - getSeverityOrder(b), - ) - assert.deepEqual(sorted, ['critical', 'high', 'medium', 'low', null, 'unknown']) - }) - }) - - describe('BatchPatchInfo structure', () => { - it('should have correct structure for batch patch info', () => { - const mockPatchInfo = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/test@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1234'], - ghsaIds: ['GHSA-xxxx-xxxx-xxxx'], - severity: 'high', - title: 'Test vulnerability', - } - - assert.equal(typeof mockPatchInfo.uuid, 'string') - assert.equal(typeof mockPatchInfo.purl, 'string') - assert.ok(['free', 'paid'].includes(mockPatchInfo.tier)) - assert.ok(Array.isArray(mockPatchInfo.cveIds)) - assert.ok(Array.isArray(mockPatchInfo.ghsaIds)) - assert.ok(mockPatchInfo.severity === null || typeof mockPatchInfo.severity === 'string') - assert.equal(typeof mockPatchInfo.title, 'string') - }) - }) - - describe('PatchSearchResult to BatchPatchInfo conversion', () => { - // Simulate the conversion logic from searchPatchesBatchViaIndividualQueries - function convertSearchResultToBatchInfo(patch: { - uuid: string - purl: string - tier: 'free' | 'paid' - description: string - vulnerabilities: Record< - string, - { - cves: string[] - summary: string - severity: string - description: string - } - > - }) { - const SEVERITY_ORDER: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, - } - - function getSeverityOrder(severity: string | null): number { - if (!severity) return 4 - return SEVERITY_ORDER[severity.toLowerCase()] ?? 4 - } - - const cveIds: string[] = [] - const ghsaIds: string[] = [] - let highestSeverity: string | null = null - let title = '' - - for (const [ghsaId, vuln] of Object.entries(patch.vulnerabilities)) { - ghsaIds.push(ghsaId) - for (const cve of vuln.cves) { - if (!cveIds.includes(cve)) { - cveIds.push(cve) - } - } - if (!highestSeverity || getSeverityOrder(vuln.severity) < getSeverityOrder(highestSeverity)) { - highestSeverity = vuln.severity || null - } - if (!title && vuln.summary) { - title = vuln.summary.length > 100 - ? vuln.summary.slice(0, 97) + '...' - : vuln.summary - } - } - - if (!title && patch.description) { - title = patch.description.length > 100 - ? patch.description.slice(0, 97) + '...' - : patch.description - } - - return { - uuid: patch.uuid, - purl: patch.purl, - tier: patch.tier, - cveIds: cveIds.sort(), - ghsaIds: ghsaIds.sort(), - severity: highestSeverity, - title, - } - } - - it('should extract CVE IDs from vulnerabilities', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'Security patch', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: ['CVE-2024-1234', 'CVE-2024-5678'], - summary: 'Prototype pollution vulnerability', - severity: 'high', - description: 'Full description here', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.deepEqual(result.cveIds, ['CVE-2024-1234', 'CVE-2024-5678']) - }) - - it('should extract GHSA IDs from vulnerabilities keys', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'Security patch', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: ['CVE-2024-1234'], - summary: 'Vuln 1', - severity: 'high', - description: '', - }, - 'GHSA-yyyy-yyyy-yyyy': { - cves: ['CVE-2024-5678'], - summary: 'Vuln 2', - severity: 'medium', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.equal(result.ghsaIds.length, 2) - assert.ok(result.ghsaIds.includes('GHSA-xxxx-xxxx-xxxx')) - assert.ok(result.ghsaIds.includes('GHSA-yyyy-yyyy-yyyy')) - }) - - it('should determine highest severity from multiple vulnerabilities', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'Security patch', - vulnerabilities: { - 'GHSA-aaaa-aaaa-aaaa': { - cves: [], - summary: 'Low severity vuln', - severity: 'low', - description: '', - }, - 'GHSA-bbbb-bbbb-bbbb': { - cves: [], - summary: 'Critical severity vuln', - severity: 'critical', - description: '', - }, - 'GHSA-cccc-cccc-cccc': { - cves: [], - summary: 'Medium severity vuln', - severity: 'medium', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.equal(result.severity, 'critical') - }) - - it('should use first summary as title', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'Fallback description', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: [], - summary: 'This is the vulnerability summary', - severity: 'high', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.equal(result.title, 'This is the vulnerability summary') - }) - - it('should truncate long titles', () => { - const longSummary = 'A'.repeat(150) // 150 character summary - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: '', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: [], - summary: longSummary, - severity: 'high', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.equal(result.title.length, 100) - assert.ok(result.title.endsWith('...')) - }) - - it('should use description as fallback title when no summary', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'This is the description fallback', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: [], - summary: '', - severity: 'high', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.equal(result.title, 'This is the description fallback') - }) - - it('should handle empty vulnerabilities', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: 'Patch description', - vulnerabilities: {}, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.deepEqual(result.cveIds, []) - assert.deepEqual(result.ghsaIds, []) - assert.equal(result.severity, null) - assert.equal(result.title, 'Patch description') - }) - - it('should sort CVE and GHSA IDs', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: '', - vulnerabilities: { - 'GHSA-zzzz-zzzz-zzzz': { - cves: ['CVE-2024-9999'], - summary: 'Z vuln', - severity: 'high', - description: '', - }, - 'GHSA-aaaa-aaaa-aaaa': { - cves: ['CVE-2024-1111'], - summary: 'A vuln', - severity: 'low', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.deepEqual(result.cveIds, ['CVE-2024-1111', 'CVE-2024-9999']) - assert.deepEqual(result.ghsaIds, ['GHSA-aaaa-aaaa-aaaa', 'GHSA-zzzz-zzzz-zzzz']) - }) - - it('should deduplicate CVE IDs', () => { - const patch = { - uuid: TEST_UUID_1, - purl: 'pkg:npm/lodash@4.17.20', - tier: 'free' as const, - description: '', - vulnerabilities: { - 'GHSA-xxxx-xxxx-xxxx': { - cves: ['CVE-2024-1234'], - summary: 'Vuln 1', - severity: 'high', - description: '', - }, - 'GHSA-yyyy-yyyy-yyyy': { - cves: ['CVE-2024-1234'], // Same CVE - summary: 'Vuln 2', - severity: 'medium', - description: '', - }, - }, - } - - const result = convertSearchResultToBatchInfo(patch) - - assert.deepEqual(result.cveIds, ['CVE-2024-1234']) // Should only appear once - }) - }) - - describe('BatchSearchResponse structure', () => { - it('should have correct structure for batch search response', () => { - const mockResponse = { - packages: [ - { - purl: 'pkg:npm/test@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/test@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1234'], - ghsaIds: ['GHSA-xxxx-xxxx-xxxx'], - severity: 'high', - title: 'Test vulnerability', - }, - ], - }, - ], - canAccessPaidPatches: false, - } - - assert.ok(Array.isArray(mockResponse.packages)) - assert.equal(typeof mockResponse.canAccessPaidPatches, 'boolean') - - for (const pkg of mockResponse.packages) { - assert.equal(typeof pkg.purl, 'string') - assert.ok(Array.isArray(pkg.patches)) - } - }) - - it('should handle empty packages array', () => { - const mockResponse = { - packages: [], - canAccessPaidPatches: false, - } - - assert.ok(Array.isArray(mockResponse.packages)) - assert.equal(mockResponse.packages.length, 0) - }) - - it('should handle multiple packages with multiple patches', () => { - const mockResponse = { - packages: [ - { - purl: 'pkg:npm/package-a@1.0.0', - patches: [ - { - uuid: TEST_UUID_1, - purl: 'pkg:npm/package-a@1.0.0', - tier: 'free' as const, - cveIds: ['CVE-2024-1111'], - ghsaIds: ['GHSA-aaaa-aaaa-aaaa'], - severity: 'critical', - title: 'Critical vulnerability', - }, - { - uuid: TEST_UUID_2, - purl: 'pkg:npm/package-a@1.0.0', - tier: 'paid' as const, - cveIds: ['CVE-2024-2222'], - ghsaIds: ['GHSA-bbbb-bbbb-bbbb'], - severity: 'medium', - title: 'Medium vulnerability', - }, - ], - }, - { - purl: 'pkg:npm/package-b@2.0.0', - patches: [ - { - uuid: '33333333-3333-4333-8333-333333333333', - purl: 'pkg:npm/package-b@2.0.0', - tier: 'free' as const, - cveIds: [], - ghsaIds: ['GHSA-cccc-cccc-cccc'], - severity: 'low', - title: 'Low severity issue', - }, - ], - }, - ], - canAccessPaidPatches: true, - } - - assert.equal(mockResponse.packages.length, 2) - assert.equal(mockResponse.packages[0].patches.length, 2) - assert.equal(mockResponse.packages[1].patches.length, 1) - assert.equal(mockResponse.canAccessPaidPatches, true) - }) - }) - - describe('Public proxy vs authenticated API behavior', () => { - it('should describe the difference in API paths', () => { - // Document the expected behavior - // Public proxy path: /patch/by-package/:purl (GET, cacheable) - // Authenticated path: /v0/orgs/:org/patches/batch (POST, not cacheable) - - const publicProxyPath = '/patch/by-package/pkg%3Anpm%2Flodash%404.17.21' - const authenticatedPath = '/v0/orgs/my-org/patches/batch' - - assert.ok(publicProxyPath.includes('/patch/by-package/')) - assert.ok(authenticatedPath.includes('/patches/batch')) - }) - - it('should describe concurrency limiting for individual queries', () => { - // Document that individual queries are made with concurrency limit - const CONCURRENCY_LIMIT = 10 - const testPurls = Array.from({ length: 25 }, (_, i) => `pkg:npm/pkg-${i}@1.0.0`) - - // Calculate expected number of batches - const expectedBatches = Math.ceil(testPurls.length / CONCURRENCY_LIMIT) - - assert.equal(expectedBatches, 3) // 25 PURLs with limit 10 = 3 batches - }) - }) -}) diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts deleted file mode 100644 index a9a7cf4..0000000 --- a/src/utils/api-client.ts +++ /dev/null @@ -1,631 +0,0 @@ -import * as https from 'node:https' -import * as http from 'node:http' - -// Default public patch API URL for free patches (no auth required). -const DEFAULT_PATCH_API_PROXY_URL = 'https://patches-api.socket.dev' - -/** - * Check if debug mode is enabled. - */ -function isDebugEnabled(): boolean { - return process.env.SOCKET_PATCH_DEBUG === '1' || process.env.SOCKET_PATCH_DEBUG === 'true' -} - -/** - * Log debug messages when debug mode is enabled. - */ -function debugLog(message: string, ...args: unknown[]): void { - if (isDebugEnabled()) { - console.error(`[socket-patch debug] ${message}`, ...args) - } -} - -// Severity order for sorting (most severe = lowest number) -const SEVERITY_ORDER: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - unknown: 4, -} - -/** - * Get numeric severity order for comparison. - * Lower numbers = higher severity. - */ -function getSeverityOrder(severity: string | null): number { - if (!severity) return 4 - return SEVERITY_ORDER[severity.toLowerCase()] ?? 4 -} - -/** - * Get the HTTP proxy URL from environment variables. - * Returns undefined if no proxy is configured. - * - * Note: Full HTTP proxy support requires manual configuration. - * Node.js native http/https modules don't support proxies natively. - * For proxy support, set NODE_EXTRA_CA_CERTS and configure your - * system/corporate proxy settings. - */ -function getHttpProxyUrl(): string | undefined { - return process.env.SOCKET_PATCH_HTTP_PROXY || - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy -} - -// Full patch response with blob content (from view endpoint) -export interface PatchResponse { - uuid: string - purl: string - publishedAt: string - files: Record< - string, - { - beforeHash?: string - afterHash?: string - socketBlob?: string - blobContent?: string // after blob content (base64) - beforeBlobContent?: string // before blob content (base64) - for rollback - } - > - vulnerabilities: Record< - string, - { - cves: string[] - summary: string - severity: string - description: string - } - > - description: string - license: string - tier: 'free' | 'paid' -} - -// Lightweight search result (from search endpoints) -export interface PatchSearchResult { - uuid: string - purl: string - publishedAt: string - description: string - license: string - tier: 'free' | 'paid' - vulnerabilities: Record< - string, - { - cves: string[] - summary: string - severity: string - description: string - } - > -} - -export interface SearchResponse { - patches: PatchSearchResult[] - canAccessPaidPatches: boolean -} - -// Minimal patch info from batch search -export interface BatchPatchInfo { - uuid: string - purl: string - tier: 'free' | 'paid' - cveIds: string[] - ghsaIds: string[] - severity: string | null - title: string -} - -export interface BatchPackagePatches { - purl: string - patches: BatchPatchInfo[] -} - -export interface BatchSearchResponse { - packages: BatchPackagePatches[] - canAccessPaidPatches: boolean -} - -export interface APIClientOptions { - apiUrl: string - apiToken?: string - /** - * When true, the client will use the public patch API proxy - * which only provides access to free patches without authentication. - */ - usePublicProxy?: boolean - /** - * Organization slug for authenticated blob downloads. - * Required when using authenticated API (not public proxy). - */ - orgSlug?: string -} - -export class APIClient { - private readonly apiUrl: string - private readonly apiToken?: string - private readonly usePublicProxy: boolean - private readonly orgSlug?: string - - constructor(options: APIClientOptions) { - this.apiUrl = options.apiUrl.replace(/\/$/, '') // Remove trailing slash - this.apiToken = options.apiToken - this.usePublicProxy = options.usePublicProxy ?? false - this.orgSlug = options.orgSlug - } - - /** - * Make a GET request to the API. - */ - private async get(path: string): Promise { - const url = `${this.apiUrl}${path}` - debugLog(`GET ${url}`) - - // Log proxy warning if configured but not natively supported. - const proxyUrl = getHttpProxyUrl() - if (proxyUrl) { - debugLog(`HTTP proxy detected: ${proxyUrl} (Note: native http/https modules don't support proxies directly)`) - } - - return new Promise((resolve, reject) => { - const urlObj = new URL(url) - const isHttps = urlObj.protocol === 'https:' - const httpModule = isHttps ? https : http - - const headers: Record = { - Accept: 'application/json', - 'User-Agent': 'SocketPatchCLI/1.0', - } - - // Only add auth header if we have a token (not using public proxy). - if (this.apiToken) { - headers['Authorization'] = `Bearer ${this.apiToken}` - } - - const options: https.RequestOptions = { - method: 'GET', - headers, - } - - const req = httpModule.request(urlObj, options, res => { - let data = '' - - res.on('data', chunk => { - data += chunk - }) - - res.on('end', () => { - if (res.statusCode === 200) { - try { - const parsed = JSON.parse(data) - resolve(parsed) - } catch (err) { - reject(new Error(`Failed to parse response: ${err}`)) - } - } else if (res.statusCode === 404) { - resolve(null) - } else if (res.statusCode === 401) { - reject(new Error('Unauthorized: Invalid API token')) - } else if (res.statusCode === 403) { - const msg = this.usePublicProxy - ? 'Forbidden: This patch is only available to paid subscribers. Sign up at https://socket.dev to access paid patches.' - : 'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.' - reject(new Error(msg)) - } else if (res.statusCode === 429) { - reject(new Error('Rate limit exceeded. Please try again later.')) - } else { - reject( - new Error( - `API request failed with status ${res.statusCode}: ${data}`, - ), - ) - } - }) - }) - - req.on('error', err => { - reject(new Error(`Network error: ${err.message}`)) - }) - - req.end() - }) - } - - /** - * Make a POST request to the API. - */ - private async post(path: string, body: unknown): Promise { - const url = `${this.apiUrl}${path}` - debugLog(`POST ${url}`) - - return new Promise((resolve, reject) => { - const urlObj = new URL(url) - const isHttps = urlObj.protocol === 'https:' - const httpModule = isHttps ? https : http - - const jsonBody = JSON.stringify(body) - - const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(jsonBody).toString(), - 'User-Agent': 'SocketPatchCLI/1.0', - } - - // Only add auth header if we have a token (not using public proxy). - if (this.apiToken) { - headers['Authorization'] = `Bearer ${this.apiToken}` - } - - const options: https.RequestOptions = { - method: 'POST', - headers, - } - - const req = httpModule.request(urlObj, options, res => { - let data = '' - - res.on('data', chunk => { - data += chunk - }) - - res.on('end', () => { - if (res.statusCode === 200) { - try { - const parsed = JSON.parse(data) - resolve(parsed) - } catch (err) { - reject(new Error(`Failed to parse response: ${err}`)) - } - } else if (res.statusCode === 404) { - resolve(null) - } else if (res.statusCode === 401) { - reject(new Error('Unauthorized: Invalid API token')) - } else if (res.statusCode === 403) { - const msg = this.usePublicProxy - ? 'Forbidden: This resource is only available to paid subscribers.' - : 'Forbidden: Access denied.' - reject(new Error(msg)) - } else if (res.statusCode === 429) { - reject(new Error('Rate limit exceeded. Please try again later.')) - } else { - reject( - new Error( - `API request failed with status ${res.statusCode}: ${data}`, - ), - ) - } - }) - }) - - req.on('error', err => { - reject(new Error(`Network error: ${err.message}`)) - }) - - req.write(jsonBody) - req.end() - }) - } - - /** - * Fetch a patch by UUID (full details with blob content) - */ - async fetchPatch( - orgSlug: string | null, - uuid: string, - ): Promise { - // Public proxy uses /patch/* prefix for patch endpoints - const path = this.usePublicProxy - ? `/patch/view/${uuid}` - : `/v0/orgs/${orgSlug}/patches/view/${uuid}` - return this.get(path) - } - - /** - * Search patches by CVE ID - * Returns lightweight search results (no blob content) - */ - async searchPatchesByCVE( - orgSlug: string | null, - cveId: string, - ): Promise { - // Public proxy uses /patch/* prefix for patch endpoints - const path = this.usePublicProxy - ? `/patch/by-cve/${encodeURIComponent(cveId)}` - : `/v0/orgs/${orgSlug}/patches/by-cve/${encodeURIComponent(cveId)}` - const result = await this.get(path) - return result ?? { patches: [], canAccessPaidPatches: false } - } - - /** - * Search patches by GHSA ID - * Returns lightweight search results (no blob content) - */ - async searchPatchesByGHSA( - orgSlug: string | null, - ghsaId: string, - ): Promise { - // Public proxy uses /patch/* prefix for patch endpoints - const path = this.usePublicProxy - ? `/patch/by-ghsa/${encodeURIComponent(ghsaId)}` - : `/v0/orgs/${orgSlug}/patches/by-ghsa/${encodeURIComponent(ghsaId)}` - const result = await this.get(path) - return result ?? { patches: [], canAccessPaidPatches: false } - } - - /** - * Search patches by package PURL - * Returns lightweight search results (no blob content) - * - * The PURL must be a valid Package URL starting with "pkg:" - * Examples: - * - pkg:npm/lodash@4.17.21 - * - pkg:npm/@types/node - * - pkg:pypi/django@3.2.0 - */ - async searchPatchesByPackage( - orgSlug: string | null, - purl: string, - ): Promise { - // Public proxy uses /patch/* prefix for patch endpoints - const path = this.usePublicProxy - ? `/patch/by-package/${encodeURIComponent(purl)}` - : `/v0/orgs/${orgSlug}/patches/by-package/${encodeURIComponent(purl)}` - const result = await this.get(path) - return result ?? { patches: [], canAccessPaidPatches: false } - } - - /** - * Search patches for multiple packages by PURL (batch) - * Returns minimal patch information for each package that has available patches. - * - * Each PURL must: - * - Start with "pkg:" - * - Include a valid ecosystem type (npm, pypi, maven, etc.) - * - Include package name - * - Include version (required for batch lookups) - * - * Maximum 500 PURLs per request. - * - * When using the public proxy, this falls back to individual GET requests - * per package since the batch endpoint is not available on the public proxy - * (POST requests with varying bodies cannot be cached by Cloudflare CDN). - */ - async searchPatchesBatch( - orgSlug: string | null, - purls: string[], - ): Promise { - // For authenticated API, use the batch endpoint - if (!this.usePublicProxy) { - const path = `/v0/orgs/${orgSlug}/patches/batch` - // Use CDX-style components format - const components = purls.map(purl => ({ purl })) - const result = await this.post(path, { components }) - return result ?? { packages: [], canAccessPaidPatches: false } - } - - // For public proxy, fall back to individual per-package GET requests - // These are cacheable by Cloudflare CDN - return this.searchPatchesBatchViaIndividualQueries(purls) - } - - /** - * Internal method to search patches by making individual GET requests - * for each PURL. Used when the batch endpoint is not available. - * - * Runs requests in parallel with a concurrency limit to avoid overwhelming - * the server while still being efficient. - */ - private async searchPatchesBatchViaIndividualQueries( - purls: string[], - ): Promise { - const CONCURRENCY_LIMIT = 10 - const packages: BatchPackagePatches[] = [] - let canAccessPaidPatches = false - - // Process PURLs in parallel with concurrency limit - const results: Array<{ purl: string; response: SearchResponse | null }> = [] - - for (let i = 0; i < purls.length; i += CONCURRENCY_LIMIT) { - const batch = purls.slice(i, i + CONCURRENCY_LIMIT) - const batchResults = await Promise.all( - batch.map(async purl => { - try { - const response = await this.searchPatchesByPackage(null, purl) - return { purl, response } - } catch (error) { - // Log error but continue with other packages - debugLog(`Error fetching patches for ${purl}:`, error) - return { purl, response: null } - } - }), - ) - results.push(...batchResults) - } - - // Convert individual responses to batch response format - for (const { purl, response } of results) { - if (!response || response.patches.length === 0) { - continue - } - - // Track paid patch access - if (response.canAccessPaidPatches) { - canAccessPaidPatches = true - } - - // Convert PatchSearchResult[] to BatchPatchInfo[] - const batchPatches: BatchPatchInfo[] = response.patches.map(patch => { - // Extract CVE and GHSA IDs from vulnerabilities - const cveIds: string[] = [] - const ghsaIds: string[] = [] - let highestSeverity: string | null = null - let title = '' - - for (const [ghsaId, vuln] of Object.entries(patch.vulnerabilities)) { - // GHSA ID is the key - ghsaIds.push(ghsaId) - // CVE IDs are in the vuln object - for (const cve of vuln.cves) { - if (!cveIds.includes(cve)) { - cveIds.push(cve) - } - } - // Track highest severity - if (!highestSeverity || getSeverityOrder(vuln.severity) < getSeverityOrder(highestSeverity)) { - highestSeverity = vuln.severity || null - } - // Use first summary as title - if (!title && vuln.summary) { - title = vuln.summary.length > 100 - ? vuln.summary.slice(0, 97) + '...' - : vuln.summary - } - } - - // Use description as fallback title - if (!title && patch.description) { - title = patch.description.length > 100 - ? patch.description.slice(0, 97) + '...' - : patch.description - } - - return { - uuid: patch.uuid, - purl: patch.purl, - tier: patch.tier, - cveIds: cveIds.sort(), - ghsaIds: ghsaIds.sort(), - severity: highestSeverity, - title, - } - }) - - packages.push({ - purl, - patches: batchPatches, - }) - } - - return { packages, canAccessPaidPatches } - } - - /** - * Fetch a blob by its SHA256 hash. - * Returns the raw binary content as a Buffer, or null if not found. - * - * Uses authenticated API endpoint when token and orgSlug are available, - * otherwise falls back to the public proxy. - * - * @param hash - SHA256 hash (64 hex characters) - * @returns Buffer containing blob data, or null if not found - */ - async fetchBlob(hash: string): Promise { - // Validate hash format (SHA256 = 64 hex chars) - if (!/^[a-f0-9]{64}$/i.test(hash)) { - throw new Error(`Invalid hash format: ${hash}. Expected SHA256 hash (64 hex characters).`) - } - - // Use authenticated API endpoint when available, otherwise use public proxy - let url: string - let useAuth = false - if (this.apiToken && this.orgSlug && !this.usePublicProxy) { - // Use authenticated endpoint - url = `${this.apiUrl}/v0/orgs/${this.orgSlug}/patches/blob/${hash}` - useAuth = true - } else { - // Fall back to public proxy - const proxyUrl = process.env.SOCKET_PATCH_PROXY_URL || DEFAULT_PATCH_API_PROXY_URL - url = `${proxyUrl}/patch/blob/${hash}` - } - - return new Promise((resolve, reject) => { - const urlObj = new URL(url) - const isHttps = urlObj.protocol === 'https:' - const httpModule = isHttps ? https : http - - const headers: Record = { - Accept: 'application/octet-stream', - 'User-Agent': 'SocketPatchCLI/1.0', - } - - // Add auth header when using authenticated endpoint - if (useAuth && this.apiToken) { - headers['Authorization'] = `Bearer ${this.apiToken}` - } - - const options: https.RequestOptions = { - method: 'GET', - headers, - } - - const req = httpModule.request(urlObj, options, res => { - const chunks: Buffer[] = [] - - res.on('data', (chunk: Buffer) => { - chunks.push(chunk) - }) - - res.on('end', () => { - if (res.statusCode === 200) { - resolve(Buffer.concat(chunks)) - } else if (res.statusCode === 404) { - resolve(null) - } else { - const errorBody = Buffer.concat(chunks).toString('utf-8') - reject( - new Error( - `Failed to fetch blob ${hash}: status ${res.statusCode} - ${errorBody}`, - ), - ) - } - }) - }) - - req.on('error', err => { - reject(new Error(`Network error fetching blob ${hash}: ${err.message}`)) - }) - - req.end() - }) - } - -} - -/** - * Get an API client configured from environment variables. - * - * If SOCKET_API_TOKEN is not set, the client will use the public patch API proxy - * which provides free access to free-tier patches without authentication. - * - * Environment variables: - * - SOCKET_API_URL: Override the API URL (defaults to https://api.socket.dev) - * - SOCKET_API_TOKEN: API token for authenticated access to all patches - * - SOCKET_PATCH_PROXY_URL: Override the public patch API URL (defaults to https://patches-api.socket.dev) - * - SOCKET_ORG_SLUG: Organization slug for authenticated blob downloads - * - * @param orgSlug - Optional organization slug (overrides SOCKET_ORG_SLUG env var) - */ -export function getAPIClientFromEnv(orgSlug?: string): { client: APIClient; usePublicProxy: boolean } { - const apiToken = process.env.SOCKET_API_TOKEN - const resolvedOrgSlug = orgSlug || process.env.SOCKET_ORG_SLUG - - if (!apiToken) { - // No token provided - use public proxy for free patches - const proxyUrl = process.env.SOCKET_PATCH_PROXY_URL || DEFAULT_PATCH_API_PROXY_URL - console.log('No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only).') - return { - client: new APIClient({ apiUrl: proxyUrl, usePublicProxy: true }), - usePublicProxy: true, - } - } - - const apiUrl = process.env.SOCKET_API_URL || 'https://api.socket.dev' - return { - client: new APIClient({ apiUrl, apiToken, orgSlug: resolvedOrgSlug }), - usePublicProxy: false, - } -} diff --git a/src/utils/blob-fetcher.test.ts b/src/utils/blob-fetcher.test.ts deleted file mode 100644 index 9443758..0000000 --- a/src/utils/blob-fetcher.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import * as os from 'os' -import { getMissingBlobs } from './blob-fetcher.js' -import type { PatchManifest } from '../schema/manifest-schema.js' - -// Valid UUIDs for testing -const TEST_UUID = '11111111-1111-4111-8111-111111111111' - -// Sample hashes for testing -const BEFORE_HASH_1 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111' -const AFTER_HASH_1 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111' -const BEFORE_HASH_2 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222' -const AFTER_HASH_2 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222' - -function createTestManifest(): PatchManifest { - return { - patches: { - 'pkg:npm/pkg-a@1.0.0': { - uuid: TEST_UUID, - exportedAt: '2024-01-01T00:00:00Z', - files: { - 'package/index.js': { - beforeHash: BEFORE_HASH_1, - afterHash: AFTER_HASH_1, - }, - 'package/lib/utils.js': { - beforeHash: BEFORE_HASH_2, - afterHash: AFTER_HASH_2, - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - }, - }, - } -} - -describe('blob-fetcher', () => { - let tempDir: string - let blobsDir: string - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-test-')) - blobsDir = path.join(tempDir, 'blobs') - await fs.mkdir(blobsDir, { recursive: true }) - }) - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - describe('getMissingBlobs', () => { - it('should return only missing afterHash blobs when all blobs are missing', async () => { - const manifest = createTestManifest() - const missing = await getMissingBlobs(manifest, blobsDir) - - // Should only include afterHash blobs, NOT beforeHash blobs - assert.equal(missing.size, 2) - assert.ok(missing.has(AFTER_HASH_1)) - assert.ok(missing.has(AFTER_HASH_2)) - assert.ok(!missing.has(BEFORE_HASH_1)) - assert.ok(!missing.has(BEFORE_HASH_2)) - }) - - it('should not include afterHash blobs that already exist', async () => { - const manifest = createTestManifest() - - // Create one of the afterHash blobs - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'test content') - - const missing = await getMissingBlobs(manifest, blobsDir) - - // Should only include the missing afterHash blob - assert.equal(missing.size, 1) - assert.ok(!missing.has(AFTER_HASH_1)) // exists on disk - assert.ok(missing.has(AFTER_HASH_2)) // missing - }) - - it('should return empty set when all afterHash blobs exist', async () => { - const manifest = createTestManifest() - - // Create all afterHash blobs - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'test content 1') - await fs.writeFile(path.join(blobsDir, AFTER_HASH_2), 'test content 2') - - const missing = await getMissingBlobs(manifest, blobsDir) - - // Should be empty - all required blobs exist - assert.equal(missing.size, 0) - }) - - it('should ignore beforeHash blobs even if they exist on disk', async () => { - const manifest = createTestManifest() - - // Create beforeHash blobs (not afterHash blobs) - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_1), 'before content 1') - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_2), 'before content 2') - - const missing = await getMissingBlobs(manifest, blobsDir) - - // Should still report afterHash blobs as missing - assert.equal(missing.size, 2) - assert.ok(missing.has(AFTER_HASH_1)) - assert.ok(missing.has(AFTER_HASH_2)) - }) - - it('should return empty set for empty manifest', async () => { - const manifest: PatchManifest = { patches: {} } - const missing = await getMissingBlobs(manifest, blobsDir) - assert.equal(missing.size, 0) - }) - - it('should work even if blobs directory does not exist', async () => { - const manifest = createTestManifest() - const nonExistentDir = path.join(tempDir, 'non-existent-blobs') - - const missing = await getMissingBlobs(manifest, nonExistentDir) - - // Should return all afterHash blobs as missing - assert.equal(missing.size, 2) - assert.ok(missing.has(AFTER_HASH_1)) - assert.ok(missing.has(AFTER_HASH_2)) - }) - }) -}) diff --git a/src/utils/blob-fetcher.ts b/src/utils/blob-fetcher.ts deleted file mode 100644 index f26f085..0000000 --- a/src/utils/blob-fetcher.ts +++ /dev/null @@ -1,311 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { PatchManifest } from '../schema/manifest-schema.js' -import { getAfterHashBlobs } from '../manifest/operations.js' -import { APIClient, getAPIClientFromEnv } from './api-client.js' - -export interface BlobFetchResult { - hash: string - success: boolean - error?: string -} - -export interface FetchMissingBlobsResult { - total: number - downloaded: number - failed: number - skipped: number - results: BlobFetchResult[] -} - -export interface FetchMissingBlobsOptions { - onProgress?: (hash: string, index: number, total: number) => void -} - -/** - * Get the set of afterHash blobs that are referenced in the manifest but missing from disk. - * Only checks for afterHash blobs since those are needed for applying patches. - * beforeHash blobs are not needed (they can be downloaded on-demand during rollback). - * - * @param manifest - The patch manifest - * @param blobsPath - Path to the blobs directory - * @returns Set of missing afterHash blob hashes - */ -export async function getMissingBlobs( - manifest: PatchManifest, - blobsPath: string, -): Promise> { - const afterHashBlobs = getAfterHashBlobs(manifest) - const missingBlobs = new Set() - - for (const hash of afterHashBlobs) { - const blobPath = path.join(blobsPath, hash) - try { - await fs.access(blobPath) - } catch { - missingBlobs.add(hash) - } - } - - return missingBlobs -} - -/** - * Download all missing blobs referenced in the manifest. - * - * @param manifest - The patch manifest - * @param blobsPath - Path to the blobs directory - * @param client - Optional API client (will create one if not provided) - * @param options - Optional callbacks for progress tracking - * @returns Results of the download operation - */ -export async function fetchMissingBlobs( - manifest: PatchManifest, - blobsPath: string, - client?: APIClient, - options?: FetchMissingBlobsOptions, -): Promise { - const missingBlobs = await getMissingBlobs(manifest, blobsPath) - - if (missingBlobs.size === 0) { - return { - total: 0, - downloaded: 0, - failed: 0, - skipped: 0, - results: [], - } - } - - // Get client from environment if not provided - const apiClient = client ?? getAPIClientFromEnv().client - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - const results: BlobFetchResult[] = [] - let downloaded = 0 - let failed = 0 - - const hashes = Array.from(missingBlobs) - for (let i = 0; i < hashes.length; i++) { - const hash = hashes[i] - - if (options?.onProgress) { - options.onProgress(hash, i + 1, hashes.length) - } - - try { - const blobData = await apiClient.fetchBlob(hash) - - if (blobData === null) { - results.push({ - hash, - success: false, - error: 'Blob not found on server', - }) - failed++ - } else { - const blobPath = path.join(blobsPath, hash) - await fs.writeFile(blobPath, blobData) - results.push({ - hash, - success: true, - }) - downloaded++ - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - results.push({ - hash, - success: false, - error: errorMessage, - }) - failed++ - } - } - - return { - total: hashes.length, - downloaded, - failed, - skipped: 0, - results, - } -} - -/** - * Ensure a blob exists locally, downloading it if necessary. - * - * @param hash - SHA256 hash of the blob - * @param blobsPath - Path to the blobs directory - * @param client - API client for downloading (null for offline mode) - * @returns true if blob exists locally (or was downloaded), false otherwise - */ -export async function ensureBlobExists( - hash: string, - blobsPath: string, - client: APIClient | null, -): Promise { - const blobPath = path.join(blobsPath, hash) - - // Check if blob already exists locally - try { - await fs.access(blobPath) - return true - } catch { - // Blob doesn't exist locally - } - - // If in offline mode (no client), we can't download - if (client === null) { - return false - } - - // Try to download the blob - try { - const blobData = await client.fetchBlob(hash) - if (blobData === null) { - return false - } - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - await fs.writeFile(blobPath, blobData) - return true - } catch { - return false - } -} - -/** - * Create an ensureBlob callback function for use with apply/rollback commands. - * - * @param blobsPath - Path to the blobs directory - * @param offline - If true, don't attempt network downloads - * @returns Callback function for ensuring blobs exist - */ -export function createBlobEnsurer( - blobsPath: string, - offline: boolean, -): (hash: string) => Promise { - const client = offline ? null : getAPIClientFromEnv().client - - return async (hash: string) => { - return ensureBlobExists(hash, blobsPath, client) - } -} - -/** - * Fetch specific blobs by their hashes. - * This is useful for downloading beforeHash blobs during rollback. - * - * @param hashes - Set of blob hashes to download - * @param blobsPath - Path to the blobs directory - * @param client - Optional API client (will create one if not provided) - * @param options - Optional callbacks for progress tracking - * @returns Results of the download operation - */ -export async function fetchBlobsByHash( - hashes: Set, - blobsPath: string, - client?: APIClient, - options?: FetchMissingBlobsOptions, -): Promise { - if (hashes.size === 0) { - return { - total: 0, - downloaded: 0, - failed: 0, - skipped: 0, - results: [], - } - } - - // Get client from environment if not provided - const apiClient = client ?? getAPIClientFromEnv().client - - // Ensure blobs directory exists - await fs.mkdir(blobsPath, { recursive: true }) - - const results: BlobFetchResult[] = [] - let downloaded = 0 - let failed = 0 - - const hashArray = Array.from(hashes) - for (let i = 0; i < hashArray.length; i++) { - const hash = hashArray[i] - - if (options?.onProgress) { - options.onProgress(hash, i + 1, hashArray.length) - } - - try { - const blobData = await apiClient.fetchBlob(hash) - - if (blobData === null) { - results.push({ - hash, - success: false, - error: 'Blob not found on server', - }) - failed++ - } else { - const blobPath = path.join(blobsPath, hash) - await fs.writeFile(blobPath, blobData) - results.push({ - hash, - success: true, - }) - downloaded++ - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - results.push({ - hash, - success: false, - error: errorMessage, - }) - failed++ - } - } - - return { - total: hashArray.length, - downloaded, - failed, - skipped: 0, - results, - } -} - -/** - * Format the fetch results for human-readable output. - */ -export function formatFetchResult(result: FetchMissingBlobsResult): string { - if (result.total === 0) { - return 'All blobs are present locally.' - } - - const lines: string[] = [] - - if (result.downloaded > 0) { - lines.push(`Downloaded ${result.downloaded} blob(s)`) - } - - if (result.failed > 0) { - lines.push(`Failed to download ${result.failed} blob(s)`) - - // Show failed blobs - const failedResults = result.results.filter(r => !r.success) - for (const r of failedResults.slice(0, 5)) { - lines.push(` - ${r.hash.slice(0, 12)}...: ${r.error}`) - } - if (failedResults.length > 5) { - lines.push(` ... and ${failedResults.length - 5} more`) - } - } - - return lines.join('\n') -} diff --git a/src/utils/cleanup-blobs.test.ts b/src/utils/cleanup-blobs.test.ts deleted file mode 100644 index fb62d10..0000000 --- a/src/utils/cleanup-blobs.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test' -import assert from 'node:assert/strict' -import * as fs from 'fs/promises' -import * as path from 'path' -import * as os from 'os' -import { cleanupUnusedBlobs } from './cleanup-blobs.js' -import type { PatchManifest } from '../schema/manifest-schema.js' - -// Valid UUIDs for testing -const TEST_UUID = '11111111-1111-4111-8111-111111111111' - -// Sample hashes for testing -const BEFORE_HASH_1 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111' -const AFTER_HASH_1 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111' -const BEFORE_HASH_2 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222' -const AFTER_HASH_2 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222' -const ORPHAN_HASH = 'oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo' - -function createTestManifest(): PatchManifest { - return { - patches: { - 'pkg:npm/pkg-a@1.0.0': { - uuid: TEST_UUID, - exportedAt: '2024-01-01T00:00:00Z', - files: { - 'package/index.js': { - beforeHash: BEFORE_HASH_1, - afterHash: AFTER_HASH_1, - }, - 'package/lib/utils.js': { - beforeHash: BEFORE_HASH_2, - afterHash: AFTER_HASH_2, - }, - }, - vulnerabilities: {}, - description: 'Test patch', - license: 'MIT', - tier: 'free', - }, - }, - } -} - -describe('cleanup-blobs', () => { - let tempDir: string - let blobsDir: string - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-test-')) - blobsDir = path.join(tempDir, 'blobs') - await fs.mkdir(blobsDir, { recursive: true }) - }) - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - describe('cleanupUnusedBlobs', () => { - it('should keep afterHash blobs and remove orphan blobs', async () => { - const manifest = createTestManifest() - - // Create blobs on disk - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'after content 1') - await fs.writeFile(path.join(blobsDir, AFTER_HASH_2), 'after content 2') - await fs.writeFile(path.join(blobsDir, ORPHAN_HASH), 'orphan content') - - const result = await cleanupUnusedBlobs(manifest, blobsDir) - - // Should remove only the orphan blob - assert.equal(result.blobsRemoved, 1) - assert.ok(result.removedBlobs.includes(ORPHAN_HASH)) - - // afterHash blobs should still exist - await fs.access(path.join(blobsDir, AFTER_HASH_1)) - await fs.access(path.join(blobsDir, AFTER_HASH_2)) - - // Orphan blob should be removed - await assert.rejects( - fs.access(path.join(blobsDir, ORPHAN_HASH)), - /ENOENT/, - ) - }) - - it('should remove beforeHash blobs since they are downloaded on-demand', async () => { - const manifest = createTestManifest() - - // Create both beforeHash and afterHash blobs - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_1), 'before content 1') - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_2), 'before content 2') - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'after content 1') - await fs.writeFile(path.join(blobsDir, AFTER_HASH_2), 'after content 2') - - const result = await cleanupUnusedBlobs(manifest, blobsDir) - - // Should remove the beforeHash blobs (they're downloaded on-demand during rollback) - assert.equal(result.blobsRemoved, 2) - assert.ok(result.removedBlobs.includes(BEFORE_HASH_1)) - assert.ok(result.removedBlobs.includes(BEFORE_HASH_2)) - - // afterHash blobs should still exist - await fs.access(path.join(blobsDir, AFTER_HASH_1)) - await fs.access(path.join(blobsDir, AFTER_HASH_2)) - - // beforeHash blobs should be removed - await assert.rejects( - fs.access(path.join(blobsDir, BEFORE_HASH_1)), - /ENOENT/, - ) - await assert.rejects( - fs.access(path.join(blobsDir, BEFORE_HASH_2)), - /ENOENT/, - ) - }) - - it('should not remove anything in dry-run mode', async () => { - const manifest = createTestManifest() - - // Create blobs on disk (including beforeHash blobs which should be marked for removal) - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_1), 'before content 1') - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'after content 1') - - const result = await cleanupUnusedBlobs(manifest, blobsDir, true) // dry-run - - // Should report beforeHash as would-be-removed - assert.equal(result.blobsRemoved, 1) - assert.ok(result.removedBlobs.includes(BEFORE_HASH_1)) - - // But both blobs should still exist - await fs.access(path.join(blobsDir, BEFORE_HASH_1)) - await fs.access(path.join(blobsDir, AFTER_HASH_1)) - }) - - it('should handle empty manifest (remove all blobs)', async () => { - const manifest: PatchManifest = { patches: {} } - - // Create some blobs - await fs.writeFile(path.join(blobsDir, AFTER_HASH_1), 'content 1') - await fs.writeFile(path.join(blobsDir, BEFORE_HASH_1), 'content 2') - - const result = await cleanupUnusedBlobs(manifest, blobsDir) - - // Should remove all blobs since none are referenced - assert.equal(result.blobsRemoved, 2) - }) - - it('should handle non-existent blobs directory', async () => { - const manifest = createTestManifest() - const nonExistentDir = path.join(tempDir, 'non-existent') - - const result = await cleanupUnusedBlobs(manifest, nonExistentDir) - - // Should return empty result - assert.equal(result.blobsChecked, 0) - assert.equal(result.blobsRemoved, 0) - }) - }) -}) diff --git a/src/utils/cleanup-blobs.ts b/src/utils/cleanup-blobs.ts deleted file mode 100644 index accd7d4..0000000 --- a/src/utils/cleanup-blobs.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type { PatchManifest } from '../schema/manifest-schema.js' -import { getAfterHashBlobs } from '../manifest/operations.js' - -export interface CleanupResult { - blobsChecked: number - blobsRemoved: number - bytesFreed: number - removedBlobs: string[] -} - -/** - * Cleans up unused blob files from the .socket/blobs directory. - * Analyzes the manifest to determine which afterHash blobs are needed for applying patches, - * then removes any blob files that are not needed. - * - * Note: beforeHash blobs are considered "unused" because they are downloaded on-demand - * during rollback operations. This saves disk space since beforeHash blobs are only - * needed for rollback, not for applying patches. - * - * @param manifest - The patch manifest containing all active patches - * @param blobsDir - Path to the .socket/blobs directory - * @param dryRun - If true, only reports what would be deleted without actually deleting - * @returns Statistics about the cleanup operation - */ -export async function cleanupUnusedBlobs( - manifest: PatchManifest, - blobsDir: string, - dryRun: boolean = false, -): Promise { - // Only keep afterHash blobs - beforeHash blobs are downloaded on-demand during rollback - const usedBlobs = getAfterHashBlobs(manifest) - - // Check if blobs directory exists - try { - await fs.access(blobsDir) - } catch { - // Blobs directory doesn't exist, nothing to clean up - return { - blobsChecked: 0, - blobsRemoved: 0, - bytesFreed: 0, - removedBlobs: [], - } - } - - // Read all files in the blobs directory - const blobFiles = await fs.readdir(blobsDir) - - const result: CleanupResult = { - blobsChecked: blobFiles.length, - blobsRemoved: 0, - bytesFreed: 0, - removedBlobs: [], - } - - // Check each blob file - for (const blobFile of blobFiles) { - // Skip hidden files and directories - if (blobFile.startsWith('.')) { - continue - } - - const blobPath = path.join(blobsDir, blobFile) - - // Check if it's a file (not a directory) - const stats = await fs.stat(blobPath) - if (!stats.isFile()) { - continue - } - - // If this blob is not in use, remove it - if (!usedBlobs.has(blobFile)) { - result.blobsRemoved++ - result.bytesFreed += stats.size - result.removedBlobs.push(blobFile) - - if (!dryRun) { - await fs.unlink(blobPath) - } - } - } - - return result -} - -/** - * Formats the cleanup result for human-readable output - */ -export function formatCleanupResult(result: CleanupResult, dryRun: boolean): string { - if (result.blobsChecked === 0) { - return 'No blobs directory found, nothing to clean up.' - } - - if (result.blobsRemoved === 0) { - return `Checked ${result.blobsChecked} blob(s), all are in use.` - } - - const action = dryRun ? 'Would remove' : 'Removed' - const bytesFormatted = formatBytes(result.bytesFreed) - - let output = `${action} ${result.blobsRemoved} unused blob(s) (${bytesFormatted} freed)` - - if (dryRun && result.removedBlobs.length > 0) { - output += '\nUnused blobs:' - for (const blob of result.removedBlobs) { - output += `\n - ${blob}` - } - } - - return output -} - -/** - * Formats bytes into a human-readable string - */ -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB` - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} diff --git a/src/utils/enumerate-packages.ts b/src/utils/enumerate-packages.ts deleted file mode 100644 index 249b02d..0000000 --- a/src/utils/enumerate-packages.ts +++ /dev/null @@ -1,200 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' - -/** - * Represents a package found in node_modules - */ -export interface EnumeratedPackage { - /** Package name (without scope) */ - name: string - /** Package version */ - version: string - /** Package scope/namespace (e.g., @types) - undefined for unscoped packages */ - namespace?: string - /** Full PURL string (e.g., pkg:npm/@types/node@20.0.0) */ - purl: string - /** Absolute path to the package directory */ - path: string -} - -/** - * Read and parse a package.json file - */ -async function readPackageJson( - pkgPath: string, -): Promise<{ name: string; version: string } | null> { - try { - const content = await fs.readFile(pkgPath, 'utf-8') - const pkg = JSON.parse(content) - if (typeof pkg.name === 'string' && typeof pkg.version === 'string') { - return { name: pkg.name, version: pkg.version } - } - return null - } catch { - return null - } -} - -/** - * Parse a package name into namespace and name components - * @param fullName - Full package name (e.g., "@types/node" or "lodash") - */ -function parsePackageName(fullName: string): { - namespace?: string - name: string -} { - if (fullName.startsWith('@')) { - const slashIndex = fullName.indexOf('/') - if (slashIndex !== -1) { - return { - namespace: fullName.substring(0, slashIndex), - name: fullName.substring(slashIndex + 1), - } - } - } - return { name: fullName } -} - -/** - * Build a PURL string from package components - */ -function buildPurl( - ecosystem: string, - namespace: string | undefined, - name: string, - version: string, -): string { - if (namespace) { - return `pkg:${ecosystem}/${namespace}/${name}@${version}` - } - return `pkg:${ecosystem}/${name}@${version}` -} - -/** - * Enumerate all packages in a node_modules directory - * - * @param cwd - Working directory to start from - * @param ecosystem - Package ecosystem (default: npm) - * @returns Array of enumerated packages - */ -export async function enumerateNodeModules( - cwd: string, - ecosystem: string = 'npm', -): Promise { - const packages: EnumeratedPackage[] = [] - const seen = new Set() - - const nodeModulesPath = path.join(cwd, 'node_modules') - - try { - await fs.access(nodeModulesPath) - } catch { - // node_modules doesn't exist - return packages - } - - await scanDirectory(nodeModulesPath, packages, seen, ecosystem) - - return packages -} - -/** - * Recursively scan a directory for packages - */ -async function scanDirectory( - dirPath: string, - packages: EnumeratedPackage[], - seen: Set, - ecosystem: string, -): Promise { - let entries: string[] - try { - entries = await fs.readdir(dirPath) - } catch { - return - } - - for (const entry of entries) { - // Skip hidden files and special directories - if (entry.startsWith('.') || entry === 'node_modules') { - continue - } - - const entryPath = path.join(dirPath, entry) - - // Handle scoped packages (@scope/package) - if (entry.startsWith('@')) { - // This is a scope directory, scan its contents - let scopeEntries: string[] - try { - scopeEntries = await fs.readdir(entryPath) - } catch { - continue - } - - for (const scopeEntry of scopeEntries) { - if (scopeEntry.startsWith('.')) continue - - const pkgPath = path.join(entryPath, scopeEntry) - const packageJsonPath = path.join(pkgPath, 'package.json') - - const pkgInfo = await readPackageJson(packageJsonPath) - if (pkgInfo) { - const { namespace, name } = parsePackageName(pkgInfo.name) - const purl = buildPurl(ecosystem, namespace, name, pkgInfo.version) - - // Deduplicate by PURL - if (!seen.has(purl)) { - seen.add(purl) - packages.push({ - name, - version: pkgInfo.version, - namespace, - purl, - path: pkgPath, - }) - } - - // Check for nested node_modules - const nestedNodeModules = path.join(pkgPath, 'node_modules') - try { - await fs.access(nestedNodeModules) - await scanDirectory(nestedNodeModules, packages, seen, ecosystem) - } catch { - // No nested node_modules - } - } - } - } else { - // Regular package - const packageJsonPath = path.join(entryPath, 'package.json') - - const pkgInfo = await readPackageJson(packageJsonPath) - if (pkgInfo) { - const { namespace, name } = parsePackageName(pkgInfo.name) - const purl = buildPurl(ecosystem, namespace, name, pkgInfo.version) - - // Deduplicate by PURL - if (!seen.has(purl)) { - seen.add(purl) - packages.push({ - name, - version: pkgInfo.version, - namespace, - purl, - path: entryPath, - }) - } - - // Check for nested node_modules - const nestedNodeModules = path.join(entryPath, 'node_modules') - try { - await fs.access(nestedNodeModules) - await scanDirectory(nestedNodeModules, packages, seen, ecosystem) - } catch { - // No nested node_modules - } - } - } - } -} diff --git a/src/utils/fuzzy-match.ts b/src/utils/fuzzy-match.ts deleted file mode 100644 index 1e51e61..0000000 --- a/src/utils/fuzzy-match.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { EnumeratedPackage } from './enumerate-packages.js' - -/** - * Match type for sorting results - */ -enum MatchType { - /** Exact match on full name (including namespace) */ - ExactFull = 0, - /** Exact match on package name only */ - ExactName = 1, - /** Query is a prefix of the full name */ - PrefixFull = 2, - /** Query is a prefix of the package name */ - PrefixName = 3, - /** Query is contained in the full name */ - ContainsFull = 4, - /** Query is contained in the package name */ - ContainsName = 5, -} - -interface MatchResult { - package: EnumeratedPackage - matchType: MatchType -} - -/** - * Get the full display name for a package (including namespace if present) - */ -function getFullName(pkg: EnumeratedPackage): string { - if (pkg.namespace) { - return `${pkg.namespace}/${pkg.name}` - } - return pkg.name -} - -/** - * Determine the match type for a package against a query - */ -function getMatchType( - pkg: EnumeratedPackage, - query: string, -): MatchType | null { - const lowerQuery = query.toLowerCase() - const fullName = getFullName(pkg).toLowerCase() - const name = pkg.name.toLowerCase() - - // Check exact matches - if (fullName === lowerQuery) { - return MatchType.ExactFull - } - if (name === lowerQuery) { - return MatchType.ExactName - } - - // Check prefix matches - if (fullName.startsWith(lowerQuery)) { - return MatchType.PrefixFull - } - if (name.startsWith(lowerQuery)) { - return MatchType.PrefixName - } - - // Check contains matches - if (fullName.includes(lowerQuery)) { - return MatchType.ContainsFull - } - if (name.includes(lowerQuery)) { - return MatchType.ContainsName - } - - return null -} - -/** - * Fuzzy match packages against a query string - * - * Matches are sorted by relevance: - * 1. Exact match on full name (e.g., "@types/node" matches "@types/node") - * 2. Exact match on package name (e.g., "node" matches "@types/node") - * 3. Prefix match on full name - * 4. Prefix match on package name - * 5. Contains match on full name - * 6. Contains match on package name - * - * @param query - Search query string - * @param packages - List of packages to search - * @param limit - Maximum number of results to return (default: 20) - * @returns Sorted list of matching packages - */ -export function fuzzyMatchPackages( - query: string, - packages: EnumeratedPackage[], - limit: number = 20, -): EnumeratedPackage[] { - if (!query || query.trim().length === 0) { - return [] - } - - const matches: MatchResult[] = [] - - for (const pkg of packages) { - const matchType = getMatchType(pkg, query) - if (matchType !== null) { - matches.push({ package: pkg, matchType }) - } - } - - // Sort by match type (lower is better), then alphabetically by name - matches.sort((a, b) => { - if (a.matchType !== b.matchType) { - return a.matchType - b.matchType - } - return getFullName(a.package).localeCompare(getFullName(b.package)) - }) - - // Return only the packages, limited to the specified count - return matches.slice(0, limit).map(m => m.package) -} - -/** - * Check if a string looks like a PURL - */ -export function isPurl(str: string): boolean { - return str.startsWith('pkg:') -} - -/** - * Check if a string looks like a scoped package name - */ -export function isScopedPackage(str: string): boolean { - return str.startsWith('@') && str.includes('/') -} diff --git a/src/utils/global-packages.ts b/src/utils/global-packages.ts deleted file mode 100644 index d1ece8a..0000000 --- a/src/utils/global-packages.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { execSync } from 'child_process' -import * as path from 'path' - -/** - * Get the npm global node_modules path using 'npm root -g' - * @returns The path to the global node_modules directory - */ -export function getNpmGlobalPrefix(): string { - try { - const result = execSync('npm root -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() - } catch (error) { - throw new Error( - 'Failed to determine npm global prefix. Ensure npm is installed and in PATH.', - ) - } -} - -/** - * Get the yarn global node_modules path - * @returns The path to yarn's global node_modules directory, or null if not available - */ -export function getYarnGlobalPrefix(): string | null { - try { - const result = execSync('yarn global dir', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return path.join(result.trim(), 'node_modules') - } catch { - return null - } -} - -/** - * Get the pnpm global node_modules path - * @returns The path to pnpm's global node_modules directory, or null if not available - */ -export function getPnpmGlobalPrefix(): string | null { - try { - const result = execSync('pnpm root -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }) - return result.trim() - } catch { - return null - } -} - -/** - * Get the bun global node_modules path - * @returns The path to bun's global node_modules directory, or null if not available - */ -export function getBunGlobalPrefix(): string | null { - try { - const binPath = execSync('bun pm bin -g', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim() - const bunRoot = path.dirname(binPath) - return path.join(bunRoot, 'install', 'global', 'node_modules') - } catch { - return null - } -} - -/** - * Get the global node_modules path, with support for custom override - * @param customPrefix - Optional custom path to use instead of auto-detection - * @returns The path to the global node_modules directory - */ -export function getGlobalPrefix(customPrefix?: string): string { - if (customPrefix) { - return customPrefix - } - return getNpmGlobalPrefix() -} - -/** - * Get all global node_modules paths for package lookup - * Returns paths from all detected package managers (npm, pnpm, yarn, bun) - * @param customPrefix - Optional custom path to use instead of auto-detection - * @returns Array of global node_modules paths - */ -export function getGlobalNodeModulesPaths(customPrefix?: string): string[] { - if (customPrefix) { - return [customPrefix] - } - - const paths: string[] = [] - - try { - paths.push(getNpmGlobalPrefix()) - } catch { - // npm not available - } - - const pnpmPath = getPnpmGlobalPrefix() - if (pnpmPath) { - paths.push(pnpmPath) - } - - const yarnPath = getYarnGlobalPrefix() - if (yarnPath) { - paths.push(yarnPath) - } - - const bunPath = getBunGlobalPrefix() - if (bunPath) { - paths.push(bunPath) - } - - return paths -} - -/** - * Check if a path is within a global node_modules directory - */ -export function isGlobalPath(pkgPath: string): boolean { - try { - const globalPaths = getGlobalNodeModulesPaths() - const normalizedPkgPath = path.normalize(pkgPath) - return globalPaths.some(globalPath => - normalizedPkgPath.startsWith(path.normalize(globalPath)), - ) - } catch { - return false - } -} diff --git a/src/utils/purl-utils.test.ts b/src/utils/purl-utils.test.ts deleted file mode 100644 index 2440498..0000000 --- a/src/utils/purl-utils.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { - stripPurlQualifiers, - isPyPIPurl, - isNpmPurl, - parsePyPIPurl, -} from './purl-utils.js' - -describe('purl-utils', () => { - describe('stripPurlQualifiers', () => { - it('should remove query string qualifiers', () => { - assert.equal( - stripPurlQualifiers('pkg:pypi/requests@2.28.0?artifact_id=abc'), - 'pkg:pypi/requests@2.28.0', - ) - }) - - it('should return unchanged if no qualifiers', () => { - assert.equal( - stripPurlQualifiers('pkg:pypi/requests@2.28.0'), - 'pkg:pypi/requests@2.28.0', - ) - }) - }) - - describe('isPyPIPurl', () => { - it('should return true for pypi PURL', () => { - assert.equal(isPyPIPurl('pkg:pypi/requests@2.28.0'), true) - }) - - it('should return false for npm PURL', () => { - assert.equal(isPyPIPurl('pkg:npm/lodash@4.17.21'), false) - }) - }) - - describe('isNpmPurl', () => { - it('should return true for npm PURL', () => { - assert.equal(isNpmPurl('pkg:npm/@scope/pkg@1.0.0'), true) - }) - - it('should return false for pypi PURL', () => { - assert.equal(isNpmPurl('pkg:pypi/requests@2.28.0'), false) - }) - }) - - describe('parsePyPIPurl', () => { - it('should extract name and version', () => { - const result = parsePyPIPurl('pkg:pypi/requests@2.28.0') - assert.deepEqual(result, { name: 'requests', version: '2.28.0' }) - }) - - it('should strip qualifiers first', () => { - const result = parsePyPIPurl('pkg:pypi/requests@2.28.0?artifact_id=abc') - assert.deepEqual(result, { name: 'requests', version: '2.28.0' }) - }) - - it('should return null for npm PURL', () => { - assert.equal(parsePyPIPurl('pkg:npm/lodash@4.17.21'), null) - }) - - it('should return null for PURL without version', () => { - assert.equal(parsePyPIPurl('pkg:pypi/requests'), null) - }) - }) -}) diff --git a/src/utils/purl-utils.ts b/src/utils/purl-utils.ts deleted file mode 100644 index 8522974..0000000 --- a/src/utils/purl-utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Strip query string qualifiers from a PURL. - * e.g., "pkg:pypi/requests@2.28.0?artifact_id=abc" -> "pkg:pypi/requests@2.28.0" - */ -export function stripPurlQualifiers(purl: string): string { - const qIdx = purl.indexOf('?') - return qIdx === -1 ? purl : purl.slice(0, qIdx) -} - -/** - * Check if a PURL is a PyPI package. - */ -export function isPyPIPurl(purl: string): boolean { - return purl.startsWith('pkg:pypi/') -} - -/** - * Check if a PURL is an npm package. - */ -export function isNpmPurl(purl: string): boolean { - return purl.startsWith('pkg:npm/') -} - -/** - * Parse a PyPI PURL to extract name and version. - * e.g., "pkg:pypi/requests@2.28.0?artifact_id=abc" -> { name: "requests", version: "2.28.0" } - */ -export function parsePyPIPurl( - purl: string, -): { name: string; version: string } | null { - const base = stripPurlQualifiers(purl) - const match = base.match(/^pkg:pypi\/([^@]+)@(.+)$/) - if (!match) return null - return { name: match[1], version: match[2] } -} diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts deleted file mode 100644 index ab0c38c..0000000 --- a/src/utils/spinner.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Lightweight terminal spinner for CLI progress indication. - * Compatible with socket-cli's spinner patterns but standalone. - */ - -// Spinner frames - matches the 'dots' style from socket-cli -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const SPINNER_INTERVAL = 80 // ms - -// CI-friendly spinner (no animation) -const CI_FRAMES = ['-'] -const CI_INTERVAL = 1000 - -export interface SpinnerOptions { - /** Stream to write to (default: process.stderr) */ - stream?: NodeJS.WriteStream - /** Use CI mode (no animation) */ - ci?: boolean - /** Disable spinner entirely (for JSON output) */ - disabled?: boolean -} - -export class Spinner { - private stream: NodeJS.WriteStream - private frames: string[] - private interval: number - private frameIndex = 0 - private text = '' - private timer: ReturnType | null = null - private disabled: boolean - - constructor(options: SpinnerOptions = {}) { - this.stream = options.stream ?? process.stderr - this.disabled = options.disabled ?? false - - // Use CI mode if explicitly requested or if not a TTY - const useCi = options.ci ?? !this.stream.isTTY - this.frames = useCi ? CI_FRAMES : SPINNER_FRAMES - this.interval = useCi ? CI_INTERVAL : SPINNER_INTERVAL - } - - /** - * Check if spinner is currently active - */ - get isSpinning(): boolean { - return this.timer !== null - } - - /** - * Start the spinner with optional text - */ - start(text?: string): this { - if (this.disabled) return this - - // Stop any existing spinner - if (this.timer) { - this.stop() - } - - this.text = text ?? '' - this.frameIndex = 0 - - // Render immediately - this.render() - - // Start animation - this.timer = setInterval(() => { - this.frameIndex = (this.frameIndex + 1) % this.frames.length - this.render() - }, this.interval) - - return this - } - - /** - * Update the spinner text without stopping - */ - update(text: string): this { - if (this.disabled) return this - this.text = text - if (this.isSpinning) { - this.render() - } - return this - } - - /** - * Stop the spinner and clear the line - */ - stop(): this { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - this.clear() - return this - } - - /** - * Stop the spinner with a success message - */ - succeed(text?: string): this { - this.stopWithSymbol('✓', text) - return this - } - - /** - * Stop the spinner with a failure message - */ - fail(text?: string): this { - this.stopWithSymbol('✗', text) - return this - } - - /** - * Stop the spinner with an info message - */ - info(text?: string): this { - this.stopWithSymbol('ℹ', text) - return this - } - - /** - * Clear the current line - */ - private clear(): void { - if (!this.stream.isTTY) return - // Move cursor to beginning and clear the line - this.stream.write('\r\x1b[K') - } - - /** - * Render the current spinner frame with text - */ - private render(): void { - if (!this.stream.isTTY) { - // In non-TTY mode, just write text on new lines occasionally - return - } - - const frame = this.frames[this.frameIndex] - const line = this.text ? `${frame} ${this.text}` : frame - - // Clear line and write new content - this.stream.write(`\r\x1b[K${line}`) - } - - /** - * Stop spinner with a symbol and optional final text - */ - private stopWithSymbol(symbol: string, text?: string): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - - if (this.disabled) return - - const finalText = text ?? this.text - if (finalText && this.stream.isTTY) { - this.stream.write(`\r\x1b[K${symbol} ${finalText}\n`) - } else if (finalText) { - this.stream.write(`${symbol} ${finalText}\n`) - } else { - this.clear() - } - } -} - -/** - * Create a new spinner instance - */ -export function createSpinner(options?: SpinnerOptions): Spinner { - return new Spinner(options) -} diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts deleted file mode 100644 index 1d978b9..0000000 --- a/src/utils/telemetry.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * Telemetry module for socket-patch CLI. - * Collects anonymous usage data for patch lifecycle events. - * - * Telemetry can be disabled via: - * - Environment variable: SOCKET_PATCH_TELEMETRY_DISABLED=1 - * - Running in test environment: VITEST=true - * - * Events are sent to: - * - Authenticated: https://api.socket.dev/v0/orgs/{org}/telemetry - * - Public proxy: https://patches-api.socket.dev/patch/telemetry - */ - -import * as https from 'node:https' -import * as http from 'node:http' -import * as os from 'node:os' -import * as crypto from 'node:crypto' - -// Default public patch API URL for free tier telemetry. -const DEFAULT_PATCH_API_PROXY_URL = 'https://patches-api.socket.dev' - -// Package version - updated during build. -const PACKAGE_VERSION = '1.0.0' - -/** - * Check if telemetry is disabled via environment variables. - */ -function isTelemetryDisabled(): boolean { - return ( - process.env['SOCKET_PATCH_TELEMETRY_DISABLED'] === '1' || - process.env['SOCKET_PATCH_TELEMETRY_DISABLED'] === 'true' || - process.env['VITEST'] === 'true' - ) -} - -/** - * Check if debug mode is enabled. - */ -function isDebugEnabled(): boolean { - return ( - process.env['SOCKET_PATCH_DEBUG'] === '1' || - process.env['SOCKET_PATCH_DEBUG'] === 'true' - ) -} - -/** - * Log debug messages when debug mode is enabled. - */ -function debugLog(message: string, ...args: unknown[]): void { - if (isDebugEnabled()) { - console.error(`[socket-patch telemetry] ${message}`, ...args) - } -} - -/** - * Generate a unique session ID for the current CLI invocation. - * This is shared across all telemetry events in a single CLI run. - */ -const SESSION_ID = crypto.randomUUID() - -/** - * Telemetry context describing the execution environment. - */ -export interface PatchTelemetryContext { - version: string - platform: string - node_version: string - arch: string - command: string -} - -/** - * Error details for telemetry events. - */ -export interface PatchTelemetryError { - type: string - message: string | undefined -} - -/** - * Telemetry event types for patch lifecycle. - */ -export type PatchTelemetryEventType = - | 'patch_applied' - | 'patch_apply_failed' - | 'patch_removed' - | 'patch_remove_failed' - | 'patch_rolled_back' - | 'patch_rollback_failed' - -/** - * Telemetry event structure for patch operations. - */ -export interface PatchTelemetryEvent { - event_sender_created_at: string - event_type: PatchTelemetryEventType - context: PatchTelemetryContext - session_id: string - metadata?: Record - error?: PatchTelemetryError -} - -/** - * Options for tracking a patch event. - */ -export interface TrackPatchEventOptions { - /** The type of event being tracked. */ - eventType: PatchTelemetryEventType - /** The CLI command being executed (e.g., 'apply', 'remove', 'rollback'). */ - command: string - /** Optional metadata to include with the event. */ - metadata?: Record - /** Optional error information if the operation failed. */ - error?: Error - /** Optional API token for authenticated telemetry endpoint. */ - apiToken?: string - /** Optional organization slug for authenticated telemetry endpoint. */ - orgSlug?: string -} - -/** - * Build the telemetry context for the current environment. - */ -function buildTelemetryContext(command: string): PatchTelemetryContext { - return { - version: PACKAGE_VERSION, - platform: process.platform, - node_version: process.version, - arch: os.arch(), - command, - } -} - -/** - * Sanitize error for telemetry. - * Removes sensitive paths and information. - */ -function sanitizeError(error: Error): PatchTelemetryError { - const homeDir = os.homedir() - let message = error.message - if (homeDir) { - message = message.replace(new RegExp(homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '~') - } - return { - type: error.constructor.name, - message, - } -} - -/** - * Build a telemetry event from the given options. - */ -function buildTelemetryEvent(options: TrackPatchEventOptions): PatchTelemetryEvent { - const event: PatchTelemetryEvent = { - event_sender_created_at: new Date().toISOString(), - event_type: options.eventType, - context: buildTelemetryContext(options.command), - session_id: SESSION_ID, - } - - if (options.metadata && Object.keys(options.metadata).length > 0) { - event.metadata = options.metadata - } - - if (options.error) { - event.error = sanitizeError(options.error) - } - - return event -} - -/** - * Send telemetry event to the API. - * Returns a promise that resolves when the request completes. - * Errors are logged but never thrown - telemetry should never block CLI operations. - */ -async function sendTelemetryEvent( - event: PatchTelemetryEvent, - apiToken?: string, - orgSlug?: string, -): Promise { - // Determine the telemetry endpoint based on authentication. - let url: string - let useAuth = false - - if (apiToken && orgSlug) { - // Authenticated endpoint. - const apiUrl = process.env['SOCKET_API_URL'] || 'https://api.socket.dev' - url = `${apiUrl}/v0/orgs/${orgSlug}/telemetry` - useAuth = true - } else { - // Public proxy endpoint. - const proxyUrl = process.env['SOCKET_PATCH_PROXY_URL'] || DEFAULT_PATCH_API_PROXY_URL - url = `${proxyUrl}/patch/telemetry` - } - - debugLog(`Sending telemetry to ${url}`, event) - - return new Promise(resolve => { - const body = JSON.stringify(event) - const urlObj = new URL(url) - const isHttps = urlObj.protocol === 'https:' - const httpModule = isHttps ? https : http - - const headers: Record = { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body).toString(), - 'User-Agent': 'SocketPatchCLI/1.0', - } - - if (useAuth && apiToken) { - headers['Authorization'] = `Bearer ${apiToken}` - } - - const requestOptions: https.RequestOptions = { - method: 'POST', - headers, - timeout: 5000, // 5 second timeout. - } - - const req = httpModule.request(urlObj, requestOptions, res => { - // Consume response body to free resources. - res.on('data', () => {}) - res.on('end', () => { - if (res.statusCode === 200) { - debugLog('Telemetry sent successfully') - } else { - debugLog(`Telemetry request returned status ${res.statusCode}`) - } - resolve() - }) - }) - - req.on('error', err => { - debugLog(`Telemetry request failed: ${err.message}`) - resolve() - }) - - req.on('timeout', () => { - debugLog('Telemetry request timed out') - req.destroy() - resolve() - }) - - req.write(body) - req.end() - }) -} - -/** - * Track a patch lifecycle event. - * - * This function is non-blocking and will never throw errors. - * Telemetry failures are logged in debug mode but don't affect CLI operation. - * - * @param options - Event tracking options. - * @returns Promise that resolves when the event is sent (or immediately if telemetry is disabled). - * - * @example - * ```typescript - * // Track successful patch application. - * await trackPatchEvent({ - * eventType: 'patch_applied', - * command: 'apply', - * metadata: { - * patches_count: 5, - * dry_run: false, - * }, - * }) - * - * // Track failed patch application. - * await trackPatchEvent({ - * eventType: 'patch_apply_failed', - * command: 'apply', - * error: new Error('Failed to apply patch'), - * metadata: { - * patches_count: 0, - * dry_run: false, - * }, - * }) - * ``` - */ -export async function trackPatchEvent(options: TrackPatchEventOptions): Promise { - if (isTelemetryDisabled()) { - debugLog('Telemetry is disabled, skipping event') - return - } - - try { - const event = buildTelemetryEvent(options) - await sendTelemetryEvent(event, options.apiToken, options.orgSlug) - } catch (err) { - // Telemetry should never block CLI operations. - debugLog(`Failed to track event: ${err instanceof Error ? err.message : String(err)}`) - } -} - -/** - * Convenience function to track a successful patch application. - */ -export async function trackPatchApplied( - patchesCount: number, - dryRun: boolean, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_applied', - command: 'apply', - metadata: { - patches_count: patchesCount, - dry_run: dryRun, - }, - apiToken, - orgSlug, - }) -} - -/** - * Convenience function to track a failed patch application. - */ -export async function trackPatchApplyFailed( - error: Error, - dryRun: boolean, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_apply_failed', - command: 'apply', - error, - metadata: { - dry_run: dryRun, - }, - apiToken, - orgSlug, - }) -} - -/** - * Convenience function to track a successful patch removal. - */ -export async function trackPatchRemoved( - removedCount: number, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_removed', - command: 'remove', - metadata: { - removed_count: removedCount, - }, - apiToken, - orgSlug, - }) -} - -/** - * Convenience function to track a failed patch removal. - */ -export async function trackPatchRemoveFailed( - error: Error, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_remove_failed', - command: 'remove', - error, - apiToken, - orgSlug, - }) -} - -/** - * Convenience function to track a successful patch rollback. - */ -export async function trackPatchRolledBack( - rolledBackCount: number, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_rolled_back', - command: 'rollback', - metadata: { - rolled_back_count: rolledBackCount, - }, - apiToken, - orgSlug, - }) -} - -/** - * Convenience function to track a failed patch rollback. - */ -export async function trackPatchRollbackFailed( - error: Error, - apiToken?: string, - orgSlug?: string, -): Promise { - await trackPatchEvent({ - eventType: 'patch_rollback_failed', - command: 'rollback', - error, - apiToken, - orgSlug, - }) -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 28d5490..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "lib": ["ES2022"], - "moduleResolution": "node", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "allowSyntheticDefaultImports": true, - "composite": true, - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From c7d3eababe54a48162374ef0027a614a9701e66a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 3 Mar 2026 19:04:27 -0500 Subject: [PATCH 05/10] feat: add Windows support for Python crawler and CI test matrix Resolve runtime gaps on Windows: use find_python_command() to discover python3/python/py, add USERPROFILE fallback for home dir, gate Unix-only paths and add Windows Python install locations (APPDATA, LOCALAPPDATA, Program Files), and add Windows uv tools path. CI now runs tests on both ubuntu-latest and windows-latest. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 +- .../src/crawlers/python_crawler.rs | 185 ++++++++++++++---- 2 files changed, 152 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 631941b..14def44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,10 @@ permissions: jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -28,8 +31,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo- - name: Run clippy run: cargo clippy --workspace -- -D warnings diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 7037ea4..5de466f 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1,9 +1,29 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use super::types::{CrawledPackage, CrawlerOptions}; +// --------------------------------------------------------------------------- +// Python command discovery +// --------------------------------------------------------------------------- + +/// Find a working Python command on the system. +/// +/// Tries `python3`, `python`, and `py` (Windows launcher) in order, +/// returning the first one that responds to `--version`. +pub fn find_python_command() -> Option<&'static str> { + ["python3", "python", "py"].into_iter().find(|cmd| { + Command::new(cmd) + .args(["--version"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() + }) +} + /// Default batch size for crawling. const _DEFAULT_BATCH_SIZE: usize = 100; @@ -215,29 +235,33 @@ pub async fn get_global_python_site_packages() -> Vec { }; // 1. Ask Python for site-packages - if let Ok(output) = Command::new("python3") - .args([ - "-c", - "import site; print('\\n'.join(site.getsitepackages())); print(site.getusersitepackages())", - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let p = line.trim(); - if !p.is_empty() { - add_path(PathBuf::from(p), &mut seen, &mut results); + if let Some(python_cmd) = find_python_command() { + if let Ok(output) = Command::new(python_cmd) + .args([ + "-c", + "import site; print('\\n'.join(site.getsitepackages())); print(site.getusersitepackages())", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let p = line.trim(); + if !p.is_empty() { + add_path(PathBuf::from(p), &mut seen, &mut results); + } } } } } // 2. Well-known system paths - let home_dir = std::env::var("HOME").unwrap_or_else(|_| "~".to_string()); + let home_dir = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "~".to_string()); // Helper closure to scan base/lib/python3.*/[dist|site]-packages async fn scan_well_known( @@ -259,27 +283,29 @@ pub async fn get_global_python_site_packages() -> Vec { } } - // Debian/Ubuntu - scan_well_known(Path::new("/usr"), "dist-packages", &mut seen, &mut results).await; - scan_well_known(Path::new("/usr"), "site-packages", &mut seen, &mut results).await; - // Debian pip / most distros / macOS - scan_well_known( - Path::new("/usr/local"), - "dist-packages", - &mut seen, - &mut results, - ) - .await; - scan_well_known( - Path::new("/usr/local"), - "site-packages", - &mut seen, - &mut results, - ) - .await; - // pip --user - let user_local = PathBuf::from(&home_dir).join(".local"); - scan_well_known(&user_local, "site-packages", &mut seen, &mut results).await; + if !cfg!(windows) { + // Debian/Ubuntu + scan_well_known(Path::new("/usr"), "dist-packages", &mut seen, &mut results).await; + scan_well_known(Path::new("/usr"), "site-packages", &mut seen, &mut results).await; + // Debian pip / most distros / macOS + scan_well_known( + Path::new("/usr/local"), + "dist-packages", + &mut seen, + &mut results, + ) + .await; + scan_well_known( + Path::new("/usr/local"), + "site-packages", + &mut seen, + &mut results, + ) + .await; + // pip --user on Unix + let user_local = PathBuf::from(&home_dir).join(".local"); + scan_well_known(&user_local, "site-packages", &mut seen, &mut results).await; + } // macOS-specific if cfg!(target_os = "macos") { @@ -311,6 +337,51 @@ pub async fn get_global_python_site_packages() -> Vec { } } + // Windows-specific + if cfg!(windows) { + // pip --user on Windows: %APPDATA%\Python\PythonXY\site-packages + if let Ok(appdata) = std::env::var("APPDATA") { + let appdata_python = PathBuf::from(&appdata).join("Python"); + if let Ok(mut entries) = tokio::fs::read_dir(&appdata_python).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let p = appdata_python.join(entry.file_name()).join("site-packages"); + if tokio::fs::metadata(&p).await.is_ok() { + add_path(p, &mut seen, &mut results); + } + } + } + } + // Common Windows Python install locations + for base in &["C:\\Python", "C:\\Program Files\\Python"] { + if let Ok(mut entries) = tokio::fs::read_dir(base).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let sp = PathBuf::from(base) + .join(entry.file_name()) + .join("Lib") + .join("site-packages"); + if tokio::fs::metadata(&sp).await.is_ok() { + add_path(sp, &mut seen, &mut results); + } + } + } + } + // Microsoft Store / python.org via LocalAppData + if let Ok(local) = std::env::var("LOCALAPPDATA") { + let programs_python = PathBuf::from(&local).join("Programs").join("Python"); + if let Ok(mut entries) = tokio::fs::read_dir(&programs_python).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let sp = programs_python + .join(entry.file_name()) + .join("Lib") + .join("site-packages"); + if tokio::fs::metadata(&sp).await.is_ok() { + add_path(sp, &mut seen, &mut results); + } + } + } + } + } + // Conda let anaconda = PathBuf::from(&home_dir).join("anaconda3"); scan_well_known(&anaconda, "site-packages", &mut seen, &mut results).await; @@ -329,6 +400,16 @@ pub async fn get_global_python_site_packages() -> Vec { for m in uv_matches { add_path(m, &mut seen, &mut results); } + } else if cfg!(windows) { + // %LOCALAPPDATA%\uv\tools + if let Ok(local) = std::env::var("LOCALAPPDATA") { + let uv_base = PathBuf::from(local).join("uv").join("tools"); + let uv_matches = + find_python_dirs(&uv_base, &["*", "Lib", "site-packages"]).await; + for m in uv_matches { + add_path(m, &mut seen, &mut results); + } + } } else { let uv_base = PathBuf::from(&home_dir) .join(".local") @@ -688,6 +769,32 @@ mod tests { assert!(packages[0].namespace.is_none()); } + #[test] + fn test_find_python_command() { + // On any platform with Python installed, this should return Some + // In CI environments, Python is typically available + let cmd = find_python_command(); + // We don't assert Some because Python may not be installed, + // but if it is, the command should be valid + if let Some(c) = cmd { + assert!( + ["python3", "python", "py"].contains(&c), + "unexpected command: {c}" + ); + } + } + + #[test] + fn test_home_dir_detection() { + // Verify the fallback chain works: HOME -> USERPROFILE -> "~" + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "~".to_string()); + // On any CI or dev machine, we should get a real path, not "~" + assert_ne!(home, "~", "expected a real home directory"); + assert!(!home.is_empty()); + } + #[tokio::test] async fn test_find_by_purls_python() { let dir = tempfile::tempdir().unwrap(); From 005c5e415ac3f6c30bb518ce8086b0c6fd2313e3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Mar 2026 11:04:59 -0500 Subject: [PATCH 06/10] feat: add cargo publish, install script, clippy fixes, and expanded tests Add crates.io publishing to release workflow, a one-line install script, and README installation docs. Fix UTF-8 truncation bug in API client, apply clippy suggestions (is_some_and, strip_prefix, div_ceil, derive Default), and add comprehensive tests across API, package_json, and blob_fetcher modules. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 25 ++ Cargo.toml | 2 +- README.md | 43 +++ crates/socket-patch-cli/src/commands/get.rs | 6 +- .../socket-patch-cli/src/commands/rollback.rs | 1 + crates/socket-patch-cli/src/commands/scan.rs | 4 +- .../socket-patch-core/src/api/blob_fetcher.rs | 84 +++++- crates/socket-patch-core/src/api/client.rs | 238 ++++++++++++++- crates/socket-patch-core/src/api/types.rs | 151 ++++++++++ .../src/manifest/recovery.rs | 17 +- .../src/package_json/detect.rs | 102 +++++++ .../src/package_json/find.rs | 283 ++++++++++++++++++ .../src/package_json/update.rs | 102 +++++++ crates/socket-patch-core/src/patch/apply.rs | 4 +- .../socket-patch-core/src/patch/rollback.rs | 4 +- scripts/install.sh | 79 +++++ scripts/version-sync.sh | 4 + 17 files changed, 1113 insertions(+), 36 deletions(-) create mode 100755 scripts/install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8053cb1..4830043 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,31 @@ jobs: --generate-notes \ artifacts/* + cargo-publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable + with: + toolchain: stable + + - name: Publish socket-patch-core + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: cargo publish -p socket-patch-core + + - name: Wait for crates.io index update + run: sleep 30 + + - name: Publish socket-patch-cli + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: cargo publish -p socket-patch-cli + npm-publish: needs: build runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index a6da89e..2abc6a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" repository = "https://github.com/SocketDev/socket-patch" [workspace.dependencies] -socket-patch-core = { path = "crates/socket-patch-core" } +socket-patch-core = { path = "crates/socket-patch-core", version = "1.2.0" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 5f41bdd..995c37e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,43 @@ Apply security patches to npm dependencies without waiting for upstream fixes. ## Installation +### One-line install (recommended) + +```bash +curl -fsSL https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh +``` + +Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, and installs to `/usr/local/bin` or `~/.local/bin`. Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. + +
+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-gnu.tar.gz | tar xz +``` + +Then move the binary onto your `PATH`: + +```bash +sudo mv socket-patch /usr/local/bin/ +``` + +
+ +### npm + ```bash npx @socketsecurity/socket-patch ``` @@ -14,6 +51,12 @@ Or install globally: npm install -g @socketsecurity/socket-patch ``` +### Cargo + +```bash +cargo install socket-patch-cli +``` + ## Commands ### `apply` diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 662f320..4aa8a00 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -348,7 +348,7 @@ pub async fn run(args: GetArgs) -> i32 { if manifest .patches .get(&patch.purl) - .map_or(false, |p| p.uuid == patch.uuid) + .is_some_and(|p| p.uuid == patch.uuid) { println!(" [skip] {} (already in manifest)", patch.purl); patches_skipped += 1; @@ -588,10 +588,10 @@ async fn save_and_apply_patch( }) .collect(); - let added = !manifest + let added = manifest .patches .get(&patch.purl) - .map_or(false, |p| p.uuid == patch.uuid); + .is_none_or(|p| p.uuid != patch.uuid); manifest.patches.insert( patch.purl.clone(), diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 08784d1..c09f161 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -462,6 +462,7 @@ async fn rollback_patches_inner( } // Export for use by remove command +#[allow(clippy::too_many_arguments)] pub async fn rollback_patches( cwd: &Path, manifest_path: &Path, diff --git a/crates/socket-patch-cli/src/commands/scan.rs b/crates/socket-patch-cli/src/commands/scan.rs index 1e69761..81478aa 100644 --- a/crates/socket-patch-cli/src/commands/scan.rs +++ b/crates/socket-patch-cli/src/commands/scan.rs @@ -147,7 +147,7 @@ pub async fn run(args: ScanArgs) -> i32 { // Query API in batches let mut all_packages_with_patches: Vec = Vec::new(); let mut can_access_paid_patches = false; - let total_batches = (all_purls.len() + args.batch_size - 1) / args.batch_size; + let total_batches = all_purls.len().div_ceil(args.batch_size); if !args.json { eprint!("Querying API for patches... (batch 1/{total_batches})"); @@ -239,7 +239,7 @@ pub async fn run(args: ScanArgs) -> i32 { println!( "{} {} {} VULNERABILITIES", "PACKAGE".to_string() + &" ".repeat(33), - "PATCHES".to_string() + &" ".repeat(1), + "PATCHES".to_string() + " ", "SEVERITY".to_string() + &" ".repeat(8), ); println!("{}", "=".repeat(100)); diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index aac019e..8496e9e 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -61,10 +61,10 @@ pub async fn get_missing_blobs( /// /// * `manifest` – Patch manifest whose `afterHash` blobs to check. /// * `blobs_path` – Directory where blob files are stored (one file per -/// hash). +/// hash). /// * `client` – [`ApiClient`] used to fetch blobs from the server. /// * `on_progress` – Optional callback invoked before each download with -/// `(hash, 1-based index, total)`. +/// `(hash, 1-based index, total)`. pub async fn fetch_missing_blobs( manifest: &PatchManifest, blobs_path: &Path, @@ -450,4 +450,84 @@ mod tests { let output = format_fetch_result(&result); assert!(output.contains("... and 3 more")); } + + // ── Group 8: format edge cases ─────────────────────────────────── + + #[test] + fn test_format_only_downloaded() { + let result = FetchMissingBlobsResult { + total: 3, + downloaded: 3, + failed: 0, + skipped: 0, + results: vec![ + BlobFetchResult { hash: "a".repeat(64), success: true, error: None }, + BlobFetchResult { hash: "b".repeat(64), success: true, error: None }, + BlobFetchResult { hash: "c".repeat(64), success: true, error: None }, + ], + }; + let output = format_fetch_result(&result); + assert!(output.contains("Downloaded 3 blob(s)")); + assert!(!output.contains("Failed")); + } + + #[test] + fn test_format_short_hash() { + let result = FetchMissingBlobsResult { + total: 1, + downloaded: 0, + failed: 1, + skipped: 0, + results: vec![BlobFetchResult { + hash: "abc".into(), + success: false, + error: Some("not found".into()), + }], + }; + let output = format_fetch_result(&result); + // Hash is < 12 chars, should show full hash + assert!(output.contains("abc...")); + } + + #[test] + fn test_format_error_none() { + let result = FetchMissingBlobsResult { + total: 1, + downloaded: 0, + failed: 1, + skipped: 0, + results: vec![BlobFetchResult { + hash: "d".repeat(64), + success: false, + error: None, + }], + }; + let output = format_fetch_result(&result); + assert!(output.contains("unknown error")); + } + + #[test] + fn test_format_only_failed() { + let result = FetchMissingBlobsResult { + total: 2, + downloaded: 0, + failed: 2, + skipped: 0, + results: vec![ + BlobFetchResult { + hash: "a".repeat(64), + success: false, + error: Some("timeout".into()), + }, + BlobFetchResult { + hash: "b".repeat(64), + success: false, + error: Some("timeout".into()), + }, + ], + }; + let output = format_fetch_result(&result); + assert!(!output.contains("Downloaded")); + assert!(output.contains("Failed to download 2 blob(s)")); + } } diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index df14827..e1757e8 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -553,6 +553,16 @@ fn urlencoding_encode(input: &str) -> String { out } +/// Truncate a string to at most `max_chars` characters, appending "..." if truncated. +/// Unlike byte slicing (`&s[..n]`), this is safe for multi-byte UTF-8 characters. +fn truncate_to_chars(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let truncated: String = s.chars().take(max_chars).collect(); + format!("{}...", truncated) +} + /// Validate that a string is a 64-character hex string (SHA-256). fn is_valid_sha256_hex(s: &str) -> bool { s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) @@ -586,21 +596,13 @@ fn convert_search_result_to_batch_info(patch: PatchSearchResult) -> BatchPatchIn // Use first non-empty summary as title if title.is_empty() && !vuln.summary.is_empty() { - title = if vuln.summary.len() > 100 { - format!("{}...", &vuln.summary[..97]) - } else { - vuln.summary.clone() - }; + title = truncate_to_chars(&vuln.summary, 97); } } // Use description as fallback title if title.is_empty() && !patch.description.is_empty() { - title = if patch.description.len() > 100 { - format!("{}...", &patch.description[..97]) - } else { - patch.description.clone() - }; + title = truncate_to_chars(&patch.description, 97); } cve_ids.sort(); @@ -647,6 +649,7 @@ pub enum ApiError { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn test_urlencoding_basic() { @@ -682,8 +685,6 @@ mod tests { #[test] fn test_convert_search_result_to_batch_info() { - use std::collections::HashMap; - let mut vulns = HashMap::new(); vulns.insert( "GHSA-1234-5678-9abc".to_string(), @@ -721,4 +722,217 @@ mod tests { assert!(is_public); assert!(client.use_public_proxy); } + + // ── Group 6: convert_search_result_to_batch_info edge cases ────── + + fn make_vuln(summary: &str, severity: &str, cves: Vec<&str>) -> VulnerabilityResponse { + VulnerabilityResponse { + cves: cves.into_iter().map(String::from).collect(), + summary: summary.into(), + severity: severity.into(), + description: "desc".into(), + } + } + + fn make_patch( + vulns: HashMap, + description: &str, + ) -> PatchSearchResult { + PatchSearchResult { + uuid: "uuid-1".into(), + purl: "pkg:npm/test@1.0.0".into(), + published_at: "2024-01-01".into(), + description: description.into(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: vulns, + } + } + + #[test] + fn test_convert_no_vulnerabilities() { + let patch = make_patch(HashMap::new(), "A patch description"); + let info = convert_search_result_to_batch_info(patch); + assert!(info.cve_ids.is_empty()); + assert!(info.ghsa_ids.is_empty()); + assert_eq!(info.title, "A patch description"); + assert!(info.severity.is_none()); + } + + #[test] + fn test_convert_multiple_vulns_picks_highest_severity() { + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln("Medium vuln", "medium", vec!["CVE-2024-0001"]), + ); + vulns.insert( + "GHSA-2222".into(), + make_vuln("Critical vuln", "critical", vec!["CVE-2024-0002"]), + ); + let patch = make_patch(vulns, "desc"); + let info = convert_search_result_to_batch_info(patch); + assert_eq!(info.severity, Some("critical".into())); + } + + #[test] + fn test_convert_duplicate_cves_deduplicated() { + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln("Vuln A", "high", vec!["CVE-2024-0001"]), + ); + vulns.insert( + "GHSA-2222".into(), + make_vuln("Vuln B", "high", vec!["CVE-2024-0001"]), + ); + let patch = make_patch(vulns, "desc"); + let info = convert_search_result_to_batch_info(patch); + // Same CVE in both vulns should only appear once + let cve_count = info.cve_ids.iter().filter(|c| *c == "CVE-2024-0001").count(); + assert_eq!(cve_count, 1); + } + + #[test] + fn test_convert_title_truncated_at_100() { + let long_summary = "x".repeat(150); + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln(&long_summary, "high", vec![]), + ); + let patch = make_patch(vulns, "desc"); + let info = convert_search_result_to_batch_info(patch); + // Should be 97 chars + "..." = 100 chars + assert_eq!(info.title.len(), 100); + assert!(info.title.ends_with("...")); + } + + #[test] + fn test_convert_title_unicode_truncation() { + // Create a summary with multi-byte chars that would panic with byte slicing + // Each emoji is 4 bytes, so 30 emojis = 120 bytes but only 30 chars + let emoji_summary = "\u{1F600}".repeat(30); + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln(&emoji_summary, "high", vec![]), + ); + let patch = make_patch(vulns, "desc"); + // This should NOT panic (validates the UTF-8 truncation fix) + let info = convert_search_result_to_batch_info(patch); + assert!(!info.title.is_empty()); + + // Also test with description fallback + let patch2 = make_patch(HashMap::new(), &"\u{1F600}".repeat(120)); + let info2 = convert_search_result_to_batch_info(patch2); + assert!(info2.title.ends_with("...")); + } + + #[test] + fn test_convert_title_falls_back_to_description() { + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln("", "high", vec![]), + ); + let patch = make_patch(vulns, "Fallback desc"); + let info = convert_search_result_to_batch_info(patch); + assert_eq!(info.title, "Fallback desc"); + } + + #[test] + fn test_convert_empty_summary_and_description() { + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-1111".into(), + make_vuln("", "high", vec![]), + ); + let patch = make_patch(vulns, ""); + let info = convert_search_result_to_batch_info(patch); + assert!(info.title.is_empty()); + } + + #[test] + fn test_convert_cves_and_ghsas_sorted() { + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-cccc".into(), + make_vuln("V1", "high", vec!["CVE-2024-0003"]), + ); + vulns.insert( + "GHSA-aaaa".into(), + make_vuln("V2", "high", vec!["CVE-2024-0001"]), + ); + vulns.insert( + "GHSA-bbbb".into(), + make_vuln("V3", "high", vec!["CVE-2024-0002"]), + ); + let patch = make_patch(vulns, "desc"); + let info = convert_search_result_to_batch_info(patch); + // Both should be sorted alphabetically + let mut sorted_cves = info.cve_ids.clone(); + sorted_cves.sort(); + assert_eq!(info.cve_ids, sorted_cves); + let mut sorted_ghsas = info.ghsa_ids.clone(); + sorted_ghsas.sort(); + assert_eq!(info.ghsa_ids, sorted_ghsas); + } + + // ── Group 7: urlencoding + SHA256 edge cases ───────────────────── + + #[test] + fn test_urlencoding_unicode() { + // Multi-byte UTF-8: 'é' = 0xC3 0xA9 + let encoded = urlencoding_encode("café"); + assert_eq!(encoded, "caf%C3%A9"); + } + + #[test] + fn test_urlencoding_empty() { + assert_eq!(urlencoding_encode(""), ""); + } + + #[test] + fn test_urlencoding_all_safe_chars() { + // Unreserved chars should pass through + let safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"; + assert_eq!(urlencoding_encode(safe), safe); + } + + #[test] + fn test_urlencoding_slash_and_at() { + assert_eq!(urlencoding_encode("/"), "%2F"); + assert_eq!(urlencoding_encode("@"), "%40"); + } + + #[test] + fn test_sha256_uppercase_valid() { + let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + assert!(is_valid_sha256_hex(upper)); + } + + #[test] + fn test_sha256_65_chars_invalid() { + let too_long = "a".repeat(65); + assert!(!is_valid_sha256_hex(&too_long)); + } + + #[test] + fn test_sha256_63_chars_invalid() { + let too_short = "a".repeat(63); + assert!(!is_valid_sha256_hex(&too_short)); + } + + #[test] + fn test_sha256_empty_invalid() { + assert!(!is_valid_sha256_hex("")); + } + + #[test] + fn test_sha256_mixed_case_valid() { + let mixed = "aAbBcCdDeEfF0123456789aAbBcCdDeEfF0123456789aAbBcCdDeEfF01234567"; + assert_eq!(mixed.len(), 64); + assert!(is_valid_sha256_hex(mixed)); + } } diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs index 1623037..688bc7c 100644 --- a/crates/socket-patch-core/src/api/types.rs +++ b/crates/socket-patch-core/src/api/types.rs @@ -78,3 +78,154 @@ pub struct BatchSearchResponse { pub packages: Vec, pub can_access_paid_patches: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_patch_response_camel_case() { + let pr = PatchResponse { + uuid: "u1".into(), + purl: "pkg:npm/x@1".into(), + published_at: "2024-01-01".into(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: "desc".into(), + license: "MIT".into(), + tier: "free".into(), + }; + let json = serde_json::to_string(&pr).unwrap(); + assert!(json.contains("publishedAt")); + assert!(!json.contains("published_at")); + } + + #[test] + fn test_patch_response_deserialize() { + let json = r#"{ + "uuid": "u1", + "purl": "pkg:npm/x@1", + "publishedAt": "2024-01-01", + "files": {}, + "vulnerabilities": {}, + "description": "A patch", + "license": "MIT", + "tier": "free" + }"#; + let pr: PatchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(pr.uuid, "u1"); + assert_eq!(pr.published_at, "2024-01-01"); + } + + #[test] + fn test_patch_file_response_optional_fields() { + let pfr = PatchFileResponse { + before_hash: None, + after_hash: None, + socket_blob: None, + blob_content: None, + before_blob_content: None, + }; + let json = serde_json::to_string(&pfr).unwrap(); + let back: PatchFileResponse = serde_json::from_str(&json).unwrap(); + assert!(back.before_hash.is_none()); + assert!(back.after_hash.is_none()); + assert!(back.socket_blob.is_none()); + assert!(back.blob_content.is_none()); + assert!(back.before_blob_content.is_none()); + // Verify camelCase field names + assert!(json.contains("beforeHash")); + assert!(json.contains("afterHash")); + assert!(json.contains("socketBlob")); + assert!(json.contains("blobContent")); + assert!(json.contains("beforeBlobContent")); + } + + #[test] + fn test_search_response_camel_case() { + let sr = SearchResponse { + patches: Vec::new(), + can_access_paid_patches: true, + }; + let json = serde_json::to_string(&sr).unwrap(); + assert!(json.contains("canAccessPaidPatches")); + assert!(!json.contains("can_access_paid_patches")); + } + + #[test] + fn test_batch_search_response_roundtrip() { + let bsr = BatchSearchResponse { + packages: vec![BatchPackagePatches { + purl: "pkg:npm/x@1".into(), + patches: vec![BatchPatchInfo { + uuid: "u1".into(), + purl: "pkg:npm/x@1".into(), + tier: "free".into(), + cve_ids: vec!["CVE-2024-0001".into()], + ghsa_ids: vec!["GHSA-1111-2222-3333".into()], + severity: Some("high".into()), + title: "Test".into(), + }], + }], + can_access_paid_patches: false, + }; + let json = serde_json::to_string(&bsr).unwrap(); + let back: BatchSearchResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(back.packages.len(), 1); + assert_eq!(back.packages[0].patches.len(), 1); + assert!(!back.can_access_paid_patches); + } + + #[test] + fn test_batch_patch_info_camel_case() { + let bpi = BatchPatchInfo { + uuid: "u1".into(), + purl: "pkg:npm/x@1".into(), + tier: "free".into(), + cve_ids: vec!["CVE-2024-0001".into()], + ghsa_ids: vec!["GHSA-1111-2222-3333".into()], + severity: Some("high".into()), + title: "Test".into(), + }; + let json = serde_json::to_string(&bpi).unwrap(); + assert!(json.contains("cveIds")); + assert!(json.contains("ghsaIds")); + assert!(!json.contains("cve_ids")); + assert!(!json.contains("ghsa_ids")); + } + + #[test] + fn test_vulnerability_response_no_rename() { + // VulnerabilityResponse does NOT have rename_all, so fields are snake_case + let vr = VulnerabilityResponse { + cves: vec!["CVE-2024-0001".into()], + summary: "Test".into(), + severity: "high".into(), + description: "A vulnerability".into(), + }; + let json = serde_json::to_string(&vr).unwrap(); + // Without rename_all, field names stay as-is (already lowercase single-word) + assert!(json.contains("\"cves\"")); + assert!(json.contains("\"summary\"")); + assert!(json.contains("\"severity\"")); + assert!(json.contains("\"description\"")); + } + + #[test] + fn test_patch_search_result_roundtrip() { + let psr = PatchSearchResult { + uuid: "u1".into(), + purl: "pkg:npm/test@1.0.0".into(), + published_at: "2024-06-15".into(), + description: "A test patch".into(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::new(), + }; + let json = serde_json::to_string(&psr).unwrap(); + let back: PatchSearchResult = serde_json::from_str(&json).unwrap(); + assert_eq!(back.uuid, "u1"); + assert_eq!(back.published_at, "2024-06-15"); + assert!(json.contains("publishedAt")); + } +} diff --git a/crates/socket-patch-core/src/manifest/recovery.rs b/crates/socket-patch-core/src/manifest/recovery.rs index 4842452..e0fb498 100644 --- a/crates/socket-patch-core/src/manifest/recovery.rs +++ b/crates/socket-patch-core/src/manifest/recovery.rs @@ -86,6 +86,7 @@ pub type RefetchPatchFn = Box< pub type OnRecoveryEventFn = Box; /// Options for manifest recovery. +#[derive(Default)] pub struct RecoveryOptions { /// Optional function to refetch patch data from external source (e.g., database). /// Should return patch data or None if not found. @@ -95,14 +96,6 @@ pub struct RecoveryOptions { pub on_recovery_event: Option, } -impl Default for RecoveryOptions { - fn default() -> Self { - Self { - refetch_patch: None, - on_recovery_event: None, - } - } -} /// Recover and validate manifest with automatic repair of invalid patches. /// @@ -265,14 +258,14 @@ pub async fn recover_manifest( } else { // No UUID or no refetch function, can't recover discarded_patches.push(purl.clone()); - if uuid.is_none() { - emit(RecoveryEvent::DiscardedPatchNoUuid { + if let Some(uuid) = uuid { + emit(RecoveryEvent::DiscardedPatchNotFound { purl: purl.clone(), + uuid, }); } else { - emit(RecoveryEvent::DiscardedPatchNotFound { + emit(RecoveryEvent::DiscardedPatchNoUuid { purl: purl.clone(), - uuid: uuid.unwrap(), }); } } diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index 822e2bb..f898dcd 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -169,4 +169,106 @@ mod tests { let current = "socket-patch apply && echo done"; assert_eq!(generate_updated_postinstall(current), current); } + + // ── Group 4: expanded edge cases ───────────────────────────────── + + #[test] + fn test_is_postinstall_configured_str_invalid_json() { + let status = is_postinstall_configured_str("not json"); + assert!(!status.configured); + assert!(status.needs_update); + } + + #[test] + fn test_is_postinstall_configured_str_legacy_npx_pattern() { + let content = r#"{"scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent"}}"#; + let status = is_postinstall_configured_str(content); + // "npx @socketsecurity/socket-patch apply" contains "socket-patch apply" + assert!(status.configured); + assert!(!status.needs_update); + } + + #[test] + fn test_is_postinstall_configured_str_socket_dash_patch() { + let content = + r#"{"scripts":{"postinstall":"socket-patch apply --silent --ecosystems npm"}}"#; + let status = is_postinstall_configured_str(content); + assert!(status.configured); + assert!(!status.needs_update); + } + + #[test] + fn test_is_postinstall_configured_no_scripts() { + let pkg: serde_json::Value = serde_json::json!({"name": "test"}); + let status = is_postinstall_configured(&pkg); + assert!(!status.configured); + assert!(status.current_script.is_empty()); + } + + #[test] + fn test_is_postinstall_configured_no_postinstall() { + let pkg: serde_json::Value = serde_json::json!({ + "scripts": {"build": "tsc"} + }); + let status = is_postinstall_configured(&pkg); + assert!(!status.configured); + assert!(status.current_script.is_empty()); + } + + #[test] + fn test_update_object_creates_scripts() { + let mut pkg: serde_json::Value = serde_json::json!({"name": "test"}); + let (modified, new_script) = update_package_json_object(&mut pkg); + assert!(modified); + assert!(new_script.contains("socket patch apply")); + assert!(pkg.get("scripts").is_some()); + assert!(pkg["scripts"]["postinstall"].is_string()); + } + + #[test] + fn test_update_object_noop_when_configured() { + let mut pkg: serde_json::Value = serde_json::json!({ + "scripts": { + "postinstall": "socket patch apply --silent --ecosystems npm" + } + }); + let (modified, existing) = update_package_json_object(&mut pkg); + assert!(!modified); + assert!(existing.contains("socket patch apply")); + } + + #[test] + fn test_update_content_roundtrip_no_scripts() { + let content = r#"{"name": "test"}"#; + let (modified, new_content, old_script, new_script) = + update_package_json_content(content).unwrap(); + assert!(modified); + assert!(old_script.is_empty()); + assert!(new_script.contains("socket patch apply")); + // new_content should be valid JSON + let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); + assert!(parsed["scripts"]["postinstall"].is_string()); + } + + #[test] + fn test_update_content_already_configured() { + let content = r#"{"scripts":{"postinstall":"socket patch apply --silent --ecosystems npm"}}"#; + let (modified, _new_content, _old, _new) = + update_package_json_content(content).unwrap(); + assert!(!modified); + } + + #[test] + fn test_update_content_invalid_json() { + let result = update_package_json_content("not json"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid package.json")); + } + + #[test] + fn test_generate_whitespace_only() { + // Whitespace-only string should be treated as empty after trim + let result = generate_updated_postinstall(" \t "); + assert_eq!(result, "socket patch apply --silent --ecosystems npm"); + } } diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 714a91e..cfa30d2 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -320,3 +320,286 @@ async fn search_nested( Box::pin(search_nested(&full_path, root_pkg, depth + 1, results)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── Group 1: parse_pnpm_workspace_patterns ─────────────────────── + + #[test] + fn test_parse_pnpm_basic() { + let yaml = "packages:\n - packages/*"; + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); + } + + #[test] + fn test_parse_pnpm_multiple_patterns() { + let yaml = "packages:\n - packages/*\n - apps/*\n - tools/*"; + assert_eq!( + parse_pnpm_workspace_patterns(yaml), + vec!["packages/*", "apps/*", "tools/*"] + ); + } + + #[test] + fn test_parse_pnpm_quoted_patterns() { + let yaml = "packages:\n - 'packages/*'\n - \"apps/*\""; + assert_eq!( + parse_pnpm_workspace_patterns(yaml), + vec!["packages/*", "apps/*"] + ); + } + + #[test] + fn test_parse_pnpm_comments_interspersed() { + let yaml = "packages:\n # workspace packages\n - packages/*\n # apps\n - apps/*"; + assert_eq!( + parse_pnpm_workspace_patterns(yaml), + vec!["packages/*", "apps/*"] + ); + } + + #[test] + fn test_parse_pnpm_empty_content() { + assert!(parse_pnpm_workspace_patterns("").is_empty()); + } + + #[test] + fn test_parse_pnpm_no_packages_key() { + let yaml = "name: my-project\nversion: 1.0.0"; + assert!(parse_pnpm_workspace_patterns(yaml).is_empty()); + } + + #[test] + fn test_parse_pnpm_stops_at_next_section() { + let yaml = "packages:\n - packages/*\ncatalog:\n lodash: 4.17.21"; + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); + } + + #[test] + fn test_parse_pnpm_indented_key() { + // The parser uses `trimmed == "packages:"` so leading spaces should match + let yaml = " packages:\n - packages/*"; + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); + } + + #[test] + fn test_parse_pnpm_dash_only_line() { + let yaml = "packages:\n -\n - packages/*"; + // A bare "-" with no value should be skipped (empty after trim) + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); + } + + #[test] + fn test_parse_pnpm_glob_star_star() { + let yaml = "packages:\n - packages/**"; + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/**"]); + } + + // ── Group 2: workspace detection + file discovery ──────────────── + + #[tokio::test] + async fn test_detect_workspaces_npm_array() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, r#"{"workspaces": ["packages/*"]}"#) + .await + .unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::Npm)); + assert_eq!(config.patterns, vec!["packages/*"]); + } + + #[tokio::test] + async fn test_detect_workspaces_npm_object() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write( + &pkg, + r#"{"workspaces": {"packages": ["packages/*", "apps/*"]}}"#, + ) + .await + .unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::Npm)); + assert_eq!(config.patterns, vec!["packages/*", "apps/*"]); + } + + #[tokio::test] + async fn test_detect_workspaces_pnpm() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap(); + let pnpm = dir.path().join("pnpm-workspace.yaml"); + fs::write(&pnpm, "packages:\n - packages/*") + .await + .unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::Pnpm)); + assert_eq!(config.patterns, vec!["packages/*"]); + } + + #[tokio::test] + async fn test_detect_workspaces_none() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::None)); + assert!(config.patterns.is_empty()); + } + + #[tokio::test] + async fn test_detect_workspaces_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, "not valid json!!!").await.unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::None)); + } + + #[tokio::test] + async fn test_detect_workspaces_file_not_found() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("nonexistent.json"); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::None)); + } + + #[tokio::test] + async fn test_find_no_root_package_json() { + let dir = tempfile::tempdir().unwrap(); + let results = find_package_json_files(dir.path()).await; + assert!(results.is_empty()); + } + + #[tokio::test] + async fn test_find_root_only() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + assert_eq!(results.len(), 1); + assert!(results[0].is_root); + } + + #[tokio::test] + async fn test_find_npm_workspaces() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"workspaces": ["packages/*"]}"#, + ) + .await + .unwrap(); + let pkg_a = dir.path().join("packages").join("a"); + fs::create_dir_all(&pkg_a).await.unwrap(); + fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + // root + workspace member + assert_eq!(results.len(), 2); + assert!(results[0].is_root); + assert!(results[1].is_workspace); + } + + #[tokio::test] + async fn test_find_pnpm_workspaces() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) + .await + .unwrap(); + fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages:\n - packages/*", + ) + .await + .unwrap(); + let pkg_a = dir.path().join("packages").join("a"); + fs::create_dir_all(&pkg_a).await.unwrap(); + fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_root); + assert!(results[1].is_workspace); + } + + #[tokio::test] + async fn test_find_nested_skips_node_modules() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) + .await + .unwrap(); + let nm = dir.path().join("node_modules").join("lodash"); + fs::create_dir_all(&nm).await.unwrap(); + fs::write(nm.join("package.json"), r#"{"name":"lodash"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + // Only root, node_modules should be skipped + assert_eq!(results.len(), 1); + assert!(results[0].is_root); + } + + #[tokio::test] + async fn test_find_nested_depth_limit() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) + .await + .unwrap(); + // Create deeply nested package.json at depth 7 (> limit of 5) + let mut deep = dir.path().to_path_buf(); + for i in 0..7 { + deep = deep.join(format!("level{}", i)); + } + fs::create_dir_all(&deep).await.unwrap(); + fs::write(deep.join("package.json"), r#"{"name":"deep"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + // Only root (the deep one exceeds depth limit) + assert_eq!(results.len(), 1); + } + + #[tokio::test] + async fn test_find_workspace_double_glob() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"workspaces": ["apps/**"]}"#, + ) + .await + .unwrap(); + let nested = dir.path().join("apps").join("web").join("client"); + fs::create_dir_all(&nested).await.unwrap(); + fs::write(nested.join("package.json"), r#"{"name":"client"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + // root + recursively found workspace member + assert!(results.len() >= 2); + } + + #[tokio::test] + async fn test_find_workspace_exact_path() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"workspaces": ["packages/core"]}"#, + ) + .await + .unwrap(); + let core = dir.path().join("packages").join("core"); + fs::create_dir_all(&core).await.unwrap(); + fs::write(core.join("package.json"), r#"{"name":"core"}"#) + .await + .unwrap(); + let results = find_package_json_files(dir.path()).await; + assert_eq!(results.len(), 2); + } +} diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs index 92ec459..4c7486a 100644 --- a/crates/socket-patch-core/src/package_json/update.rs +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -105,3 +105,105 @@ pub async fn update_multiple_package_jsons( } results } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_update_file_not_found() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("nonexistent.json"); + let result = update_package_json(&missing, false).await; + assert_eq!(result.status, UpdateStatus::Error); + assert!(result.error.is_some()); + } + + #[tokio::test] + async fn test_update_already_configured() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write( + &pkg, + r#"{"name":"test","scripts":{"postinstall":"socket patch apply --silent --ecosystems npm"}}"#, + ) + .await + .unwrap(); + let result = update_package_json(&pkg, false).await; + assert_eq!(result.status, UpdateStatus::AlreadyConfigured); + } + + #[tokio::test] + async fn test_update_dry_run_does_not_write() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + let original = r#"{"name":"test","scripts":{"build":"tsc"}}"#; + fs::write(&pkg, original).await.unwrap(); + let result = update_package_json(&pkg, true).await; + assert_eq!(result.status, UpdateStatus::Updated); + // File should remain unchanged + let content = fs::read_to_string(&pkg).await.unwrap(); + assert_eq!(content, original); + } + + #[tokio::test] + async fn test_update_writes_file() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, r#"{"name":"test","scripts":{"build":"tsc"}}"#) + .await + .unwrap(); + let result = update_package_json(&pkg, false).await; + assert_eq!(result.status, UpdateStatus::Updated); + let content = fs::read_to_string(&pkg).await.unwrap(); + assert!(content.contains("socket patch apply")); + } + + #[tokio::test] + async fn test_update_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, "not json!!!").await.unwrap(); + let result = update_package_json(&pkg, false).await; + assert_eq!(result.status, UpdateStatus::Error); + assert!(result.error.is_some()); + } + + #[tokio::test] + async fn test_update_no_scripts_key() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap(); + let result = update_package_json(&pkg, false).await; + assert_eq!(result.status, UpdateStatus::Updated); + let content = fs::read_to_string(&pkg).await.unwrap(); + assert!(content.contains("postinstall")); + assert!(content.contains("socket patch apply")); + } + + #[tokio::test] + async fn test_update_multiple_mixed() { + let dir = tempfile::tempdir().unwrap(); + + let p1 = dir.path().join("a.json"); + fs::write(&p1, r#"{"name":"a"}"#).await.unwrap(); + + let p2 = dir.path().join("b.json"); + fs::write( + &p2, + r#"{"name":"b","scripts":{"postinstall":"socket patch apply --silent --ecosystems npm"}}"#, + ) + .await + .unwrap(); + + let p3 = dir.path().join("c.json"); + // Don't create p3 — file not found + + let paths: Vec<&Path> = vec![p1.as_path(), p2.as_path(), p3.as_path()]; + let results = update_multiple_package_jsons(&paths, false).await; + assert_eq!(results.len(), 3); + assert_eq!(results[0].status, UpdateStatus::Updated); + assert_eq!(results[1].status, UpdateStatus::AlreadyConfigured); + assert_eq!(results[2].status, UpdateStatus::Error); + } +} diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 8366b14..be535a2 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -44,8 +44,8 @@ pub struct ApplyResult { /// but we need relative paths like "lib/file.js" for the actual package directory. pub fn normalize_file_path(file_name: &str) -> &str { const PACKAGE_PREFIX: &str = "package/"; - if file_name.starts_with(PACKAGE_PREFIX) { - &file_name[PACKAGE_PREFIX.len()..] + if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) { + stripped } else { file_name } diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index c2e3930..3995a8b 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -44,8 +44,8 @@ pub struct RollbackResult { /// Normalize file path by removing the "package/" prefix if present. fn normalize_file_path(file_name: &str) -> &str { const PACKAGE_PREFIX: &str = "package/"; - if file_name.starts_with(PACKAGE_PREFIX) { - &file_name[PACKAGE_PREFIX.len()..] + if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) { + stripped } else { file_name } diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..de96848 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +# Socket Patch installer +# Usage: curl -fsSL https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh + +REPO="SocketDev/socket-patch" +BINARY="socket-patch" + +# Detect platform +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Darwin) + case "$ARCH" in + arm64) TARGET="aarch64-apple-darwin" ;; + x86_64) TARGET="x86_64-apple-darwin" ;; + *) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;; + esac + ;; + Linux) + case "$ARCH" in + x86_64) TARGET="x86_64-unknown-linux-musl" ;; + aarch64) TARGET="aarch64-unknown-linux-gnu" ;; + *) echo "Error: unsupported architecture: $ARCH" >&2; exit 1 ;; + esac + ;; + *) + echo "Error: unsupported OS: $OS" >&2 + exit 1 + ;; +esac + +# Detect downloader +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL -o "$1" "$2"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -qO "$1" "$2"; } +else + echo "Error: curl or wget is required" >&2 + exit 1 +fi + +# Pick install directory +if [ -w /usr/local/bin ]; then + INSTALL_DIR="/usr/local/bin" +else + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" +fi + +# Create temp dir with cleanup +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# Download and extract +URL="https://github.com/${REPO}/releases/latest/download/${BINARY}-${TARGET}.tar.gz" +echo "Downloading ${BINARY} for ${TARGET}..." +download "$TMPDIR/${BINARY}.tar.gz" "$URL" +tar xzf "$TMPDIR/${BINARY}.tar.gz" -C "$TMPDIR" + +# Install +install -m 755 "$TMPDIR/${BINARY}" "${INSTALL_DIR}/${BINARY}" +echo "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" + +# Print version +"${INSTALL_DIR}/${BINARY}" --version 2>/dev/null || true + +# Warn if not on PATH +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + echo "" + echo "Warning: ${INSTALL_DIR} is not on your PATH." + echo "Add it with:" + echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; +esac diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 7369ea2..4cfa4f4 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -9,6 +9,10 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$REPO_ROOT/Cargo.toml" rm -f "$REPO_ROOT/Cargo.toml.bak" +# Update socket-patch-core workspace dependency version (needed for cargo publish) +sed -i.bak "s/socket-patch-core = { path = \"crates\/socket-patch-core\", version = \".*\" }/socket-patch-core = { path = \"crates\/socket-patch-core\", version = \"$VERSION\" }/" "$REPO_ROOT/Cargo.toml" +rm -f "$REPO_ROOT/Cargo.toml.bak" + # Update npm package version pkg_json="$REPO_ROOT/npm/socket-patch/package.json" node -e " From ff6d7cf37d53889c6d46ebe738a170edf2e83ba1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Mar 2026 11:10:42 -0500 Subject: [PATCH 07/10] fix: fix Windows CI test failures for telemetry and Python crawler Use home_dir_string() helper (which checks USERPROFILE on Windows) in the sanitize_error_message test instead of only checking HOME. Use platform-appropriate venv directory layout in test_crawl_all_python. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/socket-patch-core/src/crawlers/python_crawler.rs | 6 +++++- crates/socket-patch-core/src/utils/telemetry.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 5de466f..f60bf2f 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -740,7 +740,11 @@ mod tests { async fn test_crawl_all_python() { let dir = tempfile::tempdir().unwrap(); let venv = dir.path().join(".venv"); - let sp = venv.join("lib").join("python3.11").join("site-packages"); + let sp = if cfg!(windows) { + venv.join("Lib").join("site-packages") + } else { + venv.join("lib").join("python3.11").join("site-packages") + }; tokio::fs::create_dir_all(&sp).await.unwrap(); // Create a dist-info dir with METADATA diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index 856d865..2d2cf88 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -488,7 +488,7 @@ mod tests { #[test] fn test_sanitize_error_message() { - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/testuser".to_string()); + let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string()); let msg = format!("Failed to read {home}/projects/secret/file.txt"); let sanitized = sanitize_error_message(&msg); assert!(sanitized.contains("~/projects/secret/file.txt")); From 74bee5430333bbffa336c3e72f19304c8b7a4c16 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Mar 2026 11:16:06 -0500 Subject: [PATCH 08/10] fix: merge telemetry env-var tests to prevent parallel race condition The two tests (test_is_telemetry_disabled_default and test_is_telemetry_disabled_when_set) mutated shared env vars and raced when run in parallel on Windows CI. Merge them into a single test that saves/restores the original values. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../socket-patch-core/src/utils/telemetry.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index 2d2cf88..d0892a9 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -470,20 +470,35 @@ pub async fn track_patch_rollback_failed( mod tests { use super::*; + /// Combined into a single test to avoid env-var races across parallel tests. #[test] - fn test_is_telemetry_disabled_default() { + fn test_is_telemetry_disabled() { + // Save originals + let orig_disabled = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); + let orig_vitest = std::env::var("VITEST").ok(); + + // Default: not disabled std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("VITEST"); assert!(!is_telemetry_disabled()); - } - #[test] - fn test_is_telemetry_disabled_when_set() { + // Disabled via "1" std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1"); assert!(is_telemetry_disabled()); + + // Disabled via "true" std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true"); assert!(is_telemetry_disabled()); - std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + + // Restore originals + match orig_disabled { + Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v), + None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"), + } + match orig_vitest { + Some(v) => std::env::set_var("VITEST", v), + None => std::env::remove_var("VITEST"), + } } #[test] From 8fdce07a36008c9834f365bcd31f81fab2f742cd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Mar 2026 11:41:27 -0500 Subject: [PATCH 09/10] feat: add e2e tests for npm and PyPI patch lifecycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full end-to-end tests that exercise the CLI against the public Socket API: - npm: minimist@1.2.2 patch (CVE-2021-44906, prototype pollution) - PyPI: pydantic-ai@0.0.36 patch (CVE-2026-25580, SSRF) Each test covers the complete lifecycle: get → list → rollback → apply → remove, plus a dry-run test per ecosystem. Tests are gated with #[ignore] and run in CI via a dedicated e2e job on ubuntu and macos. Also fixes a bug where patches with no beforeHash (new files added by a patch) were silently dropped from the manifest. The apply and rollback engines now handle empty beforeHash correctly: - apply: creates new files, skips beforeHash verification - rollback: deletes patch-created files instead of restoring from blob - get: includes files in manifest even when beforeHash is absent Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 49 +++ Cargo.lock | 2 + crates/socket-patch-cli/Cargo.toml | 4 + crates/socket-patch-cli/src/commands/get.rs | 19 +- crates/socket-patch-cli/tests/e2e_npm.rs | 268 ++++++++++++ crates/socket-patch-cli/tests/e2e_pypi.rs | 383 ++++++++++++++++++ crates/socket-patch-core/src/patch/apply.rs | 31 ++ .../socket-patch-core/src/patch/rollback.rs | 50 +++ 8 files changed, 802 insertions(+), 4 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_npm.rs create mode 100644 crates/socket-patch-cli/tests/e2e_pypi.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14def44..ae482d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,52 @@ jobs: - name: Run tests run: cargo test --workspace + + e2e: + needs: test + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + suite: e2e_npm + - os: ubuntu-latest + suite: e2e_pypi + - os: macos-latest + suite: e2e_npm + - os: macos-latest + suite: e2e_pypi + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable + with: + toolchain: stable + + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-e2e- + + - name: Setup Node.js + if: matrix.suite == 'e2e_npm' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + + - name: Setup Python + if: matrix.suite == 'e2e_pypi' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Run e2e tests + run: cargo test -p socket-patch-cli --test ${{ matrix.suite }} -- --ignored diff --git a/Cargo.lock b/Cargo.lock index 04f8a78..4a4482a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,10 +1222,12 @@ version = "1.2.0" dependencies = [ "clap", "dialoguer", + "hex", "indicatif", "regex", "serde", "serde_json", + "sha2", "socket-patch-core", "tempfile", "tokio", diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index ef4a3fc..917946e 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -21,3 +21,7 @@ indicatif = { workspace = true } uuid = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } + +[dev-dependencies] +sha2 = { workspace = true } +hex = { workspace = true } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 4aa8a00..30987be 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -550,13 +550,14 @@ async fn save_and_apply_patch( // Build and save patch record let mut files = HashMap::new(); for (file_path, file_info) in &patch.files { - if let (Some(ref before), Some(ref after)) = - (&file_info.before_hash, &file_info.after_hash) - { + if let Some(ref after) = file_info.after_hash { files.insert( file_path.clone(), PatchFileInfo { - before_hash: before.clone(), + before_hash: file_info + .before_hash + .clone() + .unwrap_or_default(), after_hash: after.clone(), }, ); @@ -570,6 +571,16 @@ async fn save_and_apply_patch( .ok(); } } + // Also store beforeHash blob if present (needed for rollback) + if let (Some(ref before_blob), Some(ref before_hash)) = + (&file_info.before_blob_content, &file_info.before_hash) + { + if let Ok(decoded) = base64_decode(before_blob) { + tokio::fs::write(blobs_dir.join(before_hash), &decoded) + .await + .ok(); + } + } } let vulnerabilities: HashMap = patch diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs new file mode 100644 index 0000000..03208af --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -0,0 +1,268 @@ +//! End-to-end tests for the npm patch lifecycle. +//! +//! These tests exercise the full CLI against the real Socket API, using the +//! **minimist@1.2.2** patch (UUID `80630680-4da6-45f9-bba8-b888e0ffd58c`), +//! which fixes CVE-2021-44906 (Prototype Pollution). +//! +//! # Prerequisites +//! - `npm` on PATH +//! - Network access to `patches-api.socket.dev` and `registry.npmjs.org` +//! +//! # Running +//! ```sh +//! cargo test -p socket-patch-cli --test e2e_npm -- --ignored +//! ``` + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; + +/// Git SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2. +const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10"; + +/// Git SHA-256 of the *patched* `index.js` after the security fix. +const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +fn has_command(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok() +} + +/// Compute Git SHA-256: `SHA256("blob \0" ++ content)`. +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +fn git_sha256_file(path: &Path) -> String { + let content = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + git_sha256(&content) +} + +/// Run the CLI binary with the given args, setting `cwd` as the working dir. +/// Returns `(exit_code, stdout, stderr)`. +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let out: Output = Command::new(binary()) + .args(args) + .current_dir(cwd) + .env_remove("SOCKET_API_TOKEN") // force public proxy (free-tier) + .output() + .expect("failed to execute socket-patch binary"); + + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + (code, stdout, stderr) +} + +fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { + let (code, stdout, stderr) = run(cwd, args); + assert_eq!( + code, 0, + "{context} failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + (stdout, stderr) +} + +fn npm_run(cwd: &Path, args: &[&str]) { + let out = Command::new("npm") + .args(args) + .current_dir(cwd) + .output() + .expect("failed to run npm"); + assert!( + out.status.success(), + "npm {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); +} + +/// Write a minimal package.json (avoids `npm init -y` which rejects temp dir +/// names that start with `.` or contain invalid characters). +fn write_package_json(cwd: &Path) { + std::fs::write( + cwd.join("package.json"), + r#"{"name":"e2e-test","version":"0.0.0","private":true}"#, + ) + .expect("write package.json"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Full lifecycle: get → verify → list → rollback → apply → remove. +#[test] +#[ignore] +fn test_npm_full_lifecycle() { + if !has_command("npm") { + eprintln!("SKIP: npm not found on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + + // -- Setup: create a project and install minimist@1.2.2 ---------------- + write_package_json(cwd); + npm_run(cwd, &["install", "minimist@1.2.2"]); + + let index_js = cwd.join("node_modules/minimist/index.js"); + assert!(index_js.exists(), "minimist/index.js must exist after npm install"); + + // Confirm the original file matches the expected before-hash. + assert_eq!( + git_sha256_file(&index_js), + BEFORE_HASH, + "freshly installed index.js should have the expected beforeHash" + ); + + // -- GET: download + apply patch --------------------------------------- + assert_run_ok(cwd, &["get", NPM_UUID], "get"); + + // Manifest should exist and contain the patch. + let manifest_path = cwd.join(".socket/manifest.json"); + assert!(manifest_path.exists(), ".socket/manifest.json should exist after get"); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + let patch = &manifest["patches"][NPM_PURL]; + assert!(patch.is_object(), "manifest should contain {NPM_PURL}"); + assert_eq!(patch["uuid"].as_str().unwrap(), NPM_UUID); + + // The file should now be patched. + assert_eq!( + git_sha256_file(&index_js), + AFTER_HASH, + "index.js should match afterHash after get" + ); + + // -- LIST: verify JSON output ------------------------------------------ + let (stdout, _) = assert_run_ok(cwd, &["list", "--json"], "list --json"); + let list: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let patches = list["patches"].as_array().expect("patches should be an array"); + assert_eq!(patches.len(), 1); + assert_eq!(patches[0]["uuid"].as_str().unwrap(), NPM_UUID); + assert_eq!(patches[0]["purl"].as_str().unwrap(), NPM_PURL); + + let vulns = patches[0]["vulnerabilities"] + .as_array() + .expect("vulnerabilities array"); + assert!(!vulns.is_empty(), "patch should report at least one vulnerability"); + + // Verify the vulnerability details match CVE-2021-44906 + let has_cve = vulns.iter().any(|v| { + v["cves"] + .as_array() + .map_or(false, |cves| cves.iter().any(|c| c == "CVE-2021-44906")) + }); + assert!(has_cve, "vulnerability list should include CVE-2021-44906"); + + // -- ROLLBACK: restore original file ----------------------------------- + assert_run_ok(cwd, &["rollback"], "rollback"); + + assert_eq!( + git_sha256_file(&index_js), + BEFORE_HASH, + "index.js should match beforeHash after rollback" + ); + + // -- APPLY: re-apply from manifest ------------------------------------ + assert_run_ok(cwd, &["apply"], "apply"); + + assert_eq!( + git_sha256_file(&index_js), + AFTER_HASH, + "index.js should match afterHash after re-apply" + ); + + // -- REMOVE: rollback + remove from manifest --------------------------- + assert_run_ok(cwd, &["remove", NPM_UUID], "remove"); + + // File should be back to original. + assert_eq!( + git_sha256_file(&index_js), + BEFORE_HASH, + "index.js should match beforeHash after remove" + ); + + // Manifest should have no patches left. + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + assert!( + manifest["patches"].as_object().unwrap().is_empty(), + "manifest should be empty after remove" + ); +} + +/// `apply --dry-run` should not modify files on disk. +#[test] +#[ignore] +fn test_npm_dry_run() { + if !has_command("npm") { + eprintln!("SKIP: npm not found on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + + write_package_json(cwd); + npm_run(cwd, &["install", "minimist@1.2.2"]); + + let index_js = cwd.join("node_modules/minimist/index.js"); + assert_eq!(git_sha256_file(&index_js), BEFORE_HASH); + + // Download the patch *without* applying. + assert_run_ok(cwd, &["get", NPM_UUID, "--no-apply"], "get --no-apply"); + + // File should still be original. + assert_eq!( + git_sha256_file(&index_js), + BEFORE_HASH, + "file should not change after get --no-apply" + ); + + // Dry-run should succeed but leave file untouched. + assert_run_ok(cwd, &["apply", "--dry-run"], "apply --dry-run"); + + assert_eq!( + git_sha256_file(&index_js), + BEFORE_HASH, + "file should not change after apply --dry-run" + ); + + // Real apply should work. + assert_run_ok(cwd, &["apply"], "apply"); + + assert_eq!( + git_sha256_file(&index_js), + AFTER_HASH, + "file should match afterHash after real apply" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs new file mode 100644 index 0000000..7756db3 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -0,0 +1,383 @@ +//! End-to-end tests for the PyPI patch lifecycle. +//! +//! These tests exercise the full CLI against the real Socket API, using the +//! **pydantic-ai@0.0.36** patch (UUID `725a5343-52ec-4290-b7ce-e1cec55878e1`), +//! which fixes CVE-2026-25580 (SSRF in URL Download Handling). +//! +//! # Prerequisites +//! - `python3` on PATH (with `venv` and `pip` modules) +//! - Network access to `patches-api.socket.dev` and `pypi.org` +//! +//! # Running +//! ```sh +//! cargo test -p socket-patch-cli --test e2e_pypi -- --ignored +//! ``` + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PYPI_UUID: &str = "725a5343-52ec-4290-b7ce-e1cec55878e1"; +const PYPI_PURL_PREFIX: &str = "pkg:pypi/pydantic-ai@0.0.36"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +fn has_python3() -> bool { + Command::new("python3") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Compute Git SHA-256: `SHA256("blob \0" ++ content)`. +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +fn git_sha256_file(path: &Path) -> String { + let content = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + git_sha256(&content) +} + +/// Run the CLI binary with the given args, setting `cwd` as the working dir. +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let out: Output = Command::new(binary()) + .args(args) + .current_dir(cwd) + .env_remove("SOCKET_API_TOKEN") // force public proxy (free-tier) + .output() + .expect("failed to execute socket-patch binary"); + + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + (code, stdout, stderr) +} + +fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { + let (code, stdout, stderr) = run(cwd, args); + assert_eq!( + code, 0, + "{context} failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + (stdout, stderr) +} + +/// Find the `site-packages` directory inside a venv. +/// +/// On Unix: `.venv/lib/python3.X/site-packages` +/// On Windows: `.venv/Lib/site-packages` +fn find_site_packages(cwd: &Path) -> PathBuf { + let venv = cwd.join(".venv"); + if cfg!(windows) { + let sp = venv.join("Lib").join("site-packages"); + assert!(sp.exists(), "site-packages not found at {}", sp.display()); + return sp; + } + // Unix: glob for python3.* directory + let lib = venv.join("lib"); + for entry in std::fs::read_dir(&lib).expect("read .venv/lib") { + let entry = entry.unwrap(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("python3.") { + let sp = entry.path().join("site-packages"); + if sp.exists() { + return sp; + } + } + } + panic!("site-packages not found under {}", lib.display()); +} + +/// Create a venv and install pydantic-ai (without transitive deps for speed). +fn setup_venv(cwd: &Path) { + let status = Command::new("python3") + .args(["-m", "venv", ".venv"]) + .current_dir(cwd) + .status() + .expect("failed to create venv"); + assert!(status.success(), "python3 -m venv failed"); + + let pip = if cfg!(windows) { + cwd.join(".venv/Scripts/pip") + } else { + cwd.join(".venv/bin/pip") + }; + + // Install both the meta-package (for dist-info that matches the PURL) + // and the slim package (for the actual Python source files). + // --no-deps keeps the install fast by skipping transitive dependencies. + let out = Command::new(&pip) + .args([ + "install", + "--no-deps", + "--disable-pip-version-check", + "pydantic-ai==0.0.36", + "pydantic-ai-slim==0.0.36", + ]) + .current_dir(cwd) + .output() + .expect("failed to run pip install"); + assert!( + out.status.success(), + "pip install failed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); +} + +/// Read the manifest and return the files map for the pydantic-ai patch. +/// Returns `(purl, files)` where files is `{ relative_path: { beforeHash, afterHash } }`. +fn read_patch_files(manifest_path: &Path) -> (String, serde_json::Value) { + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(manifest_path).unwrap()).unwrap(); + + let patches = manifest["patches"].as_object().expect("patches object"); + let (purl, patch) = patches + .iter() + .find(|(k, _)| k.starts_with(PYPI_PURL_PREFIX)) + .unwrap_or_else(|| panic!("no patch matching {PYPI_PURL_PREFIX} in manifest")); + + (purl.clone(), patch["files"].clone()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Full lifecycle: get → verify hashes → list → rollback → apply → remove. +#[test] +#[ignore] +fn test_pypi_full_lifecycle() { + if !has_python3() { + eprintln!("SKIP: python3 not found on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + + // -- Setup: create venv and install pydantic-ai@0.0.36 ---------------- + setup_venv(cwd); + + let site_packages = find_site_packages(cwd); + assert!( + site_packages.join("pydantic_ai").exists(), + "pydantic_ai package should be installed in site-packages" + ); + + // Record original hashes of all files that will be patched. + // We'll compare against these after rollback. + let files_to_check = [ + "pydantic_ai/messages.py", + "pydantic_ai/models/__init__.py", + "pydantic_ai/models/anthropic.py", + "pydantic_ai/models/gemini.py", + "pydantic_ai/models/openai.py", + ]; + let original_hashes: Vec<(String, String)> = files_to_check + .iter() + .map(|f| { + let path = site_packages.join(f); + let hash = if path.exists() { + git_sha256_file(&path) + } else { + String::new() // file doesn't exist yet (e.g., _ssrf.py) + }; + (f.to_string(), hash) + }) + .collect(); + + // -- GET: download + apply patch --------------------------------------- + assert_run_ok(cwd, &["get", PYPI_UUID], "get"); + + let manifest_path = cwd.join(".socket/manifest.json"); + assert!(manifest_path.exists(), ".socket/manifest.json should exist after get"); + + // Parse the manifest to get file hashes from the API. + let (purl, files_value) = read_patch_files(&manifest_path); + assert!( + purl.starts_with(PYPI_PURL_PREFIX), + "purl should start with {PYPI_PURL_PREFIX}, got {purl}" + ); + + let files = files_value.as_object().expect("files should be an object"); + assert!(!files.is_empty(), "patch should modify at least one file"); + + // Verify every file's hash matches the afterHash from the manifest. + for (rel_path, info) in files { + let after_hash = info["afterHash"] + .as_str() + .expect("afterHash should be a string"); + let full_path = site_packages.join(rel_path); + assert!( + full_path.exists(), + "patched file should exist: {}", + full_path.display() + ); + assert_eq!( + git_sha256_file(&full_path), + after_hash, + "hash mismatch for {rel_path} after get" + ); + } + + // -- LIST: verify JSON output ------------------------------------------ + let (stdout, _) = assert_run_ok(cwd, &["list", "--json"], "list --json"); + let list: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let patches = list["patches"].as_array().expect("patches array"); + assert_eq!(patches.len(), 1, "should have exactly one patch"); + assert_eq!(patches[0]["uuid"].as_str().unwrap(), PYPI_UUID); + + // Verify vulnerability + let vulns = patches[0]["vulnerabilities"] + .as_array() + .expect("vulnerabilities array"); + assert!(!vulns.is_empty(), "should have vulnerability info"); + let has_cve = vulns.iter().any(|v| { + v["cves"] + .as_array() + .map_or(false, |cves| cves.iter().any(|c| c == "CVE-2026-25580")) + }); + assert!(has_cve, "vulnerability list should include CVE-2026-25580"); + + // -- ROLLBACK: restore original files ---------------------------------- + assert_run_ok(cwd, &["rollback"], "rollback"); + + // Verify files are restored to their original state. + for (rel_path, info) in files { + let before_hash = info["beforeHash"].as_str().unwrap_or(""); + let full_path = site_packages.join(rel_path); + + if before_hash.is_empty() { + // New file — should be deleted after rollback. + assert!( + !full_path.exists(), + "new file {rel_path} should be removed after rollback" + ); + } else { + // Existing file — hash should match beforeHash. + assert_eq!( + git_sha256_file(&full_path), + before_hash, + "{rel_path} should match beforeHash after rollback" + ); + } + } + + // Also verify against our originally recorded hashes. + for (rel_path, orig_hash) in &original_hashes { + if orig_hash.is_empty() { + continue; // file didn't exist before + } + let full_path = site_packages.join(rel_path); + if full_path.exists() { + assert_eq!( + git_sha256_file(&full_path), + *orig_hash, + "{rel_path} should match original hash after rollback" + ); + } + } + + // -- APPLY: re-apply from manifest ------------------------------------ + assert_run_ok(cwd, &["apply"], "apply"); + + for (rel_path, info) in files { + let after_hash = info["afterHash"] + .as_str() + .expect("afterHash should be a string"); + let full_path = site_packages.join(rel_path); + assert_eq!( + git_sha256_file(&full_path), + after_hash, + "{rel_path} should match afterHash after re-apply" + ); + } + + // -- REMOVE: rollback + remove from manifest --------------------------- + assert_run_ok(cwd, &["remove", PYPI_UUID], "remove"); + + // Manifest should be empty. + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + assert!( + manifest["patches"].as_object().unwrap().is_empty(), + "manifest should be empty after remove" + ); +} + +/// `apply --dry-run` should not modify files on disk. +#[test] +#[ignore] +fn test_pypi_dry_run() { + if !has_python3() { + eprintln!("SKIP: python3 not found on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + + setup_venv(cwd); + + let site_packages = find_site_packages(cwd); + + // Record original hashes. + let messages_py = site_packages.join("pydantic_ai/messages.py"); + assert!(messages_py.exists()); + let original_hash = git_sha256_file(&messages_py); + + // Download without applying. + assert_run_ok(cwd, &["get", PYPI_UUID, "--no-apply"], "get --no-apply"); + + // File should be unchanged. + assert_eq!( + git_sha256_file(&messages_py), + original_hash, + "file should not change after get --no-apply" + ); + + // Dry-run should leave file untouched. + assert_run_ok(cwd, &["apply", "--dry-run"], "apply --dry-run"); + assert_eq!( + git_sha256_file(&messages_py), + original_hash, + "file should not change after apply --dry-run" + ); + + // Real apply should work. + assert_run_ok(cwd, &["apply"], "apply"); + + // Read afterHash from manifest to verify. + let manifest_path = cwd.join(".socket/manifest.json"); + let (_, files_value) = read_patch_files(&manifest_path); + let files = files_value.as_object().unwrap(); + let after_hash = files["pydantic_ai/messages.py"]["afterHash"] + .as_str() + .unwrap(); + assert_eq!( + git_sha256_file(&messages_py), + after_hash, + "file should match afterHash after real apply" + ); +} diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index be535a2..9bf1256 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -60,8 +60,21 @@ pub async fn verify_file_patch( let normalized = normalize_file_path(file_name); let filepath = pkg_path.join(normalized); + let is_new_file = file_info.before_hash.is_empty(); + // Check if file exists if tokio::fs::metadata(&filepath).await.is_err() { + // New files (empty beforeHash) are expected to not exist yet. + if is_new_file { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::Ready, + message: None, + current_hash: None, + expected_hash: None, + target_hash: Some(file_info.after_hash.clone()), + }; + } return VerifyResult { file: file_name.to_string(), status: VerifyStatus::NotFound, @@ -99,6 +112,19 @@ pub async fn verify_file_patch( }; } + // New files (empty beforeHash) with existing content that doesn't match + // afterHash: treat as Ready (force overwrite). + if is_new_file { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::Ready, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: Some(file_info.after_hash.clone()), + }; + } + // Check if matches expected before hash if current_hash != file_info.before_hash { return VerifyResult { @@ -132,6 +158,11 @@ pub async fn apply_file_patch( let normalized = normalize_file_path(file_name); let filepath = pkg_path.join(normalized); + // Create parent directories if needed (e.g., new files added by a patch) + if let Some(parent) = filepath.parent() { + tokio::fs::create_dir_all(parent).await?; + } + // Write the patched content tokio::fs::write(&filepath, patched_content).await?; diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index 3995a8b..74bdbea 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -66,6 +66,44 @@ pub async fn verify_file_rollback( let normalized = normalize_file_path(file_name); let filepath = pkg_path.join(normalized); + let is_new_file = file_info.before_hash.is_empty(); + + // For new files (empty beforeHash), rollback means deleting the file. + if is_new_file { + if tokio::fs::metadata(&filepath).await.is_err() { + // File already doesn't exist — already rolled back. + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::AlreadyOriginal, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + let current_hash = compute_file_git_sha256(&filepath).await.unwrap_or_default(); + if current_hash == file_info.after_hash { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::Ready, + message: None, + current_hash: Some(current_hash), + expected_hash: None, + target_hash: None, + }; + } + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::HashMismatch, + message: Some( + "File has been modified after patching. Cannot safely rollback.".to_string(), + ), + current_hash: Some(current_hash), + expected_hash: Some(file_info.after_hash.clone()), + target_hash: None, + }; + } + // Check if file exists if tokio::fs::metadata(&filepath).await.is_err() { return VerifyRollbackResult { @@ -248,6 +286,18 @@ pub async fn rollback_package_patch( } } + // New files (empty beforeHash): delete instead of restoring. + if file_info.before_hash.is_empty() { + let normalized = normalize_file_path(file_name); + let filepath = pkg_path.join(normalized); + if let Err(e) = tokio::fs::remove_file(&filepath).await { + result.error = Some(format!("Failed to delete {}: {}", file_name, e)); + return result; + } + result.files_rolled_back.push(file_name.clone()); + continue; + } + // Read original content from blobs let blob_path = blobs_path.join(&file_info.before_hash); let original_content = match tokio::fs::read(&blob_path).await { From 68414ddb33ffa81c5e8bef32ecc3a1334058fd52 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Mar 2026 12:01:33 -0500 Subject: [PATCH 10/10] feat: use crates.io trusted publishing via OIDC instead of static token Replace CRATES_IO_TOKEN secret with rust-lang/crates-io-auth-action, which exchanges a GitHub OIDC token for a short-lived crates.io publish token. This eliminates the need to manage long-lived API secrets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4830043..90f44a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,6 +107,9 @@ jobs: cargo-publish: needs: build runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -116,17 +119,16 @@ jobs: with: toolchain: stable + - name: Authenticate with crates.io + uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3 + - name: Publish socket-patch-core - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} run: cargo publish -p socket-patch-core - name: Wait for crates.io index update run: sleep 30 - name: Publish socket-patch-cli - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} run: cargo publish -p socket-patch-cli npm-publish: