diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 0aa06e5..e30d6f9 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -1,4 +1,5 @@ -name: "CLA Assistant" +name: CLA + on: issue_comment: types: [created] @@ -10,88 +11,8 @@ permissions: statuses: write jobs: - CLAAssistant: - runs-on: ubuntu-24.04 - steps: - - name: Generate token from GitHub App - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: blacklanternsecurity - - - name: Check all committers against org and allowlist - id: cla-check - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - if [ "${{ github.event_name }}" = "pull_request_target" ]; then - PR_NUM="${{ github.event.pull_request.number }}" - else - PR_NUM="${{ github.event.issue.number }}" - fi - - COMMITTERS=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM/commits" --paginate --jq '.[].author.login' | sort -u) - ALL_EXEMPT=true - - for LOGIN in $COMMITTERS; do - # treat commits with no associated GitHub login as non-exempt - if [ -z "$LOGIN" ] || [ "$LOGIN" = "null" ]; then - echo "Unknown committer (no GitHub login) — not exempt" - ALL_EXEMPT=false - break - fi - - EXEMPT=false - - # check if account type is Bot (GitHub App accounts) - AUTHOR_TYPE=$(gh api "users/${LOGIN}" --jq '.type' 2>/dev/null || echo "Unknown") - if [ "$AUTHOR_TYPE" = "Bot" ]; then - echo "$LOGIN is a Bot account — exempt" - EXEMPT=true - fi - - # check org membership - if [ "$EXEMPT" = "false" ]; then - if gh api "orgs/blacklanternsecurity/members/$LOGIN" > /dev/null 2>&1; then - echo "$LOGIN is an org member — exempt" - EXEMPT=true - fi - fi - - if [ "$EXEMPT" = "false" ]; then - echo "$LOGIN is not exempt — CLA required" - ALL_EXEMPT=false - break - fi - done - - echo "all_exempt=$ALL_EXEMPT" >> "$GITHUB_OUTPUT" - - - name: Skip CLA when all committers are exempt - if: steps.cla-check.outputs.all_exempt == 'true' && github.event_name == 'pull_request_target' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api --method POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \ - -f state=success \ - -f context="CLAAssistant" \ - -f description="CLA check skipped — all committers are org members or bots" - - - name: "CLA Assistant" - if: | - (steps.cla-check.outputs.all_exempt != 'true') && - ((github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target') - uses: contributor-assistant/github-action@v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} - with: - path-to-signatures: "signatures/version1/cla.json" - path-to-document: "https://github.com/blacklanternsecurity/CLA/blob/main/ICLA.md" - branch: "main" - allowlist: "dependabot[bot],github-actions[bot],renovate[bot]" - remote-organization-name: "blacklanternsecurity" - remote-repository-name: "CLA" - lock-pullrequest-aftermerge: "false" + cla: + uses: blacklanternsecurity/release-workflows/.github/workflows/cla-reusable.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 1730bc4..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Dependabot auto-merge -on: pull_request - -permissions: {} - -jobs: - auto-merge: - permissions: - contents: write - pull-requests: write - checks: read - uses: blacklanternsecurity/CLA/.github/workflows/dependabot-auto-merge.yml@e532142ee9e7322888f6edc80d9a89e2f8c0d96d # 2026-10-03 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..52d51fc --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,92 @@ +name: Publish + +on: + push: + branches: [stable] + +permissions: {} + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + check: + permissions: + contents: read + actions: read + pull-requests: read + uses: blacklanternsecurity/release-workflows/.github/workflows/release-check.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + secrets: + BLS_RELEASE_PRIVATE_KEY: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} + + tests: + needs: check + if: needs.check.outputs.release == 'true' + permissions: + contents: read + uses: ./.github/workflows/tests.yml + + rust-tests: + needs: check + if: needs.check.outputs.release == 'true' + permissions: + contents: read + uses: ./.github/workflows/rust-tests.yml + + wheels: + needs: [tests, rust-tests] + permissions: + contents: read + uses: blacklanternsecurity/release-workflows/.github/workflows/maturin-wheels.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + with: + # scripts/build-openssl.sh builds on Linux only. + targets: '{"linux": ["x86_64", "x86", "aarch64", "armv7", "s390x", "ppc64le"], "musllinux": ["x86_64", "x86", "aarch64", "armv7"]}' + before-script-linux: scripts/wheel-container-setup.sh + env: '{"PYO3_USE_ABI3_FORWARD_COMPATIBILITY": "1"}' + + crates: + needs: [check, pypi] + if: needs.check.outputs.release == 'true' + permissions: + contents: read + id-token: write + uses: blacklanternsecurity/release-workflows/.github/workflows/crates.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + with: + version: ${{ needs.check.outputs.version }} + setup-script: scripts/build-openssl.sh + + pypi: + needs: wheels + runs-on: ubuntu-24.04 + environment: release + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ needs.wheels.outputs.artifacts }} + path: dist + merge-multiple: true + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/* + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + skip-existing: true + + release: + needs: [check, wheels, crates, pypi] + if: needs.check.outputs.release == 'true' + permissions: + contents: read + uses: blacklanternsecurity/release-workflows/.github/workflows/publish.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + with: + tag: ${{ needs.check.outputs.tag }} + version: ${{ needs.check.outputs.version }} + artifacts: ${{ needs.wheels.outputs.artifacts }} + assets: dist/* + secrets: + BLS_RELEASE_PRIVATE_KEY: ${{ secrets.BLS_RELEASE_PRIVATE_KEY }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml deleted file mode 100644 index 15d14a2..0000000 --- a/.github/workflows/python-tests.yml +++ /dev/null @@ -1,273 +0,0 @@ -name: Python Tests - -on: - pull_request: - push: - branches: - - stable - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - python-lint: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.13" - - name: Install ruff - run: pip install ruff==0.15.10 - - name: ruff check - run: ruff check - - name: ruff format --check - run: ruff format --check - - python-tests: - needs: python-lint - runs-on: ubuntu-24.04 - strategy: - matrix: - python-version: - - "3.11" - - "3.12" - - "3.13" - - "3.14" - - "3.15" - - steps: - - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v2 - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache OpenSSL build - uses: actions/cache@v6 - with: - path: vendor/openssl/install - key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} - - - name: Build custom OpenSSL - run: ./scripts/build-openssl.sh - - - name: Create virtualenv - run: python -m venv .venv - - - name: Build Python extension - env: - PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" - run: | - source .venv/bin/activate - # maturin 1.13+ installs `[dependency-groups]` via - # `pip install --group`, which requires pip >= 25.1. The - # Python versions shipped with actions/setup-python ship - # older pip (23.x on 3.10); upgrade first so `develop` - # doesn't fail on an unknown `--group` flag. - pip install --upgrade pip - pip install maturin - maturin develop --release - - - name: Verify module - run: | - source .venv/bin/activate - python -c "import blasthttp; print(f'blasthttp {blasthttp.__version__}')" - - - name: Install test dependencies - run: | - source .venv/bin/activate - pip install 'pytest>=8' 'pytest-asyncio>=0.23' - - - name: Run Python tests - run: | - source .venv/bin/activate - pytest tests/python/ -v - - version-check: - needs: python-tests - if: github.event_name == 'push' && github.ref == 'refs/heads/stable' - runs-on: ubuntu-24.04 - outputs: - new_version: ${{ steps.check.outputs.new_version }} - steps: - - uses: actions/checkout@v7 - - name: Check if version already on PyPI - id: check - run: | - VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/') - echo "version=$VERSION" - if curl -sf -o /dev/null "https://pypi.org/pypi/blasthttp/$VERSION/json"; then - echo "new_version=no" >> "$GITHUB_OUTPUT" - echo "Version $VERSION already on PyPI — skipping wheel builds" - else - echo "new_version=yes" >> "$GITHUB_OUTPUT" - echo "Version $VERSION not on PyPI — will build and publish" - fi - - linux-build: - needs: version-check - if: needs.version-check.outputs.new_version == 'yes' - runs-on: ${{ matrix.platform.runner }} - strategy: - matrix: - platform: - - runner: ubuntu-24.04 - target: x86_64 - - runner: ubuntu-24.04 - target: x86 - - runner: ubuntu-24.04 - target: aarch64 - - runner: ubuntu-24.04 - target: armv7 - - runner: ubuntu-24.04 - target: s390x - - runner: ubuntu-24.04 - target: ppc64le - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: 3.x - - name: Build wheels - uses: PyO3/maturin-action@v1 - env: - PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" - with: - target: ${{ matrix.platform.target }} - args: --release --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13 -i python3.14 -i python3.15 - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - manylinux: manylinux_2_28 - before-script-linux: | - set -e - # Install OpenSSL build dependencies - if command -v apk >/dev/null 2>&1; then - apk add --no-cache perl make gcc musl-dev linux-headers curl bash - elif command -v yum >/dev/null 2>&1; then - yum install -y perl-core 2>/dev/null || true - elif command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y perl make gcc curl 2>/dev/null || true - fi - # Build custom OpenSSL with weak cipher support - ./scripts/build-openssl.sh - # Override target-specific OPENSSL_DIR env vars set by maturin containers - # (openssl-sys checks _OPENSSL_DIR before OPENSSL_DIR) - OPENSSL_INSTALL="$(pwd)/vendor/openssl/install" - export OPENSSL_DIR="$OPENSSL_INSTALL" - export OPENSSL_STATIC=1 - export OPENSSL_NO_VENDOR=1 - for var in $(env | grep '_OPENSSL_DIR=' | cut -d= -f1); do - export "$var=$OPENSSL_INSTALL" - done - - name: Upload wheels - uses: actions/upload-artifact@v7 - with: - name: wheels-linux-${{ matrix.platform.target }} - path: dist - - musllinux-build: - needs: version-check - if: needs.version-check.outputs.new_version == 'yes' - runs-on: ${{ matrix.platform.runner }} - strategy: - matrix: - platform: - - runner: ubuntu-24.04 - target: x86_64 - - runner: ubuntu-24.04 - target: x86 - - runner: ubuntu-24.04 - target: aarch64 - - runner: ubuntu-24.04 - target: armv7 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: 3.x - - name: Build wheels - uses: PyO3/maturin-action@v1 - env: - PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" - with: - target: ${{ matrix.platform.target }} - args: --release --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13 -i python3.14 -i python3.15 - sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - manylinux: musllinux_1_2 - before-script-linux: | - set -e - # Install OpenSSL build dependencies - if command -v apk >/dev/null 2>&1; then - apk add --no-cache perl make gcc musl-dev linux-headers curl bash - elif command -v yum >/dev/null 2>&1; then - yum install -y perl-core 2>/dev/null || true - elif command -v apt-get >/dev/null 2>&1; then - apt-get update && apt-get install -y perl make gcc curl 2>/dev/null || true - fi - # Build custom OpenSSL with weak cipher support - ./scripts/build-openssl.sh - # Override target-specific OPENSSL_DIR env vars set by maturin containers - OPENSSL_INSTALL="$(pwd)/vendor/openssl/install" - export OPENSSL_DIR="$OPENSSL_INSTALL" - export OPENSSL_STATIC=1 - export OPENSSL_NO_VENDOR=1 - for var in $(env | grep '_OPENSSL_DIR=' | cut -d= -f1); do - export "$var=$OPENSSL_INSTALL" - done - - name: Upload wheels - uses: actions/upload-artifact@v7 - with: - name: wheels-musllinux-${{ matrix.platform.target }} - path: dist - - sdist-build: - needs: version-check - runs-on: ubuntu-24.04 - if: needs.version-check.outputs.new_version == 'yes' - steps: - - uses: actions/checkout@v7 - - name: Build sdist - uses: PyO3/maturin-action@v1 - with: - command: sdist - args: --out dist - - name: Upload sdist - uses: actions/upload-artifact@v7 - with: - name: wheels-sdist - path: dist - - release: - name: Release - needs: - - linux-build - - musllinux-build - - sdist-build - runs-on: ubuntu-24.04 - permissions: - id-token: write - contents: write - attestations: write - steps: - - uses: actions/download-artifact@v8 - - name: Generate artifact attestation - if: ${{ !github.event.repository.private }} - uses: actions/attest-build-provenance@v4 - with: - subject-path: 'wheels-*/*' - - name: Publish to PyPI - uses: PyO3/maturin-action@v1 - env: - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - with: - command: upload - args: --non-interactive --skip-existing wheels-*/* diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index c96c2de..2a32abe 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -1,29 +1,32 @@ name: Rust Tests on: - push: - branches: [stable] pull_request: + workflow_call: + +permissions: + contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: rust-tests-${{ github.workflow_ref }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: rust-tests: runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v2 + uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 - name: Check formatting run: cargo fmt --all -- --check - name: Cache OpenSSL build - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: vendor/openssl/install key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} @@ -32,7 +35,7 @@ jobs: run: ./scripts/build-openssl.sh - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" @@ -41,40 +44,3 @@ jobs: - name: Run tests run: cargo test --locked --verbose - - rust-publish: - needs: rust-tests - runs-on: ubuntu-24.04 - if: github.event_name == 'push' && github.ref == 'refs/heads/stable' - - steps: - - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v2 - - - name: Cache OpenSSL build - uses: actions/cache@v6 - with: - path: vendor/openssl/install - key: openssl-3.3.2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/build-openssl.sh') }} - - - name: Build custom OpenSSL - run: ./scripts/build-openssl.sh - - - name: Check if version already published - id: version-check - run: | - LOCAL_VERSION=$(cargo metadata --no-deps --format-version 1 | python3 -c "import sys,json; print(json.load(sys.stdin)['packages'][0]['version'])") - echo "local=$LOCAL_VERSION" >> "$GITHUB_OUTPUT" - if curl -sf -o /dev/null -H "User-Agent: blasthttp-ci" "https://crates.io/api/v1/crates/blasthttp/$LOCAL_VERSION"; then PUBLISHED=yes; else PUBLISHED=no; fi - echo "already_published=$PUBLISHED" >> "$GITHUB_OUTPUT" - echo "Version $LOCAL_VERSION — already published: $PUBLISHED" - - - name: Publish to crates.io - if: steps.version-check.outputs.already_published != 'yes' - env: - OPENSSL_DIR: ${{ github.workspace }}/vendor/openssl/install - OPENSSL_STATIC: "1" - OPENSSL_NO_VENDOR: "1" - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/stable-guard.yml b/.github/workflows/stable-guard.yml new file mode 100644 index 0000000..6f8cefa --- /dev/null +++ b/.github/workflows/stable-guard.yml @@ -0,0 +1,12 @@ +name: stable guard + +on: + pull_request: + branches: [stable] + +permissions: + contents: read + +jobs: + guard: + uses: blacklanternsecurity/release-workflows/.github/workflows/stable-guard.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..94e0459 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + pull_request: + workflow_call: + +concurrency: + group: tests-${{ github.workflow_ref }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + python: + uses: blacklanternsecurity/release-workflows/.github/workflows/python-tests.yml@c53c202eedeb26a86088936363e6cb06ce99ac15 # v1.0.0 + with: + setup-script: scripts/build-openssl.sh + env: '{"PYO3_USE_ABI3_FORWARD_COMPATIBILITY": "1"}' + + # Reports the python-tests contexts stable's ruleset requires today. Remove once it requires python / passed. + required: + name: ${{ matrix.context }} + if: always() + needs: python + runs-on: ubuntu-24.04 + strategy: + matrix: + context: ["python-tests (3.11)", "python-tests (3.12)", "python-tests (3.13)", "python-tests (3.14)", "python-tests (3.15)"] + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} diff --git a/Cargo.lock b/Cargo.lock index e6ec668..29c1a99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,7 +87,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blasthttp" -version = "0.10.1" +version = "0.10.2" dependencies = [ "brotli", "bytes", diff --git a/Cargo.toml b/Cargo.toml index a89b432..e9e7ba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blasthttp" -version = "0.10.1" +version = "0.10.2" edition = "2024" description = "Offensive-first HTTP library with Python bindings" license = "AGPL-3.0" diff --git a/README.md b/README.md index 7e42648..8452f99 100644 --- a/README.md +++ b/README.md @@ -128,19 +128,20 @@ All request methods are async — they return native Python coroutines via `pyo3 import asyncio import blasthttp + async def main(): client = blasthttp.BlastHTTP() # Single request — Response is httpx-style r = await client.request("https://example.com") - print(r.status_code, r.is_success) # 200, True - print(r.headers["Content-Type"]) # case-insensitive - print(r.peer_ip) # actual TCP peer IP - print(r.text[:80]) # UTF-8 body (lazy) - print(r.hash.body_md5) # md5/sha256/mmh3 (lazy) - print(r.cookies) # Set-Cookie parsed (lazy) - print(r.request.url, r.request.method) # original (pre-redirect) - r.raise_for_status() # raises HTTPStatusError on 4xx/5xx + print(r.status_code, r.is_success) # 200, True + print(r.headers["Content-Type"]) # case-insensitive + print(r.peer_ip) # actual TCP peer IP + print(r.text[:80]) # UTF-8 body (lazy) + print(r.hash.body_md5) # md5/sha256/mmh3 (lazy) + print(r.cookies) # Set-Cookie parsed (lazy) + print(r.request.url, r.request.method) # original (pre-redirect) + r.raise_for_status() # raises HTTPStatusError on 4xx/5xx # Batch requests — full result list at the end configs = [ @@ -161,6 +162,7 @@ async def main(): # Download to file await client.download("https://example.com/file.zip", "/tmp/file.zip") + asyncio.run(main()) ``` @@ -201,11 +203,12 @@ A response whose body won't decode is still returned: the status line and header ```python from blasthttp import HTTPStatusError + try: r = await client.request("https://example.com/404") r.raise_for_status() except HTTPStatusError as e: - print(e.response.status_code) # the failing Response is attached + print(e.response.status_code) # the failing Response is attached ``` `Response`, `BatchResult`, `RedirectHop`, and `Headers` all have Python constructors so test code can synthesize them from canned data — `blasthttp.Response(url=..., status=200, headers=[...], body=b"...", ...)`. Useful when building fixtures by hand; the `blasthttp.mock` submodule below uses them under the hood. @@ -339,20 +342,17 @@ Pass `redirect_cookies=False` (or `--no-redirect-cookies` on the CLI) to turn it ```python # On by default. -r = await client.request("https://example.com/login", method="POST", - body="user=x&pass=y", follow_redirects=True) +r = await client.request("https://example.com/login", method="POST", body="user=x&pass=y", follow_redirects=True) # Off: every hop gets only the headers you supplied. -r = await client.request("https://example.com/login", follow_redirects=True, - redirect_cookies=False) +r = await client.request("https://example.com/login", follow_redirects=True, redirect_cookies=False) ``` **A cookie you set yourself always wins.** If your request carries `Cookie: session=mine`, every hop of that chain sends `session=mine`. A `Set-Cookie` naming a cookie you set is ignored: it can't replace your value, an expiry on it can't delete your value, and the two never go out together as a duplicate pair. Sites reset cookies mid-redirect routinely, and servers disagree about which value to read when a name appears twice (some take the first, some the last), so the only rule that behaves the same everywhere is that what you wrote is what lands on the wire. Cookies the chain sets under *other* names are merged into your `Cookie` header, yours first. ```python # Every hop sends session=mine, whatever the site tries to set. -r = await client.request("https://example.com/start", follow_redirects=True, - headers=[("Cookie", "session=mine")]) +r = await client.request("https://example.com/start", follow_redirects=True, headers=[("Cookie", "session=mine")]) ``` Run with `-v` to see it happen: the debug log records both the cookies each hop is given and any `Set-Cookie` that lost to one of yours. @@ -417,6 +417,7 @@ The client-level rate limit takes precedence over the per-call `rate_limit` para import asyncio import blasthttp + async def main(): client = blasthttp.BlastHTTP() @@ -429,9 +430,7 @@ async def main(): print("TLS cert CN:", conn.cert_info.common_name if conn.cert_info else None) # Send arbitrary bytes — no Content-Length / framing added for you. - await conn.send_bytes( - b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" - ) + await conn.send_bytes(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") # Read with a per-call deadline. Empty bytes = timeout or peer close. data = await conn.read_raw(max_bytes=65536, timeout_ms=2000) @@ -439,6 +438,7 @@ async def main(): await conn.close() + asyncio.run(main()) ``` @@ -454,18 +454,22 @@ asyncio.run(main()) from blasthttp import h2 # Encode a header block with HPACK. -block = h2.encode_headers([ - h2.Header(":method", "GET"), - h2.Header(":path", "/"), - h2.Header(":authority", "example.com"), - h2.Header(":scheme", "https"), - # Permissive escape hatch: emit CRLF inside a value for - # CRLF-injection testing against H2-to-H1 downgrades. - h2.Header( - "x-injected", "bogus\r\nX-Smuggled: yes", - allow_invalid_value=True, huffman_value=False, - ), -]) +block = h2.encode_headers( + [ + h2.Header(":method", "GET"), + h2.Header(":path", "/"), + h2.Header(":authority", "example.com"), + h2.Header(":scheme", "https"), + # Permissive escape hatch: emit CRLF inside a value for + # CRLF-injection testing against H2-to-H1 downgrades. + h2.Header( + "x-injected", + "bogus\r\nX-Smuggled: yes", + allow_invalid_value=True, + huffman_value=False, + ), + ] +) # Build a full probe: preface + SETTINGS + HEADERS (+ optional DATA). probe = h2.build_probe( @@ -512,6 +516,7 @@ import re import blasthttp from blasthttp.mock import BlasthttpMock, MockResponse + async def main(): mock = BlasthttpMock() @@ -522,12 +527,13 @@ async def main(): # Programmatic — callback receives a MockRequest, returns a MockResponse def echo(req): return MockResponse(status_code=201, json={"echoed": req.method}) + mock.add_callback(echo, url="https://api.example.com/echo") # Use it like a real client r = await mock.request("https://api.example.com/users") - print(r.json()) # {"users": []} - print(isinstance(r, blasthttp.Response)) # True + print(r.json()) # {"users": []} + print(isinstance(r, blasthttp.Response)) # True # Batch streaming works the same as the real client configs = [blasthttp.BatchConfig(f"https://api.example.com/users/{i}") for i in range(5)] @@ -535,6 +541,7 @@ async def main(): async for r in mock.request_batch_stream(configs): ... + asyncio.run(main()) ``` @@ -558,6 +565,7 @@ Pytest integration is intentionally left to the consumer — wrap the mock in a import pytest from blasthttp.mock import BlasthttpMock + @pytest.fixture def http_mock(): return BlasthttpMock() @@ -606,3 +614,9 @@ This compiles the Rust code with Python bindings enabled and installs the `blast - `.cargo/config.toml` sets `OPENSSL_DIR` (relative path to `vendor/openssl/install/`) and `OPENSSL_STATIC=1` so the `openssl-sys` crate links against the custom build statically - `build.rs` runs before compilation and verifies the custom OpenSSL headers exist, failing fast with an actionable error if they don't - The `[features] python` gate means `cargo build` produces a pure Rust binary, while `maturin build` activates PyO3 and produces a Python-loadable `.so` + +## Releasing + +A release is a reviewed pull request into `stable` that bumps the manifest version. The merge starts `publish.yml`, which tests and builds, waits for a `bbot-leads` approval of the `release` environment, publishes, then creates the `v` tag and the GitHub release with its SBOM. See the org [publishing policy](https://github.com/blacklanternsecurity/.github/blob/main/standards/releases.md). + +Each job that touches a credential runs in the `release` environment, so one release asks for approval four times: release-check's `credentials` job, `pypi`, `crates`, and the GitHub release. diff --git a/pyproject.toml b/pyproject.toml index d28cd27..b0583eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "blasthttp" -version = "0.10.1" +dynamic = ["version"] description = "Offensive-first HTTP library" license = "AGPL-3.0-or-later" requires-python = ">=3.10" @@ -12,12 +12,9 @@ requires-python = ">=3.10" [tool.maturin] features = ["python"] -[tool.uv] -# maturin manages the package install — don't let uv overwrite it -managed = false - [dependency-groups] dev = [ + "maturin>=1.10", "pytest>=8", "pytest-asyncio>=0.23", "ruff==0.17.2", diff --git a/release.toml b/release.toml deleted file mode 100644 index b25e3d2..0000000 --- a/release.toml +++ /dev/null @@ -1,18 +0,0 @@ -# cargo-release configuration -# Usage: cargo release patch|minor|major - -# Don't publish to crates.io (private project) -publish = false - -# Git tag format -tag-name = "v{{version}}" - -# Commit message format -tag-message = "v{{version}}" - -# Sign tags if gpg is available -sign-tag = false -sign-commit = false - -# Push after release -push-remote = "origin" diff --git a/scripts/wheel-container-setup.sh b/scripts/wheel-container-setup.sh new file mode 100644 index 0000000..1052b5e --- /dev/null +++ b/scripts/wheel-container-setup.sh @@ -0,0 +1,15 @@ +# Sourced by maturin-action inside the manylinux and musllinux containers. +if command -v apk >/dev/null 2>&1; then + apk add --no-cache perl make gcc musl-dev linux-headers curl bash +elif command -v yum >/dev/null 2>&1; then + yum install -y perl-core +elif command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y perl make gcc curl +fi +./scripts/build-openssl.sh +OPENSSL_DIR="$(pwd)/vendor/openssl/install" +export OPENSSL_DIR OPENSSL_STATIC=1 OPENSSL_NO_VENDOR=1 +# openssl-sys reads _OPENSSL_DIR, which the cross images preset, before OPENSSL_DIR. +for var in $(env | grep '_OPENSSL_DIR=' | cut -d= -f1); do + export "$var=$OPENSSL_DIR" +done