diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.github/workflows/agents-policy.yml b/.github/workflows/agents-policy.yml new file mode 100644 index 0000000..c1a9734 --- /dev/null +++ b/.github/workflows/agents-policy.yml @@ -0,0 +1,19 @@ +name: agents policy + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: agents-policy-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-monorepo/.github/workflows/agents-policy-reusable.yml@fb2417b1a976459e3de740f788916d3d91d3669e + with: + policy_ref: fb2417b1a976459e3de740f788916d3d91d3669e diff --git a/.github/workflows/capability-drift.yml b/.github/workflows/capability-drift.yml new file mode 100644 index 0000000..95a6cfb --- /dev/null +++ b/.github/workflows/capability-drift.yml @@ -0,0 +1,38 @@ +name: client capability drift + +on: + pull_request: + paths: + - ".github/workflows/capability-drift.yml" + - "README.md" + - "scripts/check-client-capability-drift.py" + - "clients/**" + push: + branches: [main] + paths: + - ".github/workflows/capability-drift.yml" + - "README.md" + - "scripts/check-client-capability-drift.py" + - "clients/**" + workflow_dispatch: + schedule: + - cron: "23 7 * * *" + +permissions: + contents: read + +concurrency: + group: client-capability-drift-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + documented-contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Compare README capability claims with reviewed SDK code + run: python3 scripts/check-client-capability-drift.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f989c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,267 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: zed-clients-${{ github.ref }} + cancel-in-progress: true + +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.12" + - name: Validate the fourteen-language Zed target matrix + run: | + python3 - <<'PY' + import json + import pathlib + import tomllib + + root = pathlib.Path.cwd() + with (root / ".zpkg.toml").open("rb") as handle: + manifest = tomllib.load(handle) + with (root / ".zpkg.lock").open("rb") as handle: + lock = tomllib.load(handle) + assert lock["version"] == 1 + assert manifest["dependencies"] == {"zed-pkg/zed-interfaces": "^0.1.0"} + + required = { + "nodejs": ("clients/typescript", "package.json"), + "python": ("clients/python", "pyproject.toml"), + "golang": ("clients/go", "go.mod"), + "rust": ("clients/rust", "Cargo.toml"), + "rust-wasm": ("clients/wasm", "Cargo.toml"), + "dart": ("clients/dart", "pubspec.yaml"), + "gleam": ("clients/gleam", "gleam.toml"), + "erlang": ("clients/erlang", "rebar.config"), + "elixir": ("clients/elixir", "mix.exs"), + "java": ("clients/java", "pom.xml"), + "kotlin": ("clients/kotlin", "pom.xml"), + "ruby": ("clients/ruby", "zed_pkg_client.gemspec"), + "php": ("clients/php", "composer.json"), + "swift": ("clients/swift", "Package.swift"), + } + targets = manifest["targets"] + for target, (directory, native_manifest) in required.items(): + assert targets[target]["dir"] == directory, (target, targets[target]) + assert (root / directory / native_manifest).is_file(), ( + target, + native_manifest, + ) + assert targets["repository"]["dir"] == "." + assert targets["golang"]["native"]["package"].endswith("/clients/go") + assert targets["golang"]["native"]["tag_format"] == "clients/go/v{version}" + assert targets["java"]["native"]["package"] == "tech.zpkg:zed-client" + assert targets["kotlin"]["native"]["package"] == "tech.zpkg:zed-client-kotlin" + + package_json = json.loads((root / "clients/typescript/package.json").read_text()) + for runtime in ("nodejs", "deno", "bun", "edge"): + assert f"./{runtime}" in package_json["exports"] + assert (root / "clients/typescript/src/runtimes" / runtime / "index.ts").is_file() + print("zed-clients fourteen-language matrix is complete") + PY + python3 scripts/check-client-capability-drift.py + + rust: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + path: zed-clients + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: zed-pkg/zed-interfaces + path: zed-interfaces + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + - run: cargo test + working-directory: zed-clients/clients/rust + + wasm: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + path: zed-clients + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: zed-pkg/zed-interfaces + path: zed-interfaces + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + targets: wasm32-unknown-unknown + - run: cargo test + working-directory: zed-clients/clients/wasm + - run: cargo check --target wasm32-unknown-unknown + working-directory: zed-clients/clients/wasm + + typescript: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22 + - run: npm ci && npm run build && npm test + working-directory: clients/typescript + + python: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.12" + - run: python -m compileall -q zed_pkg_client && python -m unittest -v + working-directory: clients/python + + go: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 + with: + go-version: "1.22" + cache-dependency-path: clients/go/go.mod + - run: go vet ./... && go test ./... + working-directory: clients/go + + dart: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 + with: + sdk: stable + - run: dart pub get && dart analyze && dart test + working-directory: clients/dart + + gleam: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 + with: + otp-version: "27" + gleam-version: "1.16" + rebar3-version: "3" + - run: gleam test + working-directory: clients/gleam + + erlang: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 + with: + otp-version: "27" + rebar3-version: "3" + - run: rebar3 compile && rebar3 eunit + working-directory: clients/erlang + + elixir: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 + with: + otp-version: "27" + elixir-version: "1.17" + - run: mix deps.get && mix test + working-directory: clients/elixir + + java: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: java -version + - run: mvn --batch-mode --no-transfer-progress verify + working-directory: clients/java + + kotlin: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: java -version + - run: mvn --batch-mode --no-transfer-progress verify + working-directory: clients/kotlin + + ruby: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: ruby -c lib/zed_pkg_client.rb && ruby -Ilib test/client_test.rb + working-directory: clients/ruby + + php: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: php -l src/Client.php && php tests/client_test.php + working-directory: clients/php + + swift: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: swift --version + - run: swift test --parallel + working-directory: clients/swift diff --git a/.github/workflows/native-lock-provenance-canary.yml b/.github/workflows/native-lock-provenance-canary.yml new file mode 100644 index 0000000..fcfeb3c --- /dev/null +++ b/.github/workflows/native-lock-provenance-canary.yml @@ -0,0 +1,71 @@ +name: native lock provenance canary + +on: + push: + branches: + - agent/den-1565-native-lock-canary + pull_request: + paths: + - .github/workflows/native-lock-provenance-canary.yml + - tests/native-lock-consumer/** + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-lock-canary-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + certify: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out independent consumer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: zed-clients + persist-credentials: false + show-progress: false + + - name: Check out exact lock-provenance candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660 + path: zed-interfaces + persist-credentials: false + show-progress: false + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + + - name: Verify the exact product contract + working-directory: zed-interfaces + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660 + cargo fmt --all -- --check + cargo test --locked --all-targets + cargo clippy --locked --all-targets --all-features -- -D warnings + cp schemas/lockfile.json "$RUNNER_TEMP/lockfile.json" + cargo run --locked --example generate_schemas + cmp "$RUNNER_TEMP/lockfile.json" schemas/lockfile.json + git diff --exit-code + + - name: Execute the external consumer locked and offline + run: | + set -euo pipefail + manifest=zed-clients/tests/native-lock-consumer/Cargo.toml + cargo generate-lockfile --offline --manifest-path "$manifest" + cargo run --locked --offline --manifest-path "$manifest" + + - name: Verify the candidate and consumer trees stayed clean + run: | + set -euo pipefail + git -C zed-interfaces diff --exit-code + git -C zed-clients diff --exit-code -- . ':!tests/native-lock-consumer/Cargo.lock' diff --git a/.github/workflows/opto-sync-adoption.yml b/.github/workflows/opto-sync-adoption.yml new file mode 100644 index 0000000..0c735ff --- /dev/null +++ b/.github/workflows/opto-sync-adoption.yml @@ -0,0 +1,52 @@ +name: Opto Sync adoption + +on: + push: + branches: [main] + paths: ['.opto-sync.json', '.github/workflows/opto-sync-adoption.yml'] + pull_request: + paths: ['.opto-sync.json', '.github/workflows/opto-sync-adoption.yml'] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout Zed clients + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Checkout pinned Opto Sync validator + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: opto-sync/opto-sync-clients + ref: da7459fdf3ce4096ce6b88aeed5fc98066f804d9 + path: .opto-sync-tools + persist-credentials: false + - name: Validate manifest and package-security boundary + run: | + set -euo pipefail + python3 .opto-sync-tools/scripts/check-adoption-manifest.py .opto-sync.json + python3 - <<'PY' + import json + manifest = json.load(open('.opto-sync.json', encoding='utf-8')) + assert manifest['project'] == 'zed-pkg/zed-clients' + assert manifest['rollout']['phase'] == 'declared' + assert manifest['background'] == { + 'webServiceWorker': False, + 'mobileWorker': False, + 'desktopWorker': True, + } + assert {'installState', 'lockfileState', 'packageManifests'} <= set(manifest['dataScopes']) + notes = manifest['notes'].lower() + for excluded in ('registry credentials', 'signing keys', 'package blobs', 'environment secrets'): + assert excluded in notes + PY diff --git a/.github/workflows/wasm-browser-contract.yml b/.github/workflows/wasm-browser-contract.yml new file mode 100644 index 0000000..a6ce7d9 --- /dev/null +++ b/.github/workflows/wasm-browser-contract.yml @@ -0,0 +1,150 @@ +name: WASM browser transport contract + +'on': + pull_request: + paths: + - '.github/workflows/wasm-browser-contract.yml' + - 'tests/browser/wasm_transport_contract.mjs' + - 'tests/browser/wasm_fixture_csp_shim.mjs' + - 'tests/browser/wasm_expected_diagnostics_shim.mjs' + - 'clients/wasm/**' + push: + branches: [main] + paths: + - '.github/workflows/wasm-browser-contract.yml' + - 'tests/browser/wasm_transport_contract.mjs' + - 'tests/browser/wasm_fixture_csp_shim.mjs' + - 'tests/browser/wasm_expected_diagnostics_shim.mjs' + - 'clients/wasm/**' + schedule: + - cron: '37 8 * * 3' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: wasm-browser-contract-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + ZED_INTERFACES_SHA: 6e893ad0f28ccfbb7722f007d75e88548f1bcfdf + CARGO_TERM_COLOR: always + +jobs: + browser: + name: ${{ matrix.engine }} browser contract + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + engine: [chromium, firefox, webkit] + env: + BROWSER_ENGINE: ${{ matrix.engine }} + NO_COLOR: '1' + npm_config_audit: 'false' + npm_config_fund: 'false' + npm_config_package_lock: 'false' + npm_config_update_notifier: 'false' + + steps: + - name: Check out exact client candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: zed-clients + persist-credentials: false + + - name: Check out exact sibling interface contract + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: ${{ env.ZED_INTERFACES_SHA }} + path: zed-interfaces + persist-credentials: false + + - name: Install stable Rust with WebAssembly target + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + targets: wasm32-unknown-unknown + + - name: Use Node.js 22.23.1 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.23.1' + + - name: Record immutable test inputs + shell: bash + run: | + set -euo pipefail + artifact_dir="$RUNNER_TEMP/wasm-transport/${{ matrix.engine }}" + mkdir -p "$artifact_dir" + { + printf 'browser=%s\n' '${{ matrix.engine }}' + printf 'client_commit=%s\n' "$(git -C zed-clients rev-parse HEAD)" + printf 'interfaces_commit=%s\n' "$(git -C zed-interfaces rev-parse HEAD)" + printf 'rustc=%s\n' "$(rustc --version)" + printf 'cargo=%s\n' "$(cargo --version)" + printf 'node=%s\n' "$(node --version)" + printf 'npm=%s\n' "$(npm --version)" + } > "$artifact_dir/environment.txt" + test "$(git -C zed-interfaces rev-parse HEAD)" = "$ZED_INTERFACES_SHA" + + - name: Compile and test the hardened WebAssembly client + shell: bash + working-directory: zed-clients/clients/wasm + run: | + set -euo pipefail + cargo test --locked + cargo check --locked --target wasm32-unknown-unknown + cargo install wasm-pack --version 0.13.1 --locked + wasm-pack build --target web --release --out-dir pkg + test -f pkg/zed_client_wasm.js + test -f pkg/zed_client_wasm_bg.wasm + + - name: Install exact browser driver + shell: bash + working-directory: zed-clients/tests/browser + run: | + set -euo pipefail + rm -rf node_modules package.json package-lock.json + printf '%s\n' '{"name":"zed-wasm-browser-contract","private":true,"type":"module"}' > package.json + npm install --no-save --no-package-lock --ignore-scripts playwright@1.61.1 + ./node_modules/.bin/playwright install --with-deps '${{ matrix.engine }}' + node --check wasm_transport_contract.mjs + node --check wasm_fixture_csp_shim.mjs + node --check wasm_expected_diagnostics_shim.mjs + + - name: Exercise hostile transport boundaries in a real browser + shell: bash + working-directory: zed-clients/tests/browser + env: + ZED_WASM_PKG: ${{ github.workspace }}/zed-clients/clients/wasm/pkg + BROWSER_ARTIFACT_DIR: ${{ runner.temp }}/wasm-transport/${{ matrix.engine }} + NODE_OPTIONS: >- + --import=${{ github.workspace }}/zed-clients/tests/browser/wasm_fixture_csp_shim.mjs + --import=${{ github.workspace }}/zed-clients/tests/browser/wasm_expected_diagnostics_shim.mjs + run: node --test --test-reporter=spec wasm_transport_contract.mjs + + - name: Verify candidate source remained clean + if: always() + shell: bash + run: | + set -euo pipefail + rm -rf \ + zed-clients/clients/wasm/pkg \ + zed-clients/tests/browser/node_modules \ + zed-clients/tests/browser/package.json \ + zed-clients/tests/browser/package-lock.json + git -C zed-clients status --short + test -z "$(git -C zed-clients status --porcelain)" + + - name: Upload browser transport evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wasm-transport-${{ matrix.engine }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/wasm-transport/${{ matrix.engine }}/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/zed-package.yml b/.github/workflows/zed-package.yml new file mode 100644 index 0000000..aafe03e --- /dev/null +++ b/.github/workflows/zed-package.yml @@ -0,0 +1,179 @@ +name: Zed package interoperability + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + zed-package: + runs-on: ubuntu-latest + steps: + - name: Check out package source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + path: source + persist-credentials: false + + - name: Check out pinned zed CLI + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: zed-pkg/zed-cli + ref: d70e3ad2ffdd1aaa2f09fd28bb615f9f51005567 + path: zed-cli + persist-credentials: false + + - name: Check out pinned zed interfaces + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: zed-pkg/zed-interfaces + ref: 07d01604461d00e237c7d86ad3855464167574ec + path: zed-interfaces + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Configure isolated pack output + run: echo "ZED_PACK_OUT=$RUNNER_TEMP/zed-pack" >> "$GITHUB_ENV" + + - name: Pack every zed target + working-directory: source + run: cargo run --quiet --locked --manifest-path ../zed-cli/Cargo.toml -- pack --out "$ZED_PACK_OUT" + + - name: Verify whole-repository and isolated language artifacts + working-directory: source + run: | + python3 - <<'PY' + import io + import os + import pathlib + import tarfile + import tomllib + + root = pathlib.Path.cwd() + out = pathlib.Path(os.environ["ZED_PACK_OUT"]) + with (root / ".zpkg.toml").open("rb") as handle: + manifest = tomllib.load(handle) + with (root / ".zpkg.lock").open("rb") as handle: + lock = tomllib.load(handle) + assert lock["version"] == 1, "unsupported .zpkg.lock version" + + package = manifest["package"] + targets = manifest.get("targets", {}) + assert "repository" in targets, "whole-repository target is required" + assert targets["repository"]["dir"] == ".", "repository target must package the repo root" + + expected = {} + for target, section in targets.items(): + name = section.get("name", f"{package['name']}-{target}") + archive = out / f"{package['org']}-{name}-{package['version']}.tar.gz" + expected[target] = archive + assert archive.is_file(), f"missing {target} artifact: {archive.name}" + + actual = set(out.glob("*.tar.gz")) + assert actual == set(expected.values()), ( + f"unexpected artifact set: expected {len(expected)}, got {len(actual)}" + ) + + archive_members = {} + for target, archive in expected.items(): + with tarfile.open(archive, "r:gz") as packed: + names = packed.getnames() + assert names and all(name == "pkg" or name.startswith("pkg/") for name in names) + assert not any(".." in pathlib.PurePosixPath(name).parts for name in names) + member = packed.extractfile("pkg/.zpkg.toml") + assert member is not None, f"{target} has no derived manifest" + derived = tomllib.load(io.BytesIO(member.read())) + expected_name = targets[target].get("name", f"{package['name']}-{target}") + assert derived["package"]["name"] == expected_name + assert not derived.get("targets"), f"{target} artifact is still polyglot" + # Every *language* slice must state what it is for, so a + # consumer install can refuse e.g. a jvm package in an npm-only + # project. The whole-repository target is language-agnostic by + # construction and is correctly left ungated. + if target != "repository": + declared = derived["package"].get("language") + assert declared, ( + f"{target} artifact declares no language; consumers cannot be guarded" + ) + assert declared != "universal", ( + f"{target} artifact declares language `universal`; it must name its language" + ) + archive_members[target] = names + + repository_files = archive_members["repository"] + native_manifests = { + "nodejs": ["package.json"], + "python": ["pyproject.toml", "setup.py"], + "golang": ["go.mod"], + "rust": ["Cargo.toml"], + "rust-wasm": ["Cargo.toml"], + "java": ["pom.xml", "build.gradle", "build.gradle.kts"], + "csharp": ["*.csproj", "*.nuspec"], + "ruby": ["*.gemspec"], + "php": ["composer.json"], + "dart": ["pubspec.yaml"], + "flutter": ["pubspec.yaml"], + "elixir": ["mix.exs"], + "gleam": ["gleam.toml"], + "fsharp": ["*.fsproj"], + "ocaml": ["*.opam", "dune-project"], + "clojure": ["deps.edn", "project.clj"], + "scala": ["build.sbt"], + "kotlin": ["build.gradle.kts", "pom.xml"], + "erlang": ["rebar.config"], + "swift": ["Package.swift"], + "cpp": ["CMakeLists.txt"], + "zig": ["build.zig"], + "haskell": ["*.cabal"], + "julia": ["Project.toml"], + "r": ["DESCRIPTION"], + "nim": ["*.nimble"], + "crystal": ["shard.yml"], + "lua": ["*.rockspec"], + "powershell": ["*.psd1"], + } + for target, section in targets.items(): + if target == "repository": + continue + source = root / section["dir"] + patterns = native_manifests.get(target) + if patterns: + native_files = sorted({ + path + for pattern in patterns + for path in source.glob(pattern) + if path.is_file() + }) + assert native_files, ( + f"{target} has no native package-manager manifest ({', '.join(patterns)})" + ) + assert any( + f"pkg/{path.relative_to(source).as_posix()}" in archive_members[target] + for path in native_files + ), f"{target} artifact omitted its native package-manager manifest" + source_dir = section["dir"].strip("./") + prefix = f"pkg/{source_dir}/" + assert any(name.startswith(prefix) for name in repository_files), ( + f"repository artifact does not contain {target} at {section['dir']}" + ) + assert not any(name.startswith(prefix) for name in archive_members[target]), ( + f"{target} artifact was not re-rooted" + ) + + print(f"verified {len(expected)} zed artifacts: repository + {len(expected) - 1} language targets") + PY + + - name: Exercise publish planning without uploading + working-directory: source + run: cargo run --quiet --locked --manifest-path ../zed-cli/Cargo.toml -- publish --dry-run --skip-vcs-checks diff --git a/.gitignore b/.gitignore index 2a283ae..034a760 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,11 @@ tmp temp tmp/worktrees temp/worktrees + +# --- carried over from the grafted branch rescue/orphan-8060b10db775-feat-clients-complete-the-polyglot-sdk-m --- +__pycache__/ +_build/ +*.egg-info/ +clients/dart/pubspec.lock +clients/wasm/pkg/ +dist/ diff --git a/.openai/AGENTS.md b/.openai/AGENTS.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.openai/AGENTS.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.opto-sync.json b/.opto-sync.json new file mode 100644 index 0000000..e7e016c --- /dev/null +++ b/.opto-sync.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": "opto-sync.adoption.v1", + "project": "zed-pkg/zed-clients", + "clientPackage": { + "package": "opto-sync/opto-sync-clients", + "repository": "https://github.com/opto-sync/opto-sync-clients", + "version": "0.2.0", + "commit": "068414c8ff7d4262d0a395959b5209d5908f0fcc" + }, + "languages": [ + "dart", + "gleam", + "rust", + "typescript" + ], + "dataScopes": [ + "installState", + "lockfileState", + "packageManifests", + "registryMetadata" + ], + "localStores": [ + "indexeddb", + "sqlite" + ], + "transports": [ + "http", + "websocket" + ], + "writeStrategies": [ + "local-durable", + "local-then-remote", + "remote-confirmed" + ], + "conflictPolicy": { + "arrayStrategy": "mergeByKey", + "resolveByTimestamp": true, + "lwwKeys": [ + "updatedAt" + ], + "arrayMatchKeys": [ + "name" + ], + "tombstones": true + }, + "background": { + "webServiceWorker": false, + "mobileWorker": false, + "desktopWorker": true + }, + "security": { + "authenticatedTransport": true, + "encryptedAtRest": true, + "secretsInManifest": false + }, + "rollout": { + "phase": "declared", + "owners": [ + "zed-pkg" + ] + }, + "notes": "Sync package manifests, lockfile state, registry metadata, install checkpoints, and user-visible operation history. Registry credentials, signing keys, package blobs, executable payloads, environment secrets, and shell output remain outside Opto Sync." +} diff --git a/.zpkg.lock b/.zpkg.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/.zpkg.lock @@ -0,0 +1 @@ +version = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..613f14a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zed-pkg contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..3a52281 --- /dev/null +++ b/agents.md @@ -0,0 +1,26 @@ +# Agent instructions + +## Scope and hierarchy + +- These instructions apply to the whole `zed-pkg/zed-clients` repository unless a deeper lowercase `agents.md` adds narrower rules. +- Before editing, resolve the current working directory and load every readable ancestor `agents.md` from the filesystem root to the working directory. Do not search siblings. Resolve symlinks, deduplicate resolved files, and report unreadable or cyclic instruction files. +- `.claude/CLAUDE.md`, `.gemini/GEMINI.md`, and `.openai/AGENTS.md` are pointers only. Never duplicate instructions in tool-specific files. + +## Repository role + +This repository is the multi-language Zed client and SDK release set. It maps one registry/API contract into language-idiomatic clients while preserving equivalent authentication, transport, error, versioning, and package-publication behavior. + +## Working rules + +- Treat the API/OpenAPI source and generator inputs as canonical. Do not hand-edit generated files without updating the generator or manifest that owns them. +- Preserve operation coverage and behavioral parity across supported language clients; document intentional language-specific differences. +- Keep public names, request/response schemas, error categories, retry/idempotency behavior, and authentication semantics compatible. +- Never log bearer tokens, refresh tokens, signed URLs, package credentials, or sensitive response bodies. +- Keep network timeouts, redirect policy, response-size limits, and error truncation bounded and explicit. +- Regenerate deterministically and fail CI on generator drift, uncommitted output, missing packages, or lockfile changes. +- Validate each language with its native formatter, compiler/type checker, tests, package metadata checks, and a shared contract fixture. +- Coordinate API/interface changes contract-first and update release notes/versioning for every affected client. + +## Validation + +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Follow `README.md`, generator documentation, and existing CI before requesting review. diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 0000000..d056bf6 --- /dev/null +++ b/clients/README.md @@ -0,0 +1,25 @@ +# Client language matrix + +The directory names follow ecosystem conventions while Zed package target names +follow canonical language tokens. + +| Requested surface | Canonical directory | Zed target | +| --- | --- | --- | +| TypeScript / JavaScript | `typescript/` | `nodejs` | +| Python 3 | `python/` | `python` | +| Go / Golang | `go/` | `golang` | +| Gleam / Gleamlang | `gleam/` | `gleam` | +| Erlang | `erlang/` | `erlang` | +| Elixir | `elixir/` | `elixir` | +| Dart | `dart/` | `dart` | +| Rust | `rust/` | `rust` | +| Java | `java/` | `java` | +| Ruby | `ruby/` | `ruby` | +| PHP | `php/` | `php` | +| Kotlin | `kotlin/` | `kotlin` | +| Swift | `swift/` | `swift` | + +The TypeScript package has explicit runtime entry points under +`typescript/src/runtimes/{nodejs,deno,bun,edge}`. They share the fetch-based +core and exist to make runtime intent explicit without publishing four copies of +the same npm-compatible artifact. diff --git a/clients/dart/README.md b/clients/dart/README.md new file mode 100644 index 0000000..204f330 --- /dev/null +++ b/clients/dart/README.md @@ -0,0 +1,23 @@ +# zed_pkg_client (Dart) + +Dart SDK for the [zed-pkg](https://zpkg.tech) registry. Models mirror the +JSON Schemas in `zed-interfaces/schemas/`. + +```dart +import 'package:zed_pkg_client/zed_pkg_client.dart'; + +final client = ZedClient(); // https://registry.zpkg.tech +final pkg = await client.getPackage('acme', 'kit'); +final version = await client.getVersion('acme', 'kit', pkg.latest!); +final bytes = await client.downloadArtifact(version); // sha256-verified +``` + +Authenticated calls (`claimOrg`, `yank`, `publish`) need a bearer token: + +```dart +final authed = ZedClient(token: 'zpkg_...'); +await authed.claimOrg('acme'); +await authed.publish(publishMeta, artifactBytes); +``` + +Verify with `dart pub get && dart analyze && dart test`. diff --git a/clients/dart/analysis_options.yaml b/clients/dart/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/clients/dart/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/clients/dart/lib/src/client.dart b/clients/dart/lib/src/client.dart new file mode 100644 index 0000000..4a2a8c4 --- /dev/null +++ b/clients/dart/lib/src/client.dart @@ -0,0 +1,541 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show InternetAddress; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'models.dart'; + +const String defaultRegistryUrl = 'https://registry.zpkg.tech'; + +/// Bounds every request, including streamed response-body consumption. +const Duration defaultTimeout = Duration(seconds: 30); + +/// Successful JSON documents are never allowed to grow without bound. +const int maxJsonResponseBytes = 16 * 1024 * 1024; + +/// Remote error text is retained only through this bounded explicit field. +const int maxErrorBodyBytes = 16 * 1024; + +/// Maximum UTF-8 size of one opaque registry route segment. +const int maxPathSegmentBytes = 256; + +/// Hard ceiling on artifact downloads, matching the server's +/// `MAX_ARTIFACT_BYTES` default (100 MiB); plus the slack added to a +/// version's declared size. +const int maxArtifactBytes = 100 * 1024 * 1024; +const int _downloadSlack = 1024 * 1024; + +/// The declared size (when sane) plus slack, capped by the ceiling. +int downloadLimit(int size) { + if (size > 0) { + final limit = size + _downloadSlack; + return limit < maxArtifactBytes ? limit : maxArtifactBytes; + } + return maxArtifactBytes; +} + +String _requireText(String value, String name) { + if (value.trim().isEmpty) { + throw ArgumentError.value(value, name, 'must not be blank'); + } + if (value == '.' || value == '..') { + throw ArgumentError.value(value, name, 'must not be a dot segment'); + } + if (utf8.encode(value).length > maxPathSegmentBytes) { + throw ArgumentError.value( + value, + name, + 'exceeds $maxPathSegmentBytes UTF-8 bytes', + ); + } + if (value.runes.any((rune) => rune < 0x20 || rune == 0x7f)) { + throw ArgumentError.value( + value, + name, + 'must not contain control characters', + ); + } + return value; +} + +String _segment(String value, [String name = 'path segment']) => + Uri.encodeComponent(_requireText(value, name)); + +String packagePath(String org, String name) => + '/v1/packages/${_segment(org, 'org')}/${_segment(name, 'name')}'; + +String versionPath(String org, String name, String version) => + '${packagePath(org, name)}/versions/${_segment(version, 'version')}'; + +String artifactPath(String sha256) => + '/v1/artifacts/${_segment(sha256, 'sha256')}'; + +String yankPath(String org, String name, String version) => + '${versionPath(org, name, version)}/yank'; + +void _validateRawRegistryPath(String raw) { + final schemeEnd = raw.indexOf('://'); + if (schemeEnd < 0) return; + final authorityStart = schemeEnd + 3; + final pathStart = raw.indexOf('/', authorityStart); + if (pathStart < 0) return; + var pathEnd = raw.length; + for (final marker in ['?', '#']) { + final index = raw.indexOf(marker, pathStart); + if (index >= 0 && index < pathEnd) pathEnd = index; + } + final rawPath = raw.substring(pathStart, pathEnd); + final segments = rawPath.split('/'); + for (var index = 0; index < segments.length; index += 1) { + final encoded = segments[index]; + if (encoded.isEmpty) continue; + final String decoded; + try { + decoded = Uri.decodeComponent(encoded); + } on FormatException { + throw ArgumentError.value( + raw, + 'registryUrl', + 'contains invalid percent encoding', + ); + } + _requireText(decoded, 'registry path segment ${index + 1}'); + if (decoded.contains('/') || decoded.contains('\\')) { + throw ArgumentError.value( + raw, + 'registryUrl', + 'path segments must not contain encoded separators', + ); + } + } +} + +String normalizeRegistryUrl(String raw) { + final trimmed = raw.trim(); + _validateRawRegistryPath(trimmed); + final uri = Uri.tryParse(trimmed); + if (uri == null || + !uri.hasScheme || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment) { + throw ArgumentError.value( + raw, + 'registryUrl', + 'must be a credential-free absolute HTTP(S) URL without query or fragment', + ); + } + for (var index = 0; index < uri.pathSegments.length; index += 1) { + final segment = uri.pathSegments[index]; + if (segment.isEmpty) continue; + _requireText(segment, 'registry path segment ${index + 1}'); + if (segment.contains('/') || segment.contains('\\')) { + throw ArgumentError.value( + raw, + 'registryUrl', + 'path segments must not contain encoded separators', + ); + } + } + return trimmed.replaceAll(RegExp(r'/+$'), ''); +} + +bool _isLoopbackHost(String host) { + if (host == 'localhost') return true; + final bare = host.replaceAll(RegExp(r'^\[|\]$'), ''); + final address = InternetAddress.tryParse(bare); + return address?.isLoopback ?? false; +} + +/// Enforce the download-url scheme policy: https is always allowed; http only +/// for loopback hosts or when the registry base is itself http. Query strings +/// remain allowed for presigned URLs, while userinfo and fragments are refused. +String allowedDownloadUrl(String raw, String base) { + final url = Uri.tryParse(raw); + if (url == null || + !url.hasScheme || + url.host.isEmpty || + url.userInfo.isNotEmpty || + url.hasFragment) { + throw ZedApiError(0, 'bad_download_url', 'download URL is invalid'); + } + if (url.scheme == 'https') return url.toString(); + if (url.scheme == 'http' && + (_isLoopbackHost(url.host) || base.startsWith('http://'))) { + return url.toString(); + } + throw ZedApiError( + 0, + 'insecure_download_url', + 'refusing artifact download over `${url.scheme}` from $raw ' + '(https required for non-local registries)', + ); +} + +/// Verify `bytes` against an expected hex sha256. +void verifySha256(List bytes, String expected) { + final actual = sha256.convert(bytes).toString(); + if (actual.toLowerCase() != expected.toLowerCase()) { + throw ZedApiError(0, 'sha256_mismatch', 'expected $expected, got $actual'); + } +} + +/// Client for the zed-pkg registry API. +final class ZedClient { + factory ZedClient({ + String registryUrl = defaultRegistryUrl, + String? token, + http.Client? httpClient, + Duration timeout = defaultTimeout, + }) { + if (timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout', 'must be positive'); + } + final normalizedToken = token?.trim(); + return ZedClient._( + base: normalizeRegistryUrl(registryUrl), + token: normalizedToken == null || normalizedToken.isEmpty + ? null + : normalizedToken, + httpClient: httpClient ?? http.Client(), + ownsHttpClient: httpClient == null, + timeout: timeout, + ); + } + + ZedClient._({ + required this.base, + required this.token, + required http.Client httpClient, + required bool ownsHttpClient, + required Duration timeout, + }) : _http = httpClient, + _ownsHttpClient = ownsHttpClient, + _timeout = timeout; + + final String base; + final String? token; + final http.Client _http; + final bool _ownsHttpClient; + final Duration _timeout; + + void close() { + if (_ownsHttpClient) { + _http.close(); + } + } + + String _requireToken() { + final value = token; + if (value == null) { + throw ZedApiError( + 0, + 'missing_token', + 'authenticated registry operation requires a nonblank bearer token', + ); + } + return value; + } + + Map _headers({bool authorized = false, String? contentType}) { + final headers = {'accept': 'application/json'}; + if (authorized) { + headers['authorization'] = 'Bearer ${_requireToken()}'; + } + if (contentType != null) { + headers['content-type'] = contentType; + } + return headers; + } + + Never _throwApiError(int status, List body) { + final text = utf8.decode(body, allowMalformed: true); + var code = 'http_$status'; + var message = text; + try { + final parsed = jsonDecode(text); + if (parsed is Map) { + final candidate = parsed['code']; + if (candidate is String && candidate.trim().isNotEmpty) { + code = candidate.trim(); + } + message = + parsed['message'] is String ? parsed['message'] as String : text; + } + } on FormatException { + // Non-JSON error body remains available only through the explicit field. + } + throw ZedApiError(status, code, message); + } + + Future _readBounded( + http.StreamedResponse response, + int limit, { + required bool failOnOverflow, + required String overflowCode, + required String description, + }) async { + final declared = int.tryParse(response.headers['content-length'] ?? '') ?? + response.contentLength; + if (declared != null && declared > limit && failOnOverflow) { + throw ZedApiError( + 0, + overflowCode, + '$description exceeded $limit bytes; refusing', + ); + } + final builder = BytesBuilder(copy: false); + await for (final chunk in response.stream.timeout(_timeout)) { + final remaining = limit - builder.length; + if (chunk.length > remaining) { + if (remaining > 0) { + builder.add(chunk.sublist(0, remaining)); + } + if (failOnOverflow) { + throw ZedApiError( + 0, + overflowCode, + '$description exceeded $limit bytes; refusing', + ); + } + break; + } + builder.add(chunk); + } + return builder.takeBytes(); + } + + Future _sendBounded( + http.BaseRequest request, { + required int successLimit, + required String successOverflowCode, + required String successDescription, + }) { + request.followRedirects = false; + request.maxRedirects = 0; + return (() async { + final response = await _http.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await _readBounded( + response, + maxErrorBodyBytes, + failOnOverflow: false, + overflowCode: 'error_body_too_large', + description: 'registry error body', + ); + _throwApiError(response.statusCode, body); + } + return _readBounded( + response, + successLimit, + failOnOverflow: true, + overflowCode: successOverflowCode, + description: successDescription, + ); + })() + .timeout(_timeout); + } + + Future> _requestJson( + String method, + String path, { + Object? body, + bool authorized = false, + }) async { + final request = http.Request(method, Uri.parse('$base$path')); + request.headers.addAll( + _headers( + authorized: authorized, + contentType: body != null ? 'application/json' : null, + ), + ); + if (body != null) { + request.body = jsonEncode(body); + } + final bytes = await _sendBounded( + request, + successLimit: maxJsonResponseBytes, + successOverflowCode: 'response_too_large', + successDescription: 'registry JSON response', + ); + final dynamic decoded; + try { + decoded = jsonDecode(utf8.decode(bytes)); + } on FormatException catch (error) { + throw ZedApiError(0, 'invalid_response', 'invalid registry JSON: $error'); + } + if (decoded is! Map) { + throw ZedApiError(0, 'invalid_response', 'expected a JSON object'); + } + return decoded; + } + + /// `GET /v1/packages/{org}/{name}` — package metadata + version list. + Future getPackage(String org, String name) async => + PackageMetadata.fromJson( + await _requestJson('GET', packagePath(org, name)), + ); + + /// `GET /v1/packages/{org}/{name}/versions/{version}`. + Future getVersion( + String org, + String name, + String version, + ) async => + VersionMetadata.fromJson( + await _requestJson('GET', versionPath(org, name, version)), + ); + + /// `GET /v1/search?q=`. + Future search(String query) async => SearchResponse.fromJson( + await _requestJson( + 'GET', + '/v1/search?q=${Uri.encodeQueryComponent(query)}', + ), + ); + + /// `POST /v1/orgs` (bearer token). + Future claimOrg(String slug) async => + ClaimOrgResponse.fromJson( + await _requestJson( + 'POST', + '/v1/orgs', + body: {'slug': _requireText(slug, 'slug')}, + authorized: true, + ), + ); + + Future setYanked( + String org, + String name, + String version, + bool yanked, + ) async => + YankResponse.fromJson( + await _requestJson( + 'POST', + yankPath(org, name, version), + body: {'yanked': yanked}, + authorized: true, + ), + ); + + /// Yank a version. The optional argument is retained for compatibility. + Future yank( + String org, + String name, + String version, [ + bool yanked = true, + ]) async => + setYanked(org, name, version, yanked); + + /// Restore a previously yanked version. + Future restore(String org, String name, String version) async => + setYanked(org, name, version, false); + + /// Download an artifact, verify its sha256, and return the bytes. + Future downloadArtifact(VersionMetadata version) async { + final raw = version.downloadUrl.trim(); + final String url; + if (raw.isEmpty) { + url = '$base${artifactPath(version.sha256)}'; + } else { + final parsed = Uri.tryParse(raw); + if (parsed != null && parsed.hasScheme) { + url = allowedDownloadUrl(raw, base); + } else { + url = allowedDownloadUrl( + Uri.parse('$base/').resolve(raw).toString(), + base, + ); + } + } + // Deliberately no auth header: download_url may point at a third-party + // host (e.g. a presigned S3/R2 URL) and the token must not leak there. + final request = http.Request('GET', Uri.parse(url)); + final bytes = await _sendBounded( + request, + successLimit: downloadLimit(version.size), + successOverflowCode: 'artifact_too_large', + successDescription: 'artifact', + ); + verifySha256(bytes, version.sha256); + return bytes; + } + + /// Publish: multipart `meta` (PublishMeta JSON) + `artifact` bytes. + Future publish( + Map meta, + List artifact, + ) async { + _requireToken(); + if (artifact.length > maxArtifactBytes) { + throw ZedApiError( + 0, + 'artifact_too_large', + 'artifact exceeded $maxArtifactBytes bytes; refusing', + ); + } + final manifest = meta['manifest']; + final package = + manifest is Map ? manifest['package'] : null; + if (package is! Map || + package['org'] is! String || + package['name'] is! String || + package['version'] is! String) { + throw ZedApiError( + 0, + 'invalid_publish_meta', + 'meta.manifest.package is required', + ); + } + final org = _requireText( + package['org'] as String, + 'meta.manifest.package.org', + ); + final name = _requireText( + package['name'] as String, + 'meta.manifest.package.name', + ); + final version = _requireText( + package['version'] as String, + 'meta.manifest.package.version', + ); + final request = http.MultipartRequest( + 'PUT', + Uri.parse('$base${versionPath(org, name, version)}'), + ) + ..followRedirects = false + ..maxRedirects = 0 + ..headers.addAll(_headers(authorized: true)) + ..fields['meta'] = jsonEncode(meta) + ..files.add( + http.MultipartFile.fromBytes( + 'artifact', + artifact, + filename: '$org-$name-$version.tar.gz', + ), + ); + final bytes = await _sendBounded( + request, + successLimit: maxJsonResponseBytes, + successOverflowCode: 'response_too_large', + successDescription: 'registry JSON response', + ); + final dynamic decoded; + try { + decoded = jsonDecode(utf8.decode(bytes)); + } on FormatException catch (error) { + throw ZedApiError(0, 'invalid_response', 'invalid registry JSON: $error'); + } + if (decoded is! Map) { + throw ZedApiError(0, 'invalid_response', 'expected a JSON object'); + } + return PublishResponse.fromJson(decoded); + } +} diff --git a/clients/dart/lib/src/models.dart b/clients/dart/lib/src/models.dart new file mode 100644 index 0000000..bf84f0e --- /dev/null +++ b/clients/dart/lib/src/models.dart @@ -0,0 +1,192 @@ +/// Wire types mirroring `zed-interfaces/schemas/*.json` (snake_case as on the +/// wire; regenerate against those schemas when the contract changes). Unknown +/// server fields are ignored so newly added contract fields never crash older +/// clients. +library; + +/// Registry error carrying the stable `ApiError.code` and bounded explicit +/// remote text. The default diagnostic intentionally excludes that text. +class ZedApiError implements Exception { + ZedApiError(this.status, this.code, this.message); + + final int status; + final String code; + final String message; + + /// Explicit alias for callers that intentionally inspect bounded remote text. + String get registryMessage => message; + + @override + String toString() => 'registry error $status: $code'; +} + +class PackageSummary { + PackageSummary({ + required this.org, + required this.name, + this.description, + this.latest, + }); + + factory PackageSummary.fromJson(Map json) => PackageSummary( + org: json['org'] as String, + name: json['name'] as String, + description: json['description'] as String?, + latest: json['latest'] as String?, + ); + + final String org; + final String name; + final String? description; + final String? latest; +} + +class PackageMetadata { + PackageMetadata({ + required this.org, + required this.name, + required this.vcs, + required this.repoUrl, + required this.versions, + this.description, + this.latest, + this.versionScheme = 'semver', + }); + + factory PackageMetadata.fromJson(Map json) => + PackageMetadata( + org: json['org'] as String, + name: json['name'] as String, + vcs: json['vcs'] as String, + repoUrl: json['repo_url'] as String, + versions: (json['versions'] as List? ?? const []) + .cast() + .toList(), + description: json['description'] as String?, + latest: json['latest'] as String?, + versionScheme: json['version_scheme'] as String? ?? 'semver', + ); + + final String org; + final String name; + final String vcs; + final String repoUrl; + final List versions; + final String? description; + final String? latest; + final String versionScheme; +} + +class VersionMetadata { + VersionMetadata({ + required this.org, + required this.name, + required this.version, + required this.sha256, + required this.size, + required this.format, + required this.vcsTag, + required this.downloadUrl, + required this.publishedAt, + this.yanked = false, + this.vcsCommit, + }); + + factory VersionMetadata.fromJson(Map json) => + VersionMetadata( + org: json['org'] as String, + name: json['name'] as String, + version: json['version'] as String, + sha256: json['sha256'] as String, + size: (json['size'] as num).toInt(), + format: json['format'] as String, + vcsTag: json['vcs_tag'] as String, + downloadUrl: json['download_url'] as String, + publishedAt: json['published_at'] as String, + yanked: json['yanked'] as bool? ?? false, + vcsCommit: json['vcs_commit'] as String?, + ); + + final String org; + final String name; + final String version; + final String sha256; + final int size; + final String format; + final String vcsTag; + final String downloadUrl; + final String publishedAt; + final bool yanked; + final String? vcsCommit; +} + +class SearchResponse { + SearchResponse({required this.query, required this.items}); + + factory SearchResponse.fromJson(Map json) => SearchResponse( + query: json['query'] as String? ?? '', + items: (json['items'] as List? ?? const []) + .map( + (item) => PackageSummary.fromJson(item as Map)) + .toList(), + ); + + final String query; + final List items; +} + +class ClaimOrgResponse { + ClaimOrgResponse({required this.slug, required this.created}); + + factory ClaimOrgResponse.fromJson(Map json) => + ClaimOrgResponse( + slug: json['slug'] as String, + created: json['created'] as bool, + ); + + final String slug; + final bool created; +} + +class YankResponse { + YankResponse({ + required this.org, + required this.name, + required this.version, + required this.yanked, + }); + + factory YankResponse.fromJson(Map json) => YankResponse( + org: json['org'] as String, + name: json['name'] as String, + version: json['version'] as String, + yanked: json['yanked'] as bool, + ); + + final String org; + final String name; + final String version; + final bool yanked; +} + +class PublishResponse { + PublishResponse({ + required this.org, + required this.name, + required this.version, + required this.sha256, + }); + + factory PublishResponse.fromJson(Map json) => + PublishResponse( + org: json['org'] as String, + name: json['name'] as String, + version: json['version'] as String, + sha256: json['sha256'] as String, + ); + + final String org; + final String name; + final String version; + final String sha256; +} diff --git a/clients/dart/lib/zed_pkg_client.dart b/clients/dart/lib/zed_pkg_client.dart new file mode 100644 index 0000000..5935012 --- /dev/null +++ b/clients/dart/lib/zed_pkg_client.dart @@ -0,0 +1,6 @@ +/// Dart SDK for the zed-pkg registry. Models mirror the JSON Schemas in +/// `zed-interfaces/schemas/`. +library; + +export 'src/client.dart'; +export 'src/models.dart'; diff --git a/clients/dart/pubspec.yaml b/clients/dart/pubspec.yaml new file mode 100644 index 0000000..f6ecb4c --- /dev/null +++ b/clients/dart/pubspec.yaml @@ -0,0 +1,15 @@ +name: zed_pkg_client +description: Dart SDK for the zed-pkg registry. +version: 0.1.0 +repository: https://github.com/zed-pkg/zed-clients + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + crypto: ">=3.0.0 <4.0.0" + http: ">=1.2.0 <2.0.0" + +dev_dependencies: + lints: ">=4.0.0 <6.0.0" + test: ">=1.24.0 <2.0.0" diff --git a/clients/dart/test/hardening_test.dart b/clients/dart/test/hardening_test.dart new file mode 100644 index 0000000..14e5842 --- /dev/null +++ b/clients/dart/test/hardening_test.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; +import 'package:zed_pkg_client/zed_pkg_client.dart'; +import 'package:zed_pkg_client/src/client.dart' as client; + +class _HugeList extends ListBase { + @override + int get length => client.maxArtifactBytes + 1; + + @override + set length(int value) => throw UnsupportedError('immutable'); + + @override + int operator [](int index) => 0; + + @override + void operator []=(int index, int value) => + throw UnsupportedError('immutable'); +} + +class _SlowBodyClient extends http.BaseClient { + http.BaseRequest? seen; + + @override + Future send(http.BaseRequest request) async { + seen = request; + return http.StreamedResponse( + Stream>.periodic( + const Duration(milliseconds: 100), + (_) => utf8.encode('{}'), + ).take(1), + 200, + ); + } +} + +Map _meta({String org = 'acme'}) => { + 'manifest': { + 'package': {'org': org, 'name': 'kit', 'version': '1.2.0'}, + }, + }; + +VersionMetadata _version(List body, {String downloadUrl = ''}) => + VersionMetadata( + org: 'acme', + name: 'kit', + version: '1.2.0', + sha256: sha256.convert(body).toString().toUpperCase(), + size: body.length, + format: 'tar.gz', + vcsTag: 'v1.2.0', + downloadUrl: downloadUrl, + publishedAt: '2026-08-02T00:00:00Z', + ); + +void main() { + test('registry bases and hostile route segments are rejected', () { + for (final base in [ + 'relative/path', + 'ftp://registry.test', + 'https://user:secret@registry.test', + 'https://registry.test?tenant=one', + 'https://registry.test#fragment', + 'https://registry.test/../admin', + 'https://registry.test/%2e%2e/admin', + 'https://registry.test/a%2Fb', + ]) { + expect( + () => ZedClient(registryUrl: base), + throwsArgumentError, + reason: base, + ); + } + for (final value in ['', ' ', '.', '..', 'line\nbreak', 'nul\x00byte']) { + expect( + () => client.packagePath(value, 'kit'), + throwsArgumentError, + reason: value, + ); + } + expect( + () => client.versionPath( + 'acme', + 'kit', + 'x' * (client.maxPathSegmentBytes + 1), + ), + throwsArgumentError, + ); + }); + + test( + 'authenticated operations fail before transport without a token', + () async { + var calls = 0; + final mock = MockClient((request) async { + calls += 1; + throw StateError('transport must not run'); + }); + final zed = ZedClient(httpClient: mock); + final operations = Function()>[ + () => zed.claimOrg('acme'), + () => zed.yank('acme', 'kit', '1.2.0'), + () => zed.restore('acme', 'kit', '1.2.0'), + () => zed.publish(_meta(), utf8.encode('artifact')), + ]; + for (final operation in operations) { + await expectLater( + operation, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'missing_token', + ), + ), + ); + } + expect(calls, 0); + }, + ); + + test( + 'redirect refusal is explicit on registry and artifact requests', + () async { + final seen = []; + final body = utf8.encode('artifact'); + final mock = MockClient((request) async { + seen.add(request); + if (request.url.path.contains('/artifacts/')) { + return http.Response.bytes(body, 200); + } + return http.Response(jsonEncode({'query': 'x', 'items': []}), 200); + }); + final zed = ZedClient(httpClient: mock); + await zed.search('x'); + await zed.downloadArtifact(_version(body)); + expect(seen, hasLength(2)); + for (final request in seen) { + expect(request.followRedirects, isFalse); + expect(request.maxRedirects, 0); + } + }, + ); + + test('success and error bodies use independent bounds', () async { + final oversizedSuccess = MockClient( + (request) async => http.Response( + '{}', + 200, + headers: {'content-length': '${client.maxJsonResponseBytes + 1}'}, + ), + ); + await expectLater( + ZedClient(httpClient: oversizedSuccess).search('x'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'response_too_large', + ), + ), + ); + + final remote = 'provider-secret' * client.maxErrorBodyBytes; + final oversizedError = MockClient( + (request) async => http.Response(remote, 502), + ); + try { + await ZedClient(httpClient: oversizedError).search('x'); + fail('expected ZedApiError'); + } on ZedApiError catch (error) { + expect( + utf8.encode(error.registryMessage).length, + lessThanOrEqualTo(client.maxErrorBodyBytes), + ); + expect(error.toString(), 'registry error 502: http_502'); + expect(error.toString(), isNot(contains('provider-secret'))); + } + }); + + test('relative artifact urls preserve the registry gateway prefix', () async { + final body = utf8.encode('artifact'); + late Uri seen; + final mock = MockClient((request) async { + seen = request.url; + return http.Response.bytes(body, 200); + }); + final zed = ZedClient( + registryUrl: 'https://registry.test/gateway', + httpClient: mock, + ); + final bytes = await zed.downloadArtifact( + _version(body, downloadUrl: 'artifacts/hash'), + ); + expect(bytes, body); + expect(seen.toString(), 'https://registry.test/gateway/artifacts/hash'); + }); + + test( + 'credentialed and fragmented artifact urls fail before transport', + () async { + var calls = 0; + final zed = ZedClient( + httpClient: MockClient((request) async { + calls += 1; + return http.Response('', 200); + }), + ); + for (final raw in [ + 'https://user:secret@cdn.test/artifact', + 'https://cdn.test/artifact#fragment', + ]) { + await expectLater( + zed.downloadArtifact(_version([1], downloadUrl: raw)), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'bad_download_url', + ), + ), + ); + } + expect(calls, 0); + }, + ); + + test('restore sends the canonical false body with a trimmed token', () async { + late http.Request seen; + final mock = MockClient((request) async { + seen = request; + return http.Response( + jsonEncode({ + 'org': 'acme', + 'name': 'kit', + 'version': '1.2.0', + 'yanked': false, + }), + 200, + ); + }); + final result = await ZedClient( + httpClient: mock, + token: ' token ', + ).restore('acme', 'kit', '1.2.0'); + expect(result.yanked, isFalse); + expect(seen.headers['authorization'], 'Bearer token'); + expect(jsonDecode(seen.body), {'yanked': false}); + }); + + test('publish rejects oversized artifacts before transport', () async { + var calls = 0; + final zed = ZedClient( + token: 'token', + httpClient: MockClient((request) async { + calls += 1; + throw StateError('transport must not run'); + }), + ); + await expectLater( + zed.publish(_meta(), _HugeList()), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'artifact_too_large', + ), + ), + ); + expect(calls, 0); + }); + + test('deadline remains active while a response body is pending', () async { + final slow = _SlowBodyClient(); + final zed = ZedClient( + httpClient: slow, + timeout: const Duration(milliseconds: 5), + ); + await expectLater(zed.search('x'), throwsA(isA())); + expect(slow.seen?.followRedirects, isFalse); + }); +} diff --git a/clients/dart/test/zed_pkg_client_test.dart b/clients/dart/test/zed_pkg_client_test.dart new file mode 100644 index 0000000..eb5f585 --- /dev/null +++ b/clients/dart/test/zed_pkg_client_test.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; +import 'package:zed_pkg_client/zed_pkg_client.dart'; +import 'package:zed_pkg_client/src/client.dart' as client; + +VersionMetadata makeVersion({ + String sha256 = '', + int size = 0, + String downloadUrl = '', +}) => + VersionMetadata( + org: 'acme', + name: 'kit', + version: '1.2.0', + sha256: sha256, + size: size, + format: 'tar.gz', + vcsTag: 'v1.2.0', + downloadUrl: downloadUrl, + publishedAt: '2024-01-01T00:00:00Z', + ); + +void main() { + test('url helpers match the contract', () { + expect(client.packagePath('acme', 'kit'), '/v1/packages/acme/kit'); + expect( + client.versionPath('acme', 'kit', '1.2.0'), + '/v1/packages/acme/kit/versions/1.2.0', + ); + expect(client.artifactPath('abc'), '/v1/artifacts/abc'); + expect( + client.yankPath('acme', 'kit', '1.2.0'), + '/v1/packages/acme/kit/versions/1.2.0/yank', + ); + }); + + test('path segments are percent-encoded', () { + expect( + client.versionPath('acme', 'kit', 'release candidate/1'), + '/v1/packages/acme/kit/versions/release%20candidate%2F1', + ); + }); + + test('download limit caps at the ceiling', () { + expect(client.downloadLimit(0), client.maxArtifactBytes); + expect(client.downloadLimit(10), 10 + 1024 * 1024); + expect( + client.downloadLimit(client.maxArtifactBytes), + client.maxArtifactBytes, + ); + }); + + test('insecure download urls are rejected', () { + const base = 'https://registry.zpkg.tech'; + for (final raw in ['http://evil.example/a', 'file:///etc/passwd']) { + expect( + () => client.allowedDownloadUrl(raw, base), + throwsA(isA()), + ); + } + expect( + client.allowedDownloadUrl('https://cdn.example/a', base), + 'https://cdn.example/a', + ); + expect( + client.allowedDownloadUrl('http://127.0.0.1:8080/a', base), + 'http://127.0.0.1:8080/a', + ); + expect( + client.allowedDownloadUrl('http://localhost/a', base), + 'http://localhost/a', + ); + expect( + client.allowedDownloadUrl( + 'http://mirror.internal/a', + 'http://registry.internal', + ), + 'http://mirror.internal/a', + ); + }); + + test('getPackage decodes metadata and sends no auth header', () async { + late http.Request seen; + final mock = MockClient((request) async { + seen = request; + return http.Response( + jsonEncode({ + 'org': 'acme', + 'name': 'kit', + 'vcs': 'git', + 'repo_url': 'https://github.com/acme/kit', + 'latest': '1.2.0', + 'versions': ['1.2.0'], + 'version_scheme': 'calver', + 'unknown_future_field': true, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + final zed = ZedClient( + registryUrl: 'https://registry.zpkg.tech/', + httpClient: mock, + ); + final pkg = await zed.getPackage('acme', 'kit'); + expect( + seen.url.toString(), + 'https://registry.zpkg.tech/v1/packages/acme/kit', + ); + expect(seen.headers.containsKey('authorization'), isFalse); + expect(pkg.latest, '1.2.0'); + expect(pkg.versionScheme, 'calver'); + }); + + test( + 'api errors carry the registry code without leaking by default', + () async { + final mock = MockClient( + (request) async => http.Response( + jsonEncode({'code': 'org_taken', 'message': 'claimed'}), + 409, + ), + ); + final zed = ZedClient(httpClient: mock, token: 'zpkg_t'); + try { + await zed.claimOrg('acme'); + fail('expected ZedApiError'); + } on ZedApiError catch (error) { + expect(error.status, 409); + expect(error.code, 'org_taken'); + expect(error.message, 'claimed'); + expect(error.registryMessage, 'claimed'); + expect(error.toString(), 'registry error 409: org_taken'); + } + }, + ); + + test('non-JSON error bodies map to the HTTP-derived code', () async { + final mock = MockClient((request) async => http.Response('boom', 500)); + final zed = ZedClient(httpClient: mock); + try { + await zed.getVersion('acme', 'kit', '1.2.0'); + fail('expected ZedApiError'); + } on ZedApiError catch (error) { + expect(error.code, 'http_500'); + expect(error.message, 'boom'); + expect(error.toString(), 'registry error 500: http_500'); + } + }); + + test('claimOrg and yank send the bearer token', () async { + final auths = []; + final mock = MockClient((request) async { + auths.add(request.headers['authorization']); + if (request.url.path.endsWith('/yank')) { + return http.Response( + jsonEncode({ + 'org': 'acme', + 'name': 'kit', + 'version': '1.2.0', + 'yanked': true, + }), + 200, + ); + } + return http.Response(jsonEncode({'slug': 'acme', 'created': true}), 200); + }); + final zed = ZedClient(httpClient: mock, token: 'zpkg_t'); + final org = await zed.claimOrg('acme'); + expect(org.created, isTrue); + final yank = await zed.yank('acme', 'kit', '1.2.0', true); + expect(yank.yanked, isTrue); + expect(auths, ['Bearer zpkg_t', 'Bearer zpkg_t']); + }); + + test('downloadArtifact verifies sha256 and omits the token', () async { + final body = utf8.encode('artifact-bytes'); + final digest = sha256.convert(body).toString(); + String? auth; + final mock = MockClient((request) async { + auth = request.headers['authorization']; + return http.Response.bytes(body, 200); + }); + final zed = ZedClient(httpClient: mock, token: 'zpkg_t'); + final bytes = await zed.downloadArtifact( + makeVersion(sha256: digest, size: body.length), + ); + expect(bytes, body); + expect(auth, isNull, reason: 'bearer token must not leak to downloads'); + }); + + test('downloadArtifact rejects a sha mismatch', () async { + final mock = MockClient( + (request) async => http.Response.bytes(utf8.encode('tampered'), 200), + ); + final zed = ZedClient(httpClient: mock); + expect( + () => zed.downloadArtifact(makeVersion(sha256: '00', size: 8)), + throwsA( + isA().having((e) => e.code, 'code', 'sha256_mismatch'), + ), + ); + }); + + test('downloadArtifact rejects an oversize body', () async { + final limit = client.downloadLimit(1); + final mock = MockClient( + (request) async => http.Response.bytes(List.filled(limit + 64, 0), 200), + ); + final zed = ZedClient(httpClient: mock); + expect( + () => zed.downloadArtifact(makeVersion(sha256: 'deadbeef', size: 1)), + throwsA( + isA().having((e) => e.code, 'code', 'artifact_too_large'), + ), + ); + }); + + test('publish sends multipart meta + artifact', () async { + late http.Request seen; + final mock = MockClient((request) async { + seen = request; + return http.Response( + jsonEncode({ + 'org': 'acme', + 'name': 'kit', + 'version': '1.2.0', + 'sha256': 'abc', + }), + 200, + ); + }); + final zed = ZedClient(httpClient: mock, token: 'zpkg_t'); + final meta = { + 'manifest': { + 'package': {'org': 'acme', 'name': 'kit', 'version': '1.2.0'}, + }, + }; + final response = await zed.publish(meta, utf8.encode('bytes')); + expect(response.sha256, 'abc'); + expect(seen.method, 'PUT'); + expect(seen.url.path, '/v1/packages/acme/kit/versions/1.2.0'); + expect(seen.headers['content-type'], startsWith('multipart/form-data')); + expect(seen.headers['authorization'], 'Bearer zpkg_t'); + final raw = latin1.decode(seen.bodyBytes); + expect(raw, contains('name="meta"')); + expect(raw, contains('name="artifact"')); + expect(raw, contains('filename="acme-kit-1.2.0.tar.gz"')); + }); +} diff --git a/clients/elixir/README.md b/clients/elixir/README.md new file mode 100644 index 0000000..df3d71c --- /dev/null +++ b/clients/elixir/README.md @@ -0,0 +1,6 @@ +# zed_pkg_client for Elixir + +```elixir +client = ZedPkgClient.new(token: System.get_env("ZED_TOKEN")) +{:ok, package} = ZedPkgClient.package(client, "zed-pkg", "zed-clients") +``` diff --git a/clients/elixir/lib/zed_pkg_client.ex b/clients/elixir/lib/zed_pkg_client.ex new file mode 100644 index 0000000..ae1a73c --- /dev/null +++ b/clients/elixir/lib/zed_pkg_client.ex @@ -0,0 +1,192 @@ +defmodule ZedPkgClient do + @moduledoc "Core Elixir client for the zed-pkg registry." + + @default_registry "https://registry.zpkg.tech" + @max_json_bytes 16 * 1024 * 1024 + @max_artifact_bytes 100 * 1024 * 1024 + + defstruct [:base_url, :token, timeout: 30_000] + + defmodule Error do + defexception [:status, :code, :registry_message] + + @impl true + def message(error), do: "registry error #{error.status}: #{error.code}" + end + + @type t :: %__MODULE__{base_url: String.t(), token: String.t() | nil, timeout: pos_integer()} + + @spec new(keyword()) :: t() + def new(opts \\ []) do + base_url = normalize_base(Keyword.get(opts, :registry_url, @default_registry)) + %__MODULE__{base_url: base_url, token: Keyword.get(opts, :token), timeout: Keyword.get(opts, :timeout, 30_000)} + end + + def package(client, org, name), + do: json(client, :get, "/v1/packages/#{segment(org)}/#{segment(name)}") + + def version(client, org, name, version), + do: json(client, :get, "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}") + + def search(client, query), + do: json(client, :get, "/v1/search?q=#{URI.encode_www_form(to_string(query))}") + + def claim_org(client, org), + do: json(client, :post, "/v1/orgs", Jason.encode!(%{org: org}), true) + + def set_yanked(client, org, name, version, yanked, reason \\ nil) do + payload = %{yanked: !!yanked} |> maybe_reason(reason) |> Jason.encode!() + json( + client, + :post, + "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}/yank", + payload, + true + ) + end + + def yank(client, org, name, version, reason \\ nil), + do: set_yanked(client, org, name, version, true, reason) + + def restore(client, org, name, version), + do: set_yanked(client, org, name, version, false) + + def publish(client, org, name, version, artifact_path, metadata) do + artifact = File.read!(artifact_path) + boundary = "zed-#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}" + body = multipart(boundary, Jason.encode!(metadata), Path.basename(artifact_path), artifact) + + with {:ok, response} <- + request( + client, + :put, + "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}", + body, + "multipart/form-data; boundary=#{boundary}", + true, + @max_json_bytes + ), + {:ok, decoded} <- Jason.decode(response) do + {:ok, decoded} + else + {:error, %Jason.DecodeError{} = error} -> {:error, %Error{status: 0, code: "invalid_json", registry_message: Exception.message(error)}} + other -> other + end + end + + def download_artifact(client, sha256, destination \\ nil) do + expected = String.downcase(to_string(sha256)) + + if not Regex.match?(~r/^[0-9a-f]{64}$/, expected) do + {:error, %Error{status: 0, code: "invalid_sha256", registry_message: "sha256 must be 64 hexadecimal characters"}} + else + with {:ok, body} <- request(client, :get, "/v1/artifacts/#{expected}", nil, nil, false, @max_artifact_bytes), + actual = :crypto.hash(:sha256, body) |> Base.encode16(case: :lower), + :ok <- verify_digest(expected, actual), + :ok <- maybe_write(destination, body) do + {:ok, body} + end + end + end + + defp json(client, method, path, body \\ nil, auth \\ false) do + with {:ok, response} <- request(client, method, path, body, if(body, do: "application/json", else: nil), auth, @max_json_bytes), + {:ok, decoded} <- decode_json(response) do + {:ok, decoded} + end + end + + defp request(client, method, path, body, content_type, auth, limit) do + :inets.start() + :ssl.start() + url = String.to_charlist(client.base_url <> path) + headers = headers(client, auth) + options = [timeout: client.timeout, connect_timeout: min(client.timeout, 10_000), autoredirect: false] + http_options = [body_format: :binary] + + request = + if is_nil(body) do + {url, headers} + else + {url, headers, String.to_charlist(content_type), body} + end + + case :httpc.request(method, request, options, http_options) do + {:ok, {{_, status, _}, _response_headers, response_body}} when status in 200..299 -> + if byte_size(response_body) <= limit do + {:ok, response_body} + else + {:error, %Error{status: 0, code: "response_too_large", registry_message: "response exceeded #{limit} bytes"}} + end + + {:ok, {{_, status, _}, _response_headers, response_body}} -> + remote = decode_error(response_body) + {:error, %Error{status: status, code: remote.code, registry_message: remote.message}} + + {:error, reason} -> + {:error, %Error{status: 0, code: "transport_error", registry_message: inspect(reason)}} + end + end + + defp headers(client, auth) do + base = [{'accept', 'application/json'}] + + if auth and is_binary(client.token) and client.token != "" do + [{'authorization', String.to_charlist("Bearer " <> client.token)} | base] + else + base + end + end + + defp normalize_base(raw) do + uri = URI.parse(String.trim(to_string(raw))) + + if uri.scheme in ["http", "https"] and is_binary(uri.host) and is_nil(uri.userinfo) and + is_nil(uri.query) and is_nil(uri.fragment) do + %{uri | path: String.trim_trailing(uri.path || "", "/")} |> URI.to_string() |> String.trim_trailing("/") + else + raise ArgumentError, "registry_url must be a credential-free absolute HTTP(S) URL" + end + end + + defp segment(value) do + value = to_string(value) + + if String.trim(value) == "" or value in [".", ".."] or byte_size(value) > 256 or + Regex.match?(~r/[\x00-\x1f\x7f]/, value) do + raise ArgumentError, "invalid path segment" + end + + URI.encode(value, &URI.char_unreserved?/1) + end + + defp decode_json(""), do: {:ok, %{}} + defp decode_json(body), do: Jason.decode(body) + + defp decode_error(body) do + case Jason.decode(body) do + {:ok, %{"code" => code, "message" => message}} -> %{code: code, message: String.slice(to_string(message), 0, 16_384)} + _ -> %{code: "http_error", message: String.slice(body, 0, 16_384)} + end + end + + defp maybe_reason(payload, nil), do: payload + defp maybe_reason(payload, reason), do: Map.put(payload, :reason, reason) + + defp multipart(boundary, meta, filename, artifact) do + safe = String.replace(filename, ~r/[\r\n\"]/, "_") + + IO.iodata_to_binary([ + "--#{boundary}\r\nContent-Disposition: form-data; name=\"meta\"\r\nContent-Type: application/json\r\n\r\n", + meta, + "\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"artifact\"; filename=\"#{safe}\"\r\nContent-Type: application/octet-stream\r\n\r\n", + artifact, + "\r\n--#{boundary}--\r\n" + ]) + end + + defp verify_digest(expected, expected), do: :ok + defp verify_digest(expected, actual), do: {:error, %Error{status: 0, code: "digest_mismatch", registry_message: "expected #{expected}, got #{actual}"}} + defp maybe_write(nil, _body), do: :ok + defp maybe_write(path, body), do: File.write(path, body) +end diff --git a/clients/elixir/mix.exs b/clients/elixir/mix.exs new file mode 100644 index 0000000..ce86827 --- /dev/null +++ b/clients/elixir/mix.exs @@ -0,0 +1,19 @@ +defmodule ZedPkgClient.MixProject do + use Mix.Project + + def project do + [ + app: :zed_pkg_client, + version: "0.1.0", + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + deps: [{:jason, "~> 1.4"}], + description: "Elixir client for the zed-pkg registry", + package: [licenses: ["MIT"], links: %{"GitHub" => "https://github.com/zed-pkg/zed-clients"}] + ] + end + + def application do + [extra_applications: [:logger, :inets, :ssl, :crypto]] + end +end diff --git a/clients/elixir/test/test_helper.exs b/clients/elixir/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/clients/elixir/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/clients/elixir/test/zed_pkg_client_test.exs b/clients/elixir/test/zed_pkg_client_test.exs new file mode 100644 index 0000000..90e79d8 --- /dev/null +++ b/clients/elixir/test/zed_pkg_client_test.exs @@ -0,0 +1,11 @@ +defmodule ZedPkgClientTest do + use ExUnit.Case, async: true + + test "rejects credentials in the registry URL" do + assert_raise ArgumentError, fn -> ZedPkgClient.new(registry_url: "https://user:pass@example.com") end + end + + test "accepts an absolute HTTP URL" do + assert %ZedPkgClient{} = ZedPkgClient.new(registry_url: "http://localhost:8080/api/") + end +end diff --git a/clients/erlang/README.md b/clients/erlang/README.md new file mode 100644 index 0000000..caecc03 --- /dev/null +++ b/clients/erlang/README.md @@ -0,0 +1,18 @@ +# zed_pkg_client (Erlang) + +Erlang/OTP SDK for the [zed-pkg](https://zpkg.tech) registry. Stdlib only +(`httpc` + the OTP 27+ `json` module + `crypto`); maps mirror the JSON +Schemas in `zed-interfaces/schemas/`. + +```erlang +{ok, Client0} = zed_pkg_client:new(<<"https://registry.zpkg.tech">>), +{ok, Package} = zed_pkg_client:get_package(Client0, <<"acme">>, <<"kit">>), +{ok, Version} = zed_pkg_client:get_version(Client0, <<"acme">>, <<"kit">>, <<"1.2.0">>), +{ok, Bytes} = zed_pkg_client:download_artifact(Client0, Version), % sha256-verified + +Client = zed_pkg_client:with_token(Client0, <<"zpkg_...">>), +{ok, _} = zed_pkg_client:claim_org(Client, <<"acme">>), +{ok, _} = zed_pkg_client:publish(Client, <<"acme">>, <<"kit">>, <<"1.2.0">>, MetaJson, ArtifactBin). +``` + +Requires OTP 27 or newer. Verify with `rebar3 compile && rebar3 eunit`. diff --git a/clients/erlang/rebar.config b/clients/erlang/rebar.config new file mode 100644 index 0000000..8d2bfa1 --- /dev/null +++ b/clients/erlang/rebar.config @@ -0,0 +1,3 @@ +{erl_opts, [debug_info, warnings_as_errors]}. +{deps, []}. +{eunit_opts, [verbose]}. diff --git a/clients/erlang/rebar.lock b/clients/erlang/rebar.lock new file mode 100644 index 0000000..57afcca --- /dev/null +++ b/clients/erlang/rebar.lock @@ -0,0 +1 @@ +[]. diff --git a/clients/erlang/src/zed_pkg_client.app.src b/clients/erlang/src/zed_pkg_client.app.src new file mode 100644 index 0000000..c879c89 --- /dev/null +++ b/clients/erlang/src/zed_pkg_client.app.src @@ -0,0 +1,10 @@ +{application, zed_pkg_client, [ + {description, "Erlang SDK for the zed-pkg registry (stdlib only: httpc + json + crypto)"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib, inets, ssl, crypto]}, + {env, []}, + {modules, []}, + {licenses, ["MIT"]}, + {links, [{"GitHub", "https://github.com/zed-pkg/zed-clients"}]} +]}. diff --git a/clients/erlang/src/zed_pkg_client.erl b/clients/erlang/src/zed_pkg_client.erl new file mode 100644 index 0000000..a39eafd --- /dev/null +++ b/clients/erlang/src/zed_pkg_client.erl @@ -0,0 +1,743 @@ +%% Erlang SDK for the zed-pkg registry. Stdlib only (httpc + json + crypto); +%% maps mirror the JSON Schemas in zed-interfaces/schemas/. +-module(zed_pkg_client). + +-export([ + new/1, + new/2, + with_token/2, + get_package/3, + get_version/4, + search/2, + claim_org/2, + set_yanked/5, + yank/5, + restore/4, + download_artifact/2, + download_artifact/3, + publish/6, + %% Exported for tests and reuse. + package_path/2, + version_path/3, + artifact_path/1, + yank_path/3, + encode_segment/1, + download_limit/1, + allowed_download_url/2, + verify_sha256/2, + multipart_body/4 +]). + +-define(DEFAULT_REGISTRY_URL, <<"https://registry.zpkg.tech">>). + +%% Bounds every request (connect + read), in milliseconds. +-define(DEFAULT_TIMEOUT, 30000). + +%% Independent response ceilings. +-define(MAX_JSON_RESPONSE_BYTES, 16777216). +-define(MAX_ERROR_BODY_BYTES, 16384). +-define(MAX_PATH_SEGMENT_BYTES, 256). +-define(MAX_TOKEN_BYTES, 8192). + +%% Hard ceiling on artifact downloads, matching the server's +%% MAX_ARTIFACT_BYTES default (100 MiB); plus the slack added to a version's +%% declared size. +-define(MAX_ARTIFACT_BYTES, 104857600). +-define(DOWNLOAD_SLACK, 1048576). + +%%-------------------------------------------------------------------- +%% Construction +%%-------------------------------------------------------------------- + +new(BaseUrl) -> + new(BaseUrl, ?DEFAULT_TIMEOUT). + +new(BaseUrl, Timeout) when is_integer(Timeout), Timeout > 0 -> + case validate_base(BaseUrl) of + {ok, Base} -> + {ok, #{ + base => Base, + token => undefined, + token_error => undefined, + timeout => Timeout + }}; + Error -> + Error + end; +new(_BaseUrl, _Timeout) -> + {error, {invalid_configuration, <<"timeout must be a positive integer">>}}. + +%% Attach a bearer token used for authenticated calls (claim_org, yank, +%% publish). Never sent on artifact downloads. Blank tokens are absent; tokens +%% containing control characters are retained as an error so auth fails before +%% transport instead of creating an unsafe header. +with_token(Client, Token) when is_map(Client) -> + case normalize_token(Token) of + {ok, Normalized} -> Client#{token => Normalized, token_error => undefined}; + {error, Reason} -> Client#{token => undefined, token_error => Reason} + end; +with_token(_Client, _Token) -> + {error, {invalid_configuration, <<"client must be a map returned by new/1">>}}. + +normalize_token(undefined) -> + {ok, undefined}; +normalize_token(Value) when is_binary(Value); is_list(Value); is_atom(Value) -> + Token = trim_binary(text(Value)), + case Token of + <<>> -> + {ok, undefined}; + _ when byte_size(Token) > ?MAX_TOKEN_BYTES -> + {error, + {invalid_input, + <<"token must not exceed 8192 UTF-8 bytes">>}}; + _ -> + case has_control(Token) of + true -> + {error, + {invalid_input, + <<"token must not contain control characters">>}}; + false -> {ok, Token} + end + end; +normalize_token(_) -> + {error, {invalid_input, <<"token must be text">>}}. + +validate_base(Value) -> + Base = trim_binary(text(Value)), + try uri_string:parse(Base) of + Parsed when is_map(Parsed) -> + Scheme = maps:get(scheme, Parsed, undefined), + Host = maps:get(host, Parsed, undefined), + InvalidAuthority = maps:is_key(userinfo, Parsed), + InvalidSuffix = maps:is_key(query, Parsed) orelse maps:is_key(fragment, Parsed), + case + (Scheme =:= <<"http">> orelse Scheme =:= <<"https">>) + andalso is_binary(Host) + andalso Host =/= <<>> + andalso not InvalidAuthority + andalso not InvalidSuffix + of + true -> + case validate_path(maps:get(path, Parsed, <<>>), <<"registry URL path">>) of + ok -> {ok, trim_trailing_slashes(Base)}; + PathError -> PathError + end; + false -> + {error, + {invalid_configuration, + <<"registry URL must be credential-free absolute HTTP(S) without query or fragment">>}} + end; + _ -> + {error, {invalid_configuration, <<"registry URL is invalid">>}} + catch + _:_ -> {error, {invalid_configuration, <<"registry URL is invalid">>}} + end. + +trim_trailing_slashes(<<>>) -> + <<>>; +trim_trailing_slashes(Base) -> + case binary:last(Base) of + $/ -> trim_trailing_slashes(binary:part(Base, 0, byte_size(Base) - 1)); + _ -> Base + end. + +client_base(Client) when is_map(Client) -> + case maps:find(base, Client) of + {ok, Base} -> validate_base(Base); + error -> {error, {invalid_configuration, <<"client base URL is missing">>}} + end; +client_base(_) -> + {error, {invalid_configuration, <<"client must be a map returned by new/1">>}}. + +client_timeout(Client) -> + case maps:get(timeout, Client, ?DEFAULT_TIMEOUT) of + Timeout when is_integer(Timeout), Timeout > 0 -> {ok, Timeout}; + _ -> {error, {invalid_configuration, <<"timeout must be a positive integer">>}} + end. + +%%-------------------------------------------------------------------- +%% Paths +%%-------------------------------------------------------------------- + +encode_segment(Segment0) -> + Segment = text(Segment0), + case Segment of + <<".">> -> <<"%2E">>; + <<"..">> -> <<"%2E%2E">>; + _ -> iolist_to_binary(uri_string:quote(Segment)) + end. + +package_path(Org, Name) -> + <<"/v1/packages/", (encode_segment(Org))/binary, "/", (encode_segment(Name))/binary>>. + +version_path(Org, Name, Version) -> + <<(package_path(Org, Name))/binary, "/versions/", (encode_segment(Version))/binary>>. + +artifact_path(Sha256) -> + <<"/v1/artifacts/", (encode_segment(Sha256))/binary>>. + +yank_path(Org, Name, Version) -> + <<(version_path(Org, Name, Version))/binary, "/yank">>. + +validate_segment(Value, Name) -> + Segment = text(Value), + case + trim_binary(Segment) =:= <<>> + orelse Segment =:= <<".">> + orelse Segment =:= <<"..">> + orelse byte_size(Segment) > ?MAX_PATH_SEGMENT_BYTES + orelse has_control(Segment) + of + true -> + {error, + {invalid_input, + <>}}; + false -> + {ok, Segment} + end. + +validate_path(Path0, Name) -> + Path = text(Path0), + validate_path_segments(binary:split(Path, <<"/">>, [global]), Name, 1). + +validate_path_segments([], _Name, _Index) -> + ok; +validate_path_segments([<<>> | Rest], Name, Index) -> + validate_path_segments(Rest, Name, Index + 1); +validate_path_segments([Encoded | Rest], Name, Index) -> + try text(uri_string:unquote(Encoded)) of + Decoded -> + case binary:match(Decoded, <<"/">>) =/= nomatch + orelse binary:match(Decoded, <<"\\">>) =/= nomatch + of + true -> + {error, + {invalid_input, + <>}}; + false -> + SegmentName = <>, + case validate_segment(Decoded, SegmentName) of + {ok, _} -> validate_path_segments(Rest, Name, Index + 1); + SegmentError -> SegmentError + end + end + catch + _:_ -> {error, {invalid_input, <>}} + end. + +checked_package_path(Org, Name) -> + case validate_segment(Org, <<"org">>) of + {ok, CheckedOrg} -> + case validate_segment(Name, <<"name">>) of + {ok, CheckedName} -> {ok, package_path(CheckedOrg, CheckedName)}; + NameError -> NameError + end; + OrgError -> OrgError + end. + +checked_version_path(Org, Name, Version) -> + case checked_package_path(Org, Name) of + {ok, PackagePath} -> + case validate_segment(Version, <<"version">>) of + {ok, CheckedVersion} -> + {ok, <>}; + VersionError -> VersionError + end; + PackageError -> PackageError + end. + +checked_yank_path(Org, Name, Version) -> + case checked_version_path(Org, Name, Version) of + {ok, Path} -> {ok, <>}; + VersionPathError -> VersionPathError + end. + +%%-------------------------------------------------------------------- +%% Download policy and integrity +%%-------------------------------------------------------------------- + +download_limit(Size) when is_integer(Size), Size > 0 -> + min(Size + ?DOWNLOAD_SLACK, ?MAX_ARTIFACT_BYTES); +download_limit(_Size) -> + ?MAX_ARTIFACT_BYTES. + +allowed_download_url(Raw, Base) -> + RawText = text(Raw), + try uri_string:parse(RawText) of + Parsed when is_map(Parsed) -> + Scheme = maps:get(scheme, Parsed, undefined), + Host = maps:get(host, Parsed, undefined), + HasUserInfo = maps:is_key(userinfo, Parsed), + HasFragment = maps:is_key(fragment, Parsed), + case + is_binary(Host) + andalso Host =/= <<>> + andalso not HasUserInfo + andalso not HasFragment + of + false -> + {error, + {insecure_download_url, + <<"download URL contains credentials, fragment, or no host">>}}; + true -> + case Scheme of + <<"https">> -> {ok, RawText}; + <<"http">> -> + case is_loopback(Host) orelse is_http_base(text(Base)) of + true -> {ok, RawText}; + false -> + {error, + {insecure_download_url, + <<"refusing artifact download over `http`">>}} + end; + _ -> + {error, + {insecure_download_url, + <<"refusing artifact download over an unsupported scheme">>}} + end + end; + _ -> + {error, {insecure_download_url, <<"download URL is invalid">>}} + catch + _:_ -> {error, {insecure_download_url, <<"download URL is invalid">>}} + end. + +is_http_base(<<"http://", _/binary>>) -> true; +is_http_base(_) -> false. + +is_loopback(<<"localhost">>) -> + true; +is_loopback(<<"[::1]">>) -> + true; +is_loopback(Host) when is_binary(Host) -> + case inet:parse_address(binary_to_list(Host)) of + {ok, Address} -> + case Address of + {127, _, _, _} -> true; + {0, 0, 0, 0, 0, 0, 0, 1} -> true; + _ -> false + end; + {error, _} -> + false + end. + +resolve_download_url(DownloadUrl0, Base, Sha256) -> + DownloadUrl = trim_binary(text(DownloadUrl0)), + case DownloadUrl of + <<>> -> + case validate_segment(Sha256, <<"sha256">>) of + {ok, CheckedSha} -> {ok, <>}; + ShaError -> ShaError + end; + _ -> + try uri_string:parse(DownloadUrl) of + Parsed when is_map(Parsed) -> + case maps:get(scheme, Parsed, undefined) of + undefined -> + case + maps:get(host, Parsed, undefined) =:= undefined + andalso not maps:is_key(userinfo, Parsed) + andalso not maps:is_key(fragment, Parsed) + andalso not starts_with_slash(DownloadUrl) + of + true -> + case validate_path(maps:get(path, Parsed, <<>>), <<"download_url">>) of + ok -> allowed_download_url(<>, Base); + RelativePathError -> RelativePathError + end; + false -> + {error, + {insecure_download_url, + <<"relative download URL contains an authority, fragment, or absolute path">>}} + end; + _ -> + allowed_download_url(DownloadUrl, Base) + end; + _ -> + {error, {insecure_download_url, <<"download URL is invalid">>}} + catch + _:_ -> {error, {insecure_download_url, <<"download URL is invalid">>}} + end + end. + +starts_with_slash(<<"/", _/binary>>) -> true; +starts_with_slash(_) -> false. + +verify_sha256(Bytes, Expected) -> + Actual = binary:encode_hex(crypto:hash(sha256, Bytes), lowercase), + ExpectedLower = lower_binary(text(Expected)), + case Actual =:= ExpectedLower of + true -> ok; + false -> {error, {sha256_mismatch, #{expected => text(Expected), actual => Actual}}} + end. + +%%-------------------------------------------------------------------- +%% Operations +%%-------------------------------------------------------------------- + +get_package(Client, Org, Name) -> + case checked_package_path(Org, Name) of + {ok, Path} -> request_json(Client, get, Path, undefined, false); + PathError -> PathError + end. + +get_version(Client, Org, Name, Version) -> + case checked_version_path(Org, Name, Version) of + {ok, Path} -> request_json(Client, get, Path, undefined, false); + PathError -> PathError + end. + +search(Client, Query) -> + Path = <<"/v1/search?q=", (encode_segment(Query))/binary>>, + request_json(Client, get, Path, undefined, false). + +claim_org(Client, Slug) -> + case validate_segment(Slug, <<"slug">>) of + {ok, CheckedSlug} -> + request_json(Client, post, <<"/v1/orgs">>, #{<<"slug">> => CheckedSlug}, true); + SlugError -> SlugError + end. + +set_yanked(Client, Org, Name, Version, Yanked) when is_boolean(Yanked) -> + case checked_yank_path(Org, Name, Version) of + {ok, Path} -> + request_json(Client, post, Path, #{<<"yanked">> => Yanked}, true); + PathError -> PathError + end; +set_yanked(_Client, _Org, _Name, _Version, _Yanked) -> + {error, {invalid_input, <<"yanked must be a boolean">>}}. + +yank(Client, Org, Name, Version, Yanked) -> + set_yanked(Client, Org, Name, Version, Yanked). + +restore(Client, Org, Name, Version) -> + set_yanked(Client, Org, Name, Version, false). + +download_artifact(Client, Version) when is_map(Version) -> + case client_base(Client) of + {ok, Base} -> + DownloadUrl = text(maps:get(<<"download_url">>, Version, <<>>)), + Sha256 = text(maps:get(<<"sha256">>, Version, <<>>)), + Size = maps:get(<<"size">>, Version, 0), + case resolve_download_url(DownloadUrl, Base, Sha256) of + {ok, Url} -> + %% Deliberately no authorization header: download_url may + %% point at a third-party host and the bearer must not leak. + case http_request(Client, get, Url, [], undefined) of + {ok, Status, Body} when Status >= 200, Status < 300 -> + Limit = download_limit(Size), + case byte_size(Body) > Limit of + true -> {error, {artifact_too_large, Limit}}; + false -> + case verify_sha256(Body, Sha256) of + ok -> {ok, Body}; + VerifyError -> VerifyError + end + end; + {ok, Status, Body} -> + {error, decode_api_error(Status, Body)}; + RequestError -> RequestError + end; + UrlError -> UrlError + end; + BaseError -> BaseError + end; +download_artifact(_Client, _Version) -> + {error, {invalid_input, <<"version metadata must be a map">>}}. + +%% Download to DestPath atomically: the verified bytes are written to a new +%% sibling file, synced, and renamed into place. Partial or failed downloads +%% never replace an existing destination. +download_artifact(Client, Version, DestPath) -> + case download_artifact(Client, Version) of + {ok, Body} -> write_file_atomic(DestPath, Body); + DownloadError -> DownloadError + end. + +write_file_atomic(DestPath0, Body) -> + DestPath = path_text(DestPath0), + case filelib:ensure_dir(DestPath) of + ok -> + Dir = filename:dirname(DestPath), + BaseName = filename:basename(DestPath), + Nonce = integer_to_list(erlang:unique_integer([positive, monotonic])), + Temp = filename:join(Dir, "." ++ BaseName ++ ".zed-" ++ Nonce ++ ".tmp"), + case file:open(Temp, [write, binary, raw, exclusive]) of + {ok, Handle} -> + WriteResult = + case file:write(Handle, Body) of + ok -> file:sync(Handle); + WriteError -> WriteError + end, + CloseResult = file:close(Handle), + case WriteResult of + ok -> + case CloseResult of + ok -> + case file:rename(Temp, DestPath) of + ok -> ok; + RenameError -> + _ = file:delete(Temp), + RenameError + end; + CloseError -> + _ = file:delete(Temp), + CloseError + end; + WriteFailure -> + _ = file:delete(Temp), + WriteFailure + end; + OpenError -> OpenError + end; + EnsureError -> EnsureError + end. + +publish(Client, Org, Name, Version, MetaJson, Artifact) when is_binary(Artifact) -> + case auth_headers(Client, true) of + {ok, Headers} -> + case checked_version_path(Org, Name, Version) of + {ok, Path} -> + case byte_size(Artifact) > ?MAX_ARTIFACT_BYTES of + true -> + {error, {artifact_too_large, ?MAX_ARTIFACT_BYTES}}; + false -> + ExpectedOrg = text(Org), + ExpectedName = text(Name), + ExpectedVersion = text(Version), + case decode_publish_coordinate(MetaJson) of + {ok, {MetaOrg, MetaName, MetaVersion}} -> + case { + MetaOrg =:= ExpectedOrg, + MetaName =:= ExpectedName, + MetaVersion =:= ExpectedVersion + } of + {true, true, true} -> + case client_base(Client) of + {ok, Base} -> + Boundary = + <<"zedpkg", + (binary:encode_hex( + crypto:strong_rand_bytes(16), lowercase + ))/binary>>, + Body = multipart_body( + Boundary, + text(MetaJson), + <<"artifact.tar.gz">>, + Artifact + ), + ContentType = + <<"multipart/form-data; boundary=", Boundary/binary>>, + Url = <>, + case http_request( + Client, + put, + Url, + Headers, + {ContentType, Body} + ) of + {ok, Status, ResponseBody} + when Status >= 200, Status < 300 -> + decode_success_json(ResponseBody); + {ok, Status, ResponseBody} -> + {error, + decode_api_error(Status, ResponseBody)}; + RequestError -> RequestError + end; + BaseError -> BaseError + end; + _ -> + {error, + {invalid_input, + <<"publish route and meta.manifest.package coordinates differ">>}} + end; + DecodeError -> DecodeError + end + end; + PathError -> PathError + end; + AuthError -> AuthError + end; +publish(_Client, _Org, _Name, _Version, _MetaJson, _Artifact) -> + {error, {invalid_input, <<"artifact must be a binary">>}}. + +decode_publish_coordinate(MetaJson) -> + try json:decode(text(MetaJson)) of + #{ + <<"manifest">> := #{ + <<"package">> := #{ + <<"org">> := Org, + <<"name">> := Name, + <<"version">> := Version + } + } + } -> + case validate_segment(Org, <<"meta.manifest.package.org">>) of + {ok, CheckedOrg} -> + case validate_segment(Name, <<"meta.manifest.package.name">>) of + {ok, CheckedName} -> + case validate_segment( + Version, <<"meta.manifest.package.version">> + ) of + {ok, CheckedVersion} -> + {ok, {CheckedOrg, CheckedName, CheckedVersion}}; + VersionError -> VersionError + end; + NameError -> NameError + end; + OrgError -> OrgError + end; + _ -> + {error, {invalid_input, <<"meta.manifest.package is required">>}} + catch + _:_ -> {error, {invalid_input, <<"publish metadata is invalid JSON">>}} + end. + +multipart_body(Boundary, MetaJson, Filename, Artifact) -> + iolist_to_binary([ + "--", Boundary, "\r\n", + "content-disposition: form-data; name=\"meta\"\r\n\r\n", + MetaJson, "\r\n", + "--", Boundary, "\r\n", + "content-disposition: form-data; name=\"artifact\"; filename=\"", Filename, "\"\r\n", + "content-type: application/octet-stream\r\n\r\n", + Artifact, "\r\n", + "--", Boundary, "--\r\n" + ]). + +%%-------------------------------------------------------------------- +%% Transport +%%-------------------------------------------------------------------- + +request_json(Client, Method, Path, Body, Authorized) -> + case client_base(Client) of + {ok, Base} -> + case auth_headers(Client, Authorized) of + {ok, Headers} -> + Url = <>, + Payload = + case Body of + undefined -> undefined; + _ -> {<<"application/json">>, iolist_to_binary(json:encode(Body))} + end, + case http_request(Client, Method, Url, Headers, Payload) of + {ok, Status, ResponseBody} when Status >= 200, Status < 300 -> + decode_success_json(ResponseBody); + {ok, Status, ResponseBody} -> + {error, decode_api_error(Status, ResponseBody)}; + RequestError -> RequestError + end; + AuthError -> AuthError + end; + BaseError -> BaseError + end. + +auth_headers(Client, true) when is_map(Client) -> + case maps:get(token_error, Client, undefined) of + undefined -> + %% Client maps remain transparent for compatibility, so callers can mutate + %% them after construction. Revalidate the live token on every authenticated + %% operation instead of trusting only with_token/2 state. + case normalize_token(maps:get(token, Client, undefined)) of + {ok, undefined} -> + {error, + {missing_token, + <<"authenticated registry operation requires a nonblank bearer token">>}}; + {ok, Token} -> + {ok, [{"authorization", "Bearer " ++ binary_to_list(Token)}]}; + {error, Reason} -> {error, Reason} + end; + TokenError -> {error, TokenError} + end; +auth_headers(_Client, true) -> + {error, {invalid_configuration, <<"client must be a map returned by new/1">>}}; +auth_headers(_Client, false) -> + {ok, []}. + +http_request(Client, Method, Url, Headers0, Payload) -> + case client_timeout(Client) of + {ok, Timeout} -> + _ = application:ensure_all_started(inets), + _ = application:ensure_all_started(ssl), + Headers = [{"accept", "application/json"} | Headers0], + Request = + case Payload of + undefined -> + {binary_to_list(Url), Headers}; + {ContentType, RequestBody} -> + {binary_to_list(Url), Headers, binary_to_list(ContentType), RequestBody} + end, + HttpOptions = [ + {timeout, Timeout}, + {connect_timeout, Timeout}, + {autoredirect, false} + ], + Options = [{body_format, binary}], + case httpc:request(Method, Request, HttpOptions, Options) of + {ok, {{_Version, Status, _Reason}, _ResponseHeaders, ResponseBody}} -> + {ok, Status, ResponseBody}; + {error, Reason} -> + {error, {transport, Reason}} + end; + TimeoutError -> TimeoutError + end. + +%%-------------------------------------------------------------------- +%% Decoding +%%-------------------------------------------------------------------- + +decode_success_json(Body) when byte_size(Body) > ?MAX_JSON_RESPONSE_BYTES -> + {error, {response_too_large, ?MAX_JSON_RESPONSE_BYTES}}; +decode_success_json(Body) -> + decode_json(Body). + +decode_json(<<>>) -> + {error, {protocol, empty_json_body}}; +decode_json(Body) -> + try + {ok, json:decode(Body)} + catch + Class:Reason -> {error, {protocol, {invalid_json, Class, Reason}}} + end. + +decode_api_error(Status, Body) when byte_size(Body) > ?MAX_ERROR_BODY_BYTES -> + {api_error, Status, http_status_code(Status), <<"registry error body exceeded the client limit">>}; +decode_api_error(Status, Body) -> + Fallback = http_status_code(Status), + try json:decode(Body) of + #{<<"code">> := Code0, <<"message">> := Message} -> + Code = + case trim_binary(text(Code0)) of + <<>> -> Fallback; + Value -> Value + end, + {api_error, Status, Code, text(Message)}; + _ -> + {api_error, Status, Fallback, Body} + catch + _:_ -> {api_error, Status, Fallback, Body} + end. + +http_status_code(Status) -> + <<"http_", (integer_to_binary(Status))/binary>>. + +%%-------------------------------------------------------------------- +%% Helpers +%%-------------------------------------------------------------------- + +text(Value) when is_binary(Value) -> Value; +text(Value) when is_list(Value) -> unicode:characters_to_binary(Value); +text(Value) when is_atom(Value) -> atom_to_binary(Value, utf8). + +trim_binary(Value) -> + unicode:characters_to_binary(string:trim(binary_to_list(text(Value)))). + +lower_binary(Value) -> + unicode:characters_to_binary(string:lowercase(binary_to_list(text(Value)))). + +has_control(Binary) -> + lists:any(fun(Byte) -> Byte < 32 orelse Byte =:= 127 end, binary_to_list(Binary)). + +path_text(Value) when is_binary(Value) -> unicode:characters_to_list(Value); +path_text(Value) when is_list(Value) -> Value. diff --git a/clients/erlang/test/zed_pkg_client_adversarial_tests.erl b/clients/erlang/test/zed_pkg_client_adversarial_tests.erl new file mode 100644 index 0000000..5ca06e2 --- /dev/null +++ b/clients/erlang/test/zed_pkg_client_adversarial_tests.erl @@ -0,0 +1,162 @@ +-module(zed_pkg_client_adversarial_tests). + +-include_lib("eunit/include/eunit.hrl"). + +mutated_client_configuration_fails_before_transport_test() -> + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:9"), + TraversingBase = Client#{base => <<"https://registry.test/%2e%2e/admin">>}, + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:get_package(TraversingBase, "acme", "kit") + ), + InvalidTimeout = Client#{timeout => 0}, + ?assertMatch( + {error, {invalid_configuration, _}}, + zed_pkg_client:get_package(InvalidTimeout, "acme", "kit") + ). + +relative_download_escape_variants_fail_before_transport_test() -> + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:9/gateway"), + Digest = binary:encode_hex(crypto:hash(sha256, <<"artifact">>), lowercase), + lists:foreach( + fun(DownloadUrl) -> + Version = #{ + <<"download_url">> => DownloadUrl, + <<"sha256">> => Digest, + <<"size">> => 8 + }, + ?assertMatch( + {error, {_, _}}, + zed_pkg_client:download_artifact(Client, Version) + ) + end, + [ + <<"../escape">>, + <<"%2e%2e/escape">>, + <<"//evil.example/artifact">>, + <<"/absolute/artifact">>, + <<"artifacts%2Fescape">>, + <<"artifacts\\escape">> + ] + ). + +relative_download_query_is_preserved_without_bearer_test() -> + Bytes = <<"signed-artifact">>, + Digest = binary:encode_hex(crypto:hash(sha256, Bytes), uppercase), + {Port, Ref} = spawn_server(200, "application/octet-stream", Bytes), + {ok, Client0} = zed_pkg_client:new(loopback_url(Port) ++ "/gateway"), + Client = zed_pkg_client:with_token(Client0, <<"registry-secret">>), + Version = #{ + <<"download_url">> => <<"artifacts/hash?signature=one%2Btwo&expires=3">>, + <<"sha256">> => Digest, + <<"size">> => byte_size(Bytes) + }, + ?assertEqual({ok, Bytes}, zed_pkg_client:download_artifact(Client, Version)), + Request = captured_request(Ref), + ?assertMatch( + {_, _}, + binary:match( + Request, + <<"GET /gateway/artifacts/hash?signature=one%2Btwo&expires=3 HTTP/1.1">> + ) + ), + ?assertEqual(nomatch, binary:match(Request, <<"authorization">>)). + +redirect_response_is_not_followed_test() -> + Parent = self(), + DestinationRef = make_ref(), + {ok, DestinationListen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, DestinationPort} = inet:port(DestinationListen), + DestinationPid = spawn_link(fun() -> + case gen_tcp:accept(DestinationListen, 1200) of + {ok, Socket} -> + Parent ! {unexpected_redirect_request, DestinationRef}, + gen_tcp:close(Socket); + {error, timeout} -> + Parent ! {no_redirect_request, DestinationRef}; + {error, Reason} -> + Parent ! {redirect_destination_error, DestinationRef, Reason} + end, + gen_tcp:close(DestinationListen) + end), + + Location = "http://127.0.0.1:" ++ integer_to_list(DestinationPort) ++ "/escaped", + {SourcePort, SourceRef} = spawn_redirect_server(Location), + {ok, Client} = zed_pkg_client:new(loopback_url(SourcePort)), + ?assertMatch( + {error, {api_error, 302, <<"http_302">>, _}}, + zed_pkg_client:get_package(Client, "acme", "kit") + ), + _ = captured_request(SourceRef), + receive + {no_redirect_request, DestinationRef} -> ok; + {unexpected_redirect_request, DestinationRef} -> + ?assert(false); + {redirect_destination_error, DestinationRef, Reason} -> + error({redirect_destination_error, Reason}) + after 3000 -> + exit(DestinationPid, kill), + error(redirect_destination_timeout) + end. + +spawn_server(Status, ContentType, Body) -> + Parent = self(), + Ref = make_ref(), + {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(Listen), + spawn_link(fun() -> + {ok, Socket} = gen_tcp:accept(Listen, 5000), + Request = read_request(Socket, <<>>), + Parent ! {captured_request, Ref, Request}, + Response = [ + "HTTP/1.1 ", integer_to_list(Status), " status\r\n", + "content-type: ", ContentType, "\r\n", + "content-length: ", integer_to_list(byte_size(Body)), "\r\n", + "connection: close\r\n\r\n", + Body + ], + ok = gen_tcp:send(Socket, Response), + gen_tcp:close(Socket), + gen_tcp:close(Listen) + end), + {Port, Ref}. + +spawn_redirect_server(Location) -> + Parent = self(), + Ref = make_ref(), + {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(Listen), + spawn_link(fun() -> + {ok, Socket} = gen_tcp:accept(Listen, 5000), + Request = read_request(Socket, <<>>), + Parent ! {captured_request, Ref, Request}, + Response = [ + "HTTP/1.1 302 Found\r\n", + "location: ", Location, "\r\n", + "content-length: 0\r\n", + "connection: close\r\n\r\n" + ], + ok = gen_tcp:send(Socket, Response), + gen_tcp:close(Socket), + gen_tcp:close(Listen) + end), + {Port, Ref}. + +read_request(Socket, Acc) -> + case binary:match(Acc, <<"\r\n\r\n">>) of + {_, _} -> Acc; + nomatch -> + case gen_tcp:recv(Socket, 0, 2000) of + {ok, Data} -> read_request(Socket, <>); + {error, _} -> Acc + end + end. + +captured_request(Ref) -> + receive + {captured_request, Ref, Request} -> Request + after 5000 -> error(no_request_captured) + end. + +loopback_url(Port) -> + "http://127.0.0.1:" ++ integer_to_list(Port). diff --git a/clients/erlang/test/zed_pkg_client_hardening_tests.erl b/clients/erlang/test/zed_pkg_client_hardening_tests.erl new file mode 100644 index 0000000..59bd39f --- /dev/null +++ b/clients/erlang/test/zed_pkg_client_hardening_tests.erl @@ -0,0 +1,270 @@ +-module(zed_pkg_client_hardening_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-define(MAX_ERROR_BODY_BYTES, 16384). +-define(MAX_TOKEN_BYTES, 8192). + +invalid_bases_and_dot_segments_are_rejected_test() -> + InvalidBases = [ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + "https://registry.test/../admin", + "https://registry.test/%2e%2e/admin", + "https://registry.test/a%2Fb" + ], + lists:foreach( + fun(Base) -> + ?assertMatch({error, {_, _}}, zed_pkg_client:new(Base)) + end, + InvalidBases + ), + ?assertEqual( + <<"/v1/packages/%2E%2E/kit">>, + zed_pkg_client:package_path("..", "kit") + ). + +hostile_segments_and_missing_tokens_fail_before_transport_test() -> + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:9"), + lists:foreach( + fun(Value) -> + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:get_package(Client, Value, "kit") + ) + end, + [<<>>, <<" ">>, <<".">>, <<"..">>, <<"line\nbreak">>] + ), + Overlong = binary:copy(<<"x">>, 257), + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:get_version(Client, "acme", "kit", Overlong) + ), + ?assertMatch( + {error, {missing_token, _}}, + zed_pkg_client:claim_org(Client, "acme") + ), + ?assertMatch( + {error, {missing_token, _}}, + zed_pkg_client:restore(Client, "acme", "kit", "1.2.0") + ), + Meta = <<"{\"manifest\":{\"package\":{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}">>, + ?assertMatch( + {error, {missing_token, _}}, + zed_pkg_client:publish(Client, "acme", "kit", "1.2.0", Meta, <<"bytes">>) + ), + UnsafeToken = zed_pkg_client:with_token(Client, <<"token\r\nheader">>), + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:claim_org(UnsafeToken, "acme") + ), + BoundaryToken = binary:copy(<<"t">>, ?MAX_TOKEN_BYTES), + BoundaryClient = zed_pkg_client:with_token(Client, BoundaryToken), + ?assertEqual(?MAX_TOKEN_BYTES, byte_size(maps:get(token, BoundaryClient))), + OverlongToken = binary:copy(<<"t">>, ?MAX_TOKEN_BYTES + 1), + OverlongClient = zed_pkg_client:with_token(Client, OverlongToken), + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:claim_org(OverlongClient, "acme") + ), + MutatedControlClient = Client#{ + token => <<"token\r\nheader">>, + token_error => undefined + }, + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:restore(MutatedControlClient, "acme", "kit", "1.2.0") + ), + MutatedTypeClient = Client#{token => 42, token_error => undefined}, + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:claim_org(MutatedTypeClient, "acme") + ), + ?assertMatch( + {error, {invalid_configuration, _}}, + zed_pkg_client:publish( + not_a_client, + "acme", + "kit", + "1.2.0", + Meta, + <<"bytes">> + ) + ), + ?assertMatch( + {error, {invalid_configuration, _}}, + zed_pkg_client:with_token(not_a_client, <<"token">>) + ). + +artifact_url_policy_rejects_credentials_fragments_and_unsupported_schemes_test() -> + Base = <<"https://registry.zpkg.tech">>, + lists:foreach( + fun(Url) -> + ?assertMatch( + {error, {insecure_download_url, _}}, + zed_pkg_client:allowed_download_url(Url, Base) + ) + end, + [ + <<"file:///etc/passwd">>, + <<"https://user:secret@cdn.example/a">>, + <<"https://cdn.example/a#fragment">>, + <<"http://evil.example/a">> + ] + ), + ?assertEqual( + {ok, <<"https://cdn.example/a?signature=one">>}, + zed_pkg_client:allowed_download_url( + <<"https://cdn.example/a?signature=one">>, Base + ) + ), + ?assertEqual( + {ok, <<"http://[::1]:8080/a">>}, + zed_pkg_client:allowed_download_url(<<"http://[::1]:8080/a">>, Base) + ). + +sha_verification_accepts_uppercase_hex_test() -> + Digest = binary:encode_hex(crypto:hash(sha256, <<"zed">>), uppercase), + ?assertEqual(ok, zed_pkg_client:verify_sha256(<<"zed">>, Digest)). + +blank_structured_codes_fall_back_to_http_status_test() -> + Body = <<"{\"code\":\" \",\"message\":\"remote detail\"}">>, + {Port, Ref} = spawn_server(409, "application/json", Body), + {ok, Client0} = zed_pkg_client:new(loopback_url(Port)), + Client = zed_pkg_client:with_token(Client0, <<" token ">>), + ?assertEqual( + {error, {api_error, 409, <<"http_409">>, <<"remote detail">>}}, + zed_pkg_client:claim_org(Client, "acme") + ), + Request = captured_request(Ref), + ?assertMatch({_, _}, binary:match(Request, <<"authorization: Bearer token">>)). + +oversized_error_bodies_are_not_retained_test() -> + Body = binary:copy(<<"provider-secret">>, ?MAX_ERROR_BODY_BYTES), + {Port, Ref} = spawn_server(502, "text/plain", Body), + {ok, Client} = zed_pkg_client:new(loopback_url(Port)), + ?assertEqual( + {error, + {api_error, 502, <<"http_502">>, + <<"registry error body exceeded the client limit">>}}, + zed_pkg_client:get_package(Client, "acme", "kit") + ), + _ = captured_request(Ref), + ok. + +relative_downloads_preserve_gateway_and_use_no_bearer_test() -> + Bytes = <<"artifact-bytes">>, + Digest = binary:encode_hex(crypto:hash(sha256, Bytes), uppercase), + {Port, Ref} = spawn_server(200, "application/octet-stream", Bytes), + {ok, Client0} = zed_pkg_client:new(loopback_url(Port) ++ "/gateway"), + Client = zed_pkg_client:with_token(Client0, <<"registry-secret">>), + Version = #{ + <<"download_url">> => <<"artifacts/hash">>, + <<"sha256">> => Digest, + <<"size">> => byte_size(Bytes) + }, + ?assertEqual({ok, Bytes}, zed_pkg_client:download_artifact(Client, Version)), + Request = captured_request(Ref), + ?assertMatch({_, _}, binary:match(Request, <<"GET /gateway/artifacts/hash HTTP/1.1">>)), + ?assertEqual(nomatch, binary:match(Request, <<"authorization">>)). + +verified_file_download_replaces_destination_atomically_test() -> + Bytes = <<"verified-artifact">>, + Digest = binary:encode_hex(crypto:hash(sha256, Bytes), lowercase), + {Port, Ref} = spawn_server(200, "application/octet-stream", Bytes), + {ok, Client} = zed_pkg_client:new(loopback_url(Port)), + Version = #{ + <<"download_url">> => <<>>, + <<"sha256">> => Digest, + <<"size">> => byte_size(Bytes) + }, + Root = filename:join(temp_dir(), "zed-erlang-" ++ integer_to_list(erlang:unique_integer([positive]))), + Destination = filename:join([Root, "nested", "artifact.tar.gz"]), + ok = filelib:ensure_dir(Destination), + ok = file:write_file(Destination, <<"old">>), + ok = zed_pkg_client:download_artifact(Client, Version, Destination), + ?assertEqual({ok, Bytes}, file:read_file(Destination)), + {ok, Entries} = file:list_dir(filename:dirname(Destination)), + ?assertEqual([], [Entry || Entry <- Entries, string:find(Entry, ".zed-") =/= nomatch]), + _ = captured_request(Ref), + ok = file:del_dir_r(Root). + +restore_posts_false_and_publish_uses_a_fixed_filename_test() -> + RestoreBody = <<"{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\",\"yanked\":false}">>, + {RestorePort, RestoreRef} = spawn_server(200, "application/json", RestoreBody), + {ok, RestoreClient0} = zed_pkg_client:new(loopback_url(RestorePort)), + RestoreClient = zed_pkg_client:with_token(RestoreClient0, <<"token">>), + {ok, Restored} = zed_pkg_client:restore(RestoreClient, "acme", "kit", "1.2.0"), + ?assertEqual(false, maps:get(<<"yanked">>, Restored)), + RestoreRequest = captured_request(RestoreRef), + ?assertMatch({_, _}, binary:match(RestoreRequest, <<"\"yanked\":false">>)), + + PublishBody = <<"{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\",\"sha256\":\"abc\"}">>, + {PublishPort, PublishRef} = spawn_server(200, "application/json", PublishBody), + {ok, PublishClient0} = zed_pkg_client:new(loopback_url(PublishPort)), + PublishClient = zed_pkg_client:with_token(PublishClient0, <<"token">>), + Meta = <<"{\"manifest\":{\"package\":{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}">>, + {ok, _} = zed_pkg_client:publish( + PublishClient, "acme", "kit", "1.2.0", Meta, <<"bytes">> + ), + PublishRequest = captured_request(PublishRef), + ?assertMatch({_, _}, binary:match(PublishRequest, <<"filename=\"artifact.tar.gz\"">>)). + +publish_coordinate_mismatch_fails_before_transport_test() -> + {ok, Client0} = zed_pkg_client:new("http://127.0.0.1:9"), + Client = zed_pkg_client:with_token(Client0, <<"token">>), + Meta = <<"{\"manifest\":{\"package\":{\"org\":\"other\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}">>, + ?assertMatch( + {error, {invalid_input, _}}, + zed_pkg_client:publish(Client, "acme", "kit", "1.2.0", Meta, <<"bytes">>) + ). + +spawn_server(Status, ContentType, Body) -> + Parent = self(), + Ref = make_ref(), + {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(Listen), + spawn_link(fun() -> + {ok, Socket} = gen_tcp:accept(Listen, 5000), + Request = read_request(Socket, <<>>), + Parent ! {captured_request, Ref, Request}, + Response = [ + "HTTP/1.1 ", integer_to_list(Status), " status\r\n", + "content-type: ", ContentType, "\r\n", + "content-length: ", integer_to_list(byte_size(Body)), "\r\n", + "connection: close\r\n\r\n", + Body + ], + ok = gen_tcp:send(Socket, Response), + gen_tcp:close(Socket), + gen_tcp:close(Listen) + end), + {Port, Ref}. + +read_request(Socket, Acc) -> + case binary:match(Acc, <<"\r\n\r\n">>) of + {_, _} -> Acc; + nomatch -> + case gen_tcp:recv(Socket, 0, 2000) of + {ok, Data} -> read_request(Socket, <>); + {error, _} -> Acc + end + end. + +captured_request(Ref) -> + receive + {captured_request, Ref, Request} -> Request + after 5000 -> error(no_request_captured) + end. + +loopback_url(Port) -> + "http://127.0.0.1:" ++ integer_to_list(Port). + +temp_dir() -> + case os:getenv("TMPDIR") of + false -> "/tmp"; + Value -> Value + end. diff --git a/clients/erlang/test/zed_pkg_client_tests.erl b/clients/erlang/test/zed_pkg_client_tests.erl new file mode 100644 index 0000000..5489651 --- /dev/null +++ b/clients/erlang/test/zed_pkg_client_tests.erl @@ -0,0 +1,229 @@ +-module(zed_pkg_client_tests). + +-include_lib("eunit/include/eunit.hrl"). + +%%-------------------------------------------------------------------- +%% Pure helpers +%%-------------------------------------------------------------------- + +paths_match_contract_test() -> + ?assertEqual(<<"/v1/packages/acme/kit">>, zed_pkg_client:package_path("acme", "kit")), + ?assertEqual( + <<"/v1/packages/acme/kit/versions/1.2.0">>, + zed_pkg_client:version_path("acme", "kit", "1.2.0") + ), + ?assertEqual(<<"/v1/artifacts/abc">>, zed_pkg_client:artifact_path("abc")), + ?assertEqual( + <<"/v1/packages/acme/kit/versions/1.2.0/yank">>, + zed_pkg_client:yank_path("acme", "kit", "1.2.0") + ). + +path_segments_are_percent_encoded_test() -> + ?assertEqual( + <<"/v1/packages/acme/kit/versions/release%20candidate%2F1">>, + zed_pkg_client:version_path("acme", "kit", "release candidate/1") + ). + +download_limit_caps_at_ceiling_test() -> + Ceiling = 104857600, + ?assertEqual(Ceiling, zed_pkg_client:download_limit(0)), + ?assertEqual(10 + 1048576, zed_pkg_client:download_limit(10)), + ?assertEqual(Ceiling, zed_pkg_client:download_limit(Ceiling)). + +insecure_download_urls_are_rejected_test() -> + Base = <<"https://registry.zpkg.tech">>, + ?assertMatch( + {error, {insecure_download_url, _}}, + zed_pkg_client:allowed_download_url(<<"http://evil.example/a">>, Base) + ), + ?assertMatch( + {error, {insecure_download_url, _}}, + zed_pkg_client:allowed_download_url(<<"file:///etc/passwd">>, Base) + ), + ?assertEqual( + {ok, <<"https://cdn.example/a">>}, + zed_pkg_client:allowed_download_url(<<"https://cdn.example/a">>, Base) + ), + ?assertEqual( + {ok, <<"http://127.0.0.1:8080/a">>}, + zed_pkg_client:allowed_download_url(<<"http://127.0.0.1:8080/a">>, Base) + ), + ?assertEqual( + {ok, <<"http://localhost/a">>}, + zed_pkg_client:allowed_download_url(<<"http://localhost/a">>, Base) + ), + %% An http registry base opts in to plaintext downloads. + ?assertEqual( + {ok, <<"http://mirror.internal/a">>}, + zed_pkg_client:allowed_download_url( + <<"http://mirror.internal/a">>, <<"http://registry.internal">> + ) + ). + +sha_verification_test() -> + Good = binary:encode_hex(crypto:hash(sha256, <<"zed">>), lowercase), + ?assertEqual(ok, zed_pkg_client:verify_sha256(<<"zed">>, Good)), + ?assertMatch( + {error, {sha256_mismatch, _}}, + zed_pkg_client:verify_sha256(<<"zed">>, <<"00">>) + ). + +multipart_body_layout_test() -> + Body = zed_pkg_client:multipart_body( + <<"BOUNDARY">>, <<"{\"manifest\":{}}">>, <<"acme-kit-1.2.0.tar.gz">>, <<"bytes">> + ), + ?assertMatch({_, _}, binary:match(Body, <<"name=\"meta\"">>)), + ?assertMatch( + {_, _}, + binary:match(Body, <<"name=\"artifact\"; filename=\"acme-kit-1.2.0.tar.gz\"">>) + ), + Suffix = <<"--BOUNDARY--\r\n">>, + ?assertEqual( + Suffix, + binary:part(Body, byte_size(Body) - byte_size(Suffix), byte_size(Suffix)) + ). + +invalid_timeout_is_refused_test() -> + ?assertMatch({error, {invalid_configuration, _}}, zed_pkg_client:new("https://x", 0)). + +%%-------------------------------------------------------------------- +%% Round-trips against a one-shot loopback server +%%-------------------------------------------------------------------- + +%% Serve one HTTP response on a random loopback port; the captured raw +%% request (headers included) is sent to the test process. +spawn_server(Status, ContentType, Body) -> + Parent = self(), + Ref = make_ref(), + {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(Listen), + spawn_link(fun() -> + {ok, Socket} = gen_tcp:accept(Listen, 5000), + Request = read_request(Socket, <<>>), + Parent ! {captured_request, Ref, Request}, + Reason = integer_to_list(Status), + Response = [ + "HTTP/1.1 ", integer_to_list(Status), " ", Reason, "\r\n", + "content-type: ", ContentType, "\r\n", + "content-length: ", integer_to_list(byte_size(Body)), "\r\n", + "connection: close\r\n\r\n", + Body + ], + ok = gen_tcp:send(Socket, Response), + gen_tcp:close(Socket), + gen_tcp:close(Listen) + end), + {Port, Ref}. + +read_request(Socket, Acc) -> + case binary:match(Acc, <<"\r\n\r\n">>) of + {_, _} -> + Acc; + nomatch -> + case gen_tcp:recv(Socket, 0, 2000) of + {ok, Data} -> read_request(Socket, <>); + {error, _} -> Acc + end + end. + +captured_request(Ref) -> + receive + {captured_request, Ref, Request} -> Request + after 5000 -> error(no_request_captured) + end. + +get_package_round_trip_test() -> + Body = iolist_to_binary( + json:encode(#{ + <<"org">> => <<"acme">>, + <<"name">> => <<"kit">>, + <<"vcs">> => <<"git">>, + <<"repo_url">> => <<"https://github.com/acme/kit">>, + <<"latest">> => <<"1.2.0">>, + <<"versions">> => [<<"1.2.0">>] + }) + ), + {Port, Ref} = spawn_server(200, "application/json", Body), + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + {ok, Package} = zed_pkg_client:get_package(Client, "acme", "kit"), + ?assertEqual(<<"1.2.0">>, maps:get(<<"latest">>, Package)), + Request = captured_request(Ref), + ?assertMatch({_, _}, binary:match(Request, <<"GET /v1/packages/acme/kit HTTP/1.1">>)), + %% Reads are unauthenticated. + ?assertEqual(nomatch, binary:match(Request, <<"authorization">>)). + +api_errors_carry_the_registry_code_test() -> + Body = iolist_to_binary( + json:encode(#{<<"code">> => <<"org_taken">>, <<"message">> => <<"claimed">>}) + ), + {Port, Ref} = spawn_server(409, "application/json", Body), + {ok, Client0} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + Client = zed_pkg_client:with_token(Client0, <<"zpkg_t">>), + ?assertEqual( + {error, {api_error, 409, <<"org_taken">>, <<"claimed">>}}, + zed_pkg_client:claim_org(Client, "acme") + ), + Request = captured_request(Ref), + ?assertMatch({_, _}, binary:match(Request, <<"authorization: Bearer zpkg_t">>)). + +non_json_error_bodies_map_to_http_status_test() -> + {Port, Ref} = spawn_server(500, "text/plain", <<"boom">>), + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + ?assertEqual( + {error, {api_error, 500, <<"http_500">>, <<"boom">>}}, + zed_pkg_client:get_version(Client, "acme", "kit", "1.2.0") + ), + _ = captured_request(Ref), + ok. + +download_verifies_sha_and_omits_auth_test() -> + Bytes = <<"artifact-bytes">>, + Digest = binary:encode_hex(crypto:hash(sha256, Bytes), lowercase), + {Port, Ref} = spawn_server(200, "application/octet-stream", Bytes), + {ok, Client0} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + Client = zed_pkg_client:with_token(Client0, <<"zpkg_t">>), + Version = #{ + <<"download_url">> => <<>>, + <<"sha256">> => Digest, + <<"size">> => byte_size(Bytes) + }, + ?assertEqual({ok, Bytes}, zed_pkg_client:download_artifact(Client, Version)), + Request = captured_request(Ref), + ?assertMatch( + {_, _}, binary:match(Request, <<"GET /v1/artifacts/", Digest/binary>>) + ), + %% Bearer token must not leak to the download host. + ?assertEqual(nomatch, binary:match(Request, <<"authorization">>)). + +download_rejects_sha_mismatch_test() -> + {Port, Ref} = spawn_server(200, "application/octet-stream", <<"tampered">>), + {ok, Client} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + Version = #{<<"download_url">> => <<>>, <<"sha256">> => <<"00">>, <<"size">> => 8}, + ?assertMatch( + {error, {sha256_mismatch, _}}, + zed_pkg_client:download_artifact(Client, Version) + ), + _ = captured_request(Ref), + ok. + +publish_round_trip_test() -> + Body = iolist_to_binary( + json:encode(#{ + <<"org">> => <<"acme">>, + <<"name">> => <<"kit">>, + <<"version">> => <<"1.2.0">>, + <<"sha256">> => <<"abc">> + }) + ), + {Port, Ref} = spawn_server(200, "application/json", Body), + {ok, Client0} = zed_pkg_client:new("http://127.0.0.1:" ++ integer_to_list(Port)), + Client = zed_pkg_client:with_token(Client0, <<"zpkg_t">>), + Meta = <<"{\"manifest\":{\"package\":{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}">>, + {ok, Response} = zed_pkg_client:publish(Client, "acme", "kit", "1.2.0", Meta, <<"bytes">>), + ?assertEqual(<<"abc">>, maps:get(<<"sha256">>, Response)), + Request = captured_request(Ref), + ?assertMatch( + {_, _}, binary:match(Request, <<"PUT /v1/packages/acme/kit/versions/1.2.0 HTTP/1.1">>) + ), + ?assertMatch({_, _}, binary:match(Request, <<"authorization: Bearer zpkg_t">>)), + ?assertMatch({_, _}, binary:match(Request, <<"multipart/form-data; boundary=zedpkg">>)). diff --git a/clients/gleam/README.md b/clients/gleam/README.md new file mode 100644 index 0000000..553ec3f --- /dev/null +++ b/clients/gleam/README.md @@ -0,0 +1,20 @@ +# zed_pkg_client (Gleam) + +Gleam SDK for the [zed-pkg](https://zpkg.tech) registry (Erlang target, +`gleam_httpc` transport). Types mirror the JSON Schemas in +`zed-interfaces/schemas/`; the transport is injectable so tests run without a +network. + +```gleam +import zed_pkg_client as zed + +let client = zed.new("https://registry.zpkg.tech") +let assert Ok(pkg) = zed.get_package(client, "acme", "kit") +let assert Ok(version) = zed.get_version(client, "acme", "kit", "1.2.0") +let assert Ok(bytes) = zed.download_artifact(client, version) // sha256-verified + +let authed = zed.with_token(client, "zpkg_...") +let assert Ok(_) = zed.claim_org(authed, "acme") +``` + +Verify with `gleam test`. diff --git a/clients/gleam/gleam.toml b/clients/gleam/gleam.toml new file mode 100644 index 0000000..472ec7c --- /dev/null +++ b/clients/gleam/gleam.toml @@ -0,0 +1,17 @@ +name = "zed_pkg_client" +version = "0.1.0" +description = "Gleam SDK for the zed-pkg registry." +licences = ["MIT"] +repository = { type = "github", user = "zed-pkg", repo = "zed-clients" } +target = "erlang" +gleam = ">= 1.14.0" + +[dependencies] +gleam_stdlib = ">= 0.60.0 and < 2.0.0" +gleam_json = ">= 3.0.0 and < 4.0.0" +gleam_http = ">= 4.0.0 and < 5.0.0" +gleam_httpc = ">= 5.0.0 and < 6.0.0" +gleam_crypto = ">= 1.5.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/clients/gleam/manifest.toml b/clients/gleam/manifest.toml new file mode 100644 index 0000000..11edffd --- /dev/null +++ b/clients/gleam/manifest.toml @@ -0,0 +1,20 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, +] + +[requirements] +gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" } +gleam_http = { version = ">= 4.0.0 and < 5.0.0" } +gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } +gleam_json = { version = ">= 3.0.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.60.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/clients/gleam/src/zed_pkg_client.gleam b/clients/gleam/src/zed_pkg_client.gleam new file mode 100644 index 0000000..08a97e6 --- /dev/null +++ b/clients/gleam/src/zed_pkg_client.gleam @@ -0,0 +1,766 @@ +//// Gleam SDK for the zed-pkg registry. Types mirror the JSON Schemas in +//// `zed-interfaces/schemas/`; the transport is injectable so tests run +//// without a network. + +import gleam/bit_array +import gleam/crypto +import gleam/dynamic/decode +import gleam/http +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/httpc +import gleam/int +import gleam/json +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gleam/uri +import zed_pkg_client/model.{ + type ClaimOrgResponse, type PackageMetadata, type PublishResponse, + type SearchResponse, type VersionMetadata, type YankResponse, +} + +pub const default_registry_url = "https://registry.zpkg.tech" + +/// Bounds every request (connect + read), in milliseconds. +pub const default_timeout_ms = 30_000 + +/// Successful registry JSON documents are never accepted without a ceiling. +pub const max_json_response_bytes = 16_777_216 + +/// Remote error text is retained only below this independent ceiling. +pub const max_error_body_bytes = 16_384 + +/// Maximum UTF-8 size of one opaque route segment. +pub const max_path_segment_bytes = 256 + +/// Hard ceiling on artifact downloads, matching the server's +/// `MAX_ARTIFACT_BYTES` default (100 MiB); plus the slack added to a +/// version's declared size. +pub const max_artifact_bytes = 104_857_600 + +const download_slack = 1_048_576 + +/// The declared size (when sane) plus slack, capped by the ceiling. +pub fn download_limit(size: Int) -> Int { + case size > 0 { + True -> int.min(size + download_slack, max_artifact_bytes) + False -> max_artifact_bytes + } +} + +pub type ClientError { + /// The registry answered with a structured or bounded fallback error body. + ApiError(status: Int, code: String, message: String) + TransportError(error: httpc.HttpError) + InvalidConfiguration(message: String) + InvalidInput(message: String) + MissingToken + InvalidResponse(message: String) + ResponseTooLarge(limit: Int) + Sha256Mismatch(expected: String, actual: String) + ArtifactTooLarge(limit: Int) + InsecureDownloadUrl(message: String) +} + +/// Injectable transport used for deterministic tests and alternate runtimes. +pub type Transport = + fn(Request(BitArray), Int) -> Result(Response(BitArray), httpc.HttpError) + +pub opaque type Client { + Client( + base: String, + token: Option(String), + timeout_ms: Int, + transport: Transport, + configuration_error: Option(ClientError), + ) +} + +/// Construct a client against `base`. The constructor keeps its historical +/// non-throwing shape; invalid configuration is retained and every operation +/// fails before the injected transport can run. +pub fn new(base: String) -> Client { + case normalize_base(base) { + Ok(base) -> + Client( + base: base, + token: None, + timeout_ms: default_timeout_ms, + transport: httpc_transport, + configuration_error: None, + ) + Error(error) -> + Client( + base: default_registry_url, + token: None, + timeout_ms: default_timeout_ms, + transport: httpc_transport, + configuration_error: Some(error), + ) + } +} + +/// Attach a bearer token used for authenticated calls (claim_org, yank, +/// publish). Never sent on artifact downloads. Blank tokens are treated as +/// absent so mutations fail closed before transport. +pub fn with_token(client: Client, token: String) -> Client { + let token = string.trim(token) + case token { + "" -> Client(..client, token: None) + _ -> Client(..client, token: Some(token)) + } +} + +pub fn with_timeout(client: Client, timeout_ms: Int) -> Client { + case timeout_ms > 0 { + True -> Client(..client, timeout_ms: timeout_ms) + False -> + Client( + ..client, + configuration_error: Some(InvalidConfiguration( + message: "timeout_ms must be positive", + )), + ) + } +} + +pub fn with_transport(client: Client, transport: Transport) -> Client { + Client(..client, transport: transport) +} + +fn ensure_configured(client: Client) -> Result(Nil, ClientError) { + case client.configuration_error { + None -> Ok(Nil) + Some(error) -> Error(error) + } +} + +fn normalize_base(base: String) -> Result(String, ClientError) { + let trimmed = string.trim(base) + use parsed <- result.try( + uri.parse(trimmed) + |> result.map_error(fn(_) { + InvalidConfiguration( + message: "registry URL must be an absolute HTTP(S) URL", + ) + }), + ) + use _ <- result.try(validate_path(parsed.path, "registry URL path")) + case + #( + parsed.scheme, + parsed.userinfo, + parsed.host, + parsed.query, + parsed.fragment, + ) + { + #(Some("http"), None, Some(host), None, None) + | #(Some("https"), None, Some(host), None, None) + if host != "" + -> Ok(trim_trailing_slashes(trimmed)) + _ -> + Error(InvalidConfiguration( + message: "registry URL must be credential-free absolute HTTP(S) without query or fragment", + )) + } +} + +fn trim_trailing_slashes(value: String) -> String { + case string.ends_with(value, "/") { + True -> trim_trailing_slashes(string.drop_end(value, 1)) + False -> value + } +} + +fn validate_path(path: String, name: String) -> Result(Nil, ClientError) { + validate_path_segments(string.split(path, "/"), name, 1) +} + +fn validate_path_segments( + segments: List(String), + name: String, + index: Int, +) -> Result(Nil, ClientError) { + case segments { + [] -> Ok(Nil) + ["", ..rest] -> validate_path_segments(rest, name, index + 1) + [encoded, ..rest] -> { + use decoded <- result.try( + uri.percent_decode(encoded) + |> result.map_error(fn(_) { + InvalidInput(message: name <> " contains invalid percent encoding") + }), + ) + case string.contains(decoded, "/") || string.contains(decoded, "\\") { + True -> + Error(InvalidInput( + message: name <> " segments must not contain encoded separators", + )) + False -> { + use _ <- result.try(validate_segment( + decoded, + name <> " segment " <> int.to_string(index), + )) + validate_path_segments(rest, name, index + 1) + } + } + } + } +} + +fn validate_segment( + segment: String, + name: String, +) -> Result(String, ClientError) { + let size = + segment + |> bit_array.from_string + |> bit_array.byte_size + case + string.trim(segment) == "" + || segment == "." + || segment == ".." + || size > max_path_segment_bytes + || string.contains(segment, "\n") + || string.contains(segment, "\r") + || string.contains(segment, "\u{0000}") + { + True -> + Error(InvalidInput( + message: name + <> " must be nonblank, non-dot, bounded, and free of control characters", + )) + False -> Ok(segment) + } +} + +fn httpc_transport( + req: Request(BitArray), + timeout_ms: Int, +) -> Result(Response(BitArray), httpc.HttpError) { + httpc.configure() + |> httpc.timeout(timeout_ms) + // Refusing redirects prevents a mutating request body and bearer token from + // being replayed to a different target. + |> httpc.follow_redirects(False) + |> httpc.dispatch_bits(req) +} + +/// Percent-encode one path segment (everything reserved, including `/`). Dot +/// segments are encoded explicitly because RFC URL normalizers may otherwise +/// treat them as traversal even when passed as supposedly opaque coordinates. +pub fn encode_segment(segment: String) -> String { + case segment { + "." -> "%2E" + ".." -> "%2E%2E" + _ -> uri.percent_encode(segment) + } +} + +pub fn package_path(org: String, name: String) -> String { + "/v1/packages/" <> encode_segment(org) <> "/" <> encode_segment(name) +} + +pub fn version_path(org: String, name: String, version: String) -> String { + package_path(org, name) <> "/versions/" <> encode_segment(version) +} + +pub fn artifact_path(sha256: String) -> String { + "/v1/artifacts/" <> encode_segment(sha256) +} + +pub fn yank_path(org: String, name: String, version: String) -> String { + version_path(org, name, version) <> "/yank" +} + +fn checked_package_path( + org: String, + name: String, +) -> Result(String, ClientError) { + use org <- result.try(validate_segment(org, "org")) + use name <- result.try(validate_segment(name, "name")) + Ok(package_path(org, name)) +} + +fn checked_version_path( + org: String, + name: String, + version: String, +) -> Result(String, ClientError) { + use org <- result.try(validate_segment(org, "org")) + use name <- result.try(validate_segment(name, "name")) + use version <- result.try(validate_segment(version, "version")) + Ok(version_path(org, name, version)) +} + +fn checked_yank_path( + org: String, + name: String, + version: String, +) -> Result(String, ClientError) { + use path <- result.try(checked_version_path(org, name, version)) + Ok(path <> "/yank") +} + +/// Enforce the download-url scheme policy: https is always allowed; http only +/// for loopback hosts or when the registry base is itself http. Query strings +/// remain allowed for presigned URLs; userinfo and fragments are rejected. +pub fn allowed_download_url( + raw: String, + base: String, +) -> Result(String, ClientError) { + case uri.parse(raw) { + Error(_) -> Error(InsecureDownloadUrl(message: "bad download url")) + Ok(parsed) -> + case #(parsed.userinfo, parsed.host, parsed.fragment) { + #(None, Some(host), None) if host != "" -> { + let loopback = case host { + "localhost" | "127.0.0.1" | "[::1]" | "::1" -> True + _ -> string.starts_with(host, "127.") + } + case parsed.scheme { + Some("https") -> Ok(raw) + Some("http") -> + case loopback || string.starts_with(base, "http://") { + True -> Ok(raw) + False -> + Error(InsecureDownloadUrl( + message: "refusing artifact download over `http`", + )) + } + _ -> + Error(InsecureDownloadUrl( + message: "refusing artifact download over an unsupported scheme", + )) + } + } + _ -> + Error(InsecureDownloadUrl( + message: "download URL contains credentials, fragment, or no host", + )) + } + } +} + +fn resolve_download_url( + raw: String, + base: String, + sha256: String, +) -> Result(String, ClientError) { + let trimmed = string.trim(raw) + case trimmed { + "" -> { + use sha256 <- result.try(validate_segment(sha256, "sha256")) + Ok(base <> artifact_path(sha256)) + } + _ -> { + use parsed <- result.try( + uri.parse(trimmed) + |> result.map_error(fn(_) { + InsecureDownloadUrl(message: "bad download url") + }), + ) + case parsed.scheme { + Some(_) -> allowed_download_url(trimmed, base) + None -> + case #(parsed.host, parsed.userinfo, parsed.fragment) { + #(None, None, None) -> { + use _ <- result.try(validate_path(parsed.path, "download_url")) + case string.starts_with(trimmed, "/") { + True -> + Error(InsecureDownloadUrl( + message: "absolute-path download URLs are not allowed", + )) + False -> allowed_download_url(base <> "/" <> trimmed, base) + } + } + _ -> + Error(InsecureDownloadUrl( + message: "relative download URL contains an authority or fragment", + )) + } + } + } + } +} + +/// Lowercase hex sha256 of `bytes`. +pub fn sha256_hex(bytes: BitArray) -> String { + crypto.hash(crypto.Sha256, bytes) + |> bit_array.base16_encode + |> string.lowercase +} + +pub fn verify_sha256( + bytes: BitArray, + expected: String, +) -> Result(Nil, ClientError) { + let actual = sha256_hex(bytes) + case actual == string.lowercase(expected) { + True -> Ok(Nil) + False -> Error(Sha256Mismatch(expected: expected, actual: actual)) + } +} + +fn base_request( + client: Client, + path: String, +) -> Result(Request(BitArray), ClientError) { + use _ <- result.try(ensure_configured(client)) + case request.to(client.base <> path) { + Ok(req) -> + Ok( + req + |> request.set_header("accept", "application/json") + |> request.set_body(<<>>), + ) + Error(_) -> Error(InvalidResponse(message: "invalid registry url")) + } +} + +fn authorize( + client: Client, + req: Request(BitArray), +) -> Result(Request(BitArray), ClientError) { + case client.token { + Some(token) -> + Ok(request.set_header(req, "authorization", "Bearer " <> token)) + None -> Error(MissingToken) + } +} + +fn send( + client: Client, + req: Request(BitArray), +) -> Result(Response(BitArray), ClientError) { + use _ <- result.try(ensure_configured(client)) + client.transport(req, client.timeout_ms) + |> result.map_error(fn(error) { TransportError(error: error) }) +} + +/// Map a response to either a bounded successful body or a typed error. +fn check( + response: Response(BitArray), + success_limit: Int, +) -> Result(BitArray, ClientError) { + let size = bit_array.byte_size(response.body) + case response.status >= 200 && response.status < 300 { + True -> + case size > success_limit { + True -> Error(ResponseTooLarge(limit: success_limit)) + False -> Ok(response.body) + } + False -> { + let fallback_code = "http_" <> int.to_string(response.status) + case size > max_error_body_bytes { + True -> + Error(ApiError( + status: response.status, + code: fallback_code, + message: "registry error body exceeded the client limit", + )) + False -> { + let text = + bit_array.to_string(response.body) + |> result.unwrap("") + let decoder = { + use code <- decode.field("code", decode.string) + use message <- decode.field("message", decode.string) + decode.success(#(code, message)) + } + case json.parse(from: text, using: decoder) { + Ok(#(code, message)) -> { + let code = case string.trim(code) { + "" -> fallback_code + code -> code + } + Error(ApiError( + status: response.status, + code: code, + message: message, + )) + } + Error(_) -> + Error(ApiError( + status: response.status, + code: fallback_code, + message: text, + )) + } + } + } + } + } +} + +fn decode_json( + bytes: BitArray, + decoder: decode.Decoder(t), +) -> Result(t, ClientError) { + case bit_array.to_string(bytes) { + Error(_) -> Error(InvalidResponse(message: "response body is not utf-8")) + Ok(text) -> + json.parse(from: text, using: decoder) + |> result.map_error(fn(error) { + InvalidResponse( + message: "invalid registry response: " <> string.inspect(error), + ) + }) + } +} + +fn request_json( + client: Client, + method: http.Method, + path: String, + body: Option(json.Json), + authorized: Bool, + decoder: decode.Decoder(t), +) -> Result(t, ClientError) { + use req <- result.try(base_request(client, path)) + let req = request.set_method(req, method) + use req <- result.try(case authorized { + True -> authorize(client, req) + False -> Ok(req) + }) + let req = case body { + Some(payload) -> + req + |> request.set_header("content-type", "application/json") + |> request.set_body(bit_array.from_string(json.to_string(payload))) + None -> req + } + use response <- result.try(send(client, req)) + use bytes <- result.try(check(response, max_json_response_bytes)) + decode_json(bytes, decoder) +} + +/// `GET /v1/packages/{org}/{name}` — package metadata + version list. +pub fn get_package( + client: Client, + org: String, + name: String, +) -> Result(PackageMetadata, ClientError) { + use path <- result.try(checked_package_path(org, name)) + request_json( + client, + http.Get, + path, + None, + False, + model.package_metadata_decoder(), + ) +} + +/// `GET /v1/packages/{org}/{name}/versions/{version}`. +pub fn get_version( + client: Client, + org: String, + name: String, + version: String, +) -> Result(VersionMetadata, ClientError) { + use path <- result.try(checked_version_path(org, name, version)) + request_json( + client, + http.Get, + path, + None, + False, + model.version_metadata_decoder(), + ) +} + +/// `GET /v1/search?q=`. +pub fn search( + client: Client, + query: String, +) -> Result(SearchResponse, ClientError) { + request_json( + client, + http.Get, + "/v1/search?q=" <> uri.percent_encode(query), + None, + False, + model.search_response_decoder(), + ) +} + +/// `POST /v1/orgs` (bearer token). +pub fn claim_org( + client: Client, + slug: String, +) -> Result(ClaimOrgResponse, ClientError) { + use slug <- result.try(validate_segment(slug, "slug")) + request_json( + client, + http.Post, + "/v1/orgs", + Some(json.object([#("slug", json.string(slug))])), + True, + model.claim_org_response_decoder(), + ) +} + +pub fn set_yanked( + client: Client, + org: String, + name: String, + version: String, + yanked: Bool, +) -> Result(YankResponse, ClientError) { + use path <- result.try(checked_yank_path(org, name, version)) + request_json( + client, + http.Post, + path, + Some(json.object([#("yanked", json.bool(yanked))])), + True, + model.yank_response_decoder(), + ) +} + +/// Compatibility form retained for existing callers. +pub fn yank( + client: Client, + org: String, + name: String, + version: String, + yanked: Bool, +) -> Result(YankResponse, ClientError) { + set_yanked(client, org, name, version, yanked) +} + +pub fn restore( + client: Client, + org: String, + name: String, + version: String, +) -> Result(YankResponse, ClientError) { + set_yanked(client, org, name, version, False) +} + +/// Download an artifact, verify its sha256, and return the bytes. +pub fn download_artifact( + client: Client, + version: VersionMetadata, +) -> Result(BitArray, ClientError) { + use _ <- result.try(ensure_configured(client)) + use url <- result.try(resolve_download_url( + version.download_url, + client.base, + version.sha256, + )) + use req <- result.try(case request.to(url) { + Ok(req) -> Ok(request.set_body(req, <<>>)) + Error(_) -> Error(InvalidResponse(message: "invalid download url")) + }) + // Deliberately no auth header: download_url may point at a third-party host + // (e.g. a presigned S3/R2 url) and the token must not leak there. + use response <- result.try(send(client, req)) + let limit = download_limit(version.size) + use bytes <- result.try(check(response, limit)) + use _ <- result.try(verify_sha256(bytes, version.sha256)) + Ok(bytes) +} + +/// Build the `multipart/form-data` body for a publish: a `meta` field carrying +/// the PublishMeta JSON and an artifact file part. The filename is fixed so +/// caller-controlled coordinates cannot inject multipart headers. +pub fn multipart_body( + boundary: String, + meta_json: String, + filename: String, + artifact: BitArray, +) -> BitArray { + bit_array.concat([ + bit_array.from_string( + "--" + <> boundary + <> "\r\ncontent-disposition: form-data; name=\"meta\"\r\n\r\n" + <> meta_json + <> "\r\n--" + <> boundary + <> "\r\ncontent-disposition: form-data; name=\"artifact\"; filename=\"" + <> filename + <> "\"\r\ncontent-type: application/octet-stream\r\n\r\n", + ), + artifact, + bit_array.from_string("\r\n--" <> boundary <> "--\r\n"), + ]) +} + +fn publish_coordinate_decoder() -> decode.Decoder(#(String, String, String)) { + use org <- decode.then(decode.at( + ["manifest", "package", "org"], + decode.string, + )) + use name <- decode.then(decode.at( + ["manifest", "package", "name"], + decode.string, + )) + use version <- decode.then(decode.at( + ["manifest", "package", "version"], + decode.string, + )) + decode.success(#(org, name, version)) +} + +/// Publish: multipart `meta` (PublishMeta JSON) + `artifact` bytes via +/// `PUT /v1/packages/{org}/{name}/versions/{version}`. Requires a bearer +/// token. `org`, `name` and `version` must match `meta.manifest.package`. +pub fn publish( + client: Client, + org: String, + name: String, + version: String, + meta_json: String, + artifact: BitArray, +) -> Result(PublishResponse, ClientError) { + use _ <- result.try(ensure_configured(client)) + use _ <- result.try(case client.token { + Some(_) -> Ok(Nil) + None -> Error(MissingToken) + }) + use org <- result.try(validate_segment(org, "org")) + use name <- result.try(validate_segment(name, "name")) + use version <- result.try(validate_segment(version, "version")) + case bit_array.byte_size(artifact) > max_artifact_bytes { + True -> Error(ArtifactTooLarge(limit: max_artifact_bytes)) + False -> { + use coordinate <- result.try(decode_json( + bit_array.from_string(meta_json), + publish_coordinate_decoder(), + )) + case coordinate == #(org, name, version) { + False -> + Error(InvalidInput( + message: "publish route and meta.manifest.package coordinates differ", + )) + True -> { + let boundary = "zedpkg" <> sha256_hex(crypto.strong_random_bytes(16)) + use path <- result.try(checked_version_path(org, name, version)) + use req <- result.try(base_request(client, path)) + let req = + req + |> request.set_method(http.Put) + |> request.set_header( + "content-type", + "multipart/form-data; boundary=" <> boundary, + ) + |> request.set_body(multipart_body( + boundary, + meta_json, + "artifact.tar.gz", + artifact, + )) + use req <- result.try(authorize(client, req)) + use response <- result.try(send(client, req)) + use bytes <- result.try(check(response, max_json_response_bytes)) + decode_json(bytes, model.publish_response_decoder()) + } + } + } + } +} diff --git a/clients/gleam/src/zed_pkg_client/model.gleam b/clients/gleam/src/zed_pkg_client/model.gleam new file mode 100644 index 0000000..156ed10 --- /dev/null +++ b/clients/gleam/src/zed_pkg_client/model.gleam @@ -0,0 +1,169 @@ +//// Wire types mirroring `zed-interfaces/schemas/*.json` (snake_case as on +//// the wire; regenerate against those schemas when the contract changes). +//// Decoders ignore unknown server fields so newly added contract fields never +//// crash older clients. + +import gleam/dynamic/decode +import gleam/option.{type Option, None} + +pub type PackageSummary { + PackageSummary( + org: String, + name: String, + description: Option(String), + latest: Option(String), + ) +} + +pub type PackageMetadata { + PackageMetadata( + org: String, + name: String, + description: Option(String), + vcs: String, + repo_url: String, + latest: Option(String), + versions: List(String), + version_scheme: String, + ) +} + +pub type VersionMetadata { + VersionMetadata( + org: String, + name: String, + version: String, + sha256: String, + size: Int, + format: String, + vcs_tag: String, + vcs_commit: Option(String), + download_url: String, + published_at: String, + yanked: Bool, + ) +} + +pub type SearchResponse { + SearchResponse(query: String, items: List(PackageSummary)) +} + +pub type ClaimOrgResponse { + ClaimOrgResponse(slug: String, created: Bool) +} + +pub type YankResponse { + YankResponse(org: String, name: String, version: String, yanked: Bool) +} + +pub type PublishResponse { + PublishResponse(org: String, name: String, version: String, sha256: String) +} + +pub fn package_summary_decoder() -> decode.Decoder(PackageSummary) { + use org <- decode.field("org", decode.string) + use name <- decode.field("name", decode.string) + use description <- decode.optional_field( + "description", + None, + decode.optional(decode.string), + ) + use latest <- decode.optional_field( + "latest", + None, + decode.optional(decode.string), + ) + decode.success(PackageSummary(org:, name:, description:, latest:)) +} + +pub fn package_metadata_decoder() -> decode.Decoder(PackageMetadata) { + use org <- decode.field("org", decode.string) + use name <- decode.field("name", decode.string) + use description <- decode.optional_field( + "description", + None, + decode.optional(decode.string), + ) + use vcs <- decode.field("vcs", decode.string) + use repo_url <- decode.field("repo_url", decode.string) + use latest <- decode.optional_field( + "latest", + None, + decode.optional(decode.string), + ) + use versions <- decode.field("versions", decode.list(decode.string)) + use version_scheme <- decode.optional_field( + "version_scheme", + "semver", + decode.string, + ) + decode.success(PackageMetadata( + org:, + name:, + description:, + vcs:, + repo_url:, + latest:, + versions:, + version_scheme:, + )) +} + +pub fn version_metadata_decoder() -> decode.Decoder(VersionMetadata) { + use org <- decode.field("org", decode.string) + use name <- decode.field("name", decode.string) + use version <- decode.field("version", decode.string) + use sha256 <- decode.field("sha256", decode.string) + use size <- decode.field("size", decode.int) + use format <- decode.field("format", decode.string) + use vcs_tag <- decode.field("vcs_tag", decode.string) + use vcs_commit <- decode.optional_field( + "vcs_commit", + None, + decode.optional(decode.string), + ) + use download_url <- decode.field("download_url", decode.string) + use published_at <- decode.field("published_at", decode.string) + use yanked <- decode.optional_field("yanked", False, decode.bool) + decode.success(VersionMetadata( + org:, + name:, + version:, + sha256:, + size:, + format:, + vcs_tag:, + vcs_commit:, + download_url:, + published_at:, + yanked:, + )) +} + +pub fn search_response_decoder() -> decode.Decoder(SearchResponse) { + use query <- decode.optional_field("query", "", decode.string) + use items <- decode.field("items", decode.list(package_summary_decoder())) + decode.success(SearchResponse(query:, items:)) +} + +pub fn claim_org_response_decoder() -> decode.Decoder(ClaimOrgResponse) { + use slug <- decode.field("slug", decode.string) + use created <- decode.field("created", decode.bool) + decode.success(ClaimOrgResponse(slug:, created:)) +} + +pub fn yank_response_decoder() -> decode.Decoder(YankResponse) { + use org <- decode.field("org", decode.string) + use name <- decode.field("name", decode.string) + use version <- decode.field("version", decode.string) + use yanked <- decode.field("yanked", decode.bool) + decode.success(YankResponse(org:, name:, version:, yanked:)) +} + +pub fn publish_response_decoder() -> decode.Decoder(PublishResponse) { + use org <- decode.field("org", decode.string) + use name <- decode.field("name", decode.string) + use version <- decode.field("version", decode.string) + use sha256 <- decode.field("sha256", decode.string) + decode.success(PublishResponse(org:, name:, version:, sha256:)) +} diff --git a/clients/gleam/test/publish_hardening_test.gleam b/clients/gleam/test/publish_hardening_test.gleam new file mode 100644 index 0000000..ad4f15d --- /dev/null +++ b/clients/gleam/test/publish_hardening_test.gleam @@ -0,0 +1,36 @@ +import gleam/bit_array +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/httpc +import gleam/string +import zed_pkg_client as client + +fn forbidden_transport( + _request: Request(BitArray), + _timeout_ms: Int, +) -> Result(Response(BitArray), httpc.HttpError) { + panic as "transport must not run" +} + +pub fn malformed_publish_meta_fails_before_transport_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token("token") + |> client.with_transport(forbidden_transport) + let result = + client.publish( + zed, + "acme", + "kit", + "1.2.0", + "not-json", + bit_array.from_string("bytes"), + ) + case result { + Error(client.InvalidResponse(message: _)) -> Nil + other -> + panic as { + "expected malformed metadata rejection, got " <> string.inspect(other) + } + } +} diff --git a/clients/gleam/test/zed_pkg_client_test.gleam b/clients/gleam/test/zed_pkg_client_test.gleam new file mode 100644 index 0000000..324a09f --- /dev/null +++ b/clients/gleam/test/zed_pkg_client_test.gleam @@ -0,0 +1,453 @@ +import gleam/bit_array +import gleam/http +import gleam/http/request.{type Request} +import gleam/http/response.{type Response} +import gleam/httpc +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import gleeunit +import gleeunit/should +import zed_pkg_client as client +import zed_pkg_client/model + +pub fn main() { + gleeunit.main() +} + +fn make_version( + sha256 sha256: String, + size size: Int, + download_url download_url: String, +) -> model.VersionMetadata { + model.VersionMetadata( + org: "acme", + name: "kit", + version: "1.2.0", + sha256: sha256, + size: size, + format: "tar.gz", + vcs_tag: "v1.2.0", + vcs_commit: None, + download_url: download_url, + published_at: "2024-01-01T00:00:00Z", + yanked: False, + ) +} + +/// A transport that records the request and replies with a canned response. +fn canned( + status: Int, + body: String, + spy: fn(Request(BitArray)) -> Nil, +) -> client.Transport { + fn(req: Request(BitArray), _timeout: Int) -> Result( + Response(BitArray), + httpc.HttpError, + ) { + spy(req) + Ok(response.Response( + status: status, + headers: [#("content-type", "application/json")], + body: bit_array.from_string(body), + )) + } +} + +fn forbidden_transport( + _req: Request(BitArray), + _timeout: Int, +) -> Result(Response(BitArray), httpc.HttpError) { + panic as "transport must not run" +} + +fn header(req: Request(BitArray), name: String) -> Result(String, Nil) { + request.get_header(req, name) +} + +pub fn url_helpers_match_the_contract_test() { + client.package_path("acme", "kit") + |> should.equal("/v1/packages/acme/kit") + client.version_path("acme", "kit", "1.2.0") + |> should.equal("/v1/packages/acme/kit/versions/1.2.0") + client.artifact_path("abc") + |> should.equal("/v1/artifacts/abc") + client.yank_path("acme", "kit", "1.2.0") + |> should.equal("/v1/packages/acme/kit/versions/1.2.0/yank") +} + +pub fn path_segments_are_percent_encoded_test() { + client.version_path("acme", "kit", "release candidate/1") + |> should.equal("/v1/packages/acme/kit/versions/release%20candidate%2F1") + client.package_path("..", "kit") + |> should.equal("/v1/packages/%2E%2E/kit") +} + +pub fn download_limit_caps_at_ceiling_test() { + client.download_limit(0) + |> should.equal(client.max_artifact_bytes) + client.download_limit(10) + |> should.equal(10 + 1_048_576) + client.download_limit(client.max_artifact_bytes) + |> should.equal(client.max_artifact_bytes) +} + +pub fn invalid_configuration_fails_before_transport_test() { + let invalid_bases = [ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + "https://registry.test/../admin", + "https://registry.test/%2e%2e/admin", + "https://registry.test/a%2Fb", + ] + list.each(invalid_bases, fn(base) { + let zed = + client.new(base) + |> client.with_transport(forbidden_transport) + case client.get_package(zed, "acme", "kit") { + Error(client.InvalidConfiguration(message: _)) + | Error(client.InvalidInput(message: _)) -> Nil + other -> + panic as { + "expected invalid configuration, got " <> string.inspect(other) + } + } + }) + + let invalid_timeout = + client.new("https://registry.test") + |> client.with_timeout(0) + |> client.with_transport(forbidden_transport) + case client.search(invalid_timeout, "x") { + Error(client.InvalidConfiguration(message: _)) -> Nil + other -> + panic as { "expected invalid timeout, got " <> string.inspect(other) } + } +} + +pub fn hostile_segments_fail_before_transport_test() { + let zed = + client.new("https://registry.test") + |> client.with_transport(forbidden_transport) + let values = ["", " ", ".", "..", "line\nbreak"] + list.each(values, fn(value) { + case client.get_package(zed, value, "kit") { + Error(client.InvalidInput(message: _)) -> Nil + other -> + panic as { "expected invalid segment, got " <> string.inspect(other) } + } + }) + let overlong = + list.repeat("x", client.max_path_segment_bytes + 1) + |> string.concat + case client.get_version(zed, "acme", "kit", overlong) { + Error(client.InvalidInput(message: _)) -> Nil + other -> + panic as { "expected overlong rejection, got " <> string.inspect(other) } + } +} + +pub fn authenticated_operations_require_a_token_before_transport_test() { + let zed = + client.new("https://registry.test") + |> client.with_transport(forbidden_transport) + client.claim_org(zed, "acme") + |> should.equal(Error(client.MissingToken)) + client.yank(zed, "acme", "kit", "1.2.0", True) + |> should.equal(Error(client.MissingToken)) + client.restore(zed, "acme", "kit", "1.2.0") + |> should.equal(Error(client.MissingToken)) + client.publish( + zed, + "acme", + "kit", + "1.2.0", + "{\"manifest\":{\"package\":{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}", + bit_array.from_string("bytes"), + ) + |> should.equal(Error(client.MissingToken)) +} + +pub fn insecure_download_urls_are_rejected_test() { + let base = "https://registry.zpkg.tech" + client.allowed_download_url("http://evil.example/a", base) + |> should.be_error + client.allowed_download_url("file:///etc/passwd", base) + |> should.be_error + client.allowed_download_url("https://user:secret@cdn.example/a", base) + |> should.be_error + client.allowed_download_url("https://cdn.example/a#fragment", base) + |> should.be_error + client.allowed_download_url("https://cdn.example/a?signature=one", base) + |> should.equal(Ok("https://cdn.example/a?signature=one")) + client.allowed_download_url("http://127.0.0.1:8080/a", base) + |> should.equal(Ok("http://127.0.0.1:8080/a")) + client.allowed_download_url("http://localhost/a", base) + |> should.equal(Ok("http://localhost/a")) + // An http registry base opts in to plaintext downloads. + client.allowed_download_url( + "http://mirror.internal/a", + "http://registry.internal", + ) + |> should.equal(Ok("http://mirror.internal/a")) +} + +pub fn get_package_decodes_metadata_test() { + let body = + "{\"org\":\"acme\",\"name\":\"kit\",\"vcs\":\"git\"," + <> "\"repo_url\":\"https://github.com/acme/kit\",\"latest\":\"1.2.0\"," + <> "\"versions\":[\"1.2.0\"],\"version_scheme\":\"calver\"," + <> "\"unknown_future_field\":true}" + let zed = + client.new("https://registry.zpkg.tech/") + |> client.with_transport( + canned(200, body, fn(req) { + req.path + |> should.equal("/v1/packages/acme/kit") + req.method + |> should.equal(http.Get) + // No token attached: reads are unauthenticated. + header(req, "authorization") + |> should.be_error + Nil + }), + ) + let assert Ok(pkg) = client.get_package(zed, "acme", "kit") + pkg.latest + |> should.equal(Some("1.2.0")) + pkg.version_scheme + |> should.equal("calver") + pkg.versions + |> should.equal(["1.2.0"]) +} + +pub fn api_errors_carry_the_registry_code_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token(" zpkg_t ") + |> client.with_transport( + canned(409, "{\"code\":\"org_taken\",\"message\":\"claimed\"}", fn(req) { + header(req, "authorization") + |> should.equal(Ok("Bearer zpkg_t")) + Nil + }), + ) + client.claim_org(zed, "acme") + |> should.equal( + Error(client.ApiError(status: 409, code: "org_taken", message: "claimed")), + ) +} + +pub fn blank_structured_error_code_uses_http_fallback_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token("token") + |> client.with_transport( + canned(409, "{\"code\":\" \",\"message\":\"detail\"}", fn(_) { Nil }), + ) + client.claim_org(zed, "acme") + |> should.equal( + Error(client.ApiError(status: 409, code: "http_409", message: "detail")), + ) +} + +pub fn non_json_error_bodies_map_to_http_status_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_transport(canned(500, "boom", fn(_) { Nil })) + client.get_version(zed, "acme", "kit", "1.2.0") + |> should.equal( + Error(client.ApiError(status: 500, code: "http_500", message: "boom")), + ) +} + +pub fn oversized_error_body_is_not_retained_test() { + let body = + list.repeat("x", client.max_error_body_bytes + 1) + |> string.concat + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_transport(canned(502, body, fn(_) { Nil })) + client.get_package(zed, "acme", "kit") + |> should.equal( + Error(client.ApiError( + status: 502, + code: "http_502", + message: "registry error body exceeded the client limit", + )), + ) +} + +pub fn yank_and_restore_post_the_canonical_flag_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token("zpkg_t") + |> client.with_transport(fn(req, _timeout) { + req.path + |> should.equal("/v1/packages/acme/kit/versions/1.2.0/yank") + req.method + |> should.equal(http.Post) + let assert Ok(body) = bit_array.to_string(req.body) + let yanked = string.contains(body, "true") + Ok(response.Response( + status: 200, + headers: [], + body: bit_array.from_string( + "{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\",\"yanked\":" + <> case yanked { + True -> "true" + False -> "false" + } + <> "}", + ), + )) + }) + let assert Ok(yanked) = client.yank(zed, "acme", "kit", "1.2.0", True) + yanked.yanked + |> should.be_true + let assert Ok(restored) = client.restore(zed, "acme", "kit", "1.2.0") + restored.yanked + |> should.be_false +} + +pub fn download_verifies_sha_omits_auth_and_preserves_gateway_test() { + let bytes = bit_array.from_string("artifact-bytes") + let digest = client.sha256_hex(bytes) + let zed = + client.new("https://registry.zpkg.tech/gateway") + |> client.with_token("zpkg_t") + |> client.with_transport(fn(req, _timeout) { + // Bearer token must not leak to the download host. + header(req, "authorization") + |> should.be_error + req.path + |> should.equal("/gateway/artifacts/hash") + Ok(response.Response(status: 200, headers: [], body: bytes)) + }) + client.download_artifact( + zed, + make_version( + sha256: string.uppercase(digest), + size: 14, + download_url: "artifacts/hash", + ), + ) + |> should.equal(Ok(bytes)) +} + +pub fn download_rejects_sha_mismatch_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_transport(fn(_req, _timeout) { + Ok(response.Response( + status: 200, + headers: [], + body: bit_array.from_string("tampered"), + )) + }) + let result = + client.download_artifact( + zed, + make_version(sha256: "00", size: 8, download_url: ""), + ) + case result { + Error(client.Sha256Mismatch(expected: "00", actual: _)) -> Nil + other -> panic as { "expected sha mismatch, got " <> string.inspect(other) } + } +} + +pub fn download_rejects_oversize_body_test() { + let limit = client.download_limit(1) + let oversize = + list.repeat(<<0>>, limit + 64) + |> bit_array.concat + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_transport(fn(_req, _timeout) { + Ok(response.Response(status: 200, headers: [], body: oversize)) + }) + client.download_artifact( + zed, + make_version(sha256: "deadbeef", size: 1, download_url: ""), + ) + |> should.equal(Error(client.ResponseTooLarge(limit: limit))) +} + +pub fn multipart_body_layout_test() { + let body = + client.multipart_body( + "BOUNDARY", + "{\"manifest\":{}}", + "artifact.tar.gz", + bit_array.from_string("bytes"), + ) + let assert Ok(text) = bit_array.to_string(body) + string.contains(text, "--BOUNDARY\r\n") + |> should.be_true + string.contains(text, "name=\"meta\"") + |> should.be_true + string.contains(text, "name=\"artifact\"; filename=\"artifact.tar.gz\"") + |> should.be_true + string.ends_with(text, "--BOUNDARY--\r\n") + |> should.be_true +} + +pub fn publish_rejects_coordinate_mismatch_before_transport_test() { + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token("token") + |> client.with_transport(forbidden_transport) + let result = + client.publish( + zed, + "acme", + "kit", + "1.2.0", + "{\"manifest\":{\"package\":{\"org\":\"other\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}", + bit_array.from_string("bytes"), + ) + case result { + Error(client.InvalidInput(message: _)) -> Nil + other -> + panic as { "expected coordinate mismatch, got " <> string.inspect(other) } + } +} + +pub fn publish_sends_fixed_filename_multipart_put_test() { + let body = + "{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\",\"sha256\":\"abc\"}" + let zed = + client.new("https://registry.zpkg.tech") + |> client.with_token("zpkg_t") + |> client.with_transport( + canned(200, body, fn(req) { + req.method + |> should.equal(http.Put) + req.path + |> should.equal("/v1/packages/acme/kit/versions/1.2.0") + header(req, "authorization") + |> should.equal(Ok("Bearer zpkg_t")) + let assert Ok(content_type) = header(req, "content-type") + string.starts_with(content_type, "multipart/form-data; boundary=") + |> should.be_true + let assert Ok(raw_body) = bit_array.to_string(req.body) + string.contains(raw_body, "filename=\"artifact.tar.gz\"") + |> should.be_true + Nil + }), + ) + let assert Ok(published) = + client.publish( + zed, + "acme", + "kit", + "1.2.0", + "{\"manifest\":{\"package\":{\"org\":\"acme\",\"name\":\"kit\",\"version\":\"1.2.0\"}}}", + bit_array.from_string("bytes"), + ) + published.sha256 + |> should.equal("abc") +} diff --git a/clients/go/client.go b/clients/go/client.go new file mode 100644 index 0000000..8edb537 --- /dev/null +++ b/clients/go/client.go @@ -0,0 +1,675 @@ +// Package zedclient is the Go SDK for the zed-pkg registry. It is stdlib only, +// transports bearer credentials without parsing them, never retries writes, and +// refuses redirects so credentials cannot be replayed to another target. +package zedclient + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const DefaultRegistryURL = "https://registry.zpkg.tech" +const DefaultTimeout = 30 * time.Second + +const ( + maxArtifactBytes = 100 * 1024 * 1024 + downloadSlack = 1024 * 1024 + maxJSONResponseBytes = 16 * 1024 * 1024 + maxErrorBodyBytes = 16 * 1024 + maxPathSegmentBytes = 256 +) + +var errRefuseRedirect = errors.New("zed registry client refuses redirects") + +func downloadLimit(size uint64) uint64 { + if size == 0 { + return maxArtifactBytes + } + limit := size + downloadSlack + if limit < size || limit > maxArtifactBytes { + return maxArtifactBytes + } + return limit +} + +// APIError carries the registry's stable error code. Message is bounded and +// explicitly inspectable; Error deliberately excludes arbitrary remote text. +type APIError struct { + Status int + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("registry error %d: %s", e.Status, e.Code) +} + +type PackageSummary struct { + Org string `json:"org"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Latest *string `json:"latest,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type PackageMetadata struct { + Org string `json:"org"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Vcs string `json:"vcs"` + RepoURL string `json:"repo_url"` + Latest *string `json:"latest,omitempty"` + Tags []string `json:"tags,omitempty"` + Versions []string `json:"versions"` + VersionScheme string `json:"version_scheme,omitempty"` +} + +type VersionMetadata struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Sha256 string `json:"sha256"` + Size uint64 `json:"size"` + Format string `json:"format"` + VcsTag string `json:"vcs_tag"` + VcsCommit *string `json:"vcs_commit,omitempty"` + DownloadURL string `json:"download_url"` + PublishedAt string `json:"published_at"` + Yanked bool `json:"yanked"` +} + +type SearchResponse struct { + Query string `json:"query"` + Items []PackageSummary `json:"items"` +} + +type ClaimOrgResponse struct { + Slug string `json:"slug"` + Created bool `json:"created"` +} + +type YankResponse struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Yanked bool `json:"yanked"` +} + +type PublishResponse struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Sha256 string `json:"sha256"` +} + +func validateSegment(value, name string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not be blank", name) + } + if value == "." || value == ".." { + return fmt.Errorf("%s must not be a dot segment", name) + } + if len([]byte(value)) > maxPathSegmentBytes { + return fmt.Errorf("%s exceeds %d UTF-8 bytes", name, maxPathSegmentBytes) + } + for _, character := range value { + if character < 0x20 || character == 0x7f { + return fmt.Errorf("%s must not contain control characters", name) + } + } + return nil +} + +func escapeSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + default: + return url.PathEscape(value) + } +} + +func validateCoordinate(org, name, version string) error { + if err := validateSegment(org, "org"); err != nil { + return err + } + if err := validateSegment(name, "name"); err != nil { + return err + } + return validateSegment(version, "version") +} + +func PackagePath(org, name string) string { + return fmt.Sprintf("/v1/packages/%s/%s", escapeSegment(org), escapeSegment(name)) +} + +func VersionPath(org, name, version string) string { + return fmt.Sprintf( + "/v1/packages/%s/%s/versions/%s", + escapeSegment(org), + escapeSegment(name), + escapeSegment(version), + ) +} + +func YankPath(org, name, version string) string { + return VersionPath(org, name, version) + "/yank" +} + +func ArtifactPath(sha256 string) string { + return "/v1/artifacts/" + escapeSegment(sha256) +} + +type Client struct { + // These fields remain exported for compatibility. Every request revalidates + // Base, trims/requires Token, and clones HTTP while restoring safety policy. + Base string + Token string + HTTP *http.Client +} + +func New(base string) (*Client, error) { + return NewWithHTTPClient(base, nil) +} + +func NewWithHTTPClient(base string, supplied *http.Client) (*Client, error) { + normalized, err := normalizeRegistryURL(base) + if err != nil { + return nil, err + } + client := &http.Client{Timeout: DefaultTimeout} + if supplied != nil { + clone := *supplied + client = &clone + if client.Timeout <= 0 { + client.Timeout = DefaultTimeout + } + } + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return errRefuseRedirect + } + return &Client{Base: normalized, HTTP: client}, nil +} + +func (c *Client) String() string { + return fmt.Sprintf("ZedClient(base=%s, token=[REDACTED])", c.Base) +} + +func normalizeRegistryURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + parsed, err := url.Parse(trimmed) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf( + "registry URL must be a credential-free absolute HTTP(S) URL without query or fragment", + ) + } + for index, encoded := range strings.Split(parsed.EscapedPath(), "/") { + if encoded == "" { + continue + } + decoded, decodeErr := url.PathUnescape(encoded) + if decodeErr != nil { + return "", fmt.Errorf("registry path segment %d is invalid: %w", index+1, decodeErr) + } + if err := validateSegment(decoded, fmt.Sprintf("registry path segment %d", index+1)); err != nil { + return "", err + } + if strings.ContainsAny(decoded, "/\\") { + return "", fmt.Errorf("registry path segments must not contain encoded separators") + } + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} + +func (c *Client) normalizedBase() (string, error) { + if c == nil { + return "", errors.New("zed client is nil") + } + return normalizeRegistryURL(c.Base) +} + +func (c *Client) hardenedHTTPClient() (*http.Client, error) { + if c == nil { + return nil, errors.New("zed client is nil") + } + base := c.HTTP + if base == nil { + base = &http.Client{Timeout: DefaultTimeout} + } + clone := *base + if clone.Timeout <= 0 { + clone.Timeout = DefaultTimeout + } + clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return errRefuseRedirect + } + return &clone, nil +} + +func (c *Client) requireToken() (string, error) { + token := strings.TrimSpace(c.Token) + if token == "" { + return "", &APIError{ + Code: "missing_token", + Message: "authenticated registry operation requires a nonblank bearer token", + } + } + return token, nil +} + +func (c *Client) allowedDownloadURL(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.User != nil || parsed.Fragment != "" { + return "", &APIError{Code: "bad_download_url", Message: "download URL is invalid"} + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", &APIError{ + Code: "insecure_download_url", + Message: fmt.Sprintf("refusing artifact download over %q", parsed.Scheme), + } + } + if parsed.Hostname() == "" { + return "", &APIError{Code: "bad_download_url", Message: "download URL is invalid"} + } + host := parsed.Hostname() + loopback := host == "localhost" + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + loopback = true + } + base, baseErr := c.normalizedBase() + if baseErr != nil { + return "", baseErr + } + switch parsed.Scheme { + case "https": + return parsed.String(), nil + case "http": + if loopback || strings.HasPrefix(base, "http://") { + return parsed.String(), nil + } + } + return "", &APIError{ + Code: "insecure_download_url", + Message: fmt.Sprintf("refusing artifact download over %q", parsed.Scheme), + } +} + +func (c *Client) resolveDownloadURL(raw, sha256 string) (string, error) { + base, err := c.normalizedBase() + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + if err := validateSegment(sha256, "sha256"); err != nil { + return "", err + } + return base + ArtifactPath(sha256), nil + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", &APIError{Code: "bad_download_url", Message: "download URL is invalid"} + } + if !parsed.IsAbs() { + baseURL, parseErr := url.Parse(base + "/") + if parseErr != nil { + return "", parseErr + } + parsed = baseURL.ResolveReference(parsed) + } + return c.allowedDownloadURL(parsed.String()) +} + +func (c *Client) do( + method string, + path string, + body io.Reader, + contentType string, + authorized bool, + out any, +) error { + base, err := c.normalizedBase() + if err != nil { + return err + } + var token string + if authorized { + token, err = c.requireToken() + if err != nil { + return err + } + } + client, err := c.hardenedHTTPClient() + if err != nil { + return err + } + req, err := http.NewRequest(method, base+path, body) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if authorized { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + payload, readErr := readBounded(resp.Body, maxErrorBodyBytes, false) + if readErr != nil { + return readErr + } + return newAPIError(resp.StatusCode, payload) + } + payload, err := readBounded(resp.Body, maxJSONResponseBytes, true) + if err != nil { + return err + } + if out != nil { + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("decode registry response: %w", err) + } + } + return nil +} + +func readBounded(reader io.Reader, limit int, failOnOverflow bool) ([]byte, error) { + payload, err := io.ReadAll(io.LimitReader(reader, int64(limit+1))) + if err != nil { + return nil, err + } + if len(payload) > limit { + if failOnOverflow { + return nil, fmt.Errorf("registry response exceeded %d bytes", limit) + } + payload = payload[:limit] + } + return payload, nil +} + +func newAPIError(status int, payload []byte) *APIError { + apiErr := &APIError{Status: status, Code: fmt.Sprintf("http_%d", status), Message: string(payload)} + var decoded struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(payload, &decoded) == nil { + if strings.TrimSpace(decoded.Code) != "" { + apiErr.Code = strings.TrimSpace(decoded.Code) + } + if decoded.Message != "" { + apiErr.Message = decoded.Message + } + } + return apiErr +} + +func (c *Client) GetPackage(org, name string) (*PackageMetadata, error) { + if err := validateSegment(org, "org"); err != nil { + return nil, err + } + if err := validateSegment(name, "name"); err != nil { + return nil, err + } + var out PackageMetadata + if err := c.do(http.MethodGet, PackagePath(org, name), nil, "", false, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) GetVersion(org, name, version string) (*VersionMetadata, error) { + if err := validateCoordinate(org, name, version); err != nil { + return nil, err + } + var out VersionMetadata + if err := c.do(http.MethodGet, VersionPath(org, name, version), nil, "", false, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) Search(query string) (*SearchResponse, error) { + var out SearchResponse + path := "/v1/search?q=" + url.QueryEscape(query) + if err := c.do(http.MethodGet, path, nil, "", false, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) ClaimOrg(slug string) (*ClaimOrgResponse, error) { + if err := validateSegment(slug, "slug"); err != nil { + return nil, err + } + body, err := json.Marshal(map[string]string{"slug": slug}) + if err != nil { + return nil, err + } + var out ClaimOrgResponse + if err := c.do(http.MethodPost, "/v1/orgs", bytes.NewReader(body), "application/json", true, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) SetYanked(org, name, version string, yanked bool) (*YankResponse, error) { + if err := validateCoordinate(org, name, version); err != nil { + return nil, err + } + body, err := json.Marshal(map[string]bool{"yanked": yanked}) + if err != nil { + return nil, err + } + var out YankResponse + if err := c.do( + http.MethodPost, + YankPath(org, name, version), + bytes.NewReader(body), + "application/json", + true, + &out, + ); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) Yank(org, name, version string, yanked bool) (*YankResponse, error) { + return c.SetYanked(org, name, version, yanked) +} + +func (c *Client) Restore(org, name, version string) (*YankResponse, error) { + return c.SetYanked(org, name, version, false) +} + +func writeFileAtomic(destPath string, payload []byte) error { + destination, err := filepath.Abs(destPath) + if err != nil { + return err + } + directory := filepath.Dir(destination) + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(directory, ".zed-artifact-*") + if err != nil { + return err + } + temporaryName := temporary.Name() + committed := false + defer func() { + _ = temporary.Close() + if !committed { + _ = os.Remove(temporaryName) + } + }() + if _, err := temporary.Write(payload); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Chmod(0o644); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, destination); err != nil { + return err + } + committed = true + return nil +} + +func (c *Client) DownloadArtifact(version *VersionMetadata, destPath string) error { + if version == nil { + return errors.New("version metadata is required") + } + target, err := c.resolveDownloadURL(version.DownloadURL, version.Sha256) + if err != nil { + return err + } + client, err := c.hardenedHTTPClient() + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return err + } + // Deliberately no Authorization header: target may be a third-party + // presigned URL and registry credentials must never leave the registry. + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + payload, readErr := readBounded(resp.Body, maxErrorBodyBytes, false) + if readErr != nil { + return readErr + } + return newAPIError(resp.StatusCode, payload) + } + limit := downloadLimit(version.Size) + if resp.ContentLength > int64(limit) { + return &APIError{Code: "artifact_too_large", Message: fmt.Sprintf("artifact exceeded %d bytes", limit)} + } + payload, err := readBounded(resp.Body, int(limit), true) + if err != nil { + return &APIError{Code: "artifact_too_large", Message: err.Error()} + } + sum := sha256.Sum256(payload) + if actual := hex.EncodeToString(sum[:]); !strings.EqualFold(actual, version.Sha256) { + return &APIError{Code: "sha256_mismatch", Message: fmt.Sprintf("expected %s, got %s", version.Sha256, actual)} + } + return writeFileAtomic(destPath, payload) +} + +// Publish uploads multipart meta JSON plus raw artifact bytes. It does not retry. +func (c *Client) Publish( + org string, + name string, + version string, + metaJSON []byte, + artifactPath string, +) (*PublishResponse, error) { + if err := validateCoordinate(org, name, version); err != nil { + return nil, err + } + if _, err := c.requireToken(); err != nil { + return nil, err + } + var meta struct { + Manifest struct { + Package struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + } `json:"package"` + } `json:"manifest"` + } + if err := json.Unmarshal(metaJSON, &meta); err != nil { + return nil, &APIError{Code: "invalid_publish_meta", Message: err.Error()} + } + if meta.Manifest.Package.Org != org || meta.Manifest.Package.Name != name || meta.Manifest.Package.Version != version { + return nil, &APIError{ + Code: "publish_coordinate_mismatch", + Message: "publish route and meta.manifest.package coordinates differ", + } + } + info, err := os.Stat(artifactPath) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("artifact must be a regular file") + } + if info.Size() < 0 || info.Size() > maxArtifactBytes { + return nil, &APIError{ + Code: "artifact_too_large", + Message: fmt.Sprintf("artifact exceeded %d bytes", maxArtifactBytes), + } + } + + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + if err := writer.WriteField("meta", string(metaJSON)); err != nil { + return nil, err + } + part, err := writer.CreateFormFile("artifact", "artifact.tar.gz") + if err != nil { + return nil, err + } + file, err := os.Open(artifactPath) + if err != nil { + return nil, err + } + defer file.Close() + if _, err := io.Copy(part, io.LimitReader(file, maxArtifactBytes+1)); err != nil { + return nil, err + } + if buffer.Len() > maxArtifactBytes+1024*1024 { + return nil, &APIError{Code: "artifact_too_large", Message: "multipart body exceeded client limit"} + } + if err := writer.Close(); err != nil { + return nil, err + } + var out PublishResponse + if err := c.do( + http.MethodPut, + VersionPath(org, name, version), + &buffer, + writer.FormDataContentType(), + true, + &out, + ); err != nil { + return nil, err + } + return &out, nil +} diff --git a/clients/go/client_hardening_test.go b/clients/go/client_hardening_test.go new file mode 100644 index 0000000..2514d7c --- /dev/null +++ b/clients/go/client_hardening_test.go @@ -0,0 +1,221 @@ +package zedclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +type countingRoundTripper struct { + calls atomic.Int32 +} + +func (transport *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + transport.calls.Add(1) + return nil, errors.New("transport must not run") +} + +func TestHostileSegmentsFailBeforeTransport(t *testing.T) { + if got := PackagePath("..", "kit"); got != "/v1/packages/%2E%2E/kit" { + t.Fatalf("dot segment helper was not escaped: %q", got) + } + transport := &countingRoundTripper{} + client := newTestClient(t, "https://registry.test") + client.HTTP.Transport = transport + for _, test := range []struct { + name string + run func() error + }{ + {"blank", func() error { _, err := client.GetPackage("", "kit"); return err }}, + {"dot", func() error { _, err := client.GetVersion("acme", "kit", ".."); return err }}, + {"control", func() error { _, err := client.ClaimOrg("line\nbreak"); return err }}, + {"overlong", func() error { + _, err := client.GetPackage(strings.Repeat("x", maxPathSegmentBytes+1), "kit") + return err + }}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.run(); err == nil { + t.Fatal("expected validation error") + } + }) + } + if calls := transport.calls.Load(); calls != 0 { + t.Fatalf("transport called %d times", calls) + } +} + +func TestMutatedRegistryBaseIsRevalidatedBeforeTransport(t *testing.T) { + transport := &countingRoundTripper{} + client := newTestClient(t, "https://registry.test") + client.HTTP.Transport = transport + client.Base = "https://registry.test/%2e%2e/admin" + if _, err := client.Search("x"); err == nil { + t.Fatal("expected mutated base to be rejected") + } + if calls := transport.calls.Load(); calls != 0 { + t.Fatalf("transport called %d times", calls) + } +} + +func TestAuthenticatedOperationsRequireTokenBeforeTransport(t *testing.T) { + transport := &countingRoundTripper{} + client := newTestClient(t, "https://registry.test") + client.HTTP.Transport = transport + meta, err := json.Marshal(map[string]any{ + "manifest": map[string]any{ + "package": map[string]string{"org": "acme", "name": "kit", "version": "1.2.0"}, + }, + }) + if err != nil { + t.Fatal(err) + } + operations := []func() error{ + func() error { _, err := client.ClaimOrg("acme"); return err }, + func() error { _, err := client.SetYanked("acme", "kit", "1.2.0", true); return err }, + func() error { _, err := client.Restore("acme", "kit", "1.2.0"); return err }, + func() error { + _, err := client.Publish("acme", "kit", "1.2.0", meta, filepath.Join(t.TempDir(), "missing")) + return err + }, + } + for index, operation := range operations { + var apiErr *APIError + if err := operation(); !errors.As(err, &apiErr) || apiErr.Code != "missing_token" { + t.Fatalf("operation %d: expected missing_token, got %v", index, err) + } + } + if calls := transport.calls.Load(); calls != 0 { + t.Fatalf("transport called %d times", calls) + } +} + +func TestRedirectPolicyIsReassertedAfterCallerMutation(t *testing.T) { + var destinationCalls atomic.Int32 + destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + destinationCalls.Add(1) + })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, destination.URL, http.StatusFound) + })) + defer source.Close() + + client := newTestClient(t, source.URL) + client.Token = "token" + client.HTTP.CheckRedirect = nil + if _, err := client.ClaimOrg("acme"); !errors.Is(err, errRefuseRedirect) { + t.Fatalf("expected redirect refusal, got %v", err) + } + if calls := destinationCalls.Load(); calls != 0 { + t.Fatalf("redirect destination reached %d times", calls) + } +} + +func TestArtifactErrorsUseTheSmallErrorBodyLimit(t *testing.T) { + remote := strings.Repeat("provider-secret", maxErrorBodyBytes) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(remote)) + })) + defer server.Close() + client := newTestClient(t, server.URL) + err := client.DownloadArtifact( + &VersionMetadata{Sha256: "abc", DownloadURL: server.URL + "/artifact"}, + filepath.Join(t.TempDir(), "artifact"), + ) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if len(apiErr.Message) > maxErrorBodyBytes { + t.Fatalf("error body was not bounded: %d", len(apiErr.Message)) + } + if strings.Contains(err.Error(), "provider-secret") { + t.Fatal("default diagnostic leaked remote body") + } +} + +func TestVerifiedDownloadAtomicallyReplacesDestination(t *testing.T) { + body := []byte("verified artifact") + digest := sha256.Sum256(body) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + })) + defer server.Close() + client := newTestClient(t, "https://registry.test") + destination := filepath.Join(t.TempDir(), "nested", "artifact.tar.gz") + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := client.DownloadArtifact(&VersionMetadata{ + Sha256: strings.ToUpper(hex.EncodeToString(digest[:])), + Size: uint64(len(body)), + DownloadURL: server.URL + "/artifact", + }, destination); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(got) != string(body) { + t.Fatalf("destination = %q", got) + } + entries, err := os.ReadDir(filepath.Dir(destination)) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".zed-artifact-") { + t.Fatalf("temporary file was not cleaned up: %s", entry.Name()) + } + } +} + +func TestPublishRejectsOversizedAndMismatchedInputsBeforeTransport(t *testing.T) { + transport := &countingRoundTripper{} + client := newTestClient(t, "https://registry.test") + client.Token = "token" + client.HTTP.Transport = transport + meta := []byte(`{"manifest":{"package":{"org":"acme","name":"kit","version":"1.2.0"}}}`) + + oversized := filepath.Join(t.TempDir(), "oversized.tar.gz") + file, err := os.Create(oversized) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxArtifactBytes + 1); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + var apiErr *APIError + if _, err := client.Publish("acme", "kit", "1.2.0", meta, oversized); !errors.As(err, &apiErr) || apiErr.Code != "artifact_too_large" { + t.Fatalf("expected artifact_too_large, got %v", err) + } + + artifact := filepath.Join(t.TempDir(), "artifact.tar.gz") + if err := os.WriteFile(artifact, []byte("artifact"), 0o644); err != nil { + t.Fatal(err) + } + wrong := []byte(`{"manifest":{"package":{"org":"other","name":"kit","version":"1.2.0"}}}`) + if _, err := client.Publish("acme", "kit", "1.2.0", wrong, artifact); !errors.As(err, &apiErr) || apiErr.Code != "publish_coordinate_mismatch" { + t.Fatalf("expected publish_coordinate_mismatch, got %v", err) + } + if calls := transport.calls.Load(); calls != 0 { + t.Fatalf("transport called %d times", calls) + } +} diff --git a/clients/go/client_test.go b/clients/go/client_test.go new file mode 100644 index 0000000..99daa0a --- /dev/null +++ b/clients/go/client_test.go @@ -0,0 +1,249 @@ +package zedclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func newTestClient(t *testing.T, base string) *Client { + t.Helper() + client, err := New(base) + if err != nil { + t.Fatal(err) + } + return client +} + +func TestPathsMatchContract(t *testing.T) { + if got := PackagePath("acme", "kit"); got != "/v1/packages/acme/kit" { + t.Fatalf("PackagePath = %q", got) + } + if got := VersionPath("acme", "kit", "1.2.0"); got != "/v1/packages/acme/kit/versions/1.2.0" { + t.Fatalf("VersionPath = %q", got) + } + if got := YankPath("acme", "kit", "1.2.0"); got != "/v1/packages/acme/kit/versions/1.2.0/yank" { + t.Fatalf("YankPath = %q", got) + } + if got := VersionPath("acme", "kit", "release candidate/1"); got != "/v1/packages/acme/kit/versions/release%20candidate%2F1" { + t.Fatalf("escaped VersionPath = %q", got) + } + if got := PackagePath("a?b", "c#d"); got != "/v1/packages/a%3Fb/c%23d" { + t.Fatalf("escaped PackagePath = %q", got) + } +} + +func TestRegistryURLIsValidatedAndTokenIsRedacted(t *testing.T) { + client := newTestClient(t, " https://registry.zpkg.tech/gateway/// ") + client.Token = "very-secret" + if client.Base != "https://registry.zpkg.tech/gateway" { + t.Fatalf("Base = %q", client.Base) + } + if strings.Contains(client.String(), "very-secret") || !strings.Contains(client.String(), "[REDACTED]") { + t.Fatalf("unsafe client String: %s", client.String()) + } + for _, invalid := range []string{ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + } { + if _, err := New(invalid); err == nil { + t.Fatalf("accepted invalid URL %q", invalid) + } + } +} + +func TestPublicReadsOmitAuthAndAuthenticatedWritesAttachIt(t *testing.T) { + var searchAuth, yankAuth, yankPathSeen, yankBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/gateway/v1/search": + searchAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"query":"kit","items":[]}`)) + case "/gateway/v1/packages/acme/kit/versions/1.2.0/yank": + yankAuth = r.Header.Get("Authorization") + yankPathSeen = r.URL.EscapedPath() + payload, _ := readBounded(r.Body, 1024, true) + yankBody = string(payload) + _, _ = w.Write([]byte(`{"org":"acme","name":"kit","version":"1.2.0","yanked":true}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/gateway///") + client.Token = " zpkg_t " + if _, err := client.Search("kit"); err != nil { + t.Fatal(err) + } + result, err := client.Yank("acme", "kit", "1.2.0", true) + if err != nil { + t.Fatal(err) + } + if !result.Yanked || searchAuth != "" || yankAuth != "Bearer zpkg_t" { + t.Fatalf("unexpected auth/result: search=%q yank=%q result=%+v", searchAuth, yankAuth, result) + } + if yankPathSeen != "/gateway/v1/packages/acme/kit/versions/1.2.0/yank" { + t.Fatalf("yank path = %q", yankPathSeen) + } + var body map[string]bool + if err := json.Unmarshal([]byte(yankBody), &body); err != nil || !body["yanked"] { + t.Fatalf("yank body = %q, err=%v", yankBody, err) + } +} + +func TestRedirectsAreRefused(t *testing.T) { + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("redirect destination must not be reached") + })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, destination.URL, http.StatusFound) + })) + defer source.Close() + + client := newTestClient(t, source.URL) + client.Token = "secret" + _, err := client.ClaimOrg("acme") + if !errors.Is(err, errRefuseRedirect) && !strings.Contains(err.Error(), "refuses redirects") { + t.Fatalf("expected redirect refusal, got %v", err) + } +} + +func TestAPIErrorIsBoundedAndDefaultStringExcludesRemoteBody(t *testing.T) { + remote := strings.Repeat("provider-secret", maxErrorBodyBytes) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(remote)) + })) + defer server.Close() + client := newTestClient(t, server.URL) + _, err := client.Search("x") + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("want APIError, got %T: %v", err, err) + } + if apiErr.Code != "http_502" || len(apiErr.Message) > maxErrorBodyBytes { + t.Fatalf("unexpected bounded error: %+v", apiErr) + } + if strings.Contains(err.Error(), "provider-secret") { + t.Fatal("default error string leaked remote body") + } +} + +func TestPackageMetadataVersionScheme(t *testing.T) { + var meta PackageMetadata + payload := `{"org":"acme","name":"kit","vcs":"git","repo_url":"x","versions":[],"version_scheme":"calver","tags":["http"]}` + if err := json.Unmarshal([]byte(payload), &meta); err != nil { + t.Fatal(err) + } + if meta.VersionScheme != "calver" || len(meta.Tags) != 1 { + t.Fatalf("metadata = %+v", meta) + } +} + +func TestDownloadArtifactOmitsAuthResolvesRelativeAndVerifies(t *testing.T) { + body := []byte("artifact-bytes") + sum := sha256.Sum256(body) + var gotAuth, gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.EscapedPath() + _, _ = w.Write(body) + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/gateway") + client.Token = "zpkg_t" + version := &VersionMetadata{ + Sha256: hex.EncodeToString(sum[:]), + Size: uint64(len(body)), + DownloadURL: "artifact/file", + } + if err := client.DownloadArtifact(version, filepath.Join(t.TempDir(), "artifact.tar.gz")); err != nil { + t.Fatal(err) + } + if gotAuth != "" || gotPath != "/gateway/artifact/file" { + t.Fatalf("download auth/path = %q / %q", gotAuth, gotPath) + } +} + +func TestDownloadArtifactStatusPolicyAndCaps(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"code":"not_found","message":"no such artifact"}`)) + })) + defer server.Close() + client := newTestClient(t, server.URL) + client.Token = "zpkg_t" + err := client.DownloadArtifact( + &VersionMetadata{Sha256: "abc", DownloadURL: server.URL + "/artifact"}, + filepath.Join(t.TempDir(), "artifact.tar.gz"), + ) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code != "not_found" || apiErr.Message != "no such artifact" { + t.Fatalf("unexpected API error: %v / %+v", err, apiErr) + } + if strings.Contains(err.Error(), "no such artifact") { + t.Fatal("default error leaked remote message") + } + + secureClient := newTestClient(t, "https://registry.zpkg.tech") + for _, target := range []string{"http://evil.example/artifact", "file:///etc/passwd"} { + err := secureClient.DownloadArtifact( + &VersionMetadata{Sha256: "abc", DownloadURL: target}, + filepath.Join(t.TempDir(), "a"), + ) + if !errors.As(err, &apiErr) || apiErr.Code != "insecure_download_url" { + t.Fatalf("target %q: got %v", target, err) + } + } + + // An explicitly HTTP registry is a development boundary in every SDK and + // may return another HTTP artifact host (for example, a local MinIO service). + developmentClient := newTestClient(t, "http://registry.dev") + if _, err := developmentClient.allowedDownloadURL("http://artifacts.dev/artifact"); err != nil { + t.Fatalf("HTTP development artifact host was rejected: %v", err) + } + + limit := downloadLimit(1) + large := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(make([]byte, limit+64)) + })) + defer large.Close() + largeClient := newTestClient(t, large.URL) + err = largeClient.DownloadArtifact( + &VersionMetadata{Sha256: "abc", Size: 1, DownloadURL: large.URL + "/artifact"}, + filepath.Join(t.TempDir(), "a"), + ) + if !errors.As(err, &apiErr) || apiErr.Code != "artifact_too_large" { + t.Fatalf("want artifact_too_large, got %v", err) + } +} + +func TestDownloadArtifactAllowsLoopbackHTTP(t *testing.T) { + body := []byte("ok") + sum := sha256.Sum256(body) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + })) + defer server.Close() + client := newTestClient(t, "https://registry.zpkg.tech") + version := &VersionMetadata{ + Sha256: hex.EncodeToString(sum[:]), + Size: uint64(len(body)), + DownloadURL: server.URL + "/artifact", + } + if err := client.DownloadArtifact(version, filepath.Join(t.TempDir(), "a")); err != nil { + t.Fatalf("loopback HTTP download: %v", err) + } +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 0000000..faafff5 --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,3 @@ +module github.com/zed-pkg/zed-clients/clients/go + +go 1.22 diff --git a/clients/go/nix_adapter.go b/clients/go/nix_adapter.go new file mode 100644 index 0000000..5757353 --- /dev/null +++ b/clients/go/nix_adapter.go @@ -0,0 +1,729 @@ +package zedclient + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strings" + "unicode" +) + +const NixAdapterSchemaV1 = "zed.nix-adapter/v1" + +var ErrInvalidNixAdapterRecord = errors.New("invalid Nix adapter record") + +type NixAdapterDirection string + +type NixAdapterPolicyProfile string + +type NixAdapterBuilderNetwork string + +const ( + NixAdapterZedToNix NixAdapterDirection = "zed-to-nix" + NixAdapterNixToZed NixAdapterDirection = "nix-to-zed" + + NixPolicyStrictV1 NixAdapterPolicyProfile = "strict-v1" + NixPolicyDevelopment NixAdapterPolicyProfile = "development" + + NixBuilderNetworkDisabled NixAdapterBuilderNetwork = "disabled" + NixBuilderNetworkPreparationOnly NixAdapterBuilderNetwork = "preparation-only" + NixBuilderNetworkAllowed NixAdapterBuilderNetwork = "allowed" +) + +type NixPackageIdentity struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Target *string `json:"target,omitempty"` +} + +type NixInteropArtifact struct { + Format string `json:"format"` + SHA256 string `json:"sha256"` + Size uint64 `json:"size"` +} + +type NixPolicyEvidence struct { + Profile NixAdapterPolicyProfile `json:"profile"` + PureEvaluation bool `json:"pure_evaluation"` + ImportFromDerivation bool `json:"import_from_derivation"` + SandboxRequired bool `json:"sandbox_required"` + BuilderNetwork NixAdapterBuilderNetwork `json:"builder_network"` + DirtySource bool `json:"dirty_source"` + Publishable bool `json:"publishable"` +} + +type NixExportIntent struct { + Mode string `json:"mode"` + Attribute string `json:"attribute,omitempty"` + Systems []string `json:"systems"` + Outputs []string `json:"outputs"` +} + +type ZedArtifactOrigin struct { + Registry string `json:"registry"` + Artifact NixInteropArtifact `json:"artifact"` + VCSTag string `json:"vcs_tag"` + VCSCommit string `json:"vcs_commit"` + LockSHA256 *string `json:"lock_sha256,omitempty"` +} + +type NixStoreReference struct { + StorePath string `json:"store_path"` + NARHash *string `json:"nar_hash,omitempty"` + NARSize *uint64 `json:"nar_size,omitempty"` +} + +type NixRealizedOutput struct { + System string `json:"system"` + Output string `json:"output"` + DerivationJSONSHA256 string `json:"derivation_json_sha256"` + StorePath string `json:"store_path"` + NARHash string `json:"nar_hash"` + NARSize uint64 `json:"nar_size"` + References []NixStoreReference `json:"references,omitempty"` + Signatures []string `json:"signatures,omitempty"` + NixVersion string `json:"nix_version"` + StoreInfoJSONVersion uint32 `json:"store_info_json_version"` +} + +type NixOutputOrigin struct { + LockedRef string `json:"locked_ref"` + FlakeLockSHA256 string `json:"flake_lock_sha256"` + Attribute string `json:"attribute"` + Realized NixRealizedOutput `json:"realized"` +} + +type ZedToNixAdapterRecord struct { + Direction NixAdapterDirection `json:"direction"` + Schema string `json:"schema"` + Package NixPackageIdentity `json:"package"` + Source ZedArtifactOrigin `json:"source"` + Intent NixExportIntent `json:"intent"` + FlakeBundleSHA256 string `json:"flake_bundle_sha256"` + Outputs []NixRealizedOutput `json:"outputs"` + Policy NixPolicyEvidence `json:"policy"` +} + +type NixToZedAdapterRecord struct { + Direction NixAdapterDirection `json:"direction"` + Schema string `json:"schema"` + Package NixPackageIdentity `json:"package"` + Source NixOutputOrigin `json:"source"` + Artifact NixInteropArtifact `json:"artifact"` + Policy NixPolicyEvidence `json:"policy"` +} + +type NixAdapterRecord struct { + Direction NixAdapterDirection + ZedToNix *ZedToNixAdapterRecord + NixToZed *NixToZedAdapterRecord +} + +type VerifiedNixAdapterRecord struct { + Record *NixAdapterRecord + CanonicalJSON []byte + SHA256 string +} + +type VerifiedNixAdapterArtifact struct { + Record *NixAdapterRecord + SHA256 string + Size uint64 +} + +var ( + slugPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$`) + targetPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._+-]{0,126}[a-z0-9])?$`) + nixIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_'-]*$`) + nixStorePath = regexp.MustCompile(`^/nix/store/[0-9abcdfghijklmnpqrsvwxyz]{32}-[A-Za-z0-9+._?-]+$`) + sha256HexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + sha256SRIPattern = regexp.MustCompile(`^sha256-[A-Za-z0-9+/]{43}=$`) + hexRevision = regexp.MustCompile(`^[0-9A-Fa-f]{40}(?:[0-9A-Fa-f]{24})?$`) + nonHex = regexp.MustCompile(`[^0-9A-Fa-f]+`) +) + +func invalidNixAdapter(message string) error { + return fmt.Errorf("%w: %s", ErrInvalidNixAdapterRecord, message) +} + +func decodeJSONValue(data []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, invalidNixAdapter("input is not valid JSON") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return nil, invalidNixAdapter("input contains trailing JSON") + } + return value, nil +} + +func decodeStrict(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return invalidNixAdapter("input has malformed types or unknown fields") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return invalidNixAdapter("input contains trailing JSON") + } + return nil +} + +var nullableAdapterPaths = map[string]struct{}{ + "package.target": {}, + "source.lock_sha256": {}, + "intent.attribute": {}, + "outputs.references.nar_hash": {}, + "outputs.references.nar_size": {}, + "source.realized.references.nar_hash": {}, + "source.realized.references.nar_size": {}, +} + +func rejectUnexpectedNulls(value any, path string) error { + switch typed := value.(type) { + case nil: + if _, allowed := nullableAdapterPaths[path]; !allowed { + return invalidNixAdapter("explicit null is not valid at this contract field") + } + case map[string]any: + for key, child := range typed { + childPath := key + if path != "" { + childPath = path + "." + key + } + if err := rejectUnexpectedNulls(child, childPath); err != nil { + return err + } + } + case []any: + for _, child := range typed { + if err := rejectUnexpectedNulls(child, path); err != nil { + return err + } + } + } + return nil +} + +func ParseNixAdapterRecordJSON(data []byte) (*NixAdapterRecord, error) { + value, err := decodeJSONValue(data) + if err != nil { + return nil, err + } + if err := rejectUnexpectedNulls(value, ""); err != nil { + return nil, err + } + root, ok := value.(map[string]any) + if !ok { + return nil, invalidNixAdapter("record must be an object") + } + direction, ok := root["direction"].(string) + if !ok { + return nil, invalidNixAdapter("direction must be a string") + } + + switch NixAdapterDirection(direction) { + case NixAdapterZedToNix: + var record ZedToNixAdapterRecord + if err := decodeStrict(data, &record); err != nil { + return nil, err + } + if err := validateZedToNix(&record); err != nil { + return nil, err + } + normalizeZedToNix(&record) + return &NixAdapterRecord{Direction: NixAdapterZedToNix, ZedToNix: &record}, nil + case NixAdapterNixToZed: + var record NixToZedAdapterRecord + if err := decodeStrict(data, &record); err != nil { + return nil, err + } + if err := validateNixToZed(&record); err != nil { + return nil, err + } + normalizeRealized(&record.Source.Realized) + return &NixAdapterRecord{Direction: NixAdapterNixToZed, NixToZed: &record}, nil + default: + return nil, invalidNixAdapter("direction is unsupported") + } +} + +func ParseNixAdapterRecord(value any) (*NixAdapterRecord, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, invalidNixAdapter("value cannot be represented as JSON") + } + return ParseNixAdapterRecordJSON(data) +} + +func cloneJSON[T any](value T) (T, error) { + var clone T + data, err := json.Marshal(value) + if err != nil { + return clone, invalidNixAdapter("record could not be cloned") + } + if err := decodeStrict(data, &clone); err != nil { + return clone, err + } + return clone, nil +} + +func (record *NixAdapterRecord) canonicalValue() (any, error) { + if record == nil { + return nil, invalidNixAdapter("record is nil") + } + switch record.Direction { + case NixAdapterZedToNix: + if record.ZedToNix == nil || record.NixToZed != nil { + return nil, invalidNixAdapter("direction payload is inconsistent") + } + copy, err := cloneJSON(*record.ZedToNix) + if err != nil { + return nil, err + } + if err := validateZedToNix(©); err != nil { + return nil, err + } + normalizeZedToNix(©) + return copy, nil + case NixAdapterNixToZed: + if record.NixToZed == nil || record.ZedToNix != nil { + return nil, invalidNixAdapter("direction payload is inconsistent") + } + copy, err := cloneJSON(*record.NixToZed) + if err != nil { + return nil, err + } + if err := validateNixToZed(©); err != nil { + return nil, err + } + normalizeRealized(©.Source.Realized) + return copy, nil + default: + return nil, invalidNixAdapter("direction is unsupported") + } +} + +func CanonicalNixAdapterRecordJSON(record *NixAdapterRecord) ([]byte, error) { + value, err := record.canonicalValue() + if err != nil { + return nil, err + } + raw, err := json.Marshal(value) + if err != nil { + return nil, invalidNixAdapter("record could not be serialized") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var generic any + if err := decoder.Decode(&generic); err != nil { + return nil, invalidNixAdapter("record could not be canonicalized") + } + canonical, err := json.Marshal(generic) + if err != nil { + return nil, invalidNixAdapter("record could not be canonicalized") + } + return canonical, nil +} + +func NixAdapterRecordSHA256(record *NixAdapterRecord) (string, error) { + canonical, err := CanonicalNixAdapterRecordJSON(record) + if err != nil { + return "", err + } + digest := sha256.Sum256(canonical) + return hex.EncodeToString(digest[:]), nil +} + +func VerifyCanonicalNixAdapterRecordJSON(data []byte, expectedSHA256 string) (*VerifiedNixAdapterRecord, error) { + record, err := ParseNixAdapterRecordJSON(data) + if err != nil { + return nil, err + } + canonical, err := CanonicalNixAdapterRecordJSON(record) + if err != nil { + return nil, err + } + if !bytes.Equal(data, canonical) { + return nil, invalidNixAdapter("input bytes are not canonical compact JSON") + } + digest := sha256.Sum256(canonical) + digestHex := hex.EncodeToString(digest[:]) + if expectedSHA256 != "" { + if !sha256HexPattern.MatchString(expectedSHA256) { + return nil, invalidNixAdapter("expected adapter SHA-256 is invalid") + } + if digestHex != expectedSHA256 { + return nil, invalidNixAdapter("canonical adapter SHA-256 mismatch") + } + } + return &VerifiedNixAdapterRecord{ + Record: record, + CanonicalJSON: canonical, + SHA256: digestHex, + }, nil +} + +func VerifyNixAdapterArtifact(record *NixAdapterRecord, artifact []byte) (*VerifiedNixAdapterArtifact, error) { + value, err := record.canonicalValue() + if err != nil { + return nil, err + } + var expected NixInteropArtifact + switch typed := value.(type) { + case ZedToNixAdapterRecord: + expected = typed.Source.Artifact + case NixToZedAdapterRecord: + expected = typed.Artifact + default: + return nil, invalidNixAdapter("direction payload is inconsistent") + } + digest := sha256.Sum256(artifact) + digestHex := hex.EncodeToString(digest[:]) + if digestHex != expected.SHA256 { + return nil, invalidNixAdapter("Zed artifact SHA-256 mismatch") + } + if uint64(len(artifact)) != expected.Size { + return nil, invalidNixAdapter("Zed artifact size mismatch") + } + return &VerifiedNixAdapterArtifact{Record: record, SHA256: digestHex, Size: uint64(len(artifact))}, nil +} + +func validateZedToNix(record *ZedToNixAdapterRecord) error { + if record.Direction != NixAdapterZedToNix || record.Schema != NixAdapterSchemaV1 { + return invalidNixAdapter("schema or direction is unsupported") + } + if err := validatePackage(&record.Package); err != nil { + return err + } + if err := validateZedOrigin(&record.Source); err != nil { + return err + } + if err := validateIntent(&record.Intent, record.Package.Name); err != nil { + return err + } + if !sha256HexPattern.MatchString(record.FlakeBundleSHA256) { + return invalidNixAdapter("flake bundle SHA-256 is invalid") + } + if len(record.Outputs) == 0 { + return invalidNixAdapter("outputs must contain realized evidence") + } + declaredSystems := make(map[string]struct{}, len(record.Intent.Systems)) + declaredOutputs := make(map[string]struct{}, len(record.Intent.Outputs)) + for _, system := range record.Intent.Systems { + declaredSystems[system] = struct{}{} + } + for _, output := range record.Intent.Outputs { + declaredOutputs[output] = struct{}{} + } + realizedSystems := make(map[string]struct{}) + pairs := make(map[string]struct{}) + for index := range record.Outputs { + output := &record.Outputs[index] + if err := validateRealized(output); err != nil { + return err + } + if _, ok := declaredSystems[output.System]; !ok { + return invalidNixAdapter("realized output is outside declared intent") + } + if _, ok := declaredOutputs[output.Output]; !ok { + return invalidNixAdapter("realized output is outside declared intent") + } + pair := output.System + "\x00" + output.Output + if _, duplicate := pairs[pair]; duplicate { + return invalidNixAdapter("outputs contain a duplicate system/output pair") + } + pairs[pair] = struct{}{} + realizedSystems[output.System] = struct{}{} + } + for system := range declaredSystems { + if _, ok := realizedSystems[system]; !ok { + return invalidNixAdapter("a declared system lacks realized evidence") + } + } + return validatePolicy(&record.Policy) +} + +func validateNixToZed(record *NixToZedAdapterRecord) error { + if record.Direction != NixAdapterNixToZed || record.Schema != NixAdapterSchemaV1 { + return invalidNixAdapter("schema or direction is unsupported") + } + if err := validatePackage(&record.Package); err != nil { + return err + } + if err := validateNixOrigin(&record.Source); err != nil { + return err + } + if len(record.Source.Realized.References) != 0 { + return invalidNixAdapter("contract v1 Nix-to-Zed imports must be closure-free") + } + if err := validateArtifact(&record.Artifact); err != nil { + return err + } + return validatePolicy(&record.Policy) +} + +func validatePackage(value *NixPackageIdentity) error { + if !slugPattern.MatchString(value.Org) || !slugPattern.MatchString(value.Name) { + return invalidNixAdapter("package identity is invalid") + } + if strings.TrimSpace(value.Version) == "" || strings.TrimSpace(value.Version) != value.Version { + return invalidNixAdapter("package version is invalid") + } + for _, character := range value.Version { + if unicode.IsSpace(character) { + return invalidNixAdapter("package version is invalid") + } + } + if value.Target != nil && !targetPattern.MatchString(*value.Target) { + return invalidNixAdapter("package target is invalid") + } + return nil +} + +func validateArtifact(value *NixInteropArtifact) error { + if value.Format == "" { + value.Format = "tar.gz" + } + if value.Format != "tar.gz" && value.Format != "zip" { + return invalidNixAdapter("artifact format is unsupported") + } + if !sha256HexPattern.MatchString(value.SHA256) || value.Size == 0 { + return invalidNixAdapter("artifact identity is invalid") + } + return nil +} + +func validatePolicy(value *NixPolicyEvidence) error { + switch value.Profile { + case NixPolicyStrictV1: + if !value.PureEvaluation || value.ImportFromDerivation || !value.SandboxRequired || + value.BuilderNetwork != NixBuilderNetworkDisabled || value.DirtySource || !value.Publishable { + return invalidNixAdapter("strict-v1 policy evidence is inconsistent") + } + case NixPolicyDevelopment: + if value.Publishable { + return invalidNixAdapter("development policy records are never publishable") + } + default: + return invalidNixAdapter("policy profile is unsupported") + } + return nil +} + +func validateIntent(value *NixExportIntent, defaultAttribute string) error { + if value.Mode == "" { + value.Mode = "artifact" + } + if value.Mode != "artifact" { + return invalidNixAdapter("export mode is unsupported") + } + attribute := value.Attribute + if attribute == "" { + attribute = defaultAttribute + } + if !nixIdentifier.MatchString(attribute) || attribute == "default" { + return invalidNixAdapter("Nix export attribute is invalid") + } + if len(value.Systems) == 0 || len(value.Outputs) == 0 { + return invalidNixAdapter("intent requires explicit systems and outputs") + } + if err := validateUniqueStrings(value.Systems); err != nil { + return err + } + if err := validateUniqueStrings(value.Outputs); err != nil { + return err + } + for _, system := range value.Systems { + if !validNixSystem(system) { + return invalidNixAdapter("Nix system is invalid") + } + } + for _, output := range value.Outputs { + if !nixIdentifier.MatchString(output) { + return invalidNixAdapter("Nix output name is invalid") + } + } + return nil +} + +func validateZedOrigin(value *ZedArtifactOrigin) error { + if !validRegistryURL(value.Registry) { + return invalidNixAdapter("registry URL is invalid") + } + if err := validateArtifact(&value.Artifact); err != nil { + return err + } + if !validRefToken(value.VCSTag) || len(value.VCSCommit) < 7 || !validRefToken(value.VCSCommit) { + return invalidNixAdapter("VCS provenance is invalid") + } + if value.LockSHA256 != nil && !sha256HexPattern.MatchString(*value.LockSHA256) { + return invalidNixAdapter("Zed lock SHA-256 is invalid") + } + return nil +} + +func validateNixOrigin(value *NixOutputOrigin) error { + if !validImmutableNixRef(value.LockedRef) { + return invalidNixAdapter("locked Nix ref lacks immutable evidence") + } + if !sha256HexPattern.MatchString(value.FlakeLockSHA256) { + return invalidNixAdapter("flake.lock SHA-256 is invalid") + } + parts := strings.Split(value.Attribute, ".") + if len(parts) == 0 { + return invalidNixAdapter("Nix attribute path is invalid") + } + for _, part := range parts { + if !nixIdentifier.MatchString(part) { + return invalidNixAdapter("Nix attribute path is invalid") + } + } + return validateRealized(&value.Realized) +} + +func validateRealized(value *NixRealizedOutput) error { + if !validNixSystem(value.System) || !nixIdentifier.MatchString(value.Output) { + return invalidNixAdapter("realized Nix system/output is invalid") + } + if !sha256HexPattern.MatchString(value.DerivationJSONSHA256) || + !nixStorePath.MatchString(value.StorePath) || + !sha256SRIPattern.MatchString(value.NARHash) || value.NARSize == 0 { + return invalidNixAdapter("realized Nix identity is invalid") + } + referencePaths := make(map[string]struct{}) + for index := range value.References { + reference := &value.References[index] + if !nixStorePath.MatchString(reference.StorePath) { + return invalidNixAdapter("Nix store reference is invalid") + } + if reference.NARHash != nil && !sha256SRIPattern.MatchString(*reference.NARHash) { + return invalidNixAdapter("Nix store reference NAR hash is invalid") + } + if reference.NARSize != nil && *reference.NARSize == 0 { + return invalidNixAdapter("Nix store reference NAR size is invalid") + } + if _, duplicate := referencePaths[reference.StorePath]; duplicate { + return invalidNixAdapter("Nix store references contain duplicates") + } + referencePaths[reference.StorePath] = struct{}{} + } + if err := validateUniqueStrings(value.Signatures); err != nil { + return err + } + for _, signature := range value.Signatures { + if !validRefToken(signature) { + return invalidNixAdapter("Nix signature is invalid") + } + } + if strings.TrimSpace(value.NixVersion) == "" { + return invalidNixAdapter("Nix version is missing") + } + if value.StoreInfoJSONVersion < 1 || value.StoreInfoJSONVersion > 3 { + return invalidNixAdapter("store-info JSON version is unsupported") + } + return nil +} + +func validateUniqueStrings(values []string) error { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, duplicate := seen[value]; duplicate { + return invalidNixAdapter("contract collection contains duplicate values") + } + seen[value] = struct{}{} + } + return nil +} + +func normalizeZedToNix(record *ZedToNixAdapterRecord) { + sort.Strings(record.Intent.Systems) + sort.Strings(record.Intent.Outputs) + for index := range record.Outputs { + normalizeRealized(&record.Outputs[index]) + } + sort.Slice(record.Outputs, func(left, right int) bool { + if record.Outputs[left].System == record.Outputs[right].System { + return record.Outputs[left].Output < record.Outputs[right].Output + } + return record.Outputs[left].System < record.Outputs[right].System + }) +} + +func normalizeRealized(value *NixRealizedOutput) { + sort.Slice(value.References, func(left, right int) bool { + return value.References[left].StorePath < value.References[right].StorePath + }) + sort.Strings(value.Signatures) + if len(value.References) == 0 { + value.References = nil + } + if len(value.Signatures) == 0 { + value.Signatures = nil + } +} + +func validNixSystem(value string) bool { + if value == "" || !strings.Contains(value, "-") || strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") { + return false + } + for _, character := range value { + if !(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '_' && character != '-' { + return false + } + } + return true +} + +func validImmutableNixRef(value string) bool { + if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "<>") { + return false + } + for _, character := range value { + if unicode.IsSpace(character) || unicode.IsControl(character) { + return false + } + } + if strings.HasPrefix(value, "/nix/store/") || strings.HasPrefix(value, "path:/nix/store/") || strings.Contains(value, "narHash=sha256-") { + return true + } + for _, part := range nonHex.Split(value, -1) { + if hexRevision.MatchString(part) { + return true + } + } + return false +} + +func validRegistryURL(value string) bool { + if value == "" || strings.TrimSpace(value) != value { + return false + } + for _, character := range value { + if unicode.IsSpace(character) || unicode.IsControl(character) { + return false + } + } + return strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "file://") +} + +func validRefToken(value string) bool { + if value == "" || strings.TrimSpace(value) != value { + return false + } + for _, character := range value { + if unicode.IsSpace(character) { + return false + } + } + return true +} diff --git a/clients/go/nix_adapter_test.go b/clients/go/nix_adapter_test.go new file mode 100644 index 0000000..bc4bbd6 --- /dev/null +++ b/clients/go/nix_adapter_test.go @@ -0,0 +1,314 @@ +package zedclient + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "reflect" + "testing" +) + +const ( + testHexA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testHexB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + testHexC = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + testNARA = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + testStoreA = "/nix/store/00000000000000000000000000000000-tool-1.2.3" + testStoreB = "/nix/store/11111111111111111111111111111111-runtime-1.0.0" + testStoreC = "/nix/store/22222222222222222222222222222222-data-1.0.0" +) + +func strictPolicy() NixPolicyEvidence { + return NixPolicyEvidence{ + Profile: NixPolicyStrictV1, + PureEvaluation: true, + ImportFromDerivation: false, + SandboxRequired: true, + BuilderNetwork: NixBuilderNetworkDisabled, + DirtySource: false, + Publishable: true, + } +} + +func realizedOutput(system, output, store string) NixRealizedOutput { + return NixRealizedOutput{ + System: system, + Output: output, + DerivationJSONSHA256: testHexB, + StorePath: store, + NARHash: testNARA, + NARSize: 128, + NixVersion: "2.35.2", + StoreInfoJSONVersion: 3, + } +} + +func nixToZedRecord() NixToZedAdapterRecord { + return NixToZedAdapterRecord{ + Direction: NixAdapterNixToZed, + Schema: NixAdapterSchemaV1, + Package: NixPackageIdentity{ + Org: "acme", + Name: "tool", + Version: "1.2.3", + }, + Source: NixOutputOrigin{ + LockedRef: "github:acme/tool/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + FlakeLockSHA256: testHexA, + Attribute: "packages.x86_64-linux.tool", + Realized: realizedOutput("x86_64-linux", "out", testStoreA), + }, + Artifact: NixInteropArtifact{Format: "tar.gz", SHA256: testHexC, Size: 512}, + Policy: strictPolicy(), + } +} + +func zedToNixRecord() ZedToNixAdapterRecord { + lock := testHexA + x86 := realizedOutput("x86_64-linux", "out", testStoreA) + x86.References = []NixStoreReference{ + {StorePath: testStoreC}, + {StorePath: testStoreB}, + } + x86.Signatures = []string{"cache-z:signature", "cache-a:signature"} + return ZedToNixAdapterRecord{ + Direction: NixAdapterZedToNix, + Schema: NixAdapterSchemaV1, + Package: NixPackageIdentity{ + Org: "acme", + Name: "tool", + Version: "1.2.3", + }, + Source: ZedArtifactOrigin{ + Registry: "https://zpkg.example", + Artifact: NixInteropArtifact{Format: "tar.gz", SHA256: testHexC, Size: 512}, + VCSTag: "v1.2.3", + VCSCommit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + LockSHA256: &lock, + }, + Intent: NixExportIntent{ + Mode: "artifact", + Attribute: "tool", + Systems: []string{"x86_64-linux", "aarch64-linux"}, + Outputs: []string{"out", "dev"}, + }, + FlakeBundleSHA256: testHexB, + Outputs: []NixRealizedOutput{ + x86, + realizedOutput("aarch64-linux", "dev", testStoreC), + }, + Policy: strictPolicy(), + } +} + +func TestNixToZedCanonicalGolden(t *testing.T) { + record := &NixAdapterRecord{Direction: NixAdapterNixToZed} + value := nixToZedRecord() + record.NixToZed = &value + + canonical, err := CanonicalNixAdapterRecordJSON(record) + if err != nil { + t.Fatal(err) + } + expected := []byte("{\"artifact\":{\"format\":\"tar.gz\",\"sha256\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"size\":512},\"direction\":\"nix-to-zed\",\"package\":{\"name\":\"tool\",\"org\":\"acme\",\"version\":\"1.2.3\"},\"policy\":{\"builder_network\":\"disabled\",\"dirty_source\":false,\"import_from_derivation\":false,\"profile\":\"strict-v1\",\"publishable\":true,\"pure_evaluation\":true,\"sandbox_required\":true},\"schema\":\"zed.nix-adapter/v1\",\"source\":{\"attribute\":\"packages.x86_64-linux.tool\",\"flake_lock_sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"locked_ref\":\"github:acme/tool/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"realized\":{\"derivation_json_sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"nar_hash\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"nar_size\":128,\"nix_version\":\"2.35.2\",\"output\":\"out\",\"store_info_json_version\":3,\"store_path\":\"/nix/store/00000000000000000000000000000000-tool-1.2.3\",\"system\":\"x86_64-linux\"}}}") + if !bytes.Equal(canonical, expected) { + t.Fatalf("canonical mismatch\nactual: %s\nexpected: %s", canonical, expected) + } + digest, err := NixAdapterRecordSHA256(record) + if err != nil { + t.Fatal(err) + } + if digest != "dd61b8bea180140a0ec564a8cf6144d4e71ce47339c182b67bcb5848b14efe50" { + t.Fatalf("unexpected digest %s", digest) + } + verified, err := VerifyCanonicalNixAdapterRecordJSON(canonical, digest) + if err != nil { + t.Fatal(err) + } + if verified.SHA256 != digest { + t.Fatalf("verification digest mismatch") + } +} + +func TestZedToNixNormalization(t *testing.T) { + input := zedToNixRecord() + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseNixAdapterRecordJSON(data) + if err != nil { + t.Fatal(err) + } + value := parsed.ZedToNix + if !reflect.DeepEqual(value.Intent.Systems, []string{"aarch64-linux", "x86_64-linux"}) { + t.Fatalf("systems not normalized: %#v", value.Intent.Systems) + } + if !reflect.DeepEqual(value.Intent.Outputs, []string{"dev", "out"}) { + t.Fatalf("outputs not normalized: %#v", value.Intent.Outputs) + } + if value.Outputs[0].System != "aarch64-linux" || value.Outputs[1].System != "x86_64-linux" { + t.Fatalf("realized outputs not normalized: %#v", value.Outputs) + } + if !reflect.DeepEqual(value.Outputs[1].Signatures, []string{"cache-a:signature", "cache-z:signature"}) { + t.Fatalf("signatures not normalized: %#v", value.Outputs[1].Signatures) + } + if value.Outputs[1].References[0].StorePath != testStoreB { + t.Fatalf("references not normalized: %#v", value.Outputs[1].References) + } +} + +func TestNixToZedRejectsRuntimeClosure(t *testing.T) { + value := nixToZedRecord() + value.Source.Realized.References = []NixStoreReference{{StorePath: testStoreB}} + data, _ := json.Marshal(value) + _, err := ParseNixAdapterRecordJSON(data) + if err == nil || !stringsContains(err.Error(), "closure-free") { + t.Fatalf("expected closure rejection, got %v", err) + } +} + +func TestCanonicalizationDoesNotMutateCaller(t *testing.T) { + value := zedToNixRecord() + record := &NixAdapterRecord{Direction: NixAdapterZedToNix, ZedToNix: &value} + before, _ := json.Marshal(record.ZedToNix) + if _, err := CanonicalNixAdapterRecordJSON(record); err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(record.ZedToNix) + if !bytes.Equal(before, after) { + t.Fatalf("canonicalization mutated caller\nbefore: %s\nafter: %s", before, after) + } +} + +func TestArtifactVerificationValidatesRecordAndBytes(t *testing.T) { + artifact := []byte("ordinary deterministic Zed artifact") + digest := sha256.Sum256(artifact) + value := nixToZedRecord() + value.Artifact = NixInteropArtifact{ + Format: "tar.gz", + SHA256: hex.EncodeToString(digest[:]), + Size: uint64(len(artifact)), + } + record := &NixAdapterRecord{Direction: NixAdapterNixToZed, NixToZed: &value} + if _, err := VerifyNixAdapterArtifact(record, artifact); err != nil { + t.Fatal(err) + } + if _, err := VerifyNixAdapterArtifact(record, []byte("tampered")); err == nil { + t.Fatal("expected artifact tamper rejection") + } + + invalid := value + invalid.Policy.Publishable = false + invalidRecord := &NixAdapterRecord{Direction: NixAdapterNixToZed, NixToZed: &invalid} + if _, err := VerifyNixAdapterArtifact(invalidRecord, artifact); err == nil { + t.Fatal("expected invalid record rejection before artifact verification") + } +} + +func TestMalformedUnknownAndNullFieldsFailClosed(t *testing.T) { + secret := "private-cache-key-must-not-appear" + for _, data := range [][]byte{ + []byte(`{"direction":"nix-to-zed","private_cache_key":"` + secret + `"}`), + []byte(`{"direction":"nix-to-zed","schema":null}`), + []byte(`{"direction":"nix-to-zed"`), + } { + _, err := ParseNixAdapterRecordJSON(data) + if err == nil { + t.Fatalf("expected rejection for %s", data) + } + if stringsContains(err.Error(), secret) { + t.Fatalf("error leaked secret: %v", err) + } + } +} + +func TestStrictPolicyImmutableRefAndEvidenceFailures(t *testing.T) { + mutations := []func(*NixToZedAdapterRecord){ + func(value *NixToZedAdapterRecord) { value.Source.LockedRef = "github:acme/tool/main" }, + func(value *NixToZedAdapterRecord) { value.Source.Realized.StorePath = "/tmp/tool" }, + func(value *NixToZedAdapterRecord) { value.Source.Realized.NARHash = "sha256-invalid" }, + func(value *NixToZedAdapterRecord) { value.Source.Realized.StoreInfoJSONVersion = 4 }, + func(value *NixToZedAdapterRecord) { value.Policy.ImportFromDerivation = true }, + } + for _, mutate := range mutations { + value := nixToZedRecord() + mutate(&value) + data, _ := json.Marshal(value) + if _, err := ParseNixAdapterRecordJSON(data); err == nil { + t.Fatalf("expected fail-closed validation for %#v", value) + } + } +} + +func TestCanonicalInputAndDigestAreExact(t *testing.T) { + value := nixToZedRecord() + record := &NixAdapterRecord{Direction: NixAdapterNixToZed, NixToZed: &value} + canonical, err := CanonicalNixAdapterRecordJSON(record) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyCanonicalNixAdapterRecordJSON(append(canonical, '\n'), ""); err == nil { + t.Fatal("expected noncanonical newline rejection") + } + if _, err := VerifyCanonicalNixAdapterRecordJSON(canonical, stringsRepeat("0", 64)); err == nil { + t.Fatal("expected digest mismatch rejection") + } +} + +func TestDuplicateAndIntentMismatchFailures(t *testing.T) { + duplicateReference := zedToNixRecord() + duplicateReference.Outputs[0].References = []NixStoreReference{ + {StorePath: testStoreB}, + {StorePath: testStoreB}, + } + assertAdapterRejected(t, duplicateReference) + + duplicateSignature := zedToNixRecord() + duplicateSignature.Outputs[0].Signatures = []string{"cache:signature", "cache:signature"} + assertAdapterRejected(t, duplicateSignature) + + duplicateOutput := zedToNixRecord() + duplicateOutput.Outputs = append(duplicateOutput.Outputs, duplicateOutput.Outputs[0]) + assertAdapterRejected(t, duplicateOutput) + + undeclared := zedToNixRecord() + undeclared.Outputs[0].Output = "doc" + assertAdapterRejected(t, undeclared) + + missingSystem := zedToNixRecord() + missingSystem.Outputs = missingSystem.Outputs[:1] + assertAdapterRejected(t, missingSystem) +} + +func assertAdapterRejected(t *testing.T, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if _, err := ParseNixAdapterRecordJSON(data); err == nil { + t.Fatalf("expected record rejection: %s", data) + } +} + +func stringsContains(value, fragment string) bool { + return bytes.Contains([]byte(value), []byte(fragment)) +} + +func stringsRepeat(value string, count int) string { + var buffer bytes.Buffer + for range count { + buffer.WriteString(value) + } + return buffer.String() +} + +func TestErrorsWrapStableSentinel(t *testing.T) { + _, err := ParseNixAdapterRecordJSON([]byte(`{}`)) + if !errors.Is(err, ErrInvalidNixAdapterRecord) { + t.Fatalf("error does not wrap sentinel: %v", err) + } +} diff --git a/clients/java/README.md b/clients/java/README.md new file mode 100644 index 0000000..65feab7 --- /dev/null +++ b/clients/java/README.md @@ -0,0 +1,42 @@ +# Zed Registry Java client + +Java 17+ client for the stable zed-pkg registry lifecycle: + +- package and version metadata lookup; +- text search; +- organization claim; +- yank and restore; +- SHA-256-verified artifact download; +- multipart publication using JSON `meta` plus raw archive bytes. + +```java +import tech.zpkg.client.ZedClient; + +var client = new ZedClient( + "https://registry.zpkg.tech", + System.getenv("ZED_TOKEN") +); +var metadata = client.getPackage("acme", "http-kit"); +var version = client.getVersion("acme", "http-kit", "1.2.3"); +byte[] artifact = client.downloadArtifact(version); +client.yank("acme", "http-kit", "1.2.3"); +client.restore("acme", "http-kit", "1.2.3"); +``` + +The client uses `java.net.http`, refuses redirects, validates credential-free +HTTP(S) registry URLs, preserves gateway prefixes, applies 30-second deadlines, +percent-encodes path segments, bounds JSON and error bodies, and redacts bearer +credentials and remote response text from default diagnostics. + +Artifact requests never carry the registry bearer because `download_url` may be +a third-party presigned URL. HTTPS is required for remote artifact hosts; HTTP +is accepted only for loopback or when the configured development registry is +itself HTTP. Downloads are size-capped and SHA-256 verified before bytes are +returned. + +```sh +mvn --batch-mode --no-transfer-progress verify +``` + +Newer package-listing, semantic-search, embedding, and organization-audit routes +are intentionally outside this core package and tracked separately. diff --git a/clients/java/pom.xml b/clients/java/pom.xml new file mode 100644 index 0000000..435851a --- /dev/null +++ b/clients/java/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + tech.zpkg + zed-client + 0.1.0 + Zed Registry Java Client + Typed Java client for the zed-pkg registry core lifecycle. + https://github.com/zed-pkg/zed-clients + + + + MIT + https://opensource.org/license/mit + repo + + + + + 17 + UTF-8 + 2.18.3 + 5.11.4 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + + + + diff --git a/clients/java/src/main/java/tech/zpkg/client/ZedClient.java b/clients/java/src/main/java/tech/zpkg/client/ZedClient.java new file mode 100644 index 0000000..4271fa8 --- /dev/null +++ b/clients/java/src/main/java/tech/zpkg/client/ZedClient.java @@ -0,0 +1,723 @@ +package tech.zpkg.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +/** + * Java 17+ client for the stable zed-pkg registry lifecycle. + * + *

The client never follows redirects, never includes the registry bearer on + * artifact-download requests, bounds response bodies, and verifies artifact + * SHA-256 values before returning bytes.

+ */ +public final class ZedClient { + public static final String DEFAULT_REGISTRY_URL = "https://registry.zpkg.tech"; + public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + public static final int MAX_ARTIFACT_BYTES = 100 * 1024 * 1024; + + private static final int DOWNLOAD_SLACK_BYTES = 1024 * 1024; + private static final int MAX_JSON_BYTES = 2 * 1024 * 1024; + private static final int MAX_ERROR_BYTES = 16 * 1024; + private static final int MAX_SEGMENT_BYTES = 256; + + private final URI baseUri; + private final String token; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + public ZedClient() { + this(DEFAULT_REGISTRY_URL, null); + } + + public ZedClient(String registryUrl, String token) { + this( + registryUrl, + token, + HttpClient.newBuilder() + .connectTimeout(DEFAULT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + new ObjectMapper() + ); + } + + ZedClient( + String registryUrl, + String token, + HttpClient httpClient, + ObjectMapper objectMapper + ) { + this.baseUri = validateBaseUri(registryUrl); + this.token = normalizeOptional(token); + this.httpClient = Objects.requireNonNull(httpClient, "httpClient"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + } + + public PackageMetadata getPackage(String org, String name) + throws IOException, InterruptedException { + return requestJson( + "GET", + packagePath(org, name), + false, + null, + null, + PackageMetadata.class + ); + } + + public VersionMetadata getVersion(String org, String name, String version) + throws IOException, InterruptedException { + return requestJson( + "GET", + versionPath(org, name, version), + false, + null, + null, + VersionMetadata.class + ); + } + + public SearchResponse search(String query) throws IOException, InterruptedException { + if (query == null) { + throw new ValidationException("query must not be null"); + } + return requestJson( + "GET", + "/v1/search?q=" + encodeQuery(query), + false, + null, + null, + SearchResponse.class + ); + } + + public ClaimOrgResponse claimOrg(String slug) throws IOException, InterruptedException { + ObjectNode body = objectMapper.createObjectNode().put("slug", requireSegment(slug, "slug")); + return requestJson( + "POST", + "/v1/orgs", + true, + objectMapper.writeValueAsBytes(body), + "application/json", + ClaimOrgResponse.class + ); + } + + public YankResponse setYanked( + String org, + String name, + String version, + boolean yanked + ) throws IOException, InterruptedException { + ObjectNode body = objectMapper.createObjectNode().put("yanked", yanked); + return requestJson( + "POST", + yankPath(org, name, version), + true, + objectMapper.writeValueAsBytes(body), + "application/json", + YankResponse.class + ); + } + + public YankResponse yank(String org, String name, String version) + throws IOException, InterruptedException { + return setYanked(org, name, version, true); + } + + public YankResponse restore(String org, String name, String version) + throws IOException, InterruptedException { + return setYanked(org, name, version, false); + } + + /** + * Download and verify an artifact. The registry bearer is deliberately not + * attached because {@code download_url} may be a third-party presigned URL. + */ + public byte[] downloadArtifact(VersionMetadata version) + throws IOException, InterruptedException { + Objects.requireNonNull(version, "version"); + URI downloadUri = downloadUri(version); + int limit = downloadLimit(version.size()); + + HttpRequest request = HttpRequest.newBuilder(downloadUri) + .timeout(DEFAULT_TIMEOUT) + .header("Accept", "application/octet-stream") + .GET() + .build(); + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofInputStream() + ); + try (InputStream stream = response.body()) { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw apiException(response.statusCode(), stream); + } + String declaredLength = response.headers().firstValue("content-length").orElse(null); + if (declaredLength != null) { + try { + if (Long.parseLong(declaredLength) > limit) { + throw new ArtifactTooLargeException(limit); + } + } catch (NumberFormatException ignored) { + // A malformed content-length is not trusted; the streaming bound still applies. + } + } + final byte[] artifact; + try { + artifact = readBounded(stream, limit, true); + } catch (ResponseTooLargeException error) { + throw new ArtifactTooLargeException(limit); + } + String actual = sha256Hex(artifact); + if (!actual.equalsIgnoreCase(requireSegment(version.sha256(), "version.sha256"))) { + throw new Sha256MismatchException(version.sha256(), actual); + } + return artifact; + } + } + + /** + * Publish raw archive bytes as multipart fields {@code meta} and + * {@code artifact}. The package coordinate is read from + * {@code meta.manifest.package} and determines the PUT route. + */ + public PublishResponse publish(JsonNode meta, byte[] artifact) + throws IOException, InterruptedException { + requireToken(); + Objects.requireNonNull(meta, "meta"); + Objects.requireNonNull(artifact, "artifact"); + if (!meta.isObject()) { + throw new ValidationException("publish meta must be a JSON object"); + } + if (artifact.length > MAX_ARTIFACT_BYTES) { + throw new ArtifactTooLargeException(MAX_ARTIFACT_BYTES); + } + + JsonNode pkg = meta.path("manifest").path("package"); + if (!pkg.isObject()) { + throw new ValidationException("publish meta.manifest.package must be an object"); + } + String org = requireSegment(pkg.path("org").asText(null), "meta.manifest.package.org"); + String name = requireSegment(pkg.path("name").asText(null), "meta.manifest.package.name"); + String version = requireSegment( + pkg.path("version").asText(null), + "meta.manifest.package.version" + ); + + String boundary = "zed-" + UUID.randomUUID(); + byte[] body = multipartBody(boundary, meta, artifact, org, name, version); + return requestJson( + "PUT", + versionPath(org, name, version), + true, + body, + "multipart/form-data; boundary=" + boundary, + PublishResponse.class + ); + } + + @Override + public String toString() { + return "ZedClient(baseUri=" + baseUri + ", token=[REDACTED])"; + } + + URI baseUri() { + return baseUri; + } + + private T requestJson( + String method, + String path, + boolean authenticated, + byte[] body, + String contentType, + Class type + ) throws IOException, InterruptedException { + HttpResponse response = send(method, path, authenticated, body, contentType); + try (InputStream stream = response.body()) { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw apiException(response.statusCode(), stream); + } + byte[] document = readBounded(stream, MAX_JSON_BYTES, true); + try { + return objectMapper.readValue(document, type); + } catch (IOException error) { + throw new ProtocolException("registry returned invalid JSON", error); + } + } + } + + private HttpResponse send( + String method, + String path, + boolean authenticated, + byte[] body, + String contentType + ) throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder(resolve(path)) + .timeout(DEFAULT_TIMEOUT) + .header("Accept", "application/json"); + if (authenticated) { + builder.header("Authorization", "Bearer " + requireToken()); + } + if (body == null) { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + builder.header("Content-Type", Objects.requireNonNull(contentType, "contentType")) + .method(method, HttpRequest.BodyPublishers.ofByteArray(body)); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + } + + private ApiException apiException(int status, InputStream stream) throws IOException { + byte[] bounded = readBounded(stream, MAX_ERROR_BYTES, false); + String responseBody = new String(bounded, StandardCharsets.UTF_8) + .replace('\r', ' ') + .replace('\n', ' '); + String code = "http_" + status; + try { + JsonNode value = objectMapper.readTree(bounded); + if (value != null && value.path("code").isTextual()) { + String candidate = value.path("code").asText().trim(); + if (!candidate.isEmpty()) { + code = candidate; + } + } + } catch (IOException ignored) { + // Non-JSON errors retain the stable HTTP-derived code. + } + return new ApiException(status, code, responseBody); + } + + private URI resolve(String path) { + return URI.create(baseUri.toString() + path); + } + + private URI downloadUri(VersionMetadata version) { + String raw = normalizeOptional(version.downloadUrl()); + if (raw == null) { + return resolve(artifactPath(version.sha256())); + } + final URI uri; + try { + URI candidate = new URI(raw); + if (candidate.isAbsolute()) { + uri = candidate; + } else { + if (candidate.getRawAuthority() != null + || raw.startsWith("/") + || raw.startsWith("\\")) { + throw new ValidationException( + "relative download_url must not replace the registry authority or gateway path" + ); + } + validateRelativeDownloadPath(raw); + uri = URI.create(baseUri.toString() + "/").resolve(candidate); + } + } catch (URISyntaxException error) { + throw new ValidationException("download_url is invalid", error); + } + return validateDownloadUri(uri); + } + + private URI validateDownloadUri(URI uri) { + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + boolean loopback = host.equals("localhost") + || host.startsWith("127.") + || host.equals("::1"); + boolean allowed = scheme.equals("https") + || (scheme.equals("http") + && (loopback || baseUri.getScheme().equalsIgnoreCase("http"))); + if (!allowed + || host.isBlank() + || uri.getRawUserInfo() != null + || uri.getRawFragment() != null) { + throw new ValidationException( + "download_url must use HTTPS; HTTP is allowed only for loopback or an HTTP development registry" + ); + } + return uri; + } + + private static URI validateBaseUri(String input) { + if (input == null) { + throw new ValidationException("registry URL must not be null"); + } + String trimmed = input.trim(); + validateRawBasePath(trimmed); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + final URI uri; + try { + uri = new URI(trimmed); + } catch (URISyntaxException error) { + throw new ValidationException("registry URL is invalid", error); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!(scheme.equals("http") || scheme.equals("https")) + || uri.getHost() == null + || uri.getHost().isBlank() + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new ValidationException( + "registry URL must be a credential-free absolute HTTP(S) URL without query or fragment" + ); + } + return uri; + } + + private static void validateRawBasePath(String raw) { + int schemeEnd = raw.indexOf("://"); + if (schemeEnd < 0) { + return; + } + int authorityStart = schemeEnd + 3; + int pathStart = raw.indexOf('/', authorityStart); + if (pathStart < 0) { + return; + } + int query = raw.indexOf('?', pathStart); + int fragment = raw.indexOf('#', pathStart); + int pathEnd = raw.length(); + if (query >= 0) { + pathEnd = Math.min(pathEnd, query); + } + if (fragment >= 0) { + pathEnd = Math.min(pathEnd, fragment); + } + validatePathSegments(raw.substring(pathStart, pathEnd), "registry URL path"); + } + + private static void validateRelativeDownloadPath(String raw) { + int query = raw.indexOf('?'); + int fragment = raw.indexOf('#'); + int pathEnd = raw.length(); + if (query >= 0) { + pathEnd = Math.min(pathEnd, query); + } + if (fragment >= 0) { + pathEnd = Math.min(pathEnd, fragment); + } + validatePathSegments(raw.substring(0, pathEnd), "download_url"); + } + + private static void validatePathSegments(String rawPath, String name) { + String[] segments = rawPath.split("/", -1); + for (int index = 0; index < segments.length; index++) { + String encoded = segments[index]; + if (encoded.isEmpty()) { + continue; + } + final String decoded; + try { + decoded = URLDecoder.decode(encoded.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (IllegalArgumentException error) { + throw new ValidationException(name + " contains invalid percent encoding", error); + } + requireSegment(decoded, name + " segment " + (index + 1)); + if (decoded.indexOf('/') >= 0 || decoded.indexOf('\\') >= 0) { + throw new ValidationException(name + " segments must not contain encoded separators"); + } + } + } + + private static String packagePath(String org, String name) { + return "/v1/packages/" + encodeSegment(org, "org") + "/" + encodeSegment(name, "name"); + } + + private static String versionPath(String org, String name, String version) { + return packagePath(org, name) + "/versions/" + encodeSegment(version, "version"); + } + + private static String yankPath(String org, String name, String version) { + return versionPath(org, name, version) + "/yank"; + } + + private static String artifactPath(String sha256) { + return "/v1/artifacts/" + encodeSegment(sha256, "sha256"); + } + + private static String encodeSegment(String value, String name) { + String checked = requireSegment(value, name); + return URLEncoder.encode(checked, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String encodeQuery(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private String requireToken() { + if (token == null) { + throw new MissingTokenException(); + } + if (token.codePoints().anyMatch(Character::isISOControl)) { + throw new ValidationException("token must not contain control characters"); + } + return token; + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new ValidationException(name + " must not be blank"); + } + return value; + } + + private static String requireSegment(String value, String name) { + String checked = requireText(value, name); + if (checked.equals(".") || checked.equals("..")) { + throw new ValidationException(name + " must not be a dot segment"); + } + if (checked.getBytes(StandardCharsets.UTF_8).length > MAX_SEGMENT_BYTES) { + throw new ValidationException(name + " exceeds " + MAX_SEGMENT_BYTES + " UTF-8 bytes"); + } + if (checked.codePoints().anyMatch(Character::isISOControl)) { + throw new ValidationException(name + " must not contain control characters"); + } + return checked; + } + + private static String normalizeOptional(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static int downloadLimit(long declaredSize) { + if (declaredSize <= 0) { + return MAX_ARTIFACT_BYTES; + } + long withSlack; + try { + withSlack = Math.addExact(declaredSize, DOWNLOAD_SLACK_BYTES); + } catch (ArithmeticException ignored) { + withSlack = Long.MAX_VALUE; + } + return (int) Math.min(withSlack, MAX_ARTIFACT_BYTES); + } + + private byte[] multipartBody( + String boundary, + JsonNode meta, + byte[] artifact, + String org, + String name, + String version + ) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream( + Math.min(artifact.length + 4096, MAX_ARTIFACT_BYTES) + ); + writeAscii(output, "--" + boundary + "\r\n"); + writeAscii(output, "Content-Disposition: form-data; name=\"meta\"\r\n"); + writeAscii(output, "Content-Type: application/json\r\n\r\n"); + output.write(objectMapper.writeValueAsBytes(meta)); + writeAscii(output, "\r\n--" + boundary + "\r\n"); + String filename = safeFilename(org + "-" + name + "-" + version + ".tar.gz"); + writeAscii( + output, + "Content-Disposition: form-data; name=\"artifact\"; filename=\"" + + filename + + "\"\r\n" + ); + writeAscii(output, "Content-Type: application/octet-stream\r\n\r\n"); + output.write(artifact); + writeAscii(output, "\r\n--" + boundary + "--\r\n"); + return output.toByteArray(); + } + + private static void writeAscii(ByteArrayOutputStream output, String text) throws IOException { + output.write(text.getBytes(StandardCharsets.US_ASCII)); + } + + private static String safeFilename(String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private static byte[] readBounded(InputStream input, int limit, boolean failOnOverflow) + throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(limit, 8192)); + byte[] buffer = new byte[8192]; + int remaining = limit; + while (remaining > 0) { + int read = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (read < 0) { + return output.toByteArray(); + } + output.write(buffer, 0, read); + remaining -= read; + } + if (input.read() != -1 && failOnOverflow) { + throw new ResponseTooLargeException(limit); + } + return output.toByteArray(); + } + + private static String sha256Hex(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PackageSummary( + String org, + String name, + String description, + String latest + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PackageMetadata( + String org, + String name, + String description, + String vcs, + @JsonProperty("repo_url") String repoUrl, + String latest, + List versions, + @JsonProperty("version_scheme") String versionScheme + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record VersionMetadata( + String org, + String name, + String version, + String sha256, + long size, + String format, + @JsonProperty("vcs_tag") String vcsTag, + @JsonProperty("vcs_commit") String vcsCommit, + @JsonProperty("download_url") String downloadUrl, + @JsonProperty("published_at") String publishedAt, + boolean yanked + ) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchResponse(String query, List items) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ClaimOrgResponse(String slug, boolean created) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PublishResponse(String org, String name, String version, String sha256) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record YankResponse(String org, String name, String version, boolean yanked) { } + + public static final class ValidationException extends IllegalArgumentException { + ValidationException(String message) { + super(message); + } + + ValidationException(String message, Throwable cause) { + super(message, cause); + } + } + + public static final class MissingTokenException extends IllegalStateException { + MissingTokenException() { + super("authenticated registry operation requires a bearer token"); + } + } + + public static final class ApiException extends IOException { + private final int status; + private final String code; + private final String responseBody; + + ApiException(int status, String code, String responseBody) { + super("registry error " + status + ": " + code); + this.status = status; + this.code = code; + this.responseBody = responseBody; + } + + public int status() { + return status; + } + + public String code() { + return code; + } + + public String responseBody() { + return responseBody; + } + } + + public static class ResponseTooLargeException extends IOException { + private final int limit; + + ResponseTooLargeException(int limit) { + super("registry response exceeded " + limit + " bytes"); + this.limit = limit; + } + + public int limit() { + return limit; + } + } + + public static final class ArtifactTooLargeException extends ResponseTooLargeException { + ArtifactTooLargeException(int limit) { + super(limit); + } + } + + public static final class Sha256MismatchException extends IOException { + private final String expected; + private final String actual; + + Sha256MismatchException(String expected, String actual) { + super("artifact SHA-256 mismatch"); + this.expected = expected; + this.actual = actual; + } + + public String expected() { + return expected; + } + + public String actual() { + return actual; + } + } + + public static final class ProtocolException extends IOException { + ProtocolException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/clients/java/src/test/java/tech/zpkg/client/ZedClientHardeningTest.java b/clients/java/src/test/java/tech/zpkg/client/ZedClientHardeningTest.java new file mode 100644 index 0000000..a33eec6 --- /dev/null +++ b/clients/java/src/test/java/tech/zpkg/client/ZedClientHardeningTest.java @@ -0,0 +1,171 @@ +package tech.zpkg.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class ZedClientHardeningTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void rejectsRawDotSegmentsEncodedSeparatorsAndHostileCoordinatesBeforeTransport() { + for (String invalid : new String[] { + "https://registry.example/../admin", + "https://registry.example/%2e%2e/admin", + "https://registry.example/a%2Fb" + }) { + assertThrows(ZedClient.ValidationException.class, () -> new ZedClient(invalid, null)); + } + + ZedClient client = new ZedClient("http://127.0.0.1:9", null); + for (String hostile : new String[] {"", " ", ".", "..", "line\nbreak"}) { + assertThrows( + ZedClient.ValidationException.class, + () -> client.getPackage(hostile, "kit"), + hostile + ); + } + assertThrows( + ZedClient.ValidationException.class, + () -> client.getVersion("acme", "kit", "x".repeat(257)) + ); + assertThrows(ZedClient.MissingTokenException.class, () -> client.restore("acme", "kit", "1.0.0")); + assertThrows( + ZedClient.MissingTokenException.class, + () -> client.publish(JSON.createObjectNode(), new byte[] {1}) + ); + + ZedClient unsafeToken = new ZedClient( + "http://127.0.0.1:9", + "token\r\nInjected: header" + ); + assertThrows( + ZedClient.ValidationException.class, + () -> unsafeToken.claimOrg("acme") + ); + } + + @Test + void relativeArtifactTraversalAndAuthorityReplacementFailBeforeTransport() { + ZedClient client = new ZedClient("https://registry.example/gateway", null); + for (String raw : new String[] { + "../escape", + "%2e%2e/escape", + "a%2Fb", + "//evil.example/artifact", + "/absolute/artifact" + }) { + ZedClient.VersionMetadata version = new ZedClient.VersionMetadata( + "acme", + "kit", + "1.0.0", + "00", + 1, + "tar.gz", + "v1.0.0", + null, + raw, + "now", + false + ); + assertThrows( + ZedClient.ValidationException.class, + () -> client.downloadArtifact(version), + raw + ); + } + } + + @Test + void relativeArtifactUrlPreservesGatewayAndAcceptsUppercaseDigest() throws Exception { + byte[] artifact = "verified-artifact".getBytes(StandardCharsets.UTF_8); + AtomicReference path = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + try (Fixture fixture = Fixture.start(exchange -> { + path.set(exchange.getRequestURI().getRawPath()); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.sendResponseHeaders(200, artifact.length); + exchange.getResponseBody().write(artifact); + exchange.close(); + })) { + ZedClient client = new ZedClient(fixture.baseUrl() + "/gateway", "registry-secret"); + ZedClient.VersionMetadata version = new ZedClient.VersionMetadata( + "acme", + "kit", + "1.0.0", + sha256(artifact).toUpperCase(), + artifact.length, + "tar.gz", + "v1.0.0", + null, + "artifact", + "now", + false + ); + assertArrayEquals(artifact, client.downloadArtifact(version)); + } + assertEquals("/gateway/artifact", path.get()); + assertNull(authorization.get(), "registry bearer leaked to artifact host"); + } + + @Test + void blankStructuredErrorCodeFallsBackToHttpStatus() throws Exception { + try (Fixture fixture = Fixture.start(exchange -> respond( + exchange, + 409, + "{\"code\":\" \",\"message\":\"remote detail\"}" + ))) { + ZedClient client = new ZedClient(fixture.baseUrl(), "token"); + ZedClient.ApiException error = assertThrows( + ZedClient.ApiException.class, + () -> client.claimOrg("acme") + ); + assertEquals("http_409", error.code()); + assertEquals("registry error 409: http_409", error.getMessage()); + } + } + + private static String sha256(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + @FunctionalInterface + private interface Handler { + void handle(HttpExchange exchange) throws IOException; + } + + private record Fixture(HttpServer server, String baseUrl) implements AutoCloseable { + static Fixture start(Handler handler) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", handler::handle); + server.start(); + return new Fixture(server, "http://127.0.0.1:" + server.getAddress().getPort()); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/clients/java/src/test/java/tech/zpkg/client/ZedClientRelativeQueryTest.java b/clients/java/src/test/java/tech/zpkg/client/ZedClientRelativeQueryTest.java new file mode 100644 index 0000000..8650449 --- /dev/null +++ b/clients/java/src/test/java/tech/zpkg/client/ZedClientRelativeQueryTest.java @@ -0,0 +1,78 @@ +package tech.zpkg.client; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +final class ZedClientRelativeQueryTest { + @Test + void safeRelativePresignedQueryPreservesGatewayAndOmitsBearer() throws Exception { + byte[] artifact = "query-artifact".getBytes(StandardCharsets.UTF_8); + AtomicReference rawPath = new AtomicReference<>(); + AtomicReference rawQuery = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> respond( + exchange, + artifact, + rawPath, + rawQuery, + authorization + )); + server.start(); + try { + String base = "http://127.0.0.1:" + server.getAddress().getPort() + "/gateway"; + ZedClient client = new ZedClient(base, "registry-secret"); + ZedClient.VersionMetadata version = new ZedClient.VersionMetadata( + "acme", + "kit", + "1.0.0", + sha256(artifact), + artifact.length, + "tar.gz", + "v1.0.0", + null, + "artifacts/hash?signature=one", + "now", + false + ); + + assertArrayEquals(artifact, client.downloadArtifact(version)); + assertEquals("/gateway/artifacts/hash", rawPath.get()); + assertEquals("signature=one", rawQuery.get()); + assertNull(authorization.get(), "registry bearer leaked to artifact request"); + } finally { + server.stop(0); + } + } + + private static void respond( + HttpExchange exchange, + byte[] body, + AtomicReference rawPath, + AtomicReference rawQuery, + AtomicReference authorization + ) throws IOException { + rawPath.set(exchange.getRequestURI().getRawPath()); + rawQuery.set(exchange.getRequestURI().getRawQuery()); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private static String sha256(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } +} diff --git a/clients/java/src/test/java/tech/zpkg/client/ZedClientTest.java b/clients/java/src/test/java/tech/zpkg/client/ZedClientTest.java new file mode 100644 index 0000000..c4c0347 --- /dev/null +++ b/clients/java/src/test/java/tech/zpkg/client/ZedClientTest.java @@ -0,0 +1,301 @@ +package tech.zpkg.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ZedClientTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void validatesAndNormalizesRegistryUrlsWithoutLeakingTokens() { + ZedClient client = new ZedClient( + " https://registry.example/gateway/// ", + " very-secret " + ); + assertEquals("https://registry.example/gateway", client.baseUri().toString()); + assertTrue(client.toString().contains("[REDACTED]")); + assertFalse(client.toString().contains("very-secret")); + + for (String invalid : new String[] { + "relative/path", + "ftp://registry.example", + "https://user:secret@registry.example", + "https://registry.example?tenant=one", + "https://registry.example#fragment" + }) { + assertThrows(ZedClient.ValidationException.class, () -> new ZedClient(invalid, null)); + } + } + + @Test + void publicReadsAndAuthenticatedMutationsUseTheExactCoreRoutes() throws Exception { + List requests = new CopyOnWriteArrayList<>(); + try (Fixture fixture = Fixture.start(exchange -> { + Captured captured = Captured.from(exchange); + requests.add(captured); + String path = exchange.getRequestURI().getRawPath(); + if (path.endsWith("/yank")) { + respond( + exchange, + 200, + "{\"org\":\"acme\",\"name\":\"http-kit\"," + + "\"version\":\"1.0.0\",\"yanked\":true}" + ); + } else if (path.endsWith("/v1/orgs")) { + respond(exchange, 200, "{\"slug\":\"acme\",\"created\":true}"); + } else { + respond( + exchange, + 200, + "{\"org\":\"acme\",\"name\":\"http-kit\"," + + "\"vcs\":\"git\",\"repo_url\":\"https://example/acme/http-kit\"," + + "\"versions\":[\"1.0.0\"]}" + ); + } + })) { + ZedClient client = new ZedClient(fixture.baseUrl() + "/gateway///", "publisher-token"); + ZedClient.PackageMetadata metadata = client.getPackage("acme", "http kit"); + assertEquals("acme", metadata.org()); + assertEquals(List.of("1.0.0"), metadata.versions()); + assertTrue(client.claimOrg("acme").created()); + assertTrue(client.yank("acme", "http-kit", "1.0.0").yanked()); + } + + assertEquals("GET", requests.get(0).method()); + assertEquals("/gateway/v1/packages/acme/http%20kit", requests.get(0).path()); + assertNull(requests.get(0).authorization()); + + assertEquals("POST", requests.get(1).method()); + assertEquals("/gateway/v1/orgs", requests.get(1).path()); + assertEquals("Bearer publisher-token", requests.get(1).authorization()); + assertEquals("acme", JSON.readTree(requests.get(1).body()).path("slug").asText()); + + assertEquals( + "/gateway/v1/packages/acme/http-kit/versions/1.0.0/yank", + requests.get(2).path() + ); + assertEquals("Bearer publisher-token", requests.get(2).authorization()); + assertTrue(JSON.readTree(requests.get(2).body()).path("yanked").asBoolean()); + } + + @Test + void artifactDownloadNeverCarriesBearerAndVerifiesSha256() throws Exception { + byte[] artifact = new byte[] {0, 1, 2, 3, (byte) 255}; + String digest = sha256(artifact); + AtomicReference authorization = new AtomicReference<>(); + try (Fixture fixture = Fixture.start(exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.getResponseHeaders().set("Content-Type", "application/octet-stream"); + exchange.sendResponseHeaders(200, artifact.length); + exchange.getResponseBody().write(artifact); + exchange.close(); + })) { + ZedClient client = new ZedClient(fixture.baseUrl(), "registry-secret"); + ZedClient.VersionMetadata version = new ZedClient.VersionMetadata( + "acme", + "http-kit", + "1.0.0", + digest, + artifact.length, + "tar.gz", + "v1.0.0", + null, + fixture.baseUrl() + "/presigned?signature=abc", + "2026-07-31T00:00:00Z", + false + ); + assertArrayEquals(artifact, client.downloadArtifact(version)); + } + assertNull(authorization.get(), "registry bearer leaked to artifact host"); + } + + @Test + void artifactMismatchAndInsecureRemoteUrlsFailClosed() throws Exception { + byte[] artifact = "artifact".getBytes(StandardCharsets.UTF_8); + try (Fixture fixture = Fixture.start(exchange -> { + exchange.sendResponseHeaders(200, artifact.length); + exchange.getResponseBody().write(artifact); + exchange.close(); + })) { + ZedClient client = new ZedClient(fixture.baseUrl(), null); + ZedClient.VersionMetadata mismatch = new ZedClient.VersionMetadata( + "acme", + "pkg", + "1.0.0", + "0".repeat(64), + artifact.length, + "tar.gz", + "v1.0.0", + null, + fixture.baseUrl() + "/artifact", + "now", + false + ); + assertThrows(ZedClient.Sha256MismatchException.class, () -> client.downloadArtifact(mismatch)); + } + + ZedClient production = new ZedClient("https://registry.example", null); + ZedClient.VersionMetadata insecure = new ZedClient.VersionMetadata( + "acme", + "pkg", + "1.0.0", + "0".repeat(64), + 1, + "tar.gz", + "v1.0.0", + null, + "http://artifacts.example/object", + "now", + false + ); + assertThrows(ZedClient.ValidationException.class, () -> production.downloadArtifact(insecure)); + } + + @Test + void multipartPublishPreservesRawArtifactBytesAndCoordinate() throws Exception { + byte[] artifact = new byte[] {0, 7, 13, (byte) 200, (byte) 255}; + AtomicReference captured = new AtomicReference<>(); + try (Fixture fixture = Fixture.start(exchange -> { + captured.set(Captured.from(exchange)); + respond( + exchange, + 200, + "{\"org\":\"acme\",\"name\":\"http-kit\"," + + "\"version\":\"1.2.3\",\"sha256\":\"abc\"}" + ); + })) { + ZedClient client = new ZedClient(fixture.baseUrl() + "/registry", "publisher-token"); + JsonNode meta = JSON.readTree( + "{\"manifest\":{\"package\":{\"org\":\"acme\"," + + "\"name\":\"http-kit\",\"version\":\"1.2.3\"}}," + + "\"provenance\":{\"commit\":\"deadbeef\"}}" + ); + ZedClient.PublishResponse response = client.publish(meta, artifact); + assertEquals("1.2.3", response.version()); + } + + Captured request = captured.get(); + assertEquals("PUT", request.method()); + assertEquals( + "/registry/v1/packages/acme/http-kit/versions/1.2.3", + request.path() + ); + assertEquals("Bearer publisher-token", request.authorization()); + assertTrue(request.contentType().startsWith("multipart/form-data; boundary=")); + assertTrue(contains(request.bodyBytes(), artifact), "multipart body altered raw artifact bytes"); + String readable = new String(request.bodyBytes(), StandardCharsets.ISO_8859_1); + assertTrue(readable.contains("name=\"meta\"")); + assertTrue(readable.contains("name=\"artifact\"")); + assertTrue(readable.contains("\"commit\":\"deadbeef\"")); + } + + @Test + void defaultApiDiagnosticsHideRemoteBodyButRetainBoundedExplicitBody() throws Exception { + try (Fixture fixture = Fixture.start(exchange -> respond( + exchange, + 403, + "{\"code\":\"scope_denied\",\"message\":\"provider-secret\"}" + ))) { + ZedClient client = new ZedClient(fixture.baseUrl(), "token"); + ZedClient.ApiException error = assertThrows( + ZedClient.ApiException.class, + () -> client.claimOrg("acme") + ); + assertEquals(403, error.status()); + assertEquals("scope_denied", error.code()); + assertFalse(error.getMessage().contains("provider-secret")); + assertTrue(error.responseBody().contains("provider-secret")); + } + } + + @Test + void authenticatedOperationsRequireTokenBeforeTransport() { + ZedClient client = new ZedClient("https://registry.example", null); + assertThrows(ZedClient.MissingTokenException.class, () -> client.claimOrg("acme")); + } + + private static String sha256(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } + + private static boolean contains(byte[] haystack, byte[] needle) { + outer: + for (int i = 0; i <= haystack.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (haystack[i + j] != needle[j]) { + continue outer; + } + } + return true; + } + return false; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + @FunctionalInterface + private interface Handler { + void handle(HttpExchange exchange) throws IOException; + } + + private record Fixture(HttpServer server, String baseUrl) implements AutoCloseable { + static Fixture start(Handler handler) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", handler::handle); + server.start(); + return new Fixture(server, "http://127.0.0.1:" + server.getAddress().getPort()); + } + + @Override + public void close() { + server.stop(0); + } + } + + private record Captured( + String method, + String path, + String authorization, + String contentType, + byte[] bodyBytes + ) { + static Captured from(HttpExchange exchange) throws IOException { + return new Captured( + exchange.getRequestMethod(), + exchange.getRequestURI().getRawPath(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("Content-Type"), + exchange.getRequestBody().readAllBytes() + ); + } + + String body() { + return new String(bodyBytes, StandardCharsets.UTF_8); + } + } +} diff --git a/clients/kotlin/README.md b/clients/kotlin/README.md new file mode 100644 index 0000000..8c37e9e --- /dev/null +++ b/clients/kotlin/README.md @@ -0,0 +1,5 @@ +# zed-client-kotlin + +A Kotlin/JVM client using Java 17 `HttpClient`. Methods return registry JSON as +UTF-8 strings so callers may choose kotlinx.serialization, Jackson, Moshi, or +another JSON library. diff --git a/clients/kotlin/pom.xml b/clients/kotlin/pom.xml new file mode 100644 index 0000000..36702c4 --- /dev/null +++ b/clients/kotlin/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + tech.zpkg + zed-client-kotlin + 0.1.0 + zed-client-kotlin + Kotlin/JVM client for the zed-pkg registry + MIT + + 2.0.21 + 17 + UTF-8 + + + ${project.basedir}/src/main/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + compilecompile + + 17 + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + diff --git a/clients/kotlin/src/main/kotlin/tech/zpkg/client/ZedClient.kt b/clients/kotlin/src/main/kotlin/tech/zpkg/client/ZedClient.kt new file mode 100644 index 0000000..abd6eac --- /dev/null +++ b/clients/kotlin/src/main/kotlin/tech/zpkg/client/ZedClient.kt @@ -0,0 +1,185 @@ +package tech.zpkg.client + +import java.io.ByteArrayOutputStream +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.time.Duration +import java.util.UUID + +class RegistryException( + val status: Int, + val code: String, + val registryMessage: String, +) : RuntimeException("registry error $status: $code") + +class ZedClient( + registryUrl: String = "https://registry.zpkg.tech", + private val token: String? = null, + private val timeout: Duration = Duration.ofSeconds(30), + private val http: HttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(10)) + .build(), +) { + private val base: URI = normalizeBase(registryUrl) + + fun packageJson(org: String, name: String): String = + json("GET", "/v1/packages/${segment(org)}/${segment(name)}") + + fun versionJson(org: String, name: String, version: String): String = + json("GET", "/v1/packages/${segment(org)}/${segment(name)}/versions/${segment(version)}") + + fun searchJson(query: String): String = + json("GET", "/v1/search?q=${URLEncoder.encode(query, StandardCharsets.UTF_8).replace("+", "%20")}") + + fun claimOrgJson(org: String): String = + json("POST", "/v1/orgs", "{\"org\":${jsonString(org)}}", auth = true) + + fun setYankedJson( + org: String, + name: String, + version: String, + yanked: Boolean, + reason: String? = null, + ): String { + val reasonField = reason?.let { ",\"reason\":${jsonString(it)}" } ?: "" + return json( + "POST", + "/v1/packages/${segment(org)}/${segment(name)}/versions/${segment(version)}/yank", + "{\"yanked\":$yanked$reasonField}", + auth = true, + ) + } + + fun yankJson(org: String, name: String, version: String, reason: String? = null): String = + setYankedJson(org, name, version, true, reason) + + fun restoreJson(org: String, name: String, version: String): String = + setYankedJson(org, name, version, false) + + fun publishJson( + org: String, + name: String, + version: String, + artifact: Path, + metadataJson: String, + ): String { + require(Files.isRegularFile(artifact)) { "artifact must be a regular file" } + val boundary = "zed-${UUID.randomUUID()}" + val payload = multipart(boundary, metadataJson, artifact.fileName.toString(), Files.readAllBytes(artifact)) + return send( + "PUT", + "/v1/packages/${segment(org)}/${segment(name)}/versions/${segment(version)}", + payload, + "multipart/form-data; boundary=$boundary", + auth = true, + limit = MAX_JSON_BYTES, + ).toString(StandardCharsets.UTF_8) + } + + fun downloadArtifact(sha256: String, destination: Path? = null): ByteArray { + val expected = sha256.lowercase() + require(HEX_64.matches(expected)) { "sha256 must be 64 hexadecimal characters" } + val bytes = send("GET", "/v1/artifacts/$expected", null, null, auth = false, limit = MAX_ARTIFACT_BYTES) + val actual = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + if (actual != expected) { + throw RegistryException(0, "digest_mismatch", "expected $expected, got $actual") + } + destination?.let { Files.write(it, bytes) } + return bytes + } + + private fun json(method: String, path: String, body: String? = null, auth: Boolean = false): String = + send( + method, + path, + body?.toByteArray(StandardCharsets.UTF_8), + body?.let { "application/json" }, + auth, + MAX_JSON_BYTES, + ).toString(StandardCharsets.UTF_8) + + private fun send( + method: String, + path: String, + body: ByteArray?, + contentType: String?, + auth: Boolean, + limit: Int, + ): ByteArray { + val builder = HttpRequest.newBuilder(base.resolve(base.path.trimEnd('/') + path)) + .timeout(timeout) + .header("Accept", "application/json") + if (contentType != null) builder.header("Content-Type", contentType) + if (auth && !token.isNullOrBlank()) builder.header("Authorization", "Bearer $token") + builder.method(method, body?.let { HttpRequest.BodyPublishers.ofByteArray(it) } ?: HttpRequest.BodyPublishers.noBody()) + val response = http.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()) + if (response.body().size > limit) { + throw RegistryException(0, "response_too_large", "response exceeded $limit bytes") + } + if (response.statusCode() !in 200..299) { + val message = response.body().toString(StandardCharsets.UTF_8).take(16_384) + throw RegistryException(response.statusCode(), "http_error", message) + } + return response.body() + } + + private fun multipart(boundary: String, metadata: String, filename: String, artifact: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + fun text(value: String) = out.write(value.toByteArray(StandardCharsets.UTF_8)) + val safeFilename = filename.replace(Regex("[\\r\\n\\\"]"), "_") + text("--$boundary\r\nContent-Disposition: form-data; name=\"meta\"\r\nContent-Type: application/json\r\n\r\n") + text(metadata) + text("\r\n--$boundary\r\nContent-Disposition: form-data; name=\"artifact\"; filename=\"$safeFilename\"\r\nContent-Type: application/octet-stream\r\n\r\n") + out.write(artifact) + text("\r\n--$boundary--\r\n") + return out.toByteArray() + } + + private fun segment(value: String): String { + require(value.isNotBlank() && value != "." && value != "..") { "path segment must be nonblank" } + require(value.toByteArray(StandardCharsets.UTF_8).size <= 256) { "path segment is too long" } + require(value.none { it.code < 0x20 || it.code == 0x7f }) { "path segment contains a control character" } + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20") + } + + private fun jsonString(value: String): String = buildString { + append('"') + value.forEach { c -> + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000c' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (c.code < 0x20) append("\\u%04x".format(c.code)) else append(c) + } + } + append('"') + } + + companion object { + private const val MAX_JSON_BYTES = 16 * 1024 * 1024 + private const val MAX_ARTIFACT_BYTES = 100 * 1024 * 1024 + private val HEX_64 = Regex("^[0-9a-f]{64}$") + + private fun normalizeBase(raw: String): URI { + val uri = URI(raw.trim()) + require(uri.scheme in setOf("http", "https") && uri.host != null) { "registryUrl must be absolute HTTP(S)" } + require(uri.userInfo == null && uri.query == null && uri.fragment == null) { "registryUrl must not contain credentials, query, or fragment" } + val cleanPath = uri.path.trimEnd('/') + return URI(uri.scheme, null, uri.host, uri.port, cleanPath, null, null) + } + } +} diff --git a/clients/php/README.md b/clients/php/README.md new file mode 100644 index 0000000..cad194a --- /dev/null +++ b/clients/php/README.md @@ -0,0 +1,6 @@ +# zed-pkg/client for PHP + +```php +$client = new ZedPkg\Client(token: getenv('ZED_TOKEN') ?: null); +$package = $client->package('zed-pkg', 'zed-clients'); +``` diff --git a/clients/php/composer.json b/clients/php/composer.json new file mode 100644 index 0000000..c209c01 --- /dev/null +++ b/clients/php/composer.json @@ -0,0 +1,16 @@ +{ + "name": "zed-pkg/client", + "description": "PHP client for the zed-pkg registry", + "license": "MIT", + "type": "library", + "require": { + "php": ">=8.2", + "ext-curl": "*", + "ext-json": "*" + }, + "autoload": { + "psr-4": { + "ZedPkg\\": "src/" + } + } +} diff --git a/clients/php/src/Client.php b/clients/php/src/Client.php new file mode 100644 index 0000000..89f5a65 --- /dev/null +++ b/clients/php/src/Client.php @@ -0,0 +1,216 @@ +baseUrl = $this->normalizeBaseUrl($registryUrl); + } + + /** @return array */ + public function package(string $org, string $name): array + { + return $this->json('GET', '/v1/packages/' . $this->segment($org) . '/' . $this->segment($name)); + } + + /** @return array */ + public function version(string $org, string $name, string $version): array + { + return $this->json('GET', '/v1/packages/' . $this->segment($org) . '/' . $this->segment($name) . '/versions/' . $this->segment($version)); + } + + /** @return array */ + public function search(string $query): array + { + return $this->json('GET', '/v1/search?q=' . rawurlencode($query)); + } + + /** @return array */ + public function claimOrg(string $org): array + { + return $this->json('POST', '/v1/orgs', ['org' => $org], true); + } + + /** @return array */ + public function setYanked(string $org, string $name, string $version, bool $yanked, ?string $reason = null): array + { + $payload = ['yanked' => $yanked]; + if ($reason !== null) { + $payload['reason'] = $reason; + } + return $this->json( + 'POST', + '/v1/packages/' . $this->segment($org) . '/' . $this->segment($name) . '/versions/' . $this->segment($version) . '/yank', + $payload, + true, + ); + } + + /** @return array */ + public function yank(string $org, string $name, string $version, ?string $reason = null): array + { + return $this->setYanked($org, $name, $version, true, $reason); + } + + /** @return array */ + public function restore(string $org, string $name, string $version): array + { + return $this->setYanked($org, $name, $version, false); + } + + /** @param array $metadata @return array */ + public function publish(string $org, string $name, string $version, string $artifactPath, array $metadata): array + { + if (!is_file($artifactPath)) { + throw new RuntimeException("artifact does not exist: {$artifactPath}"); + } + $fields = [ + 'meta' => json_encode($metadata, JSON_THROW_ON_ERROR), + 'artifact' => new \CURLFile($artifactPath, 'application/octet-stream', basename($artifactPath)), + ]; + $body = $this->request( + 'PUT', + '/v1/packages/' . $this->segment($org) . '/' . $this->segment($name) . '/versions/' . $this->segment($version), + $fields, + true, + null, + self::MAX_RESPONSE_BYTES, + ); + return $this->decode($body); + } + + public function downloadArtifact(string $sha256, ?string $destination = null): string + { + $sha256 = strtolower($sha256); + if (!preg_match('/^[0-9a-f]{64}$/', $sha256)) { + throw new RuntimeException('sha256 must be 64 hexadecimal characters'); + } + $body = $this->request('GET', '/v1/artifacts/' . $sha256, null, false, null, self::MAX_ARTIFACT_BYTES); + $actual = hash('sha256', $body); + if (!hash_equals($sha256, $actual)) { + throw new RegistryException(0, 'digest_mismatch', "expected {$sha256}, got {$actual}"); + } + if ($destination !== null && file_put_contents($destination, $body) === false) { + throw new RuntimeException("failed to write {$destination}"); + } + return $body; + } + + /** @param array|null $payload @return array */ + private function json(string $method, string $path, ?array $payload = null, bool $auth = false): array + { + $encoded = $payload === null ? null : json_encode($payload, JSON_THROW_ON_ERROR); + return $this->decode($this->request($method, $path, $encoded, $auth, $payload === null ? null : 'application/json')); + } + + /** @return array */ + private function decode(string $body): array + { + if ($body === '') { + return []; + } + try { + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RegistryException(0, 'invalid_json', $e->getMessage()); + } + if (!is_array($decoded)) { + throw new RegistryException(0, 'invalid_json', 'expected a JSON object'); + } + return $decoded; + } + + private function request(string $method, string $path, mixed $body, bool $auth, ?string $contentType = null, int $limit = self::MAX_RESPONSE_BYTES): string + { + $ch = curl_init($this->baseUrl . $path); + if ($ch === false) { + throw new RuntimeException('failed to initialize curl'); + } + $headers = ['Accept: application/json']; + if ($contentType !== null) { + $headers[] = 'Content-Type: ' . $contentType; + } + if ($auth && $this->token !== null && $this->token !== '') { + $headers[] = 'Authorization: Bearer ' . $this->token; + } + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + if (!is_string($response)) { + $message = curl_error($ch); + curl_close($ch); + throw new RegistryException(0, 'transport_error', $message); + } + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if (strlen($response) > $limit) { + throw new RegistryException(0, 'response_too_large', "response exceeded {$limit} bytes"); + } + if ($status < 200 || $status >= 300) { + $decoded = json_decode($response, true); + $code = is_array($decoded) && is_string($decoded['code'] ?? null) ? $decoded['code'] : 'http_error'; + $message = is_array($decoded) && is_string($decoded['message'] ?? null) ? $decoded['message'] : substr($response, 0, 16384); + throw new RegistryException($status, $code, $message); + } + return $response; + } + + private function normalizeBaseUrl(string $raw): string + { + $parts = parse_url(trim($raw)); + $valid = is_array($parts) + && in_array($parts['scheme'] ?? '', ['http', 'https'], true) + && isset($parts['host']) + && !isset($parts['user']) + && !isset($parts['pass']) + && !isset($parts['query']) + && !isset($parts['fragment']); + if (!$valid) { + throw new RuntimeException('registryUrl must be a credential-free absolute HTTP(S) URL'); + } + return rtrim($raw, '/'); + } + + private function segment(string $value): string + { + if (trim($value) === '' || $value === '.' || $value === '..' || strlen($value) > 256 || preg_match('/[\x00-\x1f\x7f]/', $value)) { + throw new RuntimeException('invalid path segment'); + } + return rawurlencode($value); + } +} diff --git a/clients/php/tests/client_test.php b/clients/php/tests/client_test.php new file mode 100644 index 0000000..2c04d38 --- /dev/null +++ b/clients/php/tests/client_test.php @@ -0,0 +1,22 @@ +=3.9" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["zed_pkg_client"] diff --git a/clients/python/test_client.py b/clients/python/test_client.py new file mode 100644 index 0000000..0a5329f --- /dev/null +++ b/clients/python/test_client.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import email +import email.policy +import hashlib +import http.server +import json +import os +import tempfile +import threading +import unittest +from unittest import mock + +from zed_pkg_client import ( + MAX_ARTIFACT_BYTES, + MAX_ERROR_BODY_BYTES, + PackageMetadata, + PublishResponse, + VersionMetadata, + YankResponse, + ZedApiError, + ZedClient, + _download_limit, + artifact_path, + package_path, + version_path, + yank_path, +) + + +class _FakeResponse: + def __init__(self, payload: dict | bytes, headers: dict | None = None): + self._payload = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.headers = headers or {} + self._offset = 0 + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._payload) - self._offset + start = self._offset + self._offset = min(len(self._payload), self._offset + size) + return self._payload[start : self._offset] + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _FakeOpener: + def __init__(self, response): + self.response = response + self.requests = [] + + def open(self, request, timeout): + self.requests.append((request, timeout)) + return self.response + + +class UrlAndConstructionTest(unittest.TestCase): + def test_paths_match_and_encode_core_contract(self): + self.assertEqual(package_path("acme", "kit"), "/v1/packages/acme/kit") + self.assertEqual( + version_path("acme", "kit", "1.2.0"), + "/v1/packages/acme/kit/versions/1.2.0", + ) + self.assertEqual( + yank_path("acme", "kit", "1.2.0"), + "/v1/packages/acme/kit/versions/1.2.0/yank", + ) + self.assertEqual(artifact_path("abc"), "/v1/artifacts/abc") + self.assertEqual( + version_path("acme", "kit", "release candidate/1"), + "/v1/packages/acme/kit/versions/release%20candidate%2F1", + ) + self.assertEqual(package_path("a?b", "c#d"), "/v1/packages/a%3Fb/c%23d") + + def test_base_url_is_validated_and_token_is_redacted(self): + client = ZedClient(" https://registry.zpkg.tech/gateway/// ", token="very-secret") + self.assertEqual(client.base, "https://registry.zpkg.tech/gateway") + self.assertIn("[REDACTED]", repr(client)) + self.assertNotIn("very-secret", repr(client)) + for invalid in ( + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + ): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + ZedClient(invalid) + + +class WireAndAuthTest(unittest.TestCase): + def test_unknown_server_fields_are_ignored(self): + payload = { + "org": "acme", + "name": "kit", + "vcs": "git", + "repo_url": "https://github.com/acme/kit", + "versions": ["1.2.0"], + "version_scheme": "calver", + "tags": ["http"], + "brand_new_server_field": {"nested": True}, + } + opener = _FakeOpener(_FakeResponse(payload)) + package = ZedClient("https://x.test", opener=opener).get_package("acme", "kit") + self.assertIsInstance(package, PackageMetadata) + self.assertEqual(package.version_scheme, "calver") + self.assertEqual(package.tags, ["http"]) + self.assertFalse(hasattr(package, "brand_new_server_field")) + + def test_public_reads_omit_auth_and_mutations_attach_it(self): + search_opener = _FakeOpener(_FakeResponse({"query": "kit", "items": []})) + search_client = ZedClient("https://x.test/gateway", token=" secret ", opener=search_opener) + search_client.search("kit") + search_request = search_opener.requests[0][0] + self.assertIsNone(search_request.get_header("Authorization")) + self.assertEqual(search_request.full_url, "https://x.test/gateway/v1/search?q=kit") + + yank_opener = _FakeOpener( + _FakeResponse({"org": "acme", "name": "kit", "version": "1.2.0", "yanked": True}) + ) + yank_client = ZedClient("https://x.test", token=" secret ", opener=yank_opener) + result = yank_client.yank("acme", "kit", "1.2.0", True) + self.assertEqual( + result, + YankResponse(org="acme", name="kit", version="1.2.0", yanked=True), + ) + yank_request = yank_opener.requests[0][0] + self.assertEqual(yank_request.get_header("Authorization"), "Bearer secret") + self.assertEqual(yank_request.get_method(), "POST") + self.assertEqual(json.loads(yank_request.data), {"yanked": True}) + + def test_publish_builds_authenticated_multipart_put(self): + response = {"org": "acme", "name": "kit", "version": "1.2.0", "sha256": "abc"} + meta = {"manifest": {"package": {"org": "acme", "name": "kit", "version": "1.2.0"}}} + opener = _FakeOpener(_FakeResponse(response)) + client = ZedClient("https://x.test", token="zpkg_t", opener=opener) + result = client.publish("acme", "kit", "1.2.0", meta, b"\x1f\x8bartifact-bytes") + + self.assertEqual( + result, + PublishResponse(org="acme", name="kit", version="1.2.0", sha256="abc"), + ) + request = opener.requests[0][0] + self.assertEqual(request.get_method(), "PUT") + self.assertEqual(request.get_header("Authorization"), "Bearer zpkg_t") + content_type = request.get_header("Content-type") + self.assertTrue(content_type.startswith("multipart/form-data; boundary=")) + message = email.message_from_bytes( + f"Content-Type: {content_type}\r\n\r\n".encode() + request.data, + policy=email.policy.HTTP, + ) + meta_part, artifact_part = message.get_payload() + self.assertEqual(json.loads(meta_part.get_payload()), meta) + self.assertEqual(artifact_part.get_payload(decode=True), b"\x1f\x8bartifact-bytes") + + def test_error_text_is_bounded_and_not_in_default_diagnostic(self): + remote = b"provider-secret" * MAX_ERROR_BODY_BYTES + error = mock.MagicMock() + error.code = 502 + error.headers = {} + error.read.side_effect = lambda size=-1: remote[:size] + mapped = __import__("zed_pkg_client")._api_error(error) + self.assertEqual(str(mapped), "registry error 502: http_502") + self.assertNotIn("provider-secret", str(mapped)) + self.assertLessEqual(len(mapped.registry_message.encode()), MAX_ERROR_BODY_BYTES) + + +class _RecordingServer: + def __init__( + self, + body: bytes, + content_length: int | None = None, + status: int = 200, + redirect_to: str | None = None, + ): + self.body = body + self.content_length = content_length + self.status = status + self.redirect_to = redirect_to + self.last_headers: dict = {} + self.last_path = "" + server = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + server.last_headers = dict(self.headers) + server.last_path = self.path + if server.redirect_to is not None: + self.send_response(302) + self.send_header("Location", server.redirect_to) + self.end_headers() + return + self.send_response(server.status) + length = server.content_length + if length is None: + length = len(server.body) + self.send_header("Content-Length", str(length)) + self.end_headers() + try: + self.wfile.write(server.body) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *args): + pass + + self._httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self._httpd.server_address[1]}" + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + + def __enter__(self): + self._thread.start() + return self + + def __exit__(self, *exc): + self._httpd.shutdown() + self._httpd.server_close() + return False + + +def _version(url: str, body: bytes, size: int = 0) -> VersionMetadata: + return VersionMetadata( + org="acme", + name="kit", + version="1.2.0", + sha256=hashlib.sha256(body).hexdigest(), + size=size, + format="tar.gz", + vcs_tag="v1.2.0", + download_url=url, + published_at="2024-01-01T00:00:00Z", + ) + + +class DownloadArtifactTest(unittest.TestCase): + def test_no_auth_header_relative_resolution_and_sha_verification(self): + body = b"artifact-bytes" + with _RecordingServer(body) as server, tempfile.TemporaryDirectory() as tmp: + client = ZedClient(f"{server.url}/gateway", token="zpkg_t") + version = _version("artifact/file", body, size=len(body)) + dest = os.path.join(tmp, "artifact.tar.gz") + client.download_artifact(version, dest) + self.assertNotIn("authorization", {key.lower() for key in server.last_headers}) + self.assertEqual(server.last_path, "/gateway/artifact/file") + with open(dest, "rb") as handle: + self.assertEqual(handle.read(), body) + + def test_redirects_are_refused(self): + with _RecordingServer(b"destination") as destination: + with _RecordingServer(b"", redirect_to=destination.url) as source: + client = ZedClient(source.url, token="secret") + with self.assertRaises(ZedApiError) as context: + client.download_artifact(_version(f"{source.url}/a", b"destination"), "/dev/null") + self.assertEqual(context.exception.code, "http_302") + self.assertEqual(destination.last_path, "") + + def test_insecure_and_oversized_downloads_are_rejected(self): + client = ZedClient("https://registry.zpkg.tech", token="zpkg_t") + for target in ("http://evil.example/artifact", "file:///etc/passwd"): + with self.subTest(target=target), self.assertRaises(ZedApiError) as context: + client.download_artifact(_version(target, b"x"), "/dev/null") + self.assertEqual(context.exception.code, "insecure_download_url") + + limit = _download_limit(1) + body = b"\0" * (limit + 64) + with _RecordingServer(body) as server, tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ZedApiError) as context: + ZedClient(server.url).download_artifact( + _version(f"{server.url}/artifact", body, size=1), + os.path.join(tmp, "a"), + ) + self.assertEqual(context.exception.code, "artifact_too_large") + + with _RecordingServer(b"small", content_length=MAX_ARTIFACT_BYTES + 1) as server: + with self.assertRaises(ZedApiError) as context: + ZedClient(server.url).download_artifact( + _version(f"{server.url}/artifact", b"small"), + "/dev/null", + ) + self.assertEqual(context.exception.code, "artifact_too_large") + + +if __name__ == "__main__": + unittest.main() diff --git a/clients/python/test_hardening.py b/clients/python/test_hardening.py new file mode 100644 index 0000000..1d802c6 --- /dev/null +++ b/clients/python/test_hardening.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import tempfile +import unittest + +from zed_pkg_client import ( + MAX_ARTIFACT_BYTES, + MAX_PATH_SEGMENT_BYTES, + VersionMetadata, + ZedApiError, + ZedClient, + package_path, + version_path, +) + + +class _FakeResponse: + def __init__(self, payload: dict | bytes, headers: dict | None = None): + self._payload = payload if isinstance(payload, bytes) else json.dumps(payload).encode() + self.headers = headers or {} + self._offset = 0 + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._payload) - self._offset + start = self._offset + self._offset = min(len(self._payload), self._offset + size) + return self._payload[start : self._offset] + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _CountingOpener: + def __init__(self, response=None): + self.response = response + self.calls = [] + + def open(self, request, timeout): + self.calls.append((request, timeout)) + if self.response is None: + raise AssertionError("transport must not run") + return self.response + + +class _HugeArtifact: + def __len__(self): + return MAX_ARTIFACT_BYTES + 1 + + +META = { + "manifest": { + "package": { + "org": "acme", + "name": "kit", + "version": "1.2.0", + } + } +} + + +def _version(body: bytes, *, download_url: str = "") -> VersionMetadata: + return VersionMetadata( + org="acme", + name="kit", + version="1.2.0", + sha256=hashlib.sha256(body).hexdigest().upper(), + size=len(body), + format="tar.gz", + vcs_tag="v1.2.0", + download_url=download_url, + published_at="2026-08-02T00:00:00Z", + ) + + +class HostileInputTest(unittest.TestCase): + def test_dot_control_blank_and_overlong_segments_are_rejected(self): + for value in ("", " ", ".", "..", "line\nbreak", "nul\0byte"): + with self.subTest(value=value), self.assertRaises(ValueError): + package_path(value, "kit") + with self.assertRaises(ValueError): + version_path("acme", "kit", "x" * (MAX_PATH_SEGMENT_BYTES + 1)) + self.assertEqual( + version_path("acme", "kit", "release candidate/1"), + "/v1/packages/acme/kit/versions/release%20candidate%2F1", + ) + + def test_registry_base_rejects_dot_and_encoded_separator_segments(self): + for base in ( + "https://registry.test/../admin", + "https://registry.test/%2e%2e/admin", + "https://registry.test/a%2Fb", + ): + with self.subTest(base=base), self.assertRaises(ValueError): + ZedClient(base) + + def test_timeout_must_be_positive_and_finite(self): + for timeout in (0, -1, math.inf, -math.inf, math.nan): + with self.subTest(timeout=timeout), self.assertRaises(ValueError): + ZedClient("https://registry.test", timeout=timeout) + + +class FailClosedAuthTest(unittest.TestCase): + def test_authenticated_operations_do_not_invoke_transport_without_token(self): + opener = _CountingOpener() + client = ZedClient("https://registry.test", opener=opener) + operations = ( + lambda: client.claim_org("acme"), + lambda: client.yank("acme", "kit", "1.2.0"), + lambda: client.restore("acme", "kit", "1.2.0"), + lambda: client.publish("acme", "kit", "1.2.0", META, b"artifact"), + ) + for operation in operations: + with self.subTest(operation=operation), self.assertRaises(ZedApiError) as context: + operation() + self.assertEqual(context.exception.code, "missing_token") + self.assertEqual(opener.calls, []) + + def test_blank_structured_error_code_uses_http_fallback(self): + response = _FakeResponse({"code": " ", "message": "remote"}) + opener = _CountingOpener(response) + # _request sees a successful fake transport, so exercise the mapper through + # the public helper's explicit behavior indirectly by importing it. + from zed_pkg_client import _api_error + + class ErrorResponse(_FakeResponse): + code = 409 + + mapped = _api_error(ErrorResponse({"code": " ", "message": "remote"})) + self.assertEqual(mapped.code, "http_409") + + +class PublishAndDownloadHardeningTest(unittest.TestCase): + def test_publish_rejects_oversize_and_coordinate_mismatch_before_transport(self): + opener = _CountingOpener() + client = ZedClient("https://registry.test", token="token", opener=opener) + with self.assertRaises(ZedApiError) as oversized: + client.publish("acme", "kit", "1.2.0", META, _HugeArtifact()) + self.assertEqual(oversized.exception.code, "artifact_too_large") + + wrong = { + "manifest": { + "package": { + "org": "other", + "name": "kit", + "version": "1.2.0", + } + } + } + with self.assertRaises(ZedApiError) as mismatch: + client.publish("acme", "kit", "1.2.0", wrong, b"artifact") + self.assertEqual(mismatch.exception.code, "publish_coordinate_mismatch") + self.assertEqual(opener.calls, []) + + def test_verified_download_atomically_replaces_destination(self): + body = b"verified artifact" + opener = _CountingOpener(_FakeResponse(body)) + client = ZedClient("https://registry.test", opener=opener) + with tempfile.TemporaryDirectory() as directory: + destination = os.path.join(directory, "nested", "artifact.tar.gz") + os.makedirs(os.path.dirname(destination)) + with open(destination, "wb") as handle: + handle.write(b"old") + client.download_artifact(_version(body), destination) + with open(destination, "rb") as handle: + self.assertEqual(handle.read(), body) + leftovers = [name for name in os.listdir(os.path.dirname(destination)) if name.startswith(".zed-artifact-")] + self.assertEqual(leftovers, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/clients/python/zed_pkg_client/__init__.py b/clients/python/zed_pkg_client/__init__.py new file mode 100644 index 0000000..0ca84a0 --- /dev/null +++ b/clients/python/zed_pkg_client/__init__.py @@ -0,0 +1,472 @@ +"""Python SDK for the zed-pkg registry. + +The package is stdlib-only, transports bearer credentials without parsing them, +never retries writes, refuses redirects, bounds response bodies, and verifies +artifact sha256 values before exposing bytes on disk. +""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import math +import os +import tempfile +import urllib.error +import urllib.parse +import urllib.request +import uuid +from dataclasses import dataclass, field, fields +from typing import Any, Optional + +DEFAULT_REGISTRY_URL = "https://registry.zpkg.tech" +USER_AGENT = "zed-client-python/0.1.0" +DEFAULT_TIMEOUT = 30.0 +MAX_ARTIFACT_BYTES = 100 * 1024 * 1024 +MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024 +MAX_ERROR_BODY_BYTES = 16 * 1024 +MAX_PATH_SEGMENT_BYTES = 256 +_DOWNLOAD_SLACK = 1024 * 1024 + + +def _is_loopback_host(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _download_limit(size: int) -> int: + if size and size > 0: + return min(size + _DOWNLOAD_SLACK, MAX_ARTIFACT_BYTES) + return MAX_ARTIFACT_BYTES + + +def _require_text(value: str, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must not be blank") + if value in {".", ".."}: + raise ValueError(f"{name} must not be a dot segment") + if len(value.encode("utf-8")) > MAX_PATH_SEGMENT_BYTES: + raise ValueError(f"{name} exceeds {MAX_PATH_SEGMENT_BYTES} UTF-8 bytes") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + raise ValueError(f"{name} must not contain control characters") + return value + + +class ZedApiError(Exception): + """Registry failure with a stable code and bounded explicit remote text.""" + + def __init__(self, status: int, code: str, registry_message: str) -> None: + super().__init__(f"registry error {status}: {code}") + self.status = status + self.code = code + self.registry_message = registry_message + # Compatibility alias for callers that intentionally inspect remote text. + self.message = registry_message + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + return None + + +def _quote(segment: str, name: str = "path segment") -> str: + return urllib.parse.quote(_require_text(segment, name), safe="") + + +def package_path(org: str, name: str) -> str: + return f"/v1/packages/{_quote(org, 'org')}/{_quote(name, 'name')}" + + +def version_path(org: str, name: str, version: str) -> str: + return f"{package_path(org, name)}/versions/{_quote(version, 'version')}" + + +def yank_path(org: str, name: str, version: str) -> str: + return f"{version_path(org, name, version)}/yank" + + +def artifact_path(sha256: str) -> str: + return f"/v1/artifacts/{_quote(sha256, 'sha256')}" + + +def _normalize_registry_url(raw: str) -> str: + parsed = urllib.parse.urlsplit(raw.strip()) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError( + "registry_url must be a credential-free absolute HTTP(S) URL " + "without query or fragment" + ) + for index, encoded_segment in enumerate(parsed.path.split("/"), start=1): + if not encoded_segment: + continue + decoded_segment = urllib.parse.unquote(encoded_segment) + _require_text(decoded_segment, f"registry path segment {index}") + if "/" in decoded_segment or "\\" in decoded_segment: + raise ValueError("registry path segments must not contain encoded separators") + path = parsed.path.rstrip("/") + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/") + + +def _read_bounded(response: Any, limit: int, *, fail_on_overflow: bool) -> bytes: + declared = response.headers.get("Content-Length") if hasattr(response, "headers") else None + if declared is not None: + try: + if int(declared) > limit and fail_on_overflow: + raise ZedApiError(0, "response_too_large", f"response exceeded {limit} bytes") + except ValueError: + pass + payload = response.read(limit + 1) + if len(payload) > limit: + if fail_on_overflow: + raise ZedApiError(0, "response_too_large", f"response exceeded {limit} bytes") + payload = payload[:limit] + return payload + + +def _api_error(error: urllib.error.HTTPError) -> ZedApiError: + raw_bytes = _read_bounded(error, MAX_ERROR_BODY_BYTES, fail_on_overflow=False) + raw = raw_bytes.decode(errors="replace") + code = f"http_{error.code}" + message = raw + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + candidate = parsed.get("code") + if isinstance(candidate, str) and candidate.strip(): + code = candidate.strip() + if isinstance(parsed.get("message"), str): + message = parsed["message"] + except ValueError: + pass + return ZedApiError(error.code, code, message) + + +@dataclass +class PackageSummary: + org: str + name: str + description: Optional[str] = None + latest: Optional[str] = None + tags: list[str] = field(default_factory=list) + + +@dataclass +class PackageMetadata: + org: str + name: str + vcs: str + repo_url: str + versions: list[str] = field(default_factory=list) + description: Optional[str] = None + latest: Optional[str] = None + version_scheme: str = "semver" + tags: list[str] = field(default_factory=list) + + +@dataclass +class VersionMetadata: + org: str + name: str + version: str + sha256: str + size: int + format: str + vcs_tag: str + download_url: str + published_at: str + yanked: bool = False + vcs_commit: Optional[str] = None + + +@dataclass +class ClaimOrgResponse: + slug: str + created: bool + + +@dataclass +class YankResponse: + org: str + name: str + version: str + yanked: bool + + +@dataclass +class PublishResponse: + org: str + name: str + version: str + sha256: str + + +def _from_wire(cls: type, data: dict) -> Any: + known = {item.name for item in fields(cls)} + return cls(**{key: value for key, value in data.items() if key in known}) + + +class ZedClient: + def __init__( + self, + registry_url: str = DEFAULT_REGISTRY_URL, + token: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + opener: Optional[urllib.request.OpenerDirector] = None, + ) -> None: + self.base = _normalize_registry_url(registry_url) + self.token = token.strip() if token and token.strip() else None + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be a positive finite number") + self.timeout = timeout + self._opener = opener or urllib.request.build_opener(_NoRedirectHandler()) + + def __repr__(self) -> str: + return f"ZedClient(base={self.base!r}, token='[REDACTED]')" + + def _require_token(self) -> str: + if self.token is None: + raise ZedApiError( + 0, + "missing_token", + "authenticated registry operation requires a nonblank bearer token", + ) + return self.token + + def _allowed_download_url(self, raw: str) -> str: + parsed = urllib.parse.urlsplit(raw) + if parsed.username is not None or parsed.password is not None or parsed.fragment: + raise ZedApiError(0, "bad_download_url", "download URL is invalid") + if parsed.scheme not in {"http", "https"}: + raise ZedApiError( + 0, + "insecure_download_url", + f"refusing download over {parsed.scheme!r}", + ) + if not parsed.hostname: + raise ZedApiError(0, "bad_download_url", "download URL is invalid") + loopback = _is_loopback_host(parsed.hostname) + if parsed.scheme == "https": + return raw + if parsed.scheme == "http" and (loopback or self.base.startswith("http://")): + return raw + raise ZedApiError(0, "insecure_download_url", f"refusing download over {parsed.scheme!r}") + + def _headers(self, *, authorized: bool) -> dict[str, str]: + headers = {"user-agent": USER_AGENT, "accept": "application/json"} + if authorized: + headers["authorization"] = f"Bearer {self._require_token()}" + return headers + + def _open(self, request: urllib.request.Request): + return self._opener.open(request, timeout=self.timeout) + + def _request( + self, + path: str, + method: str = "GET", + body: Any = None, + data: Optional[bytes] = None, + content_type: Optional[str] = None, + *, + authorized: bool = False, + ) -> Any: + headers = self._headers(authorized=authorized) + if body is not None: + data = json.dumps(body).encode() + headers["content-type"] = "application/json" + elif content_type is not None: + headers["content-type"] = content_type + request = urllib.request.Request( + f"{self.base}{path}", + data=data, + method=method, + headers=headers, + ) + try: + with self._open(request) as response: + payload = _read_bounded( + response, + MAX_JSON_RESPONSE_BYTES, + fail_on_overflow=True, + ) + except urllib.error.HTTPError as error: + raise _api_error(error) from None + try: + return json.loads(payload.decode()) + except (UnicodeDecodeError, ValueError) as error: + raise ZedApiError(0, "invalid_response", f"invalid registry JSON: {error}") from None + + def get_package(self, org: str, name: str) -> PackageMetadata: + return _from_wire(PackageMetadata, self._request(package_path(org, name))) + + def get_version(self, org: str, name: str, version: str) -> VersionMetadata: + return _from_wire(VersionMetadata, self._request(version_path(org, name, version))) + + def search(self, query: str) -> list[PackageSummary]: + data = self._request(f"/v1/search?q={urllib.parse.quote(query)}") + return [_from_wire(PackageSummary, item) for item in data.get("items", [])] + + def claim_org(self, slug: str) -> ClaimOrgResponse: + checked_slug = _require_text(slug, "slug") + return _from_wire( + ClaimOrgResponse, + self._request( + "/v1/orgs", + method="POST", + body={"slug": checked_slug}, + authorized=True, + ), + ) + + def set_yanked(self, org: str, name: str, version: str, yanked: bool) -> YankResponse: + return _from_wire( + YankResponse, + self._request( + yank_path(org, name, version), + method="POST", + body={"yanked": yanked}, + authorized=True, + ), + ) + + def yank( + self, + org: str, + name: str, + version: str, + yanked: bool = True, + ) -> YankResponse: + return self.set_yanked(org, name, version, yanked) + + def restore(self, org: str, name: str, version: str) -> YankResponse: + return self.set_yanked(org, name, version, False) + + def download_artifact(self, version: VersionMetadata, dest_path: str) -> None: + raw = version.download_url.strip() + if not raw: + url = f"{self.base}{artifact_path(version.sha256)}" + elif urllib.parse.urlsplit(raw).scheme: + url = self._allowed_download_url(raw) + else: + url = self._allowed_download_url(urllib.parse.urljoin(f"{self.base}/", raw)) + + request = urllib.request.Request(url, headers={"user-agent": USER_AGENT}) + limit = _download_limit(version.size) + try: + with self._open(request) as response: + declared = response.headers.get("Content-Length") + if declared is not None: + try: + if int(declared) > limit: + raise ZedApiError( + 0, + "artifact_too_large", + f"artifact exceeded {limit} bytes", + ) + except ValueError: + pass + payload = response.read(limit + 1) + except urllib.error.HTTPError as error: + raise _api_error(error) from None + if len(payload) > limit: + raise ZedApiError(0, "artifact_too_large", f"artifact exceeded {limit} bytes") + actual = hashlib.sha256(payload).hexdigest() + if actual.casefold() != version.sha256.casefold(): + raise ZedApiError(0, "sha256_mismatch", f"expected {version.sha256}, got {actual}") + + destination = os.path.abspath(os.fspath(dest_path)) + parent = os.path.dirname(destination) + os.makedirs(parent, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".zed-artifact-", dir=parent) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o644) + os.replace(temporary, destination) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + def publish( + self, + org: str, + name: str, + version: str, + meta: Any, + artifact: bytes, + ) -> PublishResponse: + self._require_token() + checked_org = _require_text(org, "org") + checked_name = _require_text(name, "name") + checked_version = _require_text(version, "version") + if len(artifact) > MAX_ARTIFACT_BYTES: + raise ZedApiError( + 0, + "artifact_too_large", + f"artifact exceeded {MAX_ARTIFACT_BYTES} bytes", + ) + + try: + parsed_meta = json.loads(meta) if isinstance(meta, str) else meta + package = parsed_meta["manifest"]["package"] + coordinate = (package["org"], package["name"], package["version"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise ZedApiError( + 0, + "invalid_publish_meta", + f"meta.manifest.package must contain org, name, and version: {error}", + ) from None + if coordinate != (checked_org, checked_name, checked_version): + raise ZedApiError( + 0, + "publish_coordinate_mismatch", + "publish route and meta.manifest.package coordinates differ", + ) + + meta_json = meta if isinstance(meta, str) else json.dumps(meta) + boundary = uuid.uuid4().hex + multipart = b"".join( + [ + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="meta"\r\n' + "Content-Type: application/json\r\n" + "\r\n" + f"{meta_json}\r\n" + ).encode(), + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="artifact"; filename="artifact.tar.gz"\r\n' + "Content-Type: application/octet-stream\r\n" + "\r\n" + ).encode() + + artifact + + b"\r\n", + f"--{boundary}--\r\n".encode(), + ] + ) + data = self._request( + version_path(checked_org, checked_name, checked_version), + method="PUT", + data=multipart, + content_type=f"multipart/form-data; boundary={boundary}", + authorized=True, + ) + return _from_wire(PublishResponse, data) diff --git a/clients/ruby/README.md b/clients/ruby/README.md new file mode 100644 index 0000000..a2fb9aa --- /dev/null +++ b/clients/ruby/README.md @@ -0,0 +1,9 @@ +# zed_pkg_client for Ruby + +A dependency-free Ruby 3 client for the core zed-pkg registry lifecycle. + +```ruby +require "zed_pkg_client" +client = ZedPkg::Client.new(token: ENV["ZED_TOKEN"]) +puts client.package("zed-pkg", "zed-clients") +``` diff --git a/clients/ruby/lib/zed_pkg_client.rb b/clients/ruby/lib/zed_pkg_client.rb new file mode 100644 index 0000000..bf7f5f1 --- /dev/null +++ b/clients/ruby/lib/zed_pkg_client.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "digest" +require "json" +require "net/http" +require "securerandom" +require "uri" + +module ZedPkg + class Error < StandardError + attr_reader :status, :code, :registry_message + + def initialize(status, code, registry_message) + @status = status + @code = code + @registry_message = registry_message.to_s.byteslice(0, 16_384) + super("registry error #{status}: #{code}") + end + end + + class Client + DEFAULT_REGISTRY_URL = "https://registry.zpkg.tech" + MAX_RESPONSE_BYTES = 16 * 1024 * 1024 + MAX_ARTIFACT_BYTES = 100 * 1024 * 1024 + + def initialize(registry_url: DEFAULT_REGISTRY_URL, token: nil, open_timeout: 10, read_timeout: 30) + @base = normalize_base(registry_url) + @token = token + @open_timeout = open_timeout + @read_timeout = read_timeout + end + + def package(org, name) + json_request(:get, "/v1/packages/#{segment(org)}/#{segment(name)}") + end + + def version(org, name, version) + json_request(:get, "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}") + end + + def search(query) + json_request(:get, "/v1/search?q=#{URI.encode_www_form_component(query.to_s)}") + end + + def claim_org(org) + json_request(:post, "/v1/orgs", body: JSON.generate(org: org), auth: true) + end + + def set_yanked(org, name, version, yanked:, reason: nil) + body = { yanked: !!yanked } + body[:reason] = reason unless reason.nil? + json_request( + :post, + "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}/yank", + body: JSON.generate(body), + auth: true + ) + end + + def yank(org, name, version, reason: nil) + set_yanked(org, name, version, yanked: true, reason: reason) + end + + def restore(org, name, version) + set_yanked(org, name, version, yanked: false) + end + + def publish(org, name, version, artifact_path:, metadata:) + boundary = "zed-#{SecureRandom.hex(18)}" + artifact = File.binread(artifact_path) + meta = JSON.generate(metadata) + body = multipart(boundary, meta, File.basename(artifact_path), artifact) + response = request( + :put, + "/v1/packages/#{segment(org)}/#{segment(name)}/versions/#{segment(version)}", + body: body, + content_type: "multipart/form-data; boundary=#{boundary}", + auth: true + ) + parse_json(response) + end + + def download_artifact(sha256, destination: nil) + expected = sha256.to_s.downcase + raise ArgumentError, "sha256 must be 64 lowercase hex characters" unless expected.match?(/\A[0-9a-f]{64}\z/) + + response = request(:get, "/v1/artifacts/#{expected}", auth: false, limit: MAX_ARTIFACT_BYTES) + actual = Digest::SHA256.hexdigest(response.body) + raise Error.new(0, "digest_mismatch", "expected #{expected}, got #{actual}") unless actual == expected + + File.binwrite(destination, response.body) if destination + response.body + end + + private + + def normalize_base(raw) + uri = URI.parse(raw.to_s.strip) + valid = %w[http https].include?(uri.scheme) && uri.host && !uri.user && !uri.query && !uri.fragment + raise ArgumentError, "registry_url must be a credential-free absolute HTTP(S) URL" unless valid + + uri.path = uri.path.to_s.sub(%r{/+\z}, "") + uri.to_s.sub(%r{/+\z}, "") + rescue URI::InvalidURIError + raise ArgumentError, "registry_url must be a credential-free absolute HTTP(S) URL" + end + + def segment(value) + text = value.to_s + raise ArgumentError, "path segment must be nonblank" if text.strip.empty? || [".", ".."].include?(text) + raise ArgumentError, "path segment is too long" if text.bytesize > 256 + raise ArgumentError, "path segment contains a control character" if text.match?(/[\x00-\x1f\x7f]/) + + URI.encode_www_form_component(text).gsub("+", "%20") + end + + def json_request(method, path, body: nil, auth: false) + parse_json(request(method, path, body: body, content_type: body ? "application/json" : nil, auth: auth)) + end + + def request(method, path, body: nil, content_type: nil, auth: false, limit: MAX_RESPONSE_BYTES) + uri = URI.parse("#{@base}#{path}") + klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put }.fetch(method) + req = klass.new(uri) + req["Accept"] = "application/json" + req["Content-Type"] = content_type if content_type + req["Authorization"] = "Bearer #{@token}" if auth && @token && !@token.empty? + req.body = body if body + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @open_timeout + http.read_timeout = @read_timeout + response = http.request(req) + bytes = response.body.to_s.b + raise Error.new(0, "response_too_large", "response exceeded #{limit} bytes") if bytes.bytesize > limit + unless response.code.to_i.between?(200, 299) + payload = JSON.parse(bytes) rescue {} + raise Error.new(response.code.to_i, payload["code"] || "http_error", payload["message"] || bytes) + end + response.body = bytes + response + end + + def parse_json(response) + return {} if response.body.nil? || response.body.empty? + JSON.parse(response.body) + rescue JSON::ParserError => e + raise Error.new(0, "invalid_json", e.message) + end + + def multipart(boundary, meta, filename, artifact) + crlf = "\r\n" + [ + "--#{boundary}#{crlf}", + "Content-Disposition: form-data; name=\"meta\"#{crlf}", + "Content-Type: application/json#{crlf}#{crlf}", + meta, + crlf, + "--#{boundary}#{crlf}", + "Content-Disposition: form-data; name=\"artifact\"; filename=\"#{filename.gsub(/[\r\n\"]/, "_")}\"#{crlf}", + "Content-Type: application/octet-stream#{crlf}#{crlf}", + artifact, + crlf, + "--#{boundary}--#{crlf}" + ].join.b + end + end +end diff --git a/clients/ruby/test/client_test.rb b/clients/ruby/test/client_test.rb new file mode 100644 index 0000000..90ebd01 --- /dev/null +++ b/clients/ruby/test/client_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/zed_pkg_client" + +class ZedPkgClientTest < Minitest::Test + def test_rejects_credentials_in_registry_url + assert_raises(ArgumentError) { ZedPkg::Client.new(registry_url: "https://u:p@example.com") } + end + + def test_accepts_https_registry_url + assert_instance_of ZedPkg::Client, ZedPkg::Client.new(registry_url: "https://example.com/api/") + end +end diff --git a/clients/ruby/zed_pkg_client.gemspec b/clients/ruby/zed_pkg_client.gemspec new file mode 100644 index 0000000..79ffb1e --- /dev/null +++ b/clients/ruby/zed_pkg_client.gemspec @@ -0,0 +1,10 @@ +Gem::Specification.new do |spec| + spec.name = "zed_pkg_client" + spec.version = "0.1.0" + spec.summary = "Ruby client for the zed-pkg registry" + spec.authors = ["zed-pkg contributors"] + spec.license = "MIT" + spec.required_ruby_version = ">= 3.1" + spec.files = Dir["lib/**/*.rb", "README.md"] + spec.require_paths = ["lib"] +end diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock new file mode 100644 index 0000000..2d6edfe --- /dev/null +++ b/clients/rust/Cargo.lock @@ -0,0 +1,1544 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +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 = "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[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 = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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 = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[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", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zed-client" +version = "0.1.0" +dependencies = [ + "hex", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "zed-interfaces", +] + +[[package]] +name = "zed-interfaces" +version = "0.1.0" +dependencies = [ + "schemars", + "semver", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml new file mode 100644 index 0000000..4617d48 --- /dev/null +++ b/clients/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "zed-client" +version = "0.1.0" +edition = "2024" +description = "Rust SDK for the zed-pkg registry" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-clients" + +[dependencies] +zed-interfaces = { path = "../../../zed-interfaces" } +hex = "0.4" +percent-encoding = "2" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } +serde = "1" +serde_json = "1" +sha2 = "0.10" +thiserror = "2" diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs new file mode 100644 index 0000000..ec3daea --- /dev/null +++ b/clients/rust/src/lib.rs @@ -0,0 +1,764 @@ +//! Rust SDK for the zed-pkg registry. Reuses the DTOs and URL helpers from +//! `zed-interfaces`, so wire models and paths stay aligned with the contract. + +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; +use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; +use zed_interfaces::registry::{ + self, ApiError, ClaimOrgRequest, ClaimOrgResponse, PackageMetadata, PublishMeta, + PublishResponse, SearchResponse, VersionMetadata, YankRequest, YankResponse, +}; + +const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const MAX_ARTIFACT_BYTES: u64 = 100 * 1024 * 1024; +const DOWNLOAD_SLACK: u64 = 1024 * 1024; +const MAX_SEGMENT_BYTES: usize = 256; +pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_ERROR_BODY_BYTES: u64 = 16 * 1024; + +fn invalid_input(message: impl Into) -> Error { + Error::InvalidInput(message.into()) +} + +fn validate_segment<'a>(segment: &'a str, name: &str) -> Result<&'a str> { + if segment.trim().is_empty() { + return Err(invalid_input(format!("{name} must not be blank"))); + } + if matches!(segment, "." | "..") { + return Err(invalid_input(format!("{name} must not be a dot segment"))); + } + if segment.len() > MAX_SEGMENT_BYTES { + return Err(invalid_input(format!( + "{name} exceeds {MAX_SEGMENT_BYTES} UTF-8 bytes" + ))); + } + if segment.chars().any(char::is_control) { + return Err(invalid_input(format!( + "{name} must not contain control characters" + ))); + } + Ok(segment) +} + +fn encode_segment(segment: &str) -> String { + match segment { + "." => "%2E".to_string(), + ".." => "%2E%2E".to_string(), + _ => utf8_percent_encode(segment, SEGMENT).to_string(), + } +} + +fn checked_segment(segment: &str, name: &str) -> Result { + validate_segment(segment, name)?; + Ok(encode_segment(segment)) +} + +fn validate_path_segments(raw_path: &str, name: &str) -> Result<()> { + for (index, encoded) in raw_path + .split('/') + .filter(|segment| !segment.is_empty()) + .enumerate() + { + let decoded = percent_decode_str(encoded) + .decode_utf8() + .map_err(|_| invalid_input(format!("{name} contains invalid percent encoding")))?; + validate_segment(&decoded, &format!("{name} segment {}", index + 1))?; + if decoded.contains('/') || decoded.contains('\\') { + return Err(invalid_input(format!( + "{name} segments must not contain encoded separators" + ))); + } + } + Ok(()) +} + +fn validate_raw_base_path(raw: &str) -> Result<()> { + let scheme_end = raw.find("://").ok_or(Error::InvalidBaseUrl)?; + let authority_start = scheme_end + 3; + let Some(relative_path_start) = raw[authority_start..].find('/') else { + return Ok(()); + }; + let path_start = authority_start + relative_path_start; + let path_end = raw[path_start..] + .find(['?', '#']) + .map_or(raw.len(), |offset| path_start + offset); + validate_path_segments(&raw[path_start..path_end], "registry path") + .map_err(|_| Error::InvalidBaseUrl) +} + +fn validate_relative_download_path(raw: &str) -> Result<()> { + if raw.starts_with('/') || raw.starts_with('\\') { + return Err(invalid_input( + "relative download URL must not replace the registry authority or gateway path", + )); + } + let path_end = raw.find(['?', '#']).unwrap_or(raw.len()); + validate_path_segments(&raw[..path_end], "download URL") +} + +fn download_limit(size: u64) -> u64 { + if size > 0 { + size.saturating_add(DOWNLOAD_SLACK).min(MAX_ARTIFACT_BYTES) + } else { + MAX_ARTIFACT_BYTES + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// `message` is bounded and explicitly inspectable; the Display output + /// intentionally excludes arbitrary remote text. + #[error("registry error {status}: {code}")] + Api { + status: u16, + code: String, + message: String, + }, + #[error("invalid registry base URL")] + InvalidBaseUrl, + #[error("invalid client input: {0}")] + InvalidInput(String), + #[error("authenticated registry operation requires a nonblank bearer token")] + MissingToken, + #[error("{what} exceeded {limit} bytes")] + ResponseTooLarge { what: String, limit: u64 }, + #[error("artifact exceeded {limit} bytes")] + ArtifactTooLarge { limit: u64 }, + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("artifact sha256 mismatch: expected {expected}, got {actual}")] + Sha256Mismatch { expected: String, actual: String }, + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +pub struct Client { + base: String, + token: Option, + max_response_bytes: u64, + http: reqwest::blocking::Client, +} + +impl fmt::Debug for Client { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Client") + .field("base", &self.base) + .field("token", &self.token.as_ref().map(|_| "[REDACTED]")) + .field("max_response_bytes", &self.max_response_bytes) + .finish_non_exhaustive() + } +} + +impl Client { + pub fn new(base_url: impl Into) -> Result { + let raw = base_url.into(); + let trimmed = raw.trim(); + validate_raw_base_path(trimmed)?; + let mut parsed = reqwest::Url::parse(trimmed).map_err(|_| Error::InvalidBaseUrl)?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Error::InvalidBaseUrl); + } + let path = parsed.path().trim_end_matches('/').to_string(); + parsed.set_path(&path); + let base = parsed.as_str().trim_end_matches('/').to_string(); + + Ok(Self { + base, + token: None, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + http: reqwest::blocking::Client::builder() + .user_agent(concat!("zed-client-rust/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(DEFAULT_TIMEOUT) + .timeout(DEFAULT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + }) + } + + #[must_use] + pub fn with_token(mut self, token: impl Into) -> Self { + let token = token.into(); + self.token = (!token.trim().is_empty()).then(|| token.trim().to_string()); + self + } + + #[must_use] + pub fn with_max_response_bytes(mut self, limit: u64) -> Self { + self.max_response_bytes = limit.max(1); + self + } + + fn require_token(&self) -> Result<&str> { + let token = self.token.as_deref().ok_or(Error::MissingToken)?; + if token.chars().any(char::is_control) { + return Err(invalid_input("token must not contain control characters")); + } + Ok(token) + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + fn read_capped( + response: reqwest::blocking::Response, + limit: u64, + what: &str, + ) -> Result> { + let too_large = || Error::ResponseTooLarge { + what: what.to_string(), + limit, + }; + if response + .content_length() + .is_some_and(|length| length > limit) + { + return Err(too_large()); + } + let mut bytes = Vec::new(); + response + .take(limit.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(too_large()); + } + Ok(bytes) + } + + fn check(&self, response: reqwest::blocking::Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let limit = self.max_response_bytes.min(MAX_ERROR_BODY_BYTES); + let body = match Self::read_capped(response, limit, "registry error body") { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(Error::ResponseTooLarge { .. }) => { + return Err(Error::Api { + status, + code: format!("http_{status}"), + message: "registry error body exceeded the client limit".to_string(), + }); + } + Err(error) => return Err(error), + }; + match serde_json::from_str::(&body) { + Ok(error) => { + let code = if error.code.trim().is_empty() { + format!("http_{status}") + } else { + error.code.trim().to_string() + }; + Err(Error::Api { + status, + code, + message: error.message, + }) + } + Err(_) => Err(Error::Api { + status, + code: format!("http_{status}"), + message: body, + }), + } + } + + fn json(&self, response: reqwest::blocking::Response) -> Result { + let response = self.check(response)?; + let bytes = Self::read_capped(response, self.max_response_bytes, "registry response")?; + serde_json::from_slice(&bytes) + .map_err(|error| Error::Other(format!("invalid registry response: {error}"))) + } + + pub fn get_package(&self, org: &str, name: &str) -> Result { + let path = registry::package_path( + &checked_segment(org, "org")?, + &checked_segment(name, "name")?, + ); + let response = self.http.get(self.url(&path)).send()?; + self.json(response) + } + + pub fn get_version(&self, org: &str, name: &str, version: &str) -> Result { + let path = registry::version_path( + &checked_segment(org, "org")?, + &checked_segment(name, "name")?, + &checked_segment(version, "version")?, + ); + let response = self.http.get(self.url(&path)).send()?; + self.json(response) + } + + pub fn search(&self, query: &str) -> Result { + let response = self + .http + .get(self.url(®istry::search_path())) + .query(&[("q", query)]) + .send()?; + self.json(response) + } + + pub fn claim_org(&self, slug: &str) -> Result { + validate_segment(slug, "slug")?; + let token = self.require_token()?; + let response = self + .http + .post(self.url(®istry::orgs_path())) + .bearer_auth(token) + .json(&ClaimOrgRequest { + slug: slug.to_string(), + }) + .send()?; + self.json(response) + } + + pub fn set_yanked( + &self, + org: &str, + name: &str, + version: &str, + yanked: bool, + ) -> Result { + let path = registry::yank_path( + &checked_segment(org, "org")?, + &checked_segment(name, "name")?, + &checked_segment(version, "version")?, + ); + let token = self.require_token()?; + let response = self + .http + .post(self.url(&path)) + .bearer_auth(token) + .json(&YankRequest { yanked }) + .send()?; + self.json(response) + } + + /// Compatibility form retained for existing callers. + pub fn yank(&self, org: &str, name: &str, version: &str, yanked: bool) -> Result { + self.set_yanked(org, name, version, yanked) + } + + pub fn restore(&self, org: &str, name: &str, version: &str) -> Result { + self.set_yanked(org, name, version, false) + } + + fn allowed_download_url(&self, raw: &str) -> Result { + let url = reqwest::Url::parse(raw) + .map_err(|_| Error::Other("download URL is invalid".to_string()))?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(Error::Other( + "download URL contains credentials or fragment".to_string(), + )); + } + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(Error::Other(format!( + "refusing artifact download over `{}`", + url.scheme() + ))); + } + let loopback = matches!(url.host_str(), Some("localhost")) + || url + .host_str() + .and_then(|host| host.parse::().ok()) + .is_some_and(|ip| ip.is_loopback()); + match url.scheme() { + "https" => Ok(url), + "http" if loopback || self.base.starts_with("http://") => Ok(url), + scheme => Err(Error::Other(format!( + "refusing artifact download over `{scheme}`" + ))), + } + } + + fn resolve_download_url(&self, raw: &str, sha256: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + let sha256 = checked_segment(sha256, "sha256")?; + return reqwest::Url::parse(&self.url(®istry::artifact_path(&sha256))) + .map_err(|_| Error::InvalidBaseUrl); + } + if let Ok(absolute) = reqwest::Url::parse(trimmed) { + return self.allowed_download_url(absolute.as_str()); + } + validate_relative_download_path(trimmed)?; + let base = + reqwest::Url::parse(&(self.base.clone() + "/")).map_err(|_| Error::InvalidBaseUrl)?; + let resolved = base + .join(trimmed) + .map_err(|_| Error::Other("relative download URL is invalid".to_string()))?; + self.allowed_download_url(resolved.as_str()) + } + + fn write_atomic(dest: &Path, bytes: &[u8]) -> Result<()> { + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let name = dest + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("artifact"); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temporary = parent.join(format!(".{name}.zed-{}-{nonce}.tmp", std::process::id())); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, dest)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } + + pub fn download_artifact(&self, version: &VersionMetadata, dest: &Path) -> Result<()> { + let url = self.resolve_download_url(&version.download_url, &version.sha256)?; + // The shared HTTP client refuses redirects. No bearer token is attached: + // download_url may be a third-party presigned URL. + let response = self.check(self.http.get(url).send()?)?; + let limit = download_limit(version.size); + let bytes = match Self::read_capped(response, limit, "artifact") { + Ok(bytes) => bytes, + Err(Error::ResponseTooLarge { .. }) => { + return Err(Error::ArtifactTooLarge { limit }); + } + Err(error) => return Err(error), + }; + verify_sha256(&bytes, &version.sha256)?; + Self::write_atomic(dest, &bytes) + } + + pub fn publish(&self, meta: &PublishMeta, artifact: &Path) -> Result { + let token = self.require_token()?; + let package = &meta.manifest.package; + validate_segment(&package.org, "meta.manifest.package.org")?; + validate_segment(&package.name, "meta.manifest.package.name")?; + validate_segment(&package.version, "meta.manifest.package.version")?; + let metadata = fs::metadata(artifact)?; + if !metadata.is_file() { + return Err(invalid_input("artifact must be a regular file")); + } + if metadata.len() > MAX_ARTIFACT_BYTES { + return Err(Error::ArtifactTooLarge { + limit: MAX_ARTIFACT_BYTES, + }); + } + let filename = safe_filename(&format!( + "{}-{}-{}.tar.gz", + package.org, package.name, package.version + )); + let part = reqwest::blocking::multipart::Part::file(artifact)?.file_name(filename); + let form = reqwest::blocking::multipart::Form::new() + .text( + registry::PUBLISH_META_FIELD, + serde_json::to_string(meta).map_err(|error| Error::Other(error.to_string()))?, + ) + .part(registry::PUBLISH_ARTIFACT_FIELD, part); + let response = self + .http + .put(self.url(®istry::version_path( + &checked_segment(&package.org, "meta.manifest.package.org")?, + &checked_segment(&package.name, "meta.manifest.package.name")?, + &checked_segment(&package.version, "meta.manifest.package.version")?, + ))) + .bearer_auth(token) + .multipart(form) + .send()?; + self.json(response) + } +} + +fn safe_filename(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + } + }) + .collect() +} + +pub fn verify_sha256(bytes: &[u8], expected: &str) -> Result<()> { + let actual = hex::encode(Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(Error::Sha256Mismatch { + expected: expected.to_string(), + actual, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha_verification_is_case_insensitive() { + let bytes = b"zed"; + let good = hex::encode(Sha256::digest(bytes)); + assert!(verify_sha256(bytes, &good.to_uppercase()).is_ok()); + assert!(matches!( + verify_sha256(bytes, "00"), + Err(Error::Sha256Mismatch { .. }) + )); + } + + #[test] + fn base_url_is_validated_and_token_is_redacted() { + let client = Client::new(" https://registry.zpkg.tech/gateway/// ") + .unwrap() + .with_token("very-secret"); + assert_eq!( + client.url(®istry::package_path("acme", "kit")), + "https://registry.zpkg.tech/gateway/v1/packages/acme/kit" + ); + let debug = format!("{client:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("very-secret")); + for invalid in [ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + "https://registry.test/../admin", + "https://registry.test/%2e%2e/admin", + "https://registry.test/a%2Fb", + ] { + assert!(Client::new(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn api_error_display_excludes_remote_message() { + let error = Error::Api { + status: 502, + code: "http_502".to_string(), + message: "provider-secret".to_string(), + }; + assert_eq!(error.to_string(), "registry error 502: http_502"); + assert!(!error.to_string().contains("provider-secret")); + } + + #[test] + fn hostile_segments_and_missing_tokens_fail_before_transport() { + let client = Client::new("http://127.0.0.1:9").unwrap(); + for value in ["", " ", ".", "..", "line\nbreak"] { + assert!(matches!( + client.get_package(value, "kit"), + Err(Error::InvalidInput(_)) + )); + } + assert_eq!(encode_segment(".."), "%2E%2E"); + assert!(matches!(client.claim_org("acme"), Err(Error::MissingToken))); + assert!(matches!( + client.yank("acme", "kit", "1.2.0", true), + Err(Error::MissingToken) + )); + assert!(matches!( + client.restore("acme", "kit", "1.2.0"), + Err(Error::MissingToken) + )); + + let unsafe_token = Client::new("http://127.0.0.1:9") + .unwrap() + .with_token("token\r\nheader"); + assert!(matches!( + unsafe_token.claim_org("acme"), + Err(Error::InvalidInput(_)) + )); + } + + #[test] + fn path_segments_are_percent_encoded() { + assert_eq!(encode_segment("1.2.0"), "1.2.0"); + assert_eq!( + encode_segment("release candidate/1"), + "release%20candidate%2F1" + ); + assert_eq!( + registry::version_path( + &encode_segment("acme"), + &encode_segment("kit"), + &encode_segment("v/2?x"), + ), + "/v1/packages/acme/kit/versions/v%2F2%3Fx" + ); + } + + #[test] + fn download_policy_and_limits() { + let client = Client::new("https://registry.zpkg.tech/gateway").unwrap(); + assert_eq!(download_limit(0), MAX_ARTIFACT_BYTES); + assert_eq!(download_limit(10), 10 + DOWNLOAD_SLACK); + for raw in ["http://evil.example/artifact", "file:///etc/passwd"] { + assert!(client.allowed_download_url(raw).is_err(), "accepted {raw}"); + } + assert!( + client + .allowed_download_url("http://127.0.0.1:8080/a") + .is_ok() + ); + assert!(client.allowed_download_url("https://cdn.example/a").is_ok()); + for invalid in [ + "../escape", + "%2e%2e/escape", + "a%2Fb", + "//evil.example/artifact", + "/absolute/artifact", + "\\authority-replacement", + ] { + assert!( + client.resolve_download_url(invalid, "abc").is_err(), + "accepted {invalid}" + ); + } + assert_eq!( + client + .resolve_download_url("artifacts/hash", "abc") + .unwrap() + .as_str(), + "https://registry.zpkg.tech/gateway/artifacts/hash" + ); + } +} + +#[cfg(test)] +mod download_tests { + use super::*; + use std::net::TcpListener; + use std::sync::mpsc; + + fn version(url: &str, sha256: &str, size: u64) -> VersionMetadata { + VersionMetadata { + org: "acme".into(), + name: "kit".into(), + version: "1.2.0".into(), + sha256: sha256.into(), + size, + format: Default::default(), + vcs_tag: "v1.2.0".into(), + vcs_commit: None, + download_url: url.into(), + published_at: "2024-01-01T00:00:00Z".into(), + yanked: false, + } + } + + fn spawn_server(body: Vec) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buffer = [0_u8; 4096]; + let mut request = Vec::new(); + loop { + let read = stream.read(&mut buffer).unwrap_or(0); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let _ = sender.send(String::from_utf8_lossy(&request).into_owned()); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); + } + }); + (format!("http://{addr}"), receiver) + } + + #[test] + fn download_omits_auth_resolves_relative_and_verifies_sha_atomically() { + let body = b"artifact-bytes".to_vec(); + let sha = hex::encode(Sha256::digest(&body)); + let (base, receiver) = spawn_server(body.clone()); + let client = Client::new(format!("{base}/gateway")) + .unwrap() + .with_token("zpkg_t"); + let directory = std::env::temp_dir().join(format!( + "zed-dl-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let destination = directory.join("artifact.tar.gz"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&destination, b"old").unwrap(); + let metadata = version("artifact", &sha.to_uppercase(), body.len() as u64); + client.download_artifact(&metadata, &destination).unwrap(); + let request = receiver.recv().unwrap(); + assert!( + request.starts_with("GET /gateway/artifact "), + "request={request}" + ); + assert!(!request.to_lowercase().contains("authorization")); + assert_eq!(fs::read(&destination).unwrap(), body); + assert!(fs::read_dir(&directory).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".zed-") + })); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn download_rejects_oversize_body() { + let limit = download_limit(1); + let body = vec![0_u8; (limit + 64) as usize]; + let (base, _receiver) = spawn_server(body); + let client = Client::new(&base).unwrap(); + let directory = std::env::temp_dir().join(format!("zed-dl-big-{}", std::process::id())); + let destination = directory.join("artifact.tar.gz"); + let metadata = version(&format!("{base}/artifact"), "deadbeef", 1); + let error = client + .download_artifact(&metadata, &destination) + .unwrap_err(); + assert!(matches!(error, Error::ArtifactTooLarge { .. })); + let _ = fs::remove_dir_all(&directory); + } +} diff --git a/clients/swift/Package.swift b/clients/swift/Package.swift new file mode 100644 index 0000000..00e933a --- /dev/null +++ b/clients/swift/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "ZedClient", + platforms: [ + .macOS(.v12), + .iOS(.v15), + .tvOS(.v15), + .watchOS(.v8), + ], + products: [ + .library(name: "ZedClient", targets: ["ZedClient"]), + ], + targets: [ + .target(name: "ZedClient"), + .testTarget(name: "ZedClientTests", dependencies: ["ZedClient"]), + ], + swiftLanguageVersions: [.v5] +) diff --git a/clients/swift/README.md b/clients/swift/README.md new file mode 100644 index 0000000..35e1729 --- /dev/null +++ b/clients/swift/README.md @@ -0,0 +1,48 @@ +# Zed Registry Swift client + +Swift 5.9 client for the stable zed-pkg registry lifecycle on Apple platforms +and Linux: + +- package and version metadata lookup; +- text search; +- organization claim; +- yank and restore; +- SHA-256-verified artifact download; +- multipart publication using JSON `meta` plus raw archive bytes. + +```swift +import ZedClient + +let client = try ZedClient( + registryURL: "https://registry.zpkg.tech", + token: ProcessInfo.processInfo.environment["ZED_TOKEN"] +) +let metadata = try await client.getPackage(org: "acme", name: "http-kit") +let version = try await client.getVersion( + org: "acme", + name: "http-kit", + version: "1.2.3" +) +let artifact = try await client.downloadArtifact(version) +_ = try await client.yank(org: "acme", name: "http-kit", version: "1.2.3") +_ = try await client.restore(org: "acme", name: "http-kit", version: "1.2.3") +``` + +The package uses `URLSession` with a redirect-blocking, bounded streaming +delegate. It validates credential-free HTTP(S) registry URLs, preserves gateway +prefixes, applies 30-second request/resource deadlines, percent-encodes path +segments, bounds JSON and error bodies, and keeps bearer credentials and remote +response text out of default diagnostics. + +Artifact requests never carry the registry bearer because `download_url` may be +a third-party presigned URL. HTTPS is required for remote artifact hosts; HTTP +is accepted only for loopback or when the configured development registry is +itself HTTP. Downloads are size-capped while streaming and verified with the +package's dependency-free SHA-256 implementation before bytes are returned. + +```sh +swift test --parallel +``` + +Newer package-listing, semantic-search, embedding, and organization-audit routes +are intentionally outside this core package and tracked separately. diff --git a/clients/swift/Sources/ZedClient/ZedClient.swift b/clients/swift/Sources/ZedClient/ZedClient.swift new file mode 100644 index 0000000..7b82d39 --- /dev/null +++ b/clients/swift/Sources/ZedClient/ZedClient.swift @@ -0,0 +1,887 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum JSONValue: Codable, Equatable, Sendable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .object(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .string(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .bool(value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public struct PackageSummary: Codable, Equatable, Sendable { + public let org: String + public let name: String + public let description: String? + public let latest: String? +} + +public struct PackageMetadata: Codable, Equatable, Sendable { + public let org: String + public let name: String + public let description: String? + public let vcs: String + public let repoURL: String + public let latest: String? + public let versions: [String] + public let versionScheme: String? + + enum CodingKeys: String, CodingKey { + case org, name, description, vcs, latest, versions + case repoURL = "repo_url" + case versionScheme = "version_scheme" + } +} + +public struct VersionMetadata: Codable, Equatable, Sendable { + public let org: String + public let name: String + public let version: String + public let sha256: String + public let size: Int64 + public let format: String + public let vcsTag: String + public let vcsCommit: String? + public let downloadURL: String + public let publishedAt: String + public let yanked: Bool + + enum CodingKeys: String, CodingKey { + case org, name, version, sha256, size, format, yanked + case vcsTag = "vcs_tag" + case vcsCommit = "vcs_commit" + case downloadURL = "download_url" + case publishedAt = "published_at" + } +} + +public struct SearchResponse: Codable, Equatable, Sendable { + public let query: String + public let items: [PackageSummary] +} + +public struct ClaimOrgResponse: Codable, Equatable, Sendable { + public let slug: String + public let created: Bool +} + +public struct PublishResponse: Codable, Equatable, Sendable { + public let org: String + public let name: String + public let version: String + public let sha256: String +} + +public struct YankResponse: Codable, Equatable, Sendable { + public let org: String + public let name: String + public let version: String + public let yanked: Bool +} + +public enum ZedClientError: Error, Sendable, Equatable, LocalizedError { + case invalidConfiguration(String) + case missingToken + case invalidResponse + case api(status: Int, code: String, body: String) + case responseTooLarge(limit: Int) + case sha256Mismatch(expected: String, actual: String) + case decode(String) + + public var errorDescription: String? { + switch self { + case let .invalidConfiguration(message): + return "Invalid Zed client configuration: \(message)" + case .missingToken: + return "Authenticated registry operation requires a bearer token" + case .invalidResponse: + return "Registry returned a non-HTTP response" + case let .api(status, code, _): + return "Registry returned HTTP \(status): \(code)" + case let .responseTooLarge(limit): + return "Registry response exceeded \(limit) bytes" + case .sha256Mismatch: + return "Artifact SHA-256 mismatch" + case let .decode(message): + return "Registry response decode error: \(message)" + } + } +} + +private struct ErrorEnvelope: Decodable { + let code: String? +} + +private final class BoundedDataDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + typealias ResultValue = Result<(Data, HTTPURLResponse), Error> + + let successLimit: Int + let errorLimit: Int + var completion: ((ResultValue) -> Void)? + + private var body = Data() + private var response: HTTPURLResponse? + private var completed = false + + init(successLimit: Int, errorLimit: Int) { + self.successLimit = successLimit + self.errorLimit = errorLimit + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard let http = response as? HTTPURLResponse else { + completionHandler(.cancel) + finish(.failure(ZedClientError.invalidResponse)) + return + } + self.response = http + let successful = (200..<300).contains(http.statusCode) + if successful, + response.expectedContentLength >= 0, + response.expectedContentLength > Int64(successLimit) + { + completionHandler(.cancel) + finish(.failure(ZedClientError.responseTooLarge(limit: successLimit))) + return + } + completionHandler(.allow) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive chunk: Data + ) { + guard !completed, let response else { return } + let successful = (200..<300).contains(response.statusCode) + let limit = successful ? successLimit : errorLimit + let remaining = max(0, limit - body.count) + if chunk.count <= remaining { + body.append(chunk) + return + } + + if remaining > 0 { + body.append(chunk.prefix(remaining)) + } + dataTask.cancel() + if successful { + finish(.failure(ZedClientError.responseTooLarge(limit: limit))) + } else { + finish(.success((body, response))) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard !completed else { return } + if let error { + finish(.failure(error)) + } else if let response { + finish(.success((body, response))) + } else { + finish(.failure(ZedClientError.invalidResponse)) + } + } + + private func finish(_ result: ResultValue) { + guard !completed else { return } + completed = true + let callback = completion + completion = nil + callback?(result) + } +} + +public final class ZedClient: @unchecked Sendable { + public static let defaultRegistryURL = "https://registry.zpkg.tech" + public static let maxArtifactBytes = 100 * 1024 * 1024 + + static let maxJSONBytes = 2 * 1024 * 1024 + static let maxErrorBytes = 16 * 1024 + private static let downloadSlackBytes = 1024 * 1024 + private static let timeout: TimeInterval = 30 + private static let maxSegmentBytes = 256 + + let baseURL: URL + private let token: String? + private let protocolClasses: [AnyClass]? + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + registryURL: String = ZedClient.defaultRegistryURL, + token: String? = nil + ) throws { + self.baseURL = try Self.validateBaseURL(registryURL) + self.token = Self.normalizeOptional(token) + self.protocolClasses = nil + } + + init( + registryURL: String, + token: String?, + protocolClasses: [AnyClass]? + ) throws { + self.baseURL = try Self.validateBaseURL(registryURL) + self.token = Self.normalizeOptional(token) + self.protocolClasses = protocolClasses + } + + public func getPackage(org: String, name: String) async throws -> PackageMetadata { + try await requestJSON( + PackageMetadata.self, + method: "GET", + path: try Self.packagePath(org: org, name: name) + ) + } + + public func getVersion( + org: String, + name: String, + version: String + ) async throws -> VersionMetadata { + try await requestJSON( + VersionMetadata.self, + method: "GET", + path: try Self.versionPath(org: org, name: name, version: version) + ) + } + + public func search(query: String) async throws -> SearchResponse { + var components = URLComponents() + components.queryItems = [URLQueryItem(name: "q", value: query)] + let encoded = components.percentEncodedQuery ?? "q=" + return try await requestJSON( + SearchResponse.self, + method: "GET", + path: "/v1/search?\(encoded)" + ) + } + + public func claimOrg(slug: String) async throws -> ClaimOrgResponse { + let body = JSONValue.object([ + "slug": .string(try Self.requireText(slug, name: "slug")), + ]) + return try await requestJSON( + ClaimOrgResponse.self, + method: "POST", + path: "/v1/orgs", + authenticated: true, + body: encoder.encode(body), + contentType: "application/json" + ) + } + + public func setYanked( + org: String, + name: String, + version: String, + yanked: Bool + ) async throws -> YankResponse { + try await requestJSON( + YankResponse.self, + method: "POST", + path: try Self.yankPath(org: org, name: name, version: version), + authenticated: true, + body: encoder.encode(JSONValue.object(["yanked": .bool(yanked)])), + contentType: "application/json" + ) + } + + public func yank(org: String, name: String, version: String) async throws -> YankResponse { + try await setYanked(org: org, name: name, version: version, yanked: true) + } + + public func restore(org: String, name: String, version: String) async throws -> YankResponse { + try await setYanked(org: org, name: name, version: version, yanked: false) + } + + /// Downloads and verifies an artifact without attaching the registry bearer. + public func downloadArtifact(_ version: VersionMetadata) async throws -> Data { + let url = try downloadURL(version) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeout + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + + let limit = Self.downloadLimit(version.size) + let (artifact, response) = try await boundedData( + for: request, + successLimit: limit, + errorLimit: Self.maxErrorBytes + ) + guard (200..<300).contains(response.statusCode) else { + throw apiError(status: response.statusCode, data: artifact) + } + let actual = SHA256.hexDigest(artifact) + guard actual.caseInsensitiveCompare(version.sha256) == .orderedSame else { + throw ZedClientError.sha256Mismatch(expected: version.sha256, actual: actual) + } + return artifact + } + + /// Publishes raw archive bytes as multipart `meta` and `artifact` fields. + public func publish(meta: JSONValue, artifact: Data) async throws -> PublishResponse { + guard artifact.count <= Self.maxArtifactBytes else { + throw ZedClientError.responseTooLarge(limit: Self.maxArtifactBytes) + } + let coordinate = try Self.packageCoordinate(meta) + let boundary = "zed-\(UUID().uuidString)" + let body = try multipartBody( + boundary: boundary, + meta: meta, + artifact: artifact, + coordinate: coordinate + ) + return try await requestJSON( + PublishResponse.self, + method: "PUT", + path: try Self.versionPath( + org: coordinate.org, + name: coordinate.name, + version: coordinate.version + ), + authenticated: true, + body: body, + contentType: "multipart/form-data; boundary=\(boundary)" + ) + } + + public var debugDescription: String { + "ZedClient(baseURL: \(baseURL.absoluteString), token: [REDACTED])" + } + + private func requestJSON( + _ type: T.Type, + method: String, + path: String, + authenticated: Bool = false, + body: Data? = nil, + contentType: String? = nil + ) async throws -> T { + var request = URLRequest(url: try url(path: path)) + request.httpMethod = method + request.timeoutInterval = Self.timeout + request.setValue("application/json", forHTTPHeaderField: "Accept") + if authenticated { + request.setValue("Bearer \(try requireToken())", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = body + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await boundedData( + for: request, + successLimit: Self.maxJSONBytes, + errorLimit: Self.maxErrorBytes + ) + guard (200..<300).contains(response.statusCode) else { + throw apiError(status: response.statusCode, data: data) + } + do { + return try decoder.decode(type, from: data) + } catch { + throw ZedClientError.decode(String(describing: error)) + } + } + + private func boundedData( + for request: URLRequest, + successLimit: Int, + errorLimit: Int + ) async throws -> (Data, HTTPURLResponse) { + try await withCheckedThrowingContinuation { continuation in + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = Self.timeout + configuration.timeoutIntervalForResource = Self.timeout + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.protocolClasses = protocolClasses + + let delegate = BoundedDataDelegate( + successLimit: successLimit, + errorLimit: errorLimit + ) + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + let session = URLSession( + configuration: configuration, + delegate: delegate, + delegateQueue: queue + ) + delegate.completion = { result in + session.finishTasksAndInvalidate() + continuation.resume(with: result) + } + session.dataTask(with: request).resume() + } + } + + private func apiError(status: Int, data: Data) -> ZedClientError { + let body = String(decoding: data, as: UTF8.self) + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) + let code = envelope + .flatMap { Self.normalizeOptional($0.code) } + ?? "http_\(status)" + return .api(status: status, code: code, body: body) + } + + private func requireToken() throws -> String { + guard let token else { throw ZedClientError.missingToken } + return token + } + + private func url(path: String) throws -> URL { + guard let value = URL(string: baseURL.absoluteString + path) else { + throw ZedClientError.invalidConfiguration("request URL is invalid") + } + return value + } + + private func downloadURL(_ version: VersionMetadata) throws -> URL { + guard let raw = Self.normalizeOptional(version.downloadURL) else { + return try url(path: try Self.artifactPath(version.sha256)) + } + + let candidate: URL + if raw.contains("://") { + guard let absolute = URL(string: raw) else { + throw ZedClientError.invalidConfiguration("download_url is invalid") + } + candidate = absolute + } else { + try Self.validateRelativeDownloadPath(raw) + guard let base = URL(string: baseURL.absoluteString + "/"), + let relative = URL(string: raw, relativeTo: base)?.absoluteURL + else { + throw ZedClientError.invalidConfiguration("download_url is invalid") + } + candidate = relative + } + + guard let components = URLComponents( + url: candidate, + resolvingAgainstBaseURL: false + ), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + !host.isEmpty, + components.user == nil, + components.password == nil, + components.fragment == nil, + let value = components.url + else { + throw ZedClientError.invalidConfiguration("download_url is invalid") + } + let loopback = host == "localhost" || host == "::1" || host.hasPrefix("127.") + let allowed = scheme == "https" + || (scheme == "http" && (loopback || baseURL.scheme?.lowercased() == "http")) + guard allowed else { + throw ZedClientError.invalidConfiguration( + "download_url must use HTTPS; HTTP is allowed only for loopback or an HTTP development registry" + ) + } + return value +} + +private func multipartBody( + boundary: String, + meta: JSONValue, + artifact: Data, + coordinate: PackageCoordinate + ) throws -> Data { + var body = Data() + body.appendUTF8("--\(boundary)\r\n") + body.appendUTF8("Content-Disposition: form-data; name=\"meta\"\r\n") + body.appendUTF8("Content-Type: application/json\r\n\r\n") + body.append(try encoder.encode(meta)) + body.appendUTF8("\r\n--\(boundary)\r\n") + let filename = Self.safeFilename( + "\(coordinate.org)-\(coordinate.name)-\(coordinate.version).tar.gz" + ) + body.appendUTF8( + "Content-Disposition: form-data; name=\"artifact\"; filename=\"\(filename)\"\r\n" + ) + body.appendUTF8("Content-Type: application/octet-stream\r\n\r\n") + body.append(artifact) + body.appendUTF8("\r\n--\(boundary)--\r\n") + return body + } + + private static func validateRawPath( + _ raw: String, + name: String +) throws { + guard let schemeRange = raw.range(of: "://") else { return } + let authorityStart = schemeRange.upperBound + guard let pathStart = raw[authorityStart...].firstIndex(of: "/") else { return } + let suffix = raw[pathStart...] + let pathEnd = suffix.firstIndex(where: { $0 == "?" || $0 == "#" }) + ?? raw.endIndex + for (index, encoded) in raw[pathStart.. String { + let checked = try requireText(value, name: name) + guard checked != ".", + checked != "..", + checked.utf8.count <= maxSegmentBytes, + !checked.unicodeScalars.contains(where: { + CharacterSet.controlCharacters.contains($0) + }) + else { + throw ZedClientError.invalidConfiguration( + "\(name) must not be a dot segment, exceed \(maxSegmentBytes) UTF-8 bytes, or contain control characters" + ) + } + return checked +} + +private static func validateBaseURL(_ input: String) throws -> URL { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + try validateRawPath(trimmed, name: "registry URL path") + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil + else { + throw ZedClientError.invalidConfiguration( + "registry URL must be a credential-free absolute HTTP(S) URL without query or fragment" + ) + } + while components.path.hasSuffix("/") { + components.path.removeLast() + } + guard let url = components.url else { + throw ZedClientError.invalidConfiguration("registry URL could not be normalized") + } + return url + } + + private static func packagePath(org: String, name: String) throws -> String { + "/v1/packages/\(try encodeSegment(org, name: "org"))/\(try encodeSegment(name, name: "name"))" + } + + private static func versionPath( + org: String, + name: String, + version: String + ) throws -> String { + "\(try packagePath(org: org, name: name))/versions/\(try encodeSegment(version, name: "version"))" + } + + private static func yankPath(org: String, name: String, version: String) throws -> String { + "\(try versionPath(org: org, name: name, version: version))/yank" + } + + private static func artifactPath(_ sha256: String) throws -> String { + let encoded = try encodeSegment(sha256, name: "sha256") + return "/v1/artifacts/\(encoded)" +} + +private static func encodeSegment(_ value: String, name: String) throws -> String { + let checked = try validateSegmentText(value, name: name) + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + guard let encoded = checked.addingPercentEncoding(withAllowedCharacters: allowed) else { + throw ZedClientError.invalidConfiguration("\(name) could not be encoded") + } + return encoded +} + +private static func requireText(_ value: String?, name: String) throws -> String { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ZedClientError.invalidConfiguration("\(name) must not be blank") + } + return value + } + + private static func normalizeOptional(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func downloadLimit(_ declaredSize: Int64) -> Int { + guard declaredSize > 0 else { return maxArtifactBytes } + let (withSlack, overflow) = declaredSize.addingReportingOverflow(Int64(downloadSlackBytes)) + if overflow { return maxArtifactBytes } + return Int(min(withSlack, Int64(maxArtifactBytes))) + } + + private static func packageCoordinate(_ meta: JSONValue) throws -> PackageCoordinate { + guard case let .object(root) = meta, + case let .object(manifest)? = root["manifest"], + case let .object(package)? = manifest["package"], + case let .string(org)? = package["org"], + case let .string(name)? = package["name"], + case let .string(version)? = package["version"] + else { + throw ZedClientError.invalidConfiguration( + "publish meta.manifest.package must contain string org, name, and version" + ) + } + return PackageCoordinate( + org: try validateSegmentText(org, name: "meta.manifest.package.org"), + name: try validateSegmentText(name, name: "meta.manifest.package.name"), + version: try validateSegmentText( + version, + name: "meta.manifest.package.version" + ) +) + } + + private static func safeFilename(_ value: String) -> String { + String(value.map { character in + character.isASCII && (character.isLetter || character.isNumber || "._-".contains(character)) + ? character + : "_" + }) + } +} + +private struct PackageCoordinate { + let org: String + let name: String + let version: String +} + +private extension Data { + mutating func appendUTF8(_ value: String) { + append(contentsOf: value.utf8) + } +} + +private enum SHA256 { + private static let constants: [UInt32] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ] + + static func hexDigest(_ data: Data) -> String { + var message = [UInt8](data) + let bitLength = UInt64(message.count) * 8 + message.append(0x80) + while message.count % 64 != 56 { + message.append(0) + } + message.append(contentsOf: [ + UInt8((bitLength >> 56) & 0xff), + UInt8((bitLength >> 48) & 0xff), + UInt8((bitLength >> 40) & 0xff), + UInt8((bitLength >> 32) & 0xff), + UInt8((bitLength >> 24) & 0xff), + UInt8((bitLength >> 16) & 0xff), + UInt8((bitLength >> 8) & 0xff), + UInt8(bitLength & 0xff), + ]) + + var hash: [UInt32] = [ + 0x6a09e667, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ] + + for offset in stride(from: 0, to: message.count, by: 64) { + var words = [UInt32](repeating: 0, count: 64) + for index in 0..<16 { + let start = offset + index * 4 + words[index] = UInt32(message[start]) << 24 + | UInt32(message[start + 1]) << 16 + | UInt32(message[start + 2]) << 8 + | UInt32(message[start + 3]) + } + for index in 16..<64 { + let s0 = rotateRight(words[index - 15], by: 7) + ^ rotateRight(words[index - 15], by: 18) + ^ (words[index - 15] >> 3) + let s1 = rotateRight(words[index - 2], by: 17) + ^ rotateRight(words[index - 2], by: 19) + ^ (words[index - 2] >> 10) + words[index] = words[index - 16] + &+ s0 + &+ words[index - 7] + &+ s1 + } + + var a = hash[0] + var b = hash[1] + var c = hash[2] + var d = hash[3] + var e = hash[4] + var f = hash[5] + var g = hash[6] + var h = hash[7] + + for index in 0..<64 { + let sum1 = rotateRight(e, by: 6) + ^ rotateRight(e, by: 11) + ^ rotateRight(e, by: 25) + let choice = (e & f) ^ ((~e) & g) + let temporary1 = h + &+ sum1 + &+ choice + &+ constants[index] + &+ words[index] + let sum0 = rotateRight(a, by: 2) + ^ rotateRight(a, by: 13) + ^ rotateRight(a, by: 22) + let majority = (a & b) ^ (a & c) ^ (b & c) + let temporary2 = sum0 &+ majority + + h = g + g = f + f = e + e = d &+ temporary1 + d = c + c = b + b = a + a = temporary1 &+ temporary2 + } + + hash[0] = hash[0] &+ a + hash[1] = hash[1] &+ b + hash[2] = hash[2] &+ c + hash[3] = hash[3] &+ d + hash[4] = hash[4] &+ e + hash[5] = hash[5] &+ f + hash[6] = hash[6] &+ g + hash[7] = hash[7] &+ h + } + + return hash.map { String(format: "%08x", $0) }.joined() + } + + private static func rotateRight(_ value: UInt32, by amount: UInt32) -> UInt32 { + (value >> amount) | (value << (32 - amount)) + } +} diff --git a/clients/swift/Tests/ZedClientTests/ZedClientTests.swift b/clients/swift/Tests/ZedClientTests/ZedClientTests.swift new file mode 100644 index 0000000..292dc32 --- /dev/null +++ b/clients/swift/Tests/ZedClientTests/ZedClientTests.swift @@ -0,0 +1,446 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import XCTest +@testable import ZedClient + +final class ZedClientTests: XCTestCase { + override func tearDown() { + StubURLProtocol.handler = nil + super.tearDown() + } + + func testValidatesAndNormalizesRegistryURLsWithoutLeakingTokens() throws { + let client = try ZedClient( + registryURL: " https://registry.example/gateway/// ", + token: " very-secret " + ) + XCTAssertEqual(client.baseURL.absoluteString, "https://registry.example/gateway") + XCTAssertTrue(client.debugDescription.contains("[REDACTED]")) + XCTAssertFalse(client.debugDescription.contains("very-secret")) + + for invalid in [ + "relative/path", + "ftp://registry.example", + "https://user:secret@registry.example", + "https://registry.example?tenant=one", + "https://registry.example#fragment", + "https://registry.example/../admin", + "https://registry.example/%2e%2e/admin", + "https://registry.example/a%2Fb", + ] { + XCTAssertThrowsError(try ZedClient(registryURL: invalid), invalid) + } + } + + func testPublicReadsAndAuthenticatedMutationsUseExactCoreRoutes() async throws { + let recorder = RequestRecorder() + StubURLProtocol.handler = { request in + recorder.append(request) + let path = request.url?.path ?? "" + if path.hasSuffix("/yank") { + return StubResponse( + status: 200, + body: Data( + #"{"org":"acme","name":"http-kit","version":"1.0.0","yanked":true}"#.utf8 + ) + ) + } + if path.hasSuffix("/v1/orgs") { + return StubResponse( + status: 200, + body: Data(#"{"slug":"acme","created":true}"#.utf8) + ) + } + return StubResponse( + status: 200, + body: Data( + #"{"org":"acme","name":"http-kit","vcs":"git","repo_url":"https://example/acme/http-kit","versions":["1.0.0"]}"#.utf8 + ) + ) + } + + let client = try makeClient( + basePath: "/gateway///", + token: "publisher-token" + ) + let metadata = try await client.getPackage(org: "acme", name: "http kit") + let claim = try await client.claimOrg(slug: "acme") + let yank = try await client.yank( + org: "acme", + name: "http-kit", + version: "1.0.0" + ) + XCTAssertEqual(metadata.org, "acme") + XCTAssertEqual(metadata.versions, ["1.0.0"]) + XCTAssertTrue(claim.created) + XCTAssertTrue(yank.yanked) + + let requests = recorder.snapshot() + XCTAssertEqual(requests.count, 3) + XCTAssertEqual(requests[0].httpMethod, "GET") + XCTAssertEqual(requests[0].url?.path, "/gateway/v1/packages/acme/http kit") + XCTAssertNil(requests[0].value(forHTTPHeaderField: "Authorization")) + + XCTAssertEqual(requests[1].httpMethod, "POST") + XCTAssertEqual(requests[1].url?.path, "/gateway/v1/orgs") + XCTAssertEqual( + requests[1].value(forHTTPHeaderField: "Authorization"), + "Bearer publisher-token" + ) + let claimBody = try JSONDecoder().decode( + JSONValue.self, + from: try bodyData(requests[1]) + ) + XCTAssertEqual(claimBody, .object(["slug": .string("acme")])) + + XCTAssertEqual( + requests[2].url?.path, + "/gateway/v1/packages/acme/http-kit/versions/1.0.0/yank" + ) + XCTAssertEqual( + requests[2].value(forHTTPHeaderField: "Authorization"), + "Bearer publisher-token" + ) + } + + func testArtifactDownloadNeverCarriesBearerAndVerifiesSHA256() async throws { + let artifact = Data("abc".utf8) + let recorder = RequestRecorder() + StubURLProtocol.handler = { request in + recorder.append(request) + return StubResponse( + status: 200, + body: artifact, + headers: [ + "Content-Type": "application/octet-stream", + "Content-Length": "\(artifact.count)", + ] + ) + } + let client = try makeClient(token: "registry-secret") + let version = VersionMetadata( + org: "acme", + name: "http-kit", + version: "1.0.0", + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + size: Int64(artifact.count), + format: "tar.gz", + vcsTag: "v1.0.0", + vcsCommit: nil, + downloadURL: "http://127.0.0.1/presigned?signature=abc", + publishedAt: "2026-07-31T00:00:00Z", + yanked: false + ) + let downloaded = try await client.downloadArtifact(version) + XCTAssertEqual(downloaded, artifact) + let request = try XCTUnwrap(recorder.snapshot().first) + XCTAssertNil( + request.value(forHTTPHeaderField: "Authorization"), + "registry bearer leaked to artifact host" + ) + } + + func testArtifactMismatchAndInsecureRemoteURLsFailClosed() async throws { + StubURLProtocol.handler = { _ in + StubResponse(status: 200, body: Data("artifact".utf8)) + } + let development = try makeClient() + let mismatch = VersionMetadata( + org: "acme", + name: "pkg", + version: "1.0.0", + sha256: String(repeating: "0", count: 64), + size: 8, + format: "tar.gz", + vcsTag: "v1.0.0", + vcsCommit: nil, + downloadURL: "http://127.0.0.1/artifact", + publishedAt: "now", + yanked: false + ) + do { + _ = try await development.downloadArtifact(mismatch) + XCTFail("expected digest mismatch") + } catch let error as ZedClientError { + guard case .sha256Mismatch = error else { + return XCTFail("unexpected error: \(error)") + } + } + + let production = try ZedClient(registryURL: "https://registry.example") + let insecure = VersionMetadata( + org: "acme", + name: "pkg", + version: "1.0.0", + sha256: String(repeating: "0", count: 64), + size: 1, + format: "tar.gz", + vcsTag: "v1.0.0", + vcsCommit: nil, + downloadURL: "http://artifacts.example/object", + publishedAt: "now", + yanked: false + ) + do { + _ = try await production.downloadArtifact(insecure) + XCTFail("expected insecure URL refusal") + } catch let error as ZedClientError { + guard case .invalidConfiguration = error else { + return XCTFail("unexpected error: \(error)") + } + } + } + + func testMultipartPublishPreservesRawBytesAndCoordinate() async throws { + let artifact = Data([0, 7, 13, 200, 255]) + let recorder = RequestRecorder() + StubURLProtocol.handler = { request in + recorder.append(request) + return StubResponse( + status: 200, + body: Data( + #"{"org":"acme","name":"http-kit","version":"1.2.3","sha256":"abc"}"#.utf8 + ) + ) + } + let client = try makeClient(basePath: "/registry", token: "publisher-token") + let meta: JSONValue = .object([ + "manifest": .object([ + "package": .object([ + "org": .string("acme"), + "name": .string("http-kit"), + "version": .string("1.2.3"), + ]), + ]), + "provenance": .object(["commit": .string("deadbeef")]), + ]) + let response = try await client.publish(meta: meta, artifact: artifact) + XCTAssertEqual(response.version, "1.2.3") + + let request = try XCTUnwrap(recorder.snapshot().first) + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual( + request.url?.path, + "/registry/v1/packages/acme/http-kit/versions/1.2.3" + ) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer publisher-token" + ) + XCTAssertTrue( + request.value(forHTTPHeaderField: "Content-Type")? + .hasPrefix("multipart/form-data; boundary=") == true + ) + let body = try bodyData(request) + XCTAssertNotNil(body.range(of: artifact), "multipart body altered raw archive bytes") + let readable = String(decoding: body, as: UTF8.self) + XCTAssertTrue(readable.contains("name=\"meta\"")) + XCTAssertTrue(readable.contains("name=\"artifact\"")) + XCTAssertTrue(readable.contains("\"commit\":\"deadbeef\"")) + } + + func testDefaultAPIDiagnosticsHideRemoteBodyButRetainBoundedBody() async throws { + StubURLProtocol.handler = { _ in + StubResponse( + status: 403, + body: Data( + #"{"code":"scope_denied","message":"provider-secret"}"#.utf8 + ) + ) + } + let client = try makeClient(token: "token") + do { + _ = try await client.claimOrg(slug: "acme") + XCTFail("expected API error") + } catch let error as ZedClientError { + guard case let .api(status, code, body) = error else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(status, 403) + XCTAssertEqual(code, "scope_denied") + XCTAssertTrue(body.contains("provider-secret")) + XCTAssertFalse(error.errorDescription?.contains("provider-secret") == true) + } + } + + func testRejectsHostileSegmentsBeforeTransport() async throws { + let client = try makeClient() + let hostileValues = [ + "", + " ", + ".", + "..", + String(UnicodeScalar(10)!), + ] + for value in hostileValues { + do { + _ = try await client.getPackage(org: value, name: "kit") + XCTFail("expected segment rejection for \(value.debugDescription)") + } catch let error as ZedClientError { + guard case .invalidConfiguration = error else { + return XCTFail("unexpected error: \(error)") + } + } + } +} + +func testRelativeDownloadURLPreservesGatewayAndUppercaseDigest() async throws { + let artifact = Data("abc".utf8) + let recorder = RequestRecorder() + StubURLProtocol.handler = { request in + recorder.append(request) + return StubResponse(status: 200, body: artifact) + } + let client = try makeClient(basePath: "/gateway") + let version = VersionMetadata( + org: "acme", + name: "kit", + version: "1.0.0", + sha256: "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", + size: Int64(artifact.count), + format: "tar.gz", + vcsTag: "v1.0.0", + vcsCommit: nil, + downloadURL: "artifacts/hash", + publishedAt: "now", + yanked: false + ) + let downloaded = try await client.downloadArtifact(version) + XCTAssertEqual(downloaded, artifact) + XCTAssertEqual( + recorder.snapshot().first?.url?.path, + "/gateway/artifacts/hash" + ) +} + +func testBlankStructuredErrorCodeFallsBackToHTTPStatus() async throws { + StubURLProtocol.handler = { _ in + StubResponse( + status: 409, + body: Data(#"{"code":" ","message":"remote detail"}"#.utf8) + ) + } + let client = try makeClient(token: "token") + do { + _ = try await client.claimOrg(slug: "acme") + XCTFail("expected API error") + } catch let error as ZedClientError { + guard case let .api(status, code, _) = error else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(status, 409) + XCTAssertEqual(code, "http_409") + } +} + +func testAuthenticatedOperationsRequireTokenBeforeTransport() async throws { + let client = try makeClient(token: nil) + do { + _ = try await client.claimOrg(slug: "acme") + XCTFail("expected missing-token error") + } catch let error as ZedClientError { + XCTAssertEqual(error, .missingToken) + } + } + + private func makeClient( + basePath: String = "", + token: String? = nil + ) throws -> ZedClient { + try ZedClient( + registryURL: "http://127.0.0.1\(basePath)", + token: token, + protocolClasses: [StubURLProtocol.self] + ) + } +} + +private struct StubResponse { + let status: Int + let body: Data + let headers: [String: String] + + init(status: Int, body: Data, headers: [String: String] = ["Content-Type": "application/json"]) { + self.status = status + self.body = body + self.headers = headers + } +} + +private final class RequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var requests: [URLRequest] = [] + + func append(_ request: URLRequest) { + lock.lock() + requests.append(request) + lock.unlock() + } + + func snapshot() -> [URLRequest] { + lock.lock() + let value = requests + lock.unlock() + return value + } +} + +private final class StubURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> StubResponse)? + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + do { + let handler = try XCTUnwrap(Self.handler) + let stub = try handler(request) + let response = try XCTUnwrap( + HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: stub.status, + httpVersion: "HTTP/1.1", + headerFields: stub.headers + ) + ) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.body) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { } +} + +private func bodyData(_ request: URLRequest) throws -> Data { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return Data() + } + stream.open() + defer { stream.close() } + var output = Data() + var buffer = [UInt8](repeating: 0, count: 8192) + while stream.hasBytesAvailable { + let capacity = buffer.count + let count = buffer.withUnsafeMutableBytes { bytes -> Int in + guard let baseAddress = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return 0 + } + return stream.read(baseAddress, maxLength: capacity) + } + if count < 0 { + throw stream.streamError ?? URLError(.cannotDecodeContentData) + } + if count == 0 { break } + output.append(contentsOf: buffer.prefix(count)) + } + return output +} diff --git a/clients/typescript/NIX_INTEROP.md b/clients/typescript/NIX_INTEROP.md new file mode 100644 index 0000000..0faafc1 --- /dev/null +++ b/clients/typescript/NIX_INTEROP.md @@ -0,0 +1,98 @@ +# TypeScript Nix interoperability verification + +The TypeScript client exposes strict, zero-runtime-dependency readers for the +canonical Nix interoperability records published by `zed-interfaces`: + +```ts +import { + parseNixAdapterRecord, + parseNixAdapterRecordJson, + canonicalNixAdapterRecordJson, + nixAdapterRecordSha256, + verifyCanonicalNixAdapterRecordJson, + verifyNixAdapterArtifactBytes, +} from "@zed-pkg/client"; +``` + +The supported schema is exactly: + +```text +zed.nix-adapter/v1 +``` + +Unknown directions, schema versions, fields, policy values, store-info JSON +versions, and numeric values outside JavaScript's safe integer range fail +closed. + +## Structural verification + +`parseNixAdapterRecord` validates and normalizes either direction: + +- `zed-to-nix`: immutable Zed artifact origin, explicit Nix intent, at least one + realized output, system/output agreement, and strict policy evidence; +- `nix-to-zed`: immutable Nix selector and realization evidence, a translated + Zed artifact, strict policy evidence, and a closure-free selected output. + +The parser validates public package identity, exact SHA-256 and SHA-256 SRI +syntax, Nix attributes, systems, outputs, store paths, signatures, references, +artifact sizes, and supported store-info JSON versions. Runtime references in a +version-1 Nix-to-Zed record are rejected rather than hidden in a wrapper. + +Input objects are never returned directly. The parser creates a fresh normalized +object, sorts arrays whose order is non-semantic, removes default-empty optional +arrays, and rejects duplicate references, signatures, or system/output pairs. +Unknown fields cannot be used to smuggle credentials or private cache keys into +the contract. + +## Canonical bytes and digest + +Rust emits compact JSON with all object keys recursively sorted. The TypeScript +helper reproduces those bytes: + +```ts +const canonical = canonicalNixAdapterRecordJson(untrustedValue); +const digest = await nixAdapterRecordSha256(untrustedValue); +``` + +`verifyCanonicalNixAdapterRecordJson` additionally requires the supplied string +to already be canonical; a trailing newline, pretty printing, different object +key order, or omitted/default field drift is rejected. An optional expected +lowercase SHA-256 can bind the exact adapter bytes. + +Digest helpers use the standard Web Crypto API available in browsers and Node +22. They import no Node-only runtime module and add no package dependency. + +## Artifact-byte binding + +`verifyNixAdapterArtifactBytes` hashes exact Zed artifact bytes and checks both +SHA-256 and byte size: + +- for `nix-to-zed`, it verifies the translated ordinary Zed artifact; +- for `zed-to-nix`, it verifies the source Zed artifact recorded by the export. + +This catches archive tampering without requiring Nix or registry access. + +## What offline verification does not prove + +These helpers intentionally do **not**: + +- evaluate a flake or arbitrary Nix expression; +- realize a derivation; +- query a Nix store or binary cache; +- verify that a diagnostic `/nix/store/...` path currently exists; +- replay a derivation JSON digest, NAR hash, signature, or closure; +- verify the contents of a tar/zip archive beyond its exact outer hash and size; +- publish a Zed package, Nix overlay, or binary-cache object; or +- grant trust to a cache signing key. + +Those claims require the pinned `zed` CLI, Nix/store commands, explicit trust +policy, and the clean-room conformance workflows. The SDK distinguishes +structural and byte-level verification from realization replay so callers do not +mistake a well-formed provenance claim for proof that a build was reproduced. + +## Error safety + +Malformed JSON produces a generic syntax error. Unknown fields report their +field name but never their value. Contract fields contain no token, password, +private key, cache credential, or secret-delivery slot, so normalized records +cannot serialize those values. diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json new file mode 100644 index 0000000..a7afa49 --- /dev/null +++ b/clients/typescript/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@zed-pkg/client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@zed-pkg/client", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/clients/typescript/package.json b/clients/typescript/package.json new file mode 100644 index 0000000..db4daab --- /dev/null +++ b/clients/typescript/package.json @@ -0,0 +1,40 @@ +{ + "name": "@zed-pkg/client", + "version": "0.1.0", + "description": "TypeScript SDK for the zed-pkg registry (zero runtime deps)", + "license": "MIT", + "repository": "github:zed-pkg/zed-clients", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./nodejs": { + "types": "./dist/runtimes/nodejs/index.d.ts", + "default": "./dist/runtimes/nodejs/index.js" + }, + "./deno": { + "types": "./dist/runtimes/deno/index.d.ts", + "default": "./dist/runtimes/deno/index.js" + }, + "./bun": { + "types": "./dist/runtimes/bun/index.d.ts", + "default": "./dist/runtimes/bun/index.js" + }, + "./edge": { + "types": "./dist/runtimes/edge/index.d.ts", + "default": "./dist/runtimes/edge/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p .", + "test": "node --test test/*.test.mjs" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts new file mode 100644 index 0000000..97ffbad --- /dev/null +++ b/clients/typescript/src/client.ts @@ -0,0 +1,426 @@ +import type { + ApiErrorBody, + ClaimOrgResponse, + PackageMetadata, + PublishResponse, + SearchResponse, + VersionMetadata, + YankResponse, +} from "./types.js"; + +export const DEFAULT_REGISTRY_URL = "https://registry.zpkg.tech"; + +/** Bounds every request, including streamed response-body consumption. */ +export const DEFAULT_TIMEOUT_MS = 30_000; + +/** Successful JSON documents are never allowed to grow without bound. */ +export const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024; + +/** Remote error text is retained only through this bounded explicit field. */ +export const MAX_ERROR_BODY_BYTES = 16 * 1024; + +/** Maximum UTF-8 size of one opaque registry route segment. */ +export const MAX_PATH_SEGMENT_BYTES = 256; + +/** + * Hard ceiling on artifact downloads, matching the server's MAX_ARTIFACT_BYTES + * default (100 MiB); plus the slack added to a version's declared size. + */ +export const MAX_ARTIFACT_BYTES = 100 * 1024 * 1024; +const DOWNLOAD_SLACK = 1024 * 1024; +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u; + +/** The declared size (when sane) plus slack, capped by the ceiling. */ +function downloadLimit(size: number): number { + if (Number.isFinite(size) && size > 0) { + return Math.min(size + DOWNLOAD_SLACK, MAX_ARTIFACT_BYTES); + } + return MAX_ARTIFACT_BYTES; +} + +interface BoundedBytes { + bytes: Uint8Array; + truncated: boolean; +} + +/** Stream no more than `limit` bytes, cancelling once the bound is exceeded. */ +async function readAtMost(response: Response, limit: number): Promise { + const body = response.body; + if (!body) { + const source = new Uint8Array(await response.arrayBuffer()); + return { + bytes: source.slice(0, limit), + truncated: source.byteLength > limit, + }; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let truncated = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const remaining = limit - total; + if (value.byteLength > remaining) { + if (remaining > 0) chunks.push(value.slice(0, remaining)); + total = limit; + truncated = true; + await reader.cancel(); + break; + } + chunks.push(value); + total += value.byteLength; + } + + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return { bytes: output, truncated }; +} + +async function readCapped( + response: Response, + limit: number, + code: string, + description: string, +): Promise> { + const declared = response.headers.get("content-length"); + if (declared !== null && Number(declared) > limit) { + throw new ZedApiError(0, code, `${description} exceeded ${limit} bytes; refusing`); + } + const bounded = await readAtMost(response, limit); + if (bounded.truncated) { + throw new ZedApiError(0, code, `${description} exceeded ${limit} bytes; refusing`); + } + return bounded.bytes; +} + +/** + * Registry failures keep bounded remote text in `registryMessage`, while the + * ordinary Error message exposes only status and stable machine-readable code. + */ +export class ZedApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + public readonly registryMessage: string, + ) { + super(`registry error ${status}: ${code}`); + this.name = "ZedApiError"; + } +} + +export interface ClientOptions { + registryUrl?: string; + token?: string; + fetchImpl?: typeof fetch; + /** Per-request timeout in milliseconds (default {@link DEFAULT_TIMEOUT_MS}). */ + timeoutMs?: number; +} + +/** Validate and normalize one registry base URL while preserving a path prefix. */ +export function normalizeRegistryUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw new TypeError("registryUrl must be an absolute HTTP(S) URL"); + } + if ( + !["http:", "https:"].includes(url.protocol) || + url.hostname === "" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) { + throw new TypeError( + "registryUrl must be a credential-free absolute HTTP(S) URL without query or fragment", + ); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/$/, ""); +} + +/** Validate one opaque path segment before URL construction. */ +export function encodePathSegment(value: string, name = "path segment"): string { + const size = new TextEncoder().encode(value).byteLength; + if ( + value.trim() === "" || + value === "." || + value === ".." || + size > MAX_PATH_SEGMENT_BYTES || + CONTROL_CHARACTER.test(value) + ) { + throw new TypeError( + `${name} must be nonblank, at most ${MAX_PATH_SEGMENT_BYTES} UTF-8 bytes, and must not be a dot segment or contain control characters`, + ); + } + return encodeURIComponent(value); +} + +/** + * Enforce the download-url scheme policy: https is always allowed; http only + * for loopback hosts or when the registry base is itself http. Userinfo is + * rejected. Query strings remain allowed because S3/R2 presigned URLs need + * them. No registry bearer token is ever attached to this URL. + */ +export function allowedDownloadUrl(raw: string, base: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ZedApiError(0, "bad_download_url", `bad download url ${raw}`); + } + if (url.username !== "" || url.password !== "" || url.hash !== "") { + throw new ZedApiError(0, "bad_download_url", "download URL contains credentials or fragment"); + } + const host = url.hostname; + const loopback = + host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; + if (url.protocol === "https:") return url.toString(); + if (url.protocol === "http:" && (loopback || base.startsWith("http://"))) { + return url.toString(); + } + throw new ZedApiError( + 0, + "insecure_download_url", + `refusing artifact download over ${url.protocol} from ${raw}`, + ); +} + +export function packagePath(org: string, name: string): string { + return `/v1/packages/${encodePathSegment(org, "org")}/${encodePathSegment(name, "name")}`; +} + +export function versionPath(org: string, name: string, version: string): string { + return `${packagePath(org, name)}/versions/${encodePathSegment(version, "version")}`; +} + +export function yankPath(org: string, name: string, version: string): string { + return `${versionPath(org, name, version)}/yank`; +} + +export function artifactPath(sha256: string): string { + return `/v1/artifacts/${encodePathSegment(sha256, "sha256")}`; +} + +export function filePath(org: string, name: string, version: string, path: string): string { + const encodedPath = path + .split("/") + .map((segment, index) => encodePathSegment(segment, `path segment ${index + 1}`)) + .join("/"); + return `/v1/files/${encodePathSegment(org, "org")}/${encodePathSegment(name, "name")}/${encodePathSegment(version, "version")}/${encodedPath}`; +} + +export class ZedClient { + private readonly base: string; + private readonly token: string | undefined; + private readonly fetchImpl: typeof fetch; + private readonly timeoutMs: number; + + constructor(options: ClientOptions = {}) { + this.base = normalizeRegistryUrl(options.registryUrl ?? DEFAULT_REGISTRY_URL); + this.token = options.token?.trim() || undefined; + this.fetchImpl = options.fetchImpl ?? fetch; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) { + throw new TypeError("timeoutMs must be a positive finite number"); + } + } + + private requireToken(): string { + if (!this.token) { + throw new ZedApiError( + 0, + "missing_token", + "authenticated registry operation requires a nonblank bearer token", + ); + } + return this.token; + } + + private async request( + path: string, + init: RequestInit = {}, + authorized = false, + ): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (authorized) headers.set("authorization", `Bearer ${this.requireToken()}`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(`${this.base}${path}`, { + ...init, + headers, + redirect: "error", + signal: controller.signal, + }); + + if (!response.ok) { + const bounded = await readAtMost(response, MAX_ERROR_BODY_BYTES); + const text = new TextDecoder().decode(bounded.bytes); + let body: ApiErrorBody = { + code: `http_${response.status}`, + message: text, + }; + try { + const parsed = JSON.parse(text) as Partial | null; + const parsedCode = typeof parsed?.code === "string" ? parsed.code.trim() : ""; + body = { + code: parsedCode || `http_${response.status}`, + message: typeof parsed?.message === "string" ? parsed.message : text, + }; + } catch { + // Non-JSON error body remains available only through registryMessage. + } + if (bounded.truncated) body.message += "…"; + throw new ZedApiError(response.status, body.code, body.message); + } + + const bytes = await readCapped( + response, + MAX_JSON_RESPONSE_BYTES, + "response_too_large", + "registry JSON response", + ); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as T; + } catch (error) { + throw new ZedApiError(0, "invalid_response", `invalid registry JSON: ${String(error)}`); + } + } finally { + clearTimeout(timer); + } + } + + getPackage(org: string, name: string): Promise { + return this.request(packagePath(org, name)); + } + + getVersion(org: string, name: string, version: string): Promise { + return this.request(versionPath(org, name, version)); + } + + search(query: string): Promise { + return this.request(`/v1/search?q=${encodeURIComponent(query)}`); + } + + claimOrg(slug: string): Promise { + const checkedSlug = encodePathSegment(slug, "slug"); + return this.request( + "/v1/orgs", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug: decodeURIComponent(checkedSlug) }), + }, + true, + ); + } + + setYanked(org: string, name: string, version: string, yanked: boolean): Promise { + return this.request( + yankPath(org, name, version), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ yanked }), + }, + true, + ); + } + + /** Compatibility form: omitting `yanked` means yank the version. */ + yank(org: string, name: string, version: string, yanked = true): Promise { + return this.setYanked(org, name, version, yanked); + } + + restore(org: string, name: string, version: string): Promise { + return this.setYanked(org, name, version, false); + } + + /** Download an artifact and verify its sha256 (WebCrypto). */ + async downloadArtifact(version: VersionMetadata): Promise> { + const raw = version.download_url.trim(); + let url: string; + if (raw === "") { + url = `${this.base}${artifactPath(version.sha256)}`; + } else if (raw.includes("://")) { + url = allowedDownloadUrl(raw, this.base); + } else { + url = allowedDownloadUrl(new URL(raw, `${this.base}/`).toString(), this.base); + } + + const limit = downloadLimit(version.size); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + // Deliberately no auth header: download_url may point at a third-party + // host (e.g. a presigned S3/R2 URL), and the token must not leak there. + const response = await this.fetchImpl(url, { + redirect: "error", + signal: controller.signal, + }); + if (!response.ok) { + const bounded = await readAtMost(response, MAX_ERROR_BODY_BYTES); + throw new ZedApiError( + response.status, + "download_failed", + new TextDecoder().decode(bounded.bytes), + ); + } + const bytes = await readCapped(response, limit, "artifact_too_large", "artifact"); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + if (actual !== version.sha256) { + throw new ZedApiError(0, "sha256_mismatch", `expected ${version.sha256}, got ${actual}`); + } + return bytes; + } finally { + clearTimeout(timer); + } + } + + /** Publish: multipart meta (PublishMeta JSON) + artifact bytes. */ + async publish( + meta: { manifest: { package: { org: string; name: string; version: string } } } & Record< + string, + unknown + >, + artifact: Blob, + ): Promise { + if (artifact.size > MAX_ARTIFACT_BYTES) { + throw new ZedApiError( + 0, + "artifact_too_large", + `artifact exceeded ${MAX_ARTIFACT_BYTES} bytes; refusing`, + ); + } + const pkg = meta.manifest.package; + const org = decodeURIComponent(encodePathSegment(pkg.org, "meta.manifest.package.org")); + const name = decodeURIComponent(encodePathSegment(pkg.name, "meta.manifest.package.name")); + const version = decodeURIComponent( + encodePathSegment(pkg.version, "meta.manifest.package.version"), + ); + const form = new FormData(); + form.set("meta", JSON.stringify(meta)); + form.set("artifact", artifact, `${org}-${name}-${version}.tar.gz`); + return this.request( + versionPath(org, name, version), + { + method: "PUT", + body: form, + }, + true, + ); + } +} diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts new file mode 100644 index 0000000..d41ee19 --- /dev/null +++ b/clients/typescript/src/index.ts @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./client.js"; +export * from "./sync.js"; +export * from "./nix-export-plan.js"; +export * from "./nix-adapter.js"; diff --git a/clients/typescript/src/nix-adapter.ts b/clients/typescript/src/nix-adapter.ts new file mode 100644 index 0000000..58332df --- /dev/null +++ b/clients/typescript/src/nix-adapter.ts @@ -0,0 +1,747 @@ +import type { NixPackageIdentity } from "./nix-export-plan.js"; + +export const NIX_ADAPTER_SCHEMA_V1 = "zed.nix-adapter/v1" as const; + +export type NixAdapterDirection = "zed-to-nix" | "nix-to-zed"; +export type NixAdapterArtifactFormat = "tar.gz" | "zip"; +export type NixAdapterPolicyProfile = "strict-v1" | "development"; +export type NixAdapterBuilderNetwork = "disabled" | "preparation-only" | "allowed"; + +export interface NixAdapterArtifact { + format: NixAdapterArtifactFormat; + sha256: string; + size: number; +} + +export interface NixAdapterPolicyEvidence { + profile: NixAdapterPolicyProfile; + pure_evaluation: boolean; + import_from_derivation: boolean; + sandbox_required: boolean; + builder_network: NixAdapterBuilderNetwork; + dirty_source: boolean; + publishable: boolean; +} + +export interface NixAdapterExportIntent { + mode: "artifact"; + attribute?: string; + systems: string[]; + outputs: string[]; +} + +export interface ZedArtifactOrigin { + registry: string; + artifact: NixAdapterArtifact; + vcs_tag: string; + vcs_commit: string; + lock_sha256?: string; +} + +export interface NixStoreReference { + store_path: string; + nar_hash?: string; + nar_size?: number; +} + +export interface NixRealizedOutput { + system: string; + output: string; + derivation_json_sha256: string; + store_path: string; + nar_hash: string; + nar_size: number; + references?: NixStoreReference[]; + signatures?: string[]; + nix_version: string; + store_info_json_version: 1 | 2 | 3; +} + +export interface NixOutputOrigin { + locked_ref: string; + flake_lock_sha256: string; + attribute: string; + realized: NixRealizedOutput; +} + +export interface ZedToNixAdapterRecord { + direction: "zed-to-nix"; + schema: typeof NIX_ADAPTER_SCHEMA_V1; + package: NixPackageIdentity; + source: ZedArtifactOrigin; + intent: NixAdapterExportIntent; + flake_bundle_sha256: string; + outputs: NixRealizedOutput[]; + policy: NixAdapterPolicyEvidence; +} + +export interface NixToZedAdapterRecord { + direction: "nix-to-zed"; + schema: typeof NIX_ADAPTER_SCHEMA_V1; + package: NixPackageIdentity; + source: NixOutputOrigin; + artifact: NixAdapterArtifact; + policy: NixAdapterPolicyEvidence; +} + +export type NixAdapterRecord = ZedToNixAdapterRecord | NixToZedAdapterRecord; + +export interface VerifiedCanonicalNixAdapterRecord { + record: NixAdapterRecord; + canonical_json: string; + sha256: string; +} + +export interface VerifiedNixAdapterArtifact { + record: NixAdapterRecord; + sha256: string; + size: number; +} + +export class NixAdapterRecordError extends TypeError { + constructor(message: string) { + super(`invalid Nix adapter record: ${message}`); + this.name = "NixAdapterRecordError"; + } +} + +type JsonRecord = Record; + +const SHA256_RE = /^[0-9a-f]{64}$/; +const SHA256_SRI_RE = /^sha256-[A-Za-z0-9+/]{43}=$/; +const SLUG_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/; +const TARGET_RE = /^[a-z0-9](?:[a-z0-9._+-]{0,126}[a-z0-9])?$/; +const NIX_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_'-]*$/; +const NIX_STORE_RE = /^\/nix\/store\/[0-9abcdfghijklmnpqrsvwxyz]{32}-[A-Za-z0-9+._?-]+$/; +const HEX_REVISION_RE = /^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$/; + +function plainRecord(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NixAdapterRecordError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new NixAdapterRecordError(`${label} must be a plain JSON object`); + } + return value as JsonRecord; +} + +function exactKeys( + value: JsonRecord, + required: readonly string[], + optional: readonly string[], + label: string, +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + throw new NixAdapterRecordError(`${label} contains unknown field ${JSON.stringify(key)}`); + } + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + throw new NixAdapterRecordError(`${label} is missing field ${JSON.stringify(key)}`); + } + } +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new NixAdapterRecordError(`${label} must be a string`); + } + return value; +} + +function oneOf(value: unknown, allowed: readonly T[], label: string): T { + const parsed = text(value, label); + if (!allowed.includes(parsed as T)) { + throw new NixAdapterRecordError( + `${label} must be one of ${allowed.map((item) => JSON.stringify(item)).join(", ")}`, + ); + } + return parsed as T; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new NixAdapterRecordError(`${label} must be a boolean`); + } + return value; +} + +function positiveSafeInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new NixAdapterRecordError(`${label} must be a positive safe integer`); + } + return value; +} + +function supportedStoreInfoVersion(value: unknown, label: string): 1 | 2 | 3 { + if (value !== 1 && value !== 2 && value !== 3) { + throw new NixAdapterRecordError(`${label} must be 1, 2, or 3`); + } + return value; +} + +function hexSha256(value: unknown, label: string): string { + const parsed = text(value, label); + if (!SHA256_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be 64 lowercase hexadecimal characters`); + } + return parsed; +} + +function sriSha256(value: unknown, label: string): string { + const parsed = text(value, label); + if (!SHA256_SRI_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be a SHA-256 SRI value`); + } + return parsed; +} + +function token(value: unknown, label: string): string { + const parsed = text(value, label); + if (parsed.length === 0 || parsed.trim() !== parsed || /\s/u.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be a non-empty token without whitespace`); + } + return parsed; +} + +function slug(value: unknown, label: string): string { + const parsed = text(value, label); + if (!SLUG_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} is not a valid public Zed slug`); + } + return parsed; +} + +function packageVersion(value: unknown, label: string): string { + const parsed = text(value, label); + if (parsed.length === 0 || parsed.trim() !== parsed || /\s/u.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be non-empty and contain no whitespace`); + } + return parsed; +} + +function target(value: unknown, label: string): string { + const parsed = text(value, label); + if (!TARGET_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} is not a valid target name`); + } + return parsed; +} + +function nixIdentifier(value: unknown, label: string): string { + const parsed = text(value, label); + if (!NIX_IDENTIFIER_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be a Nix identifier`); + } + return parsed; +} + +function nixAttributePath(value: unknown, label: string): string { + const parsed = text(value, label); + if (parsed.length === 0 || !parsed.split(".").every((part) => NIX_IDENTIFIER_RE.test(part))) { + throw new NixAdapterRecordError(`${label} must be a dot-separated Nix attribute path`); + } + return parsed; +} + +function nixSystem(value: unknown, label: string): string { + const parsed = text(value, label); + if ( + parsed.length === 0 || + !parsed.includes("-") || + parsed.startsWith("-") || + parsed.endsWith("-") || + !/^[a-z0-9_-]+$/u.test(parsed) + ) { + throw new NixAdapterRecordError(`${label} must be a lowercase Nix system`); + } + return parsed; +} + +function nixStorePath(value: unknown, label: string): string { + const parsed = text(value, label); + if (!NIX_STORE_RE.test(parsed)) { + throw new NixAdapterRecordError(`${label} must be a valid /nix/store path`); + } + return parsed; +} + +function immutableNixRef(value: unknown, label: string): string { + const parsed = text(value, label); + if ( + parsed.length === 0 || + parsed.trim() !== parsed || + /\s/u.test(parsed) || + parsed.includes("<") || + parsed.includes(">") + ) { + throw new NixAdapterRecordError(`${label} must be an immutable Nix reference`); + } + if ( + parsed.startsWith("/nix/store/") || + parsed.startsWith("path:/nix/store/") || + parsed.includes("narHash=sha256-") + ) { + return parsed; + } + const queryRevision = parsed + .split(/[?&]/u) + .map((part) => part.startsWith("rev=") ? part.slice(4) : "") + .find((part) => HEX_REVISION_RE.test(part)); + if (queryRevision !== undefined) { + return parsed; + } + if (parsed.split(/[^0-9a-fA-F]+/u).some((part) => HEX_REVISION_RE.test(part))) { + return parsed; + } + throw new NixAdapterRecordError(`${label} must contain immutable revision or NAR-hash evidence`); +} + +function registryUrl(value: unknown, label: string): string { + const parsed = text(value, label); + if ( + parsed.trim() !== parsed || + /\s/u.test(parsed) || + !["https://", "http://", "file://"].some((prefix) => parsed.startsWith(prefix)) + ) { + throw new NixAdapterRecordError(`${label} must be an HTTP(S) or file URL without whitespace`); + } + return parsed; +} + +function unique(values: T[], key: (value: T) => string, label: string): void { + const seen = new Set(); + for (const value of values) { + const identity = key(value); + if (seen.has(identity)) { + throw new NixAdapterRecordError(`${label} contains duplicate values`); + } + seen.add(identity); + } +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value)) { + throw new NixAdapterRecordError(`${label} must be an array`); + } + return value.map((item, index) => text(item, `${label}[${index}]`)); +} + +function parsePackage(value: unknown): NixPackageIdentity { + const parsed = plainRecord(value, "package"); + exactKeys(parsed, ["org", "name", "version"], ["target"], "package"); + const result: NixPackageIdentity = { + org: slug(parsed.org, "package.org"), + name: slug(parsed.name, "package.name"), + version: packageVersion(parsed.version, "package.version"), + }; + if (parsed.target !== undefined && parsed.target !== null) { + result.target = target(parsed.target, "package.target"); + } + return result; +} + +function parseArtifact(value: unknown, label: string): NixAdapterArtifact { + const parsed = plainRecord(value, label); + exactKeys(parsed, ["sha256", "size"], ["format"], label); + return { + format: parsed.format === undefined + ? "tar.gz" + : oneOf(parsed.format, ["tar.gz", "zip"], `${label}.format`), + sha256: hexSha256(parsed.sha256, `${label}.sha256`), + size: positiveSafeInteger(parsed.size, `${label}.size`), + }; +} + +function parsePolicy(value: unknown): NixAdapterPolicyEvidence { + const parsed = plainRecord(value, "policy"); + exactKeys( + parsed, + [ + "profile", + "pure_evaluation", + "import_from_derivation", + "sandbox_required", + "builder_network", + "dirty_source", + "publishable", + ], + [], + "policy", + ); + const result: NixAdapterPolicyEvidence = { + profile: oneOf(parsed.profile, ["strict-v1", "development"], "policy.profile"), + pure_evaluation: boolean(parsed.pure_evaluation, "policy.pure_evaluation"), + import_from_derivation: boolean( + parsed.import_from_derivation, + "policy.import_from_derivation", + ), + sandbox_required: boolean(parsed.sandbox_required, "policy.sandbox_required"), + builder_network: oneOf( + parsed.builder_network, + ["disabled", "preparation-only", "allowed"], + "policy.builder_network", + ), + dirty_source: boolean(parsed.dirty_source, "policy.dirty_source"), + publishable: boolean(parsed.publishable, "policy.publishable"), + }; + if (result.profile === "strict-v1") { + if ( + !result.pure_evaluation || + result.import_from_derivation || + !result.sandbox_required || + result.builder_network !== "disabled" || + result.dirty_source || + !result.publishable + ) { + throw new NixAdapterRecordError("strict-v1 policy evidence is inconsistent"); + } + } else if (result.publishable) { + throw new NixAdapterRecordError("development policy records are never publishable"); + } + return result; +} + +function parseExportIntent(value: unknown, packageName: string): NixAdapterExportIntent { + const parsed = plainRecord(value, "intent"); + exactKeys(parsed, [], ["mode", "attribute", "systems", "outputs"], "intent"); + const attribute = parsed.attribute === undefined || parsed.attribute === null + ? undefined + : nixIdentifier(parsed.attribute, "intent.attribute"); + const resolvedAttribute = attribute ?? packageName; + if (resolvedAttribute === "default") { + throw new NixAdapterRecordError("intent.attribute must not use reserved `default`"); + } + const systems = (parsed.systems === undefined ? [] : stringArray(parsed.systems, "intent.systems")) + .map((item, index) => nixSystem(item, `intent.systems[${index}]`)); + const outputs = (parsed.outputs === undefined ? [] : stringArray(parsed.outputs, "intent.outputs")) + .map((item, index) => nixIdentifier(item, `intent.outputs[${index}]`)); + if (systems.length === 0 || outputs.length === 0) { + throw new NixAdapterRecordError("intent requires explicit systems and outputs"); + } + unique(systems, (item) => item, "intent.systems"); + unique(outputs, (item) => item, "intent.outputs"); + const result: NixAdapterExportIntent = { + mode: parsed.mode === undefined + ? "artifact" + : oneOf(parsed.mode, ["artifact"], "intent.mode"), + systems: [...systems].sort(), + outputs: [...outputs].sort(), + }; + if (attribute !== undefined) { + result.attribute = attribute; + } + return result; +} + +function parseZedOrigin(value: unknown): ZedArtifactOrigin { + const parsed = plainRecord(value, "source"); + exactKeys( + parsed, + ["registry", "artifact", "vcs_tag", "vcs_commit"], + ["lock_sha256"], + "source", + ); + const vcsCommit = token(parsed.vcs_commit, "source.vcs_commit"); + if (vcsCommit.length < 7) { + throw new NixAdapterRecordError("source.vcs_commit must be an immutable identifier"); + } + const result: ZedArtifactOrigin = { + registry: registryUrl(parsed.registry, "source.registry"), + artifact: parseArtifact(parsed.artifact, "source.artifact"), + vcs_tag: token(parsed.vcs_tag, "source.vcs_tag"), + vcs_commit: vcsCommit, + }; + if (parsed.lock_sha256 !== undefined && parsed.lock_sha256 !== null) { + result.lock_sha256 = hexSha256(parsed.lock_sha256, "source.lock_sha256"); + } + return result; +} + +function parseStoreReference(value: unknown, label: string): NixStoreReference { + const parsed = plainRecord(value, label); + exactKeys(parsed, ["store_path"], ["nar_hash", "nar_size"], label); + const result: NixStoreReference = { + store_path: nixStorePath(parsed.store_path, `${label}.store_path`), + }; + if (parsed.nar_hash !== undefined && parsed.nar_hash !== null) { + result.nar_hash = sriSha256(parsed.nar_hash, `${label}.nar_hash`); + } + if (parsed.nar_size !== undefined && parsed.nar_size !== null) { + result.nar_size = positiveSafeInteger(parsed.nar_size, `${label}.nar_size`); + } + return result; +} + +function parseRealizedOutput(value: unknown, label: string): NixRealizedOutput { + const parsed = plainRecord(value, label); + exactKeys( + parsed, + [ + "system", + "output", + "derivation_json_sha256", + "store_path", + "nar_hash", + "nar_size", + "nix_version", + "store_info_json_version", + ], + ["references", "signatures"], + label, + ); + const references = parsed.references === undefined + ? [] + : Array.isArray(parsed.references) + ? parsed.references.map((item, index) => parseStoreReference(item, `${label}.references[${index}]`)) + : (() => { + throw new NixAdapterRecordError(`${label}.references must be an array`); + })(); + unique(references, (item) => item.store_path, `${label}.references`); + + const signatures = parsed.signatures === undefined + ? [] + : stringArray(parsed.signatures, `${label}.signatures`) + .map((item, index) => token(item, `${label}.signatures[${index}]`)); + unique(signatures, (item) => item, `${label}.signatures`); + + const nixVersion = text(parsed.nix_version, `${label}.nix_version`); + if (nixVersion.trim().length === 0) { + throw new NixAdapterRecordError(`${label}.nix_version must be recorded`); + } + + const result: NixRealizedOutput = { + system: nixSystem(parsed.system, `${label}.system`), + output: nixIdentifier(parsed.output, `${label}.output`), + derivation_json_sha256: hexSha256( + parsed.derivation_json_sha256, + `${label}.derivation_json_sha256`, + ), + store_path: nixStorePath(parsed.store_path, `${label}.store_path`), + nar_hash: sriSha256(parsed.nar_hash, `${label}.nar_hash`), + nar_size: positiveSafeInteger(parsed.nar_size, `${label}.nar_size`), + nix_version: nixVersion, + store_info_json_version: supportedStoreInfoVersion( + parsed.store_info_json_version, + `${label}.store_info_json_version`, + ), + }; + if (references.length !== 0) { + result.references = [...references] + .sort((left, right) => left.store_path.localeCompare(right.store_path)); + } + if (signatures.length !== 0) { + result.signatures = [...signatures].sort(); + } + return result; +} + +function parseNixOrigin(value: unknown): NixOutputOrigin { + const parsed = plainRecord(value, "source"); + exactKeys( + parsed, + ["locked_ref", "flake_lock_sha256", "attribute", "realized"], + [], + "source", + ); + return { + locked_ref: immutableNixRef(parsed.locked_ref, "source.locked_ref"), + flake_lock_sha256: hexSha256(parsed.flake_lock_sha256, "source.flake_lock_sha256"), + attribute: nixAttributePath(parsed.attribute, "source.attribute"), + realized: parseRealizedOutput(parsed.realized, "source.realized"), + }; +} + +function parseZedToNix(parsed: JsonRecord): ZedToNixAdapterRecord { + exactKeys( + parsed, + [ + "direction", + "schema", + "package", + "source", + "intent", + "flake_bundle_sha256", + "outputs", + "policy", + ], + [], + "record", + ); + const pkg = parsePackage(parsed.package); + const intent = parseExportIntent(parsed.intent, pkg.name); + if (!Array.isArray(parsed.outputs) || parsed.outputs.length === 0) { + throw new NixAdapterRecordError("outputs must contain realized evidence"); + } + const outputs = parsed.outputs + .map((item, index) => parseRealizedOutput(item, `outputs[${index}]`)); + unique(outputs, (item) => `${item.system}\u0000${item.output}`, "outputs"); + const declaredSystems = new Set(intent.systems); + const declaredOutputs = new Set(intent.outputs); + const realizedSystems = new Set(); + for (const output of outputs) { + if (!declaredSystems.has(output.system) || !declaredOutputs.has(output.output)) { + throw new NixAdapterRecordError("realized output is outside declared intent"); + } + realizedSystems.add(output.system); + } + for (const system of declaredSystems) { + if (!realizedSystems.has(system)) { + throw new NixAdapterRecordError("a declared system lacks realized evidence"); + } + } + outputs.sort((left, right) => { + const bySystem = left.system.localeCompare(right.system); + return bySystem === 0 ? left.output.localeCompare(right.output) : bySystem; + }); + return { + direction: oneOf(parsed.direction, ["zed-to-nix"], "direction"), + schema: oneOf(parsed.schema, [NIX_ADAPTER_SCHEMA_V1], "schema"), + package: pkg, + source: parseZedOrigin(parsed.source), + intent, + flake_bundle_sha256: hexSha256(parsed.flake_bundle_sha256, "flake_bundle_sha256"), + outputs, + policy: parsePolicy(parsed.policy), + }; +} + +function parseNixToZed(parsed: JsonRecord): NixToZedAdapterRecord { + exactKeys( + parsed, + ["direction", "schema", "package", "source", "artifact", "policy"], + [], + "record", + ); + const source = parseNixOrigin(parsed.source); + if ((source.realized.references?.length ?? 0) !== 0) { + throw new NixAdapterRecordError( + "contract v1 Nix-to-Zed imports must be closure-free", + ); + } + return { + direction: oneOf(parsed.direction, ["nix-to-zed"], "direction"), + schema: oneOf(parsed.schema, [NIX_ADAPTER_SCHEMA_V1], "schema"), + package: parsePackage(parsed.package), + source, + artifact: parseArtifact(parsed.artifact, "artifact"), + policy: parsePolicy(parsed.policy), + }; +} + +/** Parse and normalize one strict `zed.nix-adapter/v1` record. */ +export function parseNixAdapterRecord(value: unknown): NixAdapterRecord { + const parsed = plainRecord(value, "record"); + const direction = text(parsed.direction, "direction"); + if (direction === "zed-to-nix") { + return parseZedToNix(parsed); + } + if (direction === "nix-to-zed") { + return parseNixToZed(parsed); + } + throw new NixAdapterRecordError("direction is unsupported"); +} + +/** Parse untrusted adapter JSON without echoing the source document on error. */ +export function parseNixAdapterRecordJson(json: string): NixAdapterRecord { + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch { + throw new NixAdapterRecordError("input is not valid JSON"); + } + return parseNixAdapterRecord(value); +} + +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJson); + } + if (typeof value === "object" && value !== null) { + const record = value as JsonRecord; + const result: JsonRecord = {}; + for (const key of Object.keys(record).sort()) { + result[key] = canonicalizeJson(record[key]); + } + return result; + } + return value; +} + +/** Rust-compatible compact JSON with recursively lexicographic object keys. */ +export function canonicalNixAdapterRecordJson(value: unknown): string { + return JSON.stringify(canonicalizeJson(parseNixAdapterRecord(value))); +} + +function subtleCrypto(): SubtleCrypto { + const subtle = globalThis.crypto?.subtle; + if (subtle === undefined) { + throw new NixAdapterRecordError("Web Crypto SHA-256 is unavailable"); + } + return subtle; +} + +async function sha256Hex(value: string | Uint8Array): Promise { + const bytes = typeof value === "string" + ? new TextEncoder().encode(value) + : Uint8Array.from(value); + const digest = await subtleCrypto().digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +/** SHA-256 hex of the canonical adapter bytes. */ +export async function nixAdapterRecordSha256(value: unknown): Promise { + return sha256Hex(canonicalNixAdapterRecordJson(value)); +} + +/** + * Verify that external adapter text is already canonical and optionally bound + * to an expected digest. This is structural/offline verification only; it does + * not replay Nix realization or prove that a store path currently exists. + */ +export async function verifyCanonicalNixAdapterRecordJson( + json: string, + expectedSha256?: string, +): Promise { + const record = parseNixAdapterRecordJson(json); + const canonicalJson = canonicalNixAdapterRecordJson(record); + if (json !== canonicalJson) { + throw new NixAdapterRecordError("input bytes are not canonical compact JSON"); + } + const digest = await sha256Hex(canonicalJson); + if (expectedSha256 !== undefined) { + const expected = hexSha256(expectedSha256, "expected adapter SHA-256"); + if (digest !== expected) { + throw new NixAdapterRecordError("canonical adapter SHA-256 mismatch"); + } + } + return { record, canonical_json: canonicalJson, sha256: digest }; +} + +/** + * Bind exact Zed artifact bytes to the artifact identity carried by the record. + * Nix-to-Zed records verify the translated artifact; Zed-to-Nix records verify + * the source Zed artifact. Nix realization claims still require CLI/store replay. + */ +export async function verifyNixAdapterArtifactBytes( + value: unknown, + bytes: Uint8Array, +): Promise { + const record = parseNixAdapterRecord(value); + const artifact = record.direction === "nix-to-zed" + ? record.artifact + : record.source.artifact; + const digest = await sha256Hex(bytes); + if (digest !== artifact.sha256) { + throw new NixAdapterRecordError("Zed artifact SHA-256 mismatch"); + } + if (bytes.byteLength !== artifact.size) { + throw new NixAdapterRecordError("Zed artifact size mismatch"); + } + return { record, sha256: digest, size: bytes.byteLength }; +} diff --git a/clients/typescript/src/nix-export-plan.ts b/clients/typescript/src/nix-export-plan.ts new file mode 100644 index 0000000..51c9a2b --- /dev/null +++ b/clients/typescript/src/nix-export-plan.ts @@ -0,0 +1,402 @@ +export const NIX_EXPORT_PLAN_SCHEMA_V1 = "zed.nix-export-plan/v1" as const; + +export type NixExportPackageClass = "data" | "prebuilt-bin"; +export type NixExportMode = "artifact"; +export type NixPolicyProfile = "strict-v1"; +export type NixBuilderNetwork = "disabled"; +export type NixArtifactFormat = "tar.gz" | "zip"; + +export interface NixPackageIdentity { + org: string; + name: string; + version: string; + target?: string; +} + +export interface ResolvedNixExportIntent { + mode: NixExportMode; + attribute: string; + systems: string[]; + outputs: string[]; +} + +export interface NixInteropArtifact { + format: NixArtifactFormat; + sha256: string; + size: number; +} + +export interface PlannedZedExportArtifact { + file_name: string; + artifact: NixInteropArtifact; + manifest_sha256: string; + lock_sha256: string; +} + +export interface PlannedNixExportDependency { + org: string; + name: string; + version: string; + sha256: string; +} + +export interface NixPolicyEvidence { + profile: NixPolicyProfile; + pure_evaluation: true; + import_from_derivation: false; + sandbox_required: true; + builder_network: NixBuilderNetwork; + dirty_source: false; + publishable: true; +} + +export interface NixExportPlan { + schema: typeof NIX_EXPORT_PLAN_SCHEMA_V1; + package: NixPackageIdentity; + package_class: NixExportPackageClass; + intent: ResolvedNixExportIntent; + source: PlannedZedExportArtifact; + bins: Record; + dependencies: PlannedNixExportDependency[]; + policy: NixPolicyEvidence; +} + +export class NixExportPlanError extends TypeError { + constructor(message: string) { + super(`invalid Nix export plan: ${message}`); + this.name = "NixExportPlanError"; + } +} + +type JsonRecord = Record; + +const SHA256_RE = /^[0-9a-f]{64}$/; +const SLUG_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/; +const TARGET_RE = /^[a-z0-9](?:[a-z0-9._+-]{0,126}[a-z0-9])?$/; +const NIX_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_'-]*$/; +const NIX_SYSTEM_RE = /^[a-z0-9_]+(?:-[a-z0-9_]+)+$/; +const BIN_NAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/; + +const ROOT_KEYS = [ + "schema", + "package", + "package_class", + "intent", + "source", + "bins", + "dependencies", + "policy", +] as const; + +function record(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NixExportPlanError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new NixExportPlanError(`${label} must be a plain JSON object`); + } + return value as JsonRecord; +} + +function exactKeys( + value: JsonRecord, + required: readonly string[], + optional: readonly string[], + label: string, +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + throw new NixExportPlanError(`${label} contains unknown field ${JSON.stringify(key)}`); + } + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + throw new NixExportPlanError(`${label} is missing field ${JSON.stringify(key)}`); + } + } +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new NixExportPlanError(`${label} must be a string`); + } + return value; +} + +function oneOf(value: unknown, allowed: readonly T[], label: string): T { + const parsed = string(value, label); + if (!allowed.includes(parsed as T)) { + throw new NixExportPlanError( + `${label} must be one of ${allowed.map((item) => JSON.stringify(item)).join(", ")}`, + ); + } + return parsed as T; +} + +function boolLiteral(value: unknown, expected: T, label: string): T { + if (value !== expected) { + throw new NixExportPlanError(`${label} must be ${String(expected)}`); + } + return expected; +} + +function positiveSafeInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new NixExportPlanError(`${label} must be a positive safe integer`); + } + return value; +} + +function sha256(value: unknown, label: string): string { + const parsed = string(value, label); + if (!SHA256_RE.test(parsed)) { + throw new NixExportPlanError(`${label} must be 64 lowercase hexadecimal characters`); + } + return parsed; +} + +function slug(value: unknown, label: string): string { + const parsed = string(value, label); + if (!SLUG_RE.test(parsed)) { + throw new NixExportPlanError(`${label} is not a valid public Zed slug`); + } + return parsed; +} + +function version(value: unknown, label: string): string { + const parsed = string(value, label); + if ( + parsed.length === 0 || + parsed.length > 256 || + parsed.trim() !== parsed || + /[\s/\\\u0000-\u001f\u007f]/u.test(parsed) + ) { + throw new NixExportPlanError(`${label} must be non-empty and filename-safe`); + } + return parsed; +} + +function target(value: unknown, label: string): string { + const parsed = string(value, label); + if (!TARGET_RE.test(parsed)) { + throw new NixExportPlanError(`${label} is not a valid target name`); + } + return parsed; +} + +function sortedUniqueStrings( + value: unknown, + label: string, + validate: (item: string, itemLabel: string) => string, +): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new NixExportPlanError(`${label} must be a non-empty array`); + } + const parsed = value.map((item, index) => validate(string(item, `${label}[${index}]`), `${label}[${index}]`)); + for (let index = 1; index < parsed.length; index += 1) { + if (parsed[index - 1] >= parsed[index]) { + throw new NixExportPlanError(`${label} must be sorted and unique`); + } + } + return parsed; +} + +function nixSystem(value: string, label: string): string { + if (!NIX_SYSTEM_RE.test(value)) { + throw new NixExportPlanError(`${label} must be a lowercase Nix system`); + } + return value; +} + +function nixIdentifier(value: string, label: string): string { + if (!NIX_IDENTIFIER_RE.test(value)) { + throw new NixExportPlanError(`${label} must be a Nix identifier`); + } + return value; +} + +function nixAttributePath(value: string, label: string): string { + if (value.length === 0 || !value.split(".").every((part) => NIX_IDENTIFIER_RE.test(part))) { + throw new NixExportPlanError(`${label} must be a dot-separated Nix attribute path`); + } + return value; +} + +function artifactRelativePath(value: unknown, label: string): string { + const parsed = string(value, label); + if ( + parsed.length === 0 || + parsed.startsWith("/") || + parsed.endsWith("/") || + parsed.includes("\\") || + parsed.split("/").some((part) => part.length === 0 || part === "." || part === ".." || /[\u0000-\u001f\u007f]/u.test(part)) + ) { + throw new NixExportPlanError(`${label} must be a safe forward-slash artifact-relative path`); + } + return parsed; +} + +function parsePackage(value: unknown): NixPackageIdentity { + const parsed = record(value, "package"); + exactKeys(parsed, ["org", "name", "version"], ["target"], "package"); + const result: NixPackageIdentity = { + org: slug(parsed.org, "package.org"), + name: slug(parsed.name, "package.name"), + version: version(parsed.version, "package.version"), + }; + if (parsed.target !== undefined) { + result.target = target(parsed.target, "package.target"); + } + return result; +} + +function parseIntent(value: unknown): ResolvedNixExportIntent { + const parsed = record(value, "intent"); + exactKeys(parsed, ["mode", "attribute", "systems", "outputs"], [], "intent"); + return { + mode: oneOf(parsed.mode, ["artifact"], "intent.mode"), + attribute: nixAttributePath(string(parsed.attribute, "intent.attribute"), "intent.attribute"), + systems: sortedUniqueStrings(parsed.systems, "intent.systems", nixSystem), + outputs: sortedUniqueStrings(parsed.outputs, "intent.outputs", nixIdentifier), + }; +} + +function parseArtifact(value: unknown): NixInteropArtifact { + const parsed = record(value, "source.artifact"); + exactKeys(parsed, ["format", "sha256", "size"], [], "source.artifact"); + return { + format: oneOf(parsed.format, ["tar.gz", "zip"], "source.artifact.format"), + sha256: sha256(parsed.sha256, "source.artifact.sha256"), + size: positiveSafeInteger(parsed.size, "source.artifact.size"), + }; +} + +function parseSource(value: unknown, pkg: NixPackageIdentity): PlannedZedExportArtifact { + const parsed = record(value, "source"); + exactKeys( + parsed, + ["file_name", "artifact", "manifest_sha256", "lock_sha256"], + [], + "source", + ); + const artifact = parseArtifact(parsed.artifact); + const expectedName = `${pkg.org}-${pkg.name}-${pkg.version}.${artifact.format}`; + const fileName = string(parsed.file_name, "source.file_name"); + if (fileName !== expectedName || fileName.startsWith(".") || fileName.includes("/") || fileName.includes("\\") || /\s/u.test(fileName)) { + throw new NixExportPlanError( + `source.file_name must be the canonical safe basename ${JSON.stringify(expectedName)}`, + ); + } + return { + file_name: fileName, + artifact, + manifest_sha256: sha256(parsed.manifest_sha256, "source.manifest_sha256"), + lock_sha256: sha256(parsed.lock_sha256, "source.lock_sha256"), + }; +} + +function parseBins(value: unknown): Record { + const parsed = record(value, "bins"); + const result: Record = {}; + for (const name of Object.keys(parsed).sort()) { + if (!BIN_NAME_RE.test(name)) { + throw new NixExportPlanError(`bins contains invalid executable name ${JSON.stringify(name)}`); + } + result[name] = artifactRelativePath(parsed[name], `bins.${name}`); + } + return result; +} + +function parseDependencies(value: unknown): PlannedNixExportDependency[] { + if (!Array.isArray(value)) { + throw new NixExportPlanError("dependencies must be an array"); + } + if (value.length !== 0) { + throw new NixExportPlanError("contract v1 accepts dependency-free packages only"); + } + return []; +} + +function parsePolicy(value: unknown): NixPolicyEvidence { + const parsed = record(value, "policy"); + exactKeys( + parsed, + [ + "profile", + "pure_evaluation", + "import_from_derivation", + "sandbox_required", + "builder_network", + "dirty_source", + "publishable", + ], + [], + "policy", + ); + return { + profile: oneOf(parsed.profile, ["strict-v1"], "policy.profile"), + pure_evaluation: boolLiteral(parsed.pure_evaluation, true, "policy.pure_evaluation"), + import_from_derivation: boolLiteral( + parsed.import_from_derivation, + false, + "policy.import_from_derivation", + ), + sandbox_required: boolLiteral(parsed.sandbox_required, true, "policy.sandbox_required"), + builder_network: oneOf(parsed.builder_network, ["disabled"], "policy.builder_network"), + dirty_source: boolLiteral(parsed.dirty_source, false, "policy.dirty_source"), + publishable: boolLiteral(parsed.publishable, true, "policy.publishable"), + }; +} + +/** Parse and normalize one strict `zed.nix-export-plan/v1` JSON value. */ +export function parseNixExportPlan(value: unknown): NixExportPlan { + const parsed = record(value, "plan"); + exactKeys( + parsed, + ["schema", "package", "package_class", "intent", "source", "policy"], + ["bins", "dependencies"], + "plan", + ); + const schema = oneOf(parsed.schema, [NIX_EXPORT_PLAN_SCHEMA_V1], "schema"); + const pkg = parsePackage(parsed.package); + const packageClass = oneOf( + parsed.package_class, + ["data", "prebuilt-bin"], + "package_class", + ); + const bins = parseBins(parsed.bins ?? {}); + if (packageClass === "data" && Object.keys(bins).length !== 0) { + throw new NixExportPlanError("data packages must have an empty bins object"); + } + if (packageClass === "prebuilt-bin" && Object.keys(bins).length === 0) { + throw new NixExportPlanError("prebuilt-bin packages must declare at least one executable"); + } + return { + schema, + package: pkg, + package_class: packageClass, + intent: parseIntent(parsed.intent), + source: parseSource(parsed.source, pkg), + bins, + dependencies: parseDependencies(parsed.dependencies ?? []), + policy: parsePolicy(parsed.policy), + }; +} + +/** Parse untrusted JSON text without exposing parser internals or source values. */ +export function parseNixExportPlanJson(json: string): NixExportPlan { + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch { + throw new NixExportPlanError("input is not valid JSON"); + } + return parseNixExportPlan(value); +} + +/** Stable compact JSON using the canonical schema field order and sorted bins. */ +export function canonicalNixExportPlanJson(value: unknown): string { + return JSON.stringify(parseNixExportPlan(value)); +} diff --git a/clients/typescript/src/runtimes/bun/index.ts b/clients/typescript/src/runtimes/bun/index.ts new file mode 100644 index 0000000..b867fd5 --- /dev/null +++ b/clients/typescript/src/runtimes/bun/index.ts @@ -0,0 +1,3 @@ +/** Explicit bun entry point for the shared fetch-based SDK. */ +export * from "../../index.js"; +export const zedRuntime = "bun" as const; diff --git a/clients/typescript/src/runtimes/deno/index.ts b/clients/typescript/src/runtimes/deno/index.ts new file mode 100644 index 0000000..d7a8794 --- /dev/null +++ b/clients/typescript/src/runtimes/deno/index.ts @@ -0,0 +1,3 @@ +/** Explicit deno entry point for the shared fetch-based SDK. */ +export * from "../../index.js"; +export const zedRuntime = "deno" as const; diff --git a/clients/typescript/src/runtimes/edge/index.ts b/clients/typescript/src/runtimes/edge/index.ts new file mode 100644 index 0000000..722f405 --- /dev/null +++ b/clients/typescript/src/runtimes/edge/index.ts @@ -0,0 +1,3 @@ +/** Explicit edge entry point for the shared fetch-based SDK. */ +export * from "../../index.js"; +export const zedRuntime = "edge" as const; diff --git a/clients/typescript/src/runtimes/nodejs/index.ts b/clients/typescript/src/runtimes/nodejs/index.ts new file mode 100644 index 0000000..1a14bed --- /dev/null +++ b/clients/typescript/src/runtimes/nodejs/index.ts @@ -0,0 +1,3 @@ +/** Explicit nodejs entry point for the shared fetch-based SDK. */ +export * from "../../index.js"; +export const zedRuntime = "nodejs" as const; diff --git a/clients/typescript/src/sync.ts b/clients/typescript/src/sync.ts new file mode 100644 index 0000000..a068136 --- /dev/null +++ b/clients/typescript/src/sync.ts @@ -0,0 +1,45 @@ +// Sync interop types for @zed-pkg/client consumers. +// +// The runtime sync engine is @zed-pkg/sync (optional peer); this module mirrors +// its wire contract — the same enums and change envelope zed-interfaces +// publishes as JSON Schema (schemas/sync-*.json) — so a client app can name a +// WriteMode or type a ChangeEvent without pulling in the engine. zed-sync +// remains the behavioral source of truth. + +export type Hlc = { wall_ms: number; counter: number; actor: string }; +export type SyncOp = "upsert" | "delete"; + +export const SyncWriteMode = { + LOCAL_ONLY: "local_only", + OPTIMISTIC_QUEUE: "optimistic_queue", + OPTIMISTIC_AWAIT_ACK: "optimistic_await_ack", + SERVER_FIRST: "server_first", + SERVER_ONLY: "server_only", +} as const; +export type SyncWriteMode = (typeof SyncWriteMode)[keyof typeof SyncWriteMode]; + +export const SyncErrorPolicy = { + THROW_ONLY: "throw_only", + EMIT_ONLY: "emit_only", + THROW_AND_EMIT: "throw_and_emit", + SILENT: "silent", +} as const; +export type SyncErrorPolicy = (typeof SyncErrorPolicy)[keyof typeof SyncErrorPolicy]; + +export const SyncConflictResolution = { + SERVER_WINS: "server_wins", + LAST_WRITE_WINS: "last_write_wins", +} as const; +export type SyncConflictResolution = + (typeof SyncConflictResolution)[keyof typeof SyncConflictResolution]; + +export interface SyncChangeEvent { + table: string; + op: SyncOp; + id: string; + version: Hlc; + row?: Record | null; + at_ms: number; + write_key?: string; + sync_sequence?: number; +} diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts new file mode 100644 index 0000000..aa1bfab --- /dev/null +++ b/clients/typescript/src/types.ts @@ -0,0 +1,69 @@ +// Wire types mirroring zed-interfaces/schemas/*.json (snake_case as on the +// wire; regenerate against those schemas when the contract changes). + +export type Vcs = "git" | "hg" | "jj" | "sapling" | "fossil" | "pijul"; +export type ArtifactFormat = "tar.gz" | "zip"; +export type VersionScheme = "semver" | "calver" | "opaque"; + +export interface PackageSummary { + org: string; + name: string; + description?: string | null; + latest?: string | null; + tags?: string[]; +} + +export interface PackageMetadata { + org: string; + name: string; + description?: string | null; + vcs: Vcs; + repo_url: string; + latest?: string | null; + versions: string[]; + version_scheme?: VersionScheme; + tags?: string[]; +} + +export interface VersionMetadata { + org: string; + name: string; + version: string; + sha256: string; + size: number; + format: ArtifactFormat; + vcs_tag: string; + vcs_commit?: string | null; + download_url: string; + published_at: string; + yanked: boolean; +} + +export interface SearchResponse { + query: string; + items: PackageSummary[]; +} + +export interface ClaimOrgResponse { + slug: string; + created: boolean; +} + +export interface YankResponse { + org: string; + name: string; + version: string; + yanked: boolean; +} + +export interface PublishResponse { + org: string; + name: string; + version: string; + sha256: string; +} + +export interface ApiErrorBody { + code: string; + message: string; +} diff --git a/clients/typescript/test/client.test.mjs b/clients/typescript/test/client.test.mjs new file mode 100644 index 0000000..496a769 --- /dev/null +++ b/clients/typescript/test/client.test.mjs @@ -0,0 +1,278 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + ZedClient, + ZedApiError, + MAX_ARTIFACT_BYTES, + MAX_ERROR_BODY_BYTES, + packagePath, + versionPath, + yankPath, + filePath, + normalizeRegistryUrl, +} from "../dist/index.js"; + +function makeVersion(overrides = {}) { + return { + org: "acme", + name: "kit", + version: "1.2.0", + sha256: "", + size: 0, + format: "tar.gz", + vcs_tag: "v1.2.0", + download_url: "", + published_at: "2024-01-01T00:00:00Z", + yanked: false, + ...overrides, + }; +} + +const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +test("url helpers match and encode the core contract", () => { + assert.equal(packagePath("acme", "kit"), "/v1/packages/acme/kit"); + assert.equal(versionPath("acme", "kit", "1.2.0"), "/v1/packages/acme/kit/versions/1.2.0"); + assert.equal(yankPath("acme", "kit", "1.2.0"), "/v1/packages/acme/kit/versions/1.2.0/yank"); + assert.equal(filePath("acme", "kit", "1.2.0", "dist/x.css"), "/v1/files/acme/kit/1.2.0/dist/x.css"); + assert.equal( + versionPath("acme", "kit", "release candidate/1"), + "/v1/packages/acme/kit/versions/release%20candidate%2F1", + ); + assert.equal(packagePath("a?b", "c#d"), "/v1/packages/a%3Fb/c%23d"); +}); + +test("registry base URLs are validated and gateway prefixes are preserved", () => { + assert.equal(normalizeRegistryUrl(" https://registry.test/gateway/// "), "https://registry.test/gateway"); + for (const invalid of [ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + ]) { + assert.throws(() => new ZedClient({ registryUrl: invalid }), TypeError, invalid); + } +}); + +test("complete structured errors preserve their stable registry code", async () => { + const remote = "provider-secret"; + const fakeFetch = async () => + new Response(JSON.stringify({ code: "org_taken", message: remote }), { status: 409 }); + const client = new ZedClient({ + registryUrl: "https://x.test///", + token: "token", + fetchImpl: fakeFetch, + }); + await assert.rejects( + () => client.claimOrg("acme"), + (error) => { + assert.ok(error instanceof ZedApiError); + assert.equal(error.code, "org_taken"); + assert.equal(error.status, 409); + assert.equal(error.message, "registry error 409: org_taken"); + assert.equal(error.registryMessage, remote); + assert.ok(!error.message.includes("provider-secret")); + return true; + }, + ); +}); + +test("truncated JSON errors fall back to a stable HTTP-derived code", async () => { + const remote = "provider-secret".repeat(MAX_ERROR_BODY_BYTES); + const fakeFetch = async () => + new Response(JSON.stringify({ code: "org_taken", message: remote }), { status: 409 }); + const client = new ZedClient({ + registryUrl: "https://x.test///", + token: "token", + fetchImpl: fakeFetch, + }); + await assert.rejects( + () => client.claimOrg("acme"), + (error) => { + assert.ok(error instanceof ZedApiError); + assert.equal(error.code, "http_409"); + assert.equal(error.status, 409); + assert.equal(error.message, "registry error 409: http_409"); + assert.ok(!error.message.includes("provider-secret")); + assert.ok(error.registryMessage.length <= MAX_ERROR_BODY_BYTES + 1); + return true; + }, + ); +}); + +test("error code falls back to http status and non-JSON text stays explicit only", async () => { + const fakeFetch = async () => new Response("bad gateway", { status: 502 }); + const client = new ZedClient({ registryUrl: "https://x.test", fetchImpl: fakeFetch }); + await assert.rejects( + () => client.search("x"), + (error) => + error instanceof ZedApiError && + error.code === "http_502" && + error.message === "registry error 502: http_502" && + error.registryMessage === "bad gateway", + ); +}); + +test("public reads omit bearer auth, authenticated writes attach it, and redirects are refused", async () => { + const seen = []; + const fakeFetch = async (url, init) => { + seen.push({ + url: String(url), + authorization: new Headers(init.headers).get("authorization"), + redirect: init.redirect, + body: init.body, + }); + if (String(url).endsWith("/v1/orgs")) { + return new Response(JSON.stringify({ slug: "acme", created: true }), { status: 200 }); + } + return new Response(JSON.stringify({ query: "http", items: [] }), { status: 200 }); + }; + const client = new ZedClient({ + registryUrl: "https://x.test/gateway", + token: " zpkg_t ", + fetchImpl: fakeFetch, + }); + await client.search("http"); + await client.claimOrg("acme"); + assert.equal(seen[0].url, "https://x.test/gateway/v1/search?q=http"); + assert.equal(seen[0].authorization, null); + assert.equal(seen[0].redirect, "error"); + assert.equal(seen[1].authorization, "Bearer zpkg_t"); + assert.equal(seen[1].redirect, "error"); +}); + +test("yank is an authenticated core operation with the canonical body", async () => { + let captured; + const fakeFetch = async (url, init) => { + captured = { url: String(url), init }; + return new Response( + JSON.stringify({ org: "acme", name: "kit", version: "1.2.0", yanked: true }), + { status: 200 }, + ); + }; + const client = new ZedClient({ registryUrl: "https://x.test", token: "token", fetchImpl: fakeFetch }); + const result = await client.yank("acme", "kit", "1.2.0", true); + assert.equal(result.yanked, true); + assert.equal(captured.url, "https://x.test/v1/packages/acme/kit/versions/1.2.0/yank"); + assert.equal(captured.init.method, "POST"); + assert.equal(new Headers(captured.init.headers).get("authorization"), "Bearer token"); + assert.deepEqual(JSON.parse(captured.init.body), { yanked: true }); +}); + +test("download omits auth, refuses redirects, and verifies sha256", async () => { + const body = new TextEncoder().encode("artifact-bytes"); + let captured; + const fakeFetch = async (url, init) => { + captured = { url: String(url), init }; + return new Response(body, { status: 200 }); + }; + const client = new ZedClient({ + registryUrl: "https://x.test", + token: "zpkg_t", + fetchImpl: fakeFetch, + }); + const version = makeVersion({ + sha256: sha256Hex(body), + size: body.length, + download_url: "https://cdn.example/artifact?signature=one", + }); + const out = await client.downloadArtifact(version); + assert.equal(new Headers(captured.init?.headers).get("authorization"), null); + assert.equal(captured.init.redirect, "error"); + assert.equal(captured.url, "https://cdn.example/artifact?signature=one"); + assert.deepEqual(out, body); +}); + +test("download resolves registry-relative URLs and rejects insecure schemes", async () => { + const body = new TextEncoder().encode("ok"); + let seenUrl; + const fakeFetch = async (url) => { + seenUrl = String(url); + return new Response(body, { status: 200 }); + }; + const client = new ZedClient({ registryUrl: "https://x.test/gateway", fetchImpl: fakeFetch }); + await client.downloadArtifact( + makeVersion({ + sha256: sha256Hex(body), + size: body.length, + download_url: "artifacts/hash", + }), + ); + assert.equal(seenUrl, "https://x.test/gateway/artifacts/hash"); + + for (const url of ["http://evil.example/artifact", "file:///etc/passwd"]) { + await assert.rejects( + () => client.downloadArtifact(makeVersion({ download_url: url })), + (error) => error instanceof ZedApiError && error.code === "insecure_download_url", + ); + } +}); + +test("explicit HTTP development registries may use another HTTP artifact host", async () => { + const body = new TextEncoder().encode("development-artifact"); + let seenUrl; + const client = new ZedClient({ + registryUrl: "http://registry.dev/gateway", + fetchImpl: async (url) => { + seenUrl = String(url); + return new Response(body, { status: 200 }); + }, + }); + const out = await client.downloadArtifact( + makeVersion({ + sha256: sha256Hex(body), + size: body.length, + download_url: "http://artifacts.dev/artifact", + }), + ); + assert.equal(seenUrl, "http://artifacts.dev/artifact"); + assert.deepEqual(out, body); +}); + +test("download allows loopback HTTP and enforces streamed and declared caps", async () => { + const body = new TextEncoder().encode("ok"); + const client = new ZedClient({ + registryUrl: "https://x.test", + fetchImpl: async () => new Response(body, { status: 200 }), + }); + const out = await client.downloadArtifact( + makeVersion({ + sha256: sha256Hex(body), + size: body.length, + download_url: "http://127.0.0.1:8080/artifact", + }), + ); + assert.deepEqual(out, body); + + const limit = 1 + 1024 * 1024; + const big = new Uint8Array(limit + 64); + const streamed = new ZedClient({ + registryUrl: "https://x.test", + fetchImpl: async () => new Response(big, { status: 200 }), + }); + await assert.rejects( + () => + streamed.downloadArtifact( + makeVersion({ sha256: sha256Hex(big), size: 1, download_url: "https://cdn.example/a" }), + ), + (error) => error instanceof ZedApiError && error.code === "artifact_too_large", + ); + + const declared = new ZedClient({ + registryUrl: "https://x.test", + fetchImpl: async () => + new Response(body, { + status: 200, + headers: { "content-length": String(MAX_ARTIFACT_BYTES + 1) }, + }), + }); + await assert.rejects( + () => + declared.downloadArtifact( + makeVersion({ sha256: sha256Hex(body), download_url: "https://cdn.example/a" }), + ), + (error) => error instanceof ZedApiError && error.code === "artifact_too_large", + ); +}); diff --git a/clients/typescript/test/hardening.test.mjs b/clients/typescript/test/hardening.test.mjs new file mode 100644 index 0000000..6fb1ec5 --- /dev/null +++ b/clients/typescript/test/hardening.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MAX_ARTIFACT_BYTES, + MAX_PATH_SEGMENT_BYTES, + ZedApiError, + ZedClient, + encodePathSegment, + filePath, + packagePath, +} from "../dist/index.js"; + +const publishMeta = { + manifest: { package: { org: "acme", name: "kit", version: "1.2.0" } }, +}; + +test("hostile and ambiguous route segments are rejected before transport", () => { + for (const value of ["", " ", ".", "..", "line\nbreak", "nul\0byte"]) { + assert.throws(() => encodePathSegment(value), TypeError, value); + } + assert.throws( + () => encodePathSegment("x".repeat(MAX_PATH_SEGMENT_BYTES + 1)), + TypeError, + ); + assert.throws(() => packagePath("..", "kit"), TypeError); + assert.throws(() => filePath("acme", "kit", "1.2.0", "dist/../secret"), TypeError); + assert.equal(encodePathSegment("release candidate/1"), "release%20candidate%2F1"); +}); + +test("authenticated operations fail closed before invoking fetch", async () => { + let calls = 0; + const client = new ZedClient({ + registryUrl: "https://registry.test", + fetchImpl: async () => { + calls += 1; + throw new Error("transport must not run"); + }, + }); + + for (const operation of [ + () => client.claimOrg("acme"), + () => client.yank("acme", "kit", "1.2.0"), + () => client.restore("acme", "kit", "1.2.0"), + () => client.publish(publishMeta, new Blob(["artifact"])), + ]) { + await assert.rejects( + operation, + (error) => error instanceof ZedApiError && error.code === "missing_token", + ); + } + assert.equal(calls, 0); +}); + +test("publish rejects oversized artifacts before invoking fetch", async () => { + let calls = 0; + const client = new ZedClient({ + registryUrl: "https://registry.test", + token: "token", + fetchImpl: async () => { + calls += 1; + throw new Error("transport must not run"); + }, + }); + await assert.rejects( + () => client.publish(publishMeta, { size: MAX_ARTIFACT_BYTES + 1 }), + (error) => error instanceof ZedApiError && error.code === "artifact_too_large", + ); + assert.equal(calls, 0); +}); + +test("yank and restore share the canonical authenticated endpoint", async () => { + const bodies = []; + const client = new ZedClient({ + registryUrl: "https://registry.test", + token: " token ", + fetchImpl: async (_url, init) => { + bodies.push({ + authorization: new Headers(init.headers).get("authorization"), + body: JSON.parse(init.body), + }); + return new Response( + JSON.stringify({ org: "acme", name: "kit", version: "1.2.0", yanked: init.body.includes("true") }), + { status: 200 }, + ); + }, + }); + assert.equal((await client.yank("acme", "kit", "1.2.0")).yanked, true); + assert.equal((await client.restore("acme", "kit", "1.2.0")).yanked, false); + assert.deepEqual(bodies, [ + { authorization: "Bearer token", body: { yanked: true } }, + { authorization: "Bearer token", body: { yanked: false } }, + ]); +}); + +test("the request deadline remains active while consuming a streamed body", async () => { + const client = new ZedClient({ + registryUrl: "https://registry.test", + timeoutMs: 10, + fetchImpl: async (_url, init) => { + const stream = new ReadableStream({ + start(controller) { + init.signal.addEventListener( + "abort", + () => controller.error(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }, + }); + return new Response(stream, { status: 200 }); + }, + }); + await assert.rejects( + () => client.search("never-completes"), + (error) => error?.name === "AbortError" || /abort/i.test(String(error)), + ); +}); + +test("blank structured error codes fall back to the HTTP-derived code", async () => { + const client = new ZedClient({ + registryUrl: "https://registry.test", + fetchImpl: async () => + new Response(JSON.stringify({ code: " ", message: "remote detail" }), { status: 409 }), + }); + await assert.rejects( + () => client.search("x"), + (error) => error instanceof ZedApiError && error.code === "http_409", + ); +}); diff --git a/clients/typescript/test/nix-adapter.test.mjs b/clients/typescript/test/nix-adapter.test.mjs new file mode 100644 index 0000000..9c8b237 --- /dev/null +++ b/clients/typescript/test/nix-adapter.test.mjs @@ -0,0 +1,291 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + NIX_ADAPTER_SCHEMA_V1, + NixAdapterRecordError, + canonicalNixAdapterRecordJson, + nixAdapterRecordSha256, + parseNixAdapterRecord, + parseNixAdapterRecordJson, + verifyCanonicalNixAdapterRecordJson, + verifyNixAdapterArtifactBytes, +} from "../dist/index.js"; + +const HEX_A = "a".repeat(64); +const HEX_B = "b".repeat(64); +const HEX_C = "c".repeat(64); +const NAR_A = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const STORE_A = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; +const STORE_B = "/nix/store/11111111111111111111111111111111-runtime-1.0.0"; +const STORE_C = "/nix/store/22222222222222222222222222222222-data-1.0.0"; + +const STRICT_POLICY = { + profile: "strict-v1", + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: "disabled", + dirty_source: false, + publishable: true, +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function realized(overrides = {}) { + return { + system: "x86_64-linux", + output: "out", + derivation_json_sha256: HEX_B, + store_path: STORE_A, + nar_hash: NAR_A, + nar_size: 128, + nix_version: "2.35.2", + store_info_json_version: 3, + ...overrides, + }; +} + +function nixToZed(overrides = {}) { + return { + direction: "nix-to-zed", + schema: NIX_ADAPTER_SCHEMA_V1, + package: { + org: "acme", + name: "tool", + version: "1.2.3", + }, + source: { + locked_ref: `github:acme/tool/${HEX_A.slice(0, 40)}`, + flake_lock_sha256: HEX_A, + attribute: "packages.x86_64-linux.tool", + realized: realized(), + }, + artifact: { + format: "tar.gz", + sha256: HEX_C, + size: 512, + }, + policy: STRICT_POLICY, + ...overrides, + }; +} + +function zedToNix(overrides = {}) { + return { + direction: "zed-to-nix", + schema: NIX_ADAPTER_SCHEMA_V1, + package: { + org: "acme", + name: "tool", + version: "1.2.3", + }, + source: { + registry: "https://zpkg.example", + artifact: { + format: "tar.gz", + sha256: HEX_C, + size: 512, + }, + vcs_tag: "v1.2.3", + vcs_commit: HEX_A.slice(0, 40), + lock_sha256: HEX_A, + }, + intent: { + mode: "artifact", + attribute: "tool", + systems: ["x86_64-linux", "aarch64-linux"], + outputs: ["out", "dev"], + }, + flake_bundle_sha256: HEX_B, + outputs: [ + realized({ + system: "x86_64-linux", + output: "out", + references: [ + { store_path: STORE_C, nar_hash: NAR_A, nar_size: 64 }, + { store_path: STORE_B }, + ], + signatures: ["cache-z:signature", "cache-a:signature"], + }), + realized({ + system: "aarch64-linux", + output: "dev", + store_path: STORE_C, + }), + ], + policy: STRICT_POLICY, + ...overrides, + }; +} + +test("Nix-to-Zed canonical bytes and SHA-256 match the Rust golden vector", async () => { + const expected = "{\"artifact\":{\"format\":\"tar.gz\",\"sha256\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"size\":512},\"direction\":\"nix-to-zed\",\"package\":{\"name\":\"tool\",\"org\":\"acme\",\"version\":\"1.2.3\"},\"policy\":{\"builder_network\":\"disabled\",\"dirty_source\":false,\"import_from_derivation\":false,\"profile\":\"strict-v1\",\"publishable\":true,\"pure_evaluation\":true,\"sandbox_required\":true},\"schema\":\"zed.nix-adapter/v1\",\"source\":{\"attribute\":\"packages.x86_64-linux.tool\",\"flake_lock_sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"locked_ref\":\"github:acme/tool/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"realized\":{\"derivation_json_sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"nar_hash\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"nar_size\":128,\"nix_version\":\"2.35.2\",\"output\":\"out\",\"store_info_json_version\":3,\"store_path\":\"/nix/store/00000000000000000000000000000000-tool-1.2.3\",\"system\":\"x86_64-linux\"}}}"; + const expectedDigest = "dd61b8bea180140a0ec564a8cf6144d4e71ce47339c182b67bcb5848b14efe50"; + + assert.equal(canonicalNixAdapterRecordJson(nixToZed()), expected); + assert.equal(await nixAdapterRecordSha256(nixToZed()), expectedDigest); + const verified = await verifyCanonicalNixAdapterRecordJson(expected, expectedDigest); + assert.equal(verified.canonical_json, expected); + assert.equal(verified.sha256, expectedDigest); + assert.equal(verified.record.direction, "nix-to-zed"); +}); + +test("canonical verification rejects formatting drift and digest drift", async () => { + const canonical = canonicalNixAdapterRecordJson(nixToZed()); + await assert.rejects( + verifyCanonicalNixAdapterRecordJson(`${canonical}\n`), + /not canonical compact JSON/, + ); + await assert.rejects( + verifyCanonicalNixAdapterRecordJson(canonical, "0".repeat(64)), + /SHA-256 mismatch/, + ); +}); + +test("Zed-to-Nix arrays normalize exactly like the Rust record", () => { + const parsed = parseNixAdapterRecord(zedToNix()); + assert.deepEqual(parsed.intent.systems, ["aarch64-linux", "x86_64-linux"]); + assert.deepEqual(parsed.intent.outputs, ["dev", "out"]); + assert.deepEqual( + parsed.outputs.map((output) => `${output.system}/${output.output}`), + ["aarch64-linux/dev", "x86_64-linux/out"], + ); + const x86 = parsed.outputs[1]; + assert.deepEqual( + x86.references.map((reference) => reference.store_path), + [STORE_B, STORE_C], + ); + assert.deepEqual(x86.signatures, ["cache-a:signature", "cache-z:signature"]); +}); + +test("Nix-to-Zed contract v1 rejects any runtime store reference", () => { + const record = nixToZed(); + record.source.realized.references = [{ store_path: STORE_B }]; + assert.throws( + () => parseNixAdapterRecord(record), + /must be closure-free/, + ); +}); + +test("immutable selector and realization evidence fail closed", () => { + const mutations = [ + ["mutable locked ref", (record) => { record.source.locked_ref = "github:acme/tool/main"; }], + ["invalid store path", (record) => { record.source.realized.store_path = "/tmp/tool"; }], + ["invalid NAR hash", (record) => { record.source.realized.nar_hash = "sha256-not-base64"; }], + ["unknown JSON version", (record) => { record.source.realized.store_info_json_version = 4; }], + ["unsafe numeric size", (record) => { record.source.realized.nar_size = Number.MAX_SAFE_INTEGER + 1; }], + ]; + for (const [label, mutate] of mutations) { + const record = nixToZed(); + mutate(record); + assert.throws( + () => parseNixAdapterRecord(record), + NixAdapterRecordError, + label, + ); + } +}); + +test("strict policy cannot downgrade and development cannot publish", () => { + for (const [field, value] of [ + ["pure_evaluation", false], + ["import_from_derivation", true], + ["sandbox_required", false], + ["builder_network", "allowed"], + ["dirty_source", true], + ["publishable", false], + ]) { + const record = nixToZed(); + record.policy = { ...record.policy, [field]: value }; + assert.throws(() => parseNixAdapterRecord(record), /strict-v1 policy/); + } + + const development = nixToZed({ + policy: { + ...STRICT_POLICY, + profile: "development", + publishable: true, + }, + }); + assert.throws(() => parseNixAdapterRecord(development), /never publishable/); +}); + +test("duplicate and intent-inconsistent realization evidence is rejected", () => { + const duplicateReference = zedToNix(); + duplicateReference.outputs[0].references = [ + { store_path: STORE_B }, + { store_path: STORE_B }, + ]; + assert.throws(() => parseNixAdapterRecord(duplicateReference), /duplicate values/); + + const duplicateSignature = zedToNix(); + duplicateSignature.outputs[0].signatures = ["cache:signature", "cache:signature"]; + assert.throws(() => parseNixAdapterRecord(duplicateSignature), /duplicate values/); + + const duplicateOutput = zedToNix(); + duplicateOutput.outputs.push(clone(duplicateOutput.outputs[0])); + assert.throws(() => parseNixAdapterRecord(duplicateOutput), /duplicate values/); + + const undeclared = zedToNix(); + undeclared.outputs[0].output = "doc"; + assert.throws(() => parseNixAdapterRecord(undeclared), /outside declared intent/); + + const missingSystem = zedToNix(); + missingSystem.outputs = missingSystem.outputs.filter((output) => output.system !== "aarch64-linux"); + assert.throws(() => parseNixAdapterRecord(missingSystem), /lacks realized evidence/); +}); + +test("unknown fields and malformed JSON never echo secret values", () => { + const secret = "private-cache-key-must-not-appear"; + const record = nixToZed({ private_cache_key: secret }); + assert.throws( + () => parseNixAdapterRecord(record), + (error) => error instanceof NixAdapterRecordError && !error.message.includes(secret), + ); + assert.throws( + () => parseNixAdapterRecordJson(`{\"secret\":\"${secret}\"`), + (error) => + error instanceof NixAdapterRecordError && + error.message === "invalid Nix adapter record: input is not valid JSON" && + !error.message.includes(secret), + ); +}); + +test("artifact bytes bind to the direction-specific Zed artifact identity", async () => { + const bytes = new TextEncoder().encode("ordinary deterministic Zed artifact"); + const digest = createHash("sha256").update(bytes).digest("hex"); + + const imported = nixToZed({ + artifact: { format: "tar.gz", sha256: digest, size: bytes.byteLength }, + }); + const verifiedImport = await verifyNixAdapterArtifactBytes(imported, bytes); + assert.equal(verifiedImport.sha256, digest); + assert.equal(verifiedImport.size, bytes.byteLength); + + const exported = zedToNix(); + exported.source.artifact = { + format: "tar.gz", + sha256: digest, + size: bytes.byteLength, + }; + const verifiedExport = await verifyNixAdapterArtifactBytes(exported, bytes); + assert.equal(verifiedExport.record.direction, "zed-to-nix"); + + await assert.rejects( + verifyNixAdapterArtifactBytes(imported, new TextEncoder().encode("tampered")), + /artifact SHA-256 mismatch/, + ); +}); + +test("parsing returns fresh normalized objects without mutating callers", () => { + const input = zedToNix(); + const before = clone(input); + const parsed = parseNixAdapterRecord(input); + parsed.package.name = "changed"; + parsed.outputs[0].system = "riscv64-linux"; + assert.deepEqual(input, before); +}); diff --git a/clients/typescript/test/nix-export-plan.test.mjs b/clients/typescript/test/nix-export-plan.test.mjs new file mode 100644 index 0000000..8c534fc --- /dev/null +++ b/clients/typescript/test/nix-export-plan.test.mjs @@ -0,0 +1,250 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + NIX_EXPORT_PLAN_SCHEMA_V1, + NixExportPlanError, + canonicalNixExportPlanJson, + parseNixExportPlan, + parseNixExportPlanJson, +} from "../dist/index.js"; + +const digest = (character) => character.repeat(64); + +function validPlan(overrides = {}) { + return { + schema: NIX_EXPORT_PLAN_SCHEMA_V1, + package: { + org: "acme", + name: "dataset", + version: "1.2.3", + }, + package_class: "data", + intent: { + mode: "artifact", + attribute: "packages.dataset", + systems: ["aarch64-linux", "x86_64-linux"], + outputs: ["out"], + }, + source: { + file_name: "acme-dataset-1.2.3.tar.gz", + artifact: { + format: "tar.gz", + sha256: digest("a"), + size: 1234, + }, + manifest_sha256: digest("b"), + lock_sha256: digest("c"), + }, + bins: {}, + dependencies: [], + policy: { + profile: "strict-v1", + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: "disabled", + dirty_source: false, + publishable: true, + }, + ...overrides, + }; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +test("strict data plans parse and retain explicit empty collections", () => { + const parsed = parseNixExportPlan(validPlan()); + assert.equal(parsed.schema, NIX_EXPORT_PLAN_SCHEMA_V1); + assert.deepEqual(parsed.bins, {}); + assert.deepEqual(parsed.dependencies, []); + assert.deepEqual(parsed.intent.systems, ["aarch64-linux", "x86_64-linux"]); + assert.equal(parsed.intent.attribute, "packages.dataset"); + assert.equal(parsed.policy.builder_network, "disabled"); +}); + +test("prebuilt-bin plans sort executable maps and use forward-slash paths", () => { + const plan = validPlan({ + package: { org: "acme", name: "tool", version: "2.0.0", target: "nodejs" }, + package_class: "prebuilt-bin", + source: { + file_name: "acme-tool-2.0.0.zip", + artifact: { format: "zip", sha256: digest("d"), size: 77 }, + manifest_sha256: digest("e"), + lock_sha256: digest("f"), + }, + bins: { + zeta: "bin/zeta", + alpha: "dist/bin/alpha", + }, + }); + const parsed = parseNixExportPlan(plan); + assert.deepEqual(Object.keys(parsed.bins), ["alpha", "zeta"]); + assert.equal(parsed.package.target, "nodejs"); +}); + +test("canonical JSON is stable across input object key order", () => { + const normal = validPlan(); + const shuffled = { + policy: normal.policy, + dependencies: normal.dependencies, + bins: normal.bins, + source: normal.source, + intent: normal.intent, + package_class: normal.package_class, + package: normal.package, + schema: normal.schema, + }; + const canonical = canonicalNixExportPlanJson(normal); + assert.equal(canonicalNixExportPlanJson(shuffled), canonical); + assert.equal(JSON.stringify(JSON.parse(canonical)), canonical); + assert.match(canonical, /"bins":\{\},"dependencies":\[\]/); +}); + +test("untrusted JSON syntax errors do not echo source text", () => { + const secret = "credential-that-must-not-appear"; + assert.throws( + () => parseNixExportPlanJson(`{"schema":"${secret}"`), + (error) => + error instanceof NixExportPlanError && + error.message === "invalid Nix export plan: input is not valid JSON" && + !error.message.includes(secret), + ); +}); + +test("unknown root and nested fields fail closed without echoing values", () => { + const root = validPlan({ registry: "https://person:secret@example.invalid" }); + assert.throws( + () => parseNixExportPlan(root), + (error) => + error instanceof NixExportPlanError && + error.message.includes("unknown field") && + !error.message.includes("person:secret"), + ); + + const nested = validPlan(); + nested.source.registry_url = "https://person:secret@example.invalid"; + assert.throws( + () => parseNixExportPlan(nested), + (error) => + error instanceof NixExportPlanError && + error.message.includes("source contains unknown field") && + !error.message.includes("person:secret"), + ); +}); + +test("omitted empty collections normalize to explicit canonical values", () => { + const omitted = validPlan(); + delete omitted.bins; + delete omitted.dependencies; + const parsed = parseNixExportPlan(omitted); + assert.deepEqual(parsed.bins, {}); + assert.deepEqual(parsed.dependencies, []); + assert.match( + canonicalNixExportPlanJson(omitted), + /"bins":\{\},"dependencies":\[\]/, + ); +}); + +test("systems and outputs must already be canonical and unique", () => { + for (const systems of [ + ["x86_64-linux", "aarch64-linux"], + ["x86_64-linux", "x86_64-linux"], + ]) { + const plan = validPlan(); + plan.intent.systems = systems; + assert.throws(() => parseNixExportPlan(plan), /systems must be sorted and unique/); + } + + const multiSegment = validPlan(); + multiSegment.intent.systems = ["aarch64-linux-gnu", "x86_64-linux"]; + assert.doesNotThrow(() => parseNixExportPlan(multiSegment)); + + const duplicateOutputs = validPlan(); + duplicateOutputs.intent.outputs = ["out", "out"]; + assert.throws(() => parseNixExportPlan(duplicateOutputs), /outputs must be sorted and unique/); +}); + +test("artifact identity and exact input digests fail closed", () => { + const wrongName = validPlan(); + wrongName.source.file_name = "other.tar.gz"; + assert.throws(() => parseNixExportPlan(wrongName), /canonical safe basename/); + + for (const mutate of [ + (plan) => { + plan.source.artifact.sha256 = "A".repeat(64); + }, + (plan) => { + plan.source.manifest_sha256 = "short"; + }, + (plan) => { + plan.source.lock_sha256 = "0".repeat(63); + }, + (plan) => { + plan.source.artifact.size = 0; + }, + (plan) => { + plan.source.artifact.size = Number.MAX_SAFE_INTEGER + 1; + }, + ]) { + const plan = validPlan(); + mutate(plan); + assert.throws(() => parseNixExportPlan(plan), NixExportPlanError); + } +}); + +test("class and executable inventory must agree", () => { + const dataWithBin = validPlan({ bins: { tool: "bin/tool" } }); + assert.throws(() => parseNixExportPlan(dataWithBin), /data packages must have an empty bins/); + + const emptyPrebuilt = validPlan({ package_class: "prebuilt-bin" }); + assert.throws(() => parseNixExportPlan(emptyPrebuilt), /must declare at least one executable/); +}); + +test("artifact-relative executable paths reject traversal and host separators", () => { + for (const path of ["../outside", "/absolute", "bin\\tool", "bin//tool", "bin/./tool", "bin/tool/"]) { + const plan = validPlan({ + package: { org: "acme", name: "tool", version: "1.0.0" }, + package_class: "prebuilt-bin", + source: { + file_name: "acme-tool-1.0.0.tar.gz", + artifact: { format: "tar.gz", sha256: digest("d"), size: 10 }, + manifest_sha256: digest("e"), + lock_sha256: digest("f"), + }, + bins: { tool: path }, + }); + assert.throws(() => parseNixExportPlan(plan), /artifact-relative path/); + } +}); + +test("contract v1 rejects dependency edges and non-strict policy", () => { + const dependency = validPlan(); + dependency.dependencies = [ + { org: "acme", name: "other", version: "1.0.0", sha256: digest("d") }, + ]; + assert.throws(() => parseNixExportPlan(dependency), /dependency-free packages only/); + + for (const [field, value] of [ + ["pure_evaluation", false], + ["import_from_derivation", true], + ["sandbox_required", false], + ["builder_network", "enabled"], + ["dirty_source", true], + ["publishable", false], + ]) { + const plan = validPlan(); + plan.policy[field] = value; + assert.throws(() => parseNixExportPlan(plan), NixExportPlanError, field); + } +}); + +test("input objects are normalized into fresh output objects", () => { + const input = validPlan(); + const original = clone(input); + const parsed = parseNixExportPlan(input); + parsed.package.name = "changed"; + parsed.intent.systems.push("riscv64-linux"); + assert.deepEqual(input, original); +}); diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json new file mode 100644 index 0000000..75b1f9e --- /dev/null +++ b/clients/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "node16", + "lib": ["ES2022", "DOM"], + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/clients/wasm/Cargo.lock b/clients/wasm/Cargo.lock new file mode 100644 index 0000000..f673270 --- /dev/null +++ b/clients/wasm/Cargo.lock @@ -0,0 +1,816 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zed-client-wasm" +version = "0.1.0" +dependencies = [ + "hex", + "js-sys", + "percent-encoding", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "zed-interfaces", +] + +[[package]] +name = "zed-interfaces" +version = "0.1.0" +dependencies = [ + "schemars", + "semver", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/clients/wasm/Cargo.toml b/clients/wasm/Cargo.toml new file mode 100644 index 0000000..8cdc31c --- /dev/null +++ b/clients/wasm/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "zed-client-wasm" +version = "0.1.0" +edition = "2021" +description = "Browser/worker WebAssembly SDK for the zed-pkg registry" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-clients" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +zed-interfaces = { path = "../../../zed-interfaces" } +hex = "0.4" +js-sys = "0.3" +percent-encoding = "2" +serde = "1" +serde_json = "1" +serde-wasm-bindgen = "0.6" +sha2 = "0.10" +url = "2.5" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "AbortController", + "AbortSignal", + "Blob", + "BlobPropertyBag", + "FormData", + "Headers", + "Request", + "RequestInit", + "RequestRedirect", + "Response", +] } diff --git a/clients/wasm/README.md b/clients/wasm/README.md new file mode 100644 index 0000000..91154c3 --- /dev/null +++ b/clients/wasm/README.md @@ -0,0 +1,32 @@ +# zed-client-wasm + +WebAssembly SDK for the [zed-pkg](https://zpkg.tech) registry, compiled from +Rust with [`wasm-pack`](https://rustwasm.github.io/wasm-pack/). Runs in +browsers and workers via the global `fetch`; reuses the `zed-interfaces` DTOs +so it cannot drift from the contract. + +```js +import init, { ZedClient } from "@zed-pkg/client-wasm"; + +await init(); +const client = new ZedClient("https://registry.zpkg.tech"); +const pkg = await client.getPackage("acme", "kit"); +const version = await client.getVersion("acme", "kit", pkg.latest); +const bytes = await client.downloadArtifact(version); // sha256-verified +``` + +Authenticated calls (`claimOrg`, `yank`, `publish`) need a bearer token: + +```js +client.withToken("zpkg_..."); +await client.claimOrg("acme"); +await client.publish(JSON.stringify(publishMeta), artifactBytes); +``` + +Build and verify: + +``` +npm run build # wasm-pack build --target web --release +npm run check # cargo check --target wasm32-unknown-unknown +npm test # host-target unit tests for the pure logic +``` diff --git a/clients/wasm/package.json b/clients/wasm/package.json new file mode 100644 index 0000000..66d8e2f --- /dev/null +++ b/clients/wasm/package.json @@ -0,0 +1,14 @@ +{ + "name": "@zed-pkg/client-wasm", + "version": "0.1.0", + "private": false, + "description": "Browser/worker WebAssembly SDK for the zed-pkg registry", + "license": "MIT", + "repository": "github:zed-pkg/zed-clients", + "files": ["pkg"], + "scripts": { + "build": "wasm-pack build --target web --release", + "check": "cargo check --target wasm32-unknown-unknown", + "test": "cargo test" + } +} diff --git a/clients/wasm/src/lib.rs b/clients/wasm/src/lib.rs new file mode 100644 index 0000000..8f349b2 --- /dev/null +++ b/clients/wasm/src/lib.rs @@ -0,0 +1,753 @@ +//! WebAssembly SDK for the zed-pkg registry (browsers and workers via +//! `wasm-pack`). Reuses DTOs and route helpers from `zed-interfaces` so wire +//! models and paths stay aligned with the contract. + +use js_sys::{Array, Function, Reflect, Uint8Array}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + AbortController, Blob, BlobPropertyBag, FormData, Request, RequestInit, RequestRedirect, + Response, +}; +use zed_interfaces::registry::{ + self, ApiError, ClaimOrgResponse, PackageMetadata, PublishResponse, SearchResponse, + VersionMetadata, YankResponse, DEFAULT_REGISTRY_URL, +}; + +const SEGMENT: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +const MAX_ARTIFACT_BYTES: u64 = 100 * 1024 * 1024; +const DOWNLOAD_SLACK: u64 = 1024 * 1024; +const MAX_JSON_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_ERROR_BODY_BYTES: u64 = 16 * 1024; +const MAX_SEGMENT_BYTES: usize = 256; +const DEFAULT_TIMEOUT_MS: u32 = 30_000; + +fn validate_segment<'a>(segment: &'a str, name: &str) -> Result<&'a str, String> { + if segment.trim().is_empty() { + return Err(format!("{name} must not be blank")); + } + if matches!(segment, "." | "..") { + return Err(format!("{name} must not be a dot segment")); + } + if segment.len() > MAX_SEGMENT_BYTES { + return Err(format!("{name} exceeds {MAX_SEGMENT_BYTES} UTF-8 bytes")); + } + if segment.chars().any(char::is_control) { + return Err(format!("{name} must not contain control characters")); + } + Ok(segment) +} + +fn encode_segment(segment: &str) -> String { + match segment { + "." => "%2E".to_string(), + ".." => "%2E%2E".to_string(), + _ => utf8_percent_encode(segment, SEGMENT).to_string(), + } +} + +fn checked_segment(segment: &str, name: &str) -> Result { + validate_segment(segment, name)?; + Ok(encode_segment(segment)) +} + +fn validate_path_segments(raw_path: &str, name: &str) -> Result<(), String> { + for (index, encoded) in raw_path + .split('/') + .filter(|value| !value.is_empty()) + .enumerate() + { + let decoded = percent_decode_str(encoded) + .decode_utf8() + .map_err(|_| format!("{name} contains invalid percent encoding"))?; + validate_segment(&decoded, &format!("{name} segment {}", index + 1))?; + if decoded.contains('/') || decoded.contains('\\') { + return Err(format!( + "{name} segments must not contain encoded separators" + )); + } + } + Ok(()) +} + +fn validate_raw_base_path(raw: &str) -> Result<(), String> { + let Some(scheme_end) = raw.find("://") else { + return Ok(()); + }; + let authority_start = scheme_end + 3; + let Some(path_offset) = raw[authority_start..].find('/') else { + return Ok(()); + }; + let path_start = authority_start + path_offset; + let path_end = raw[path_start..] + .find(['?', '#']) + .map_or(raw.len(), |offset| path_start + offset); + validate_path_segments(&raw[path_start..path_end], "registry URL path") +} + +fn validate_relative_download_path(raw: &str) -> Result<(), String> { + if raw.starts_with('/') || raw.starts_with('\\') { + return Err( + "relative download URL must not replace the registry authority or gateway path" + .to_string(), + ); + } + let path_end = raw.find(['?', '#']).unwrap_or(raw.len()); + validate_path_segments(&raw[..path_end], "download_url") +} + +fn normalize_base(raw: &str) -> Result { + let trimmed = raw.trim(); + validate_raw_base_path(trimmed)?; + let mut url = url::Url::parse(trimmed) + .map_err(|error| format!("registry URL must be absolute HTTP(S): {error}"))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err( + "registry URL must be a credential-free absolute HTTP(S) URL without query or fragment" + .to_string(), + ); + } + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&path); + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +fn download_limit(size: u64) -> u64 { + if size > 0 { + size.saturating_add(DOWNLOAD_SLACK).min(MAX_ARTIFACT_BYTES) + } else { + MAX_ARTIFACT_BYTES + } +} + +fn allowed_download_url(raw: &str, base: &str) -> Result { + let url = url::Url::parse(raw).map_err(|error| format!("bad download URL: {error}"))?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err("download URL contains credentials or fragment".to_string()); + } + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(format!( + "refusing artifact download over `{}`", + url.scheme() + )); + } + let loopback = matches!(url.host_str(), Some("localhost")) + || url + .host_str() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|ip| ip.is_loopback()); + match url.scheme() { + "https" => Ok(url.to_string()), + "http" if loopback || base.starts_with("http://") => Ok(url.to_string()), + scheme => Err(format!( + "refusing artifact download over `{scheme}` from {raw}" + )), + } +} + +fn resolve_download_url(raw: &str, base: &str, sha256: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + let sha256 = checked_segment(sha256, "sha256")?; + return Ok(format!("{base}{}", registry::artifact_path(&sha256))); + } + if let Ok(absolute) = url::Url::parse(trimmed) { + return allowed_download_url(absolute.as_str(), base); + } + validate_relative_download_path(trimmed)?; + let base_url = url::Url::parse(&(base.to_string() + "/")) + .map_err(|error| format!("invalid registry base: {error}"))?; + let resolved = base_url + .join(trimmed) + .map_err(|error| format!("invalid relative download URL: {error}"))?; + allowed_download_url(resolved.as_str(), base) +} + +fn verify_sha256(bytes: &[u8], expected: &str) -> Result<(), String> { + let actual = hex::encode(Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!( + "artifact sha256 mismatch: expected {expected}, got {actual}" + )); + } + Ok(()) +} + +fn safe_filename(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + } + }) + .collect() +} + +fn js_error(message: impl AsRef) -> JsValue { + js_sys::Error::new(message.as_ref()).into() +} + +fn api_js_error(status: u16, code: &str, registry_message: &str) -> JsValue { + let value: JsValue = js_sys::Error::new(&format!("registry error {status}: {code}")).into(); + let _ = Reflect::set( + &value, + &JsValue::from_str("status"), + &JsValue::from_f64(f64::from(status)), + ); + let _ = Reflect::set(&value, &JsValue::from_str("code"), &JsValue::from_str(code)); + let _ = Reflect::set( + &value, + &JsValue::from_str("registryMessage"), + &JsValue::from_str(registry_message), + ); + value +} + +fn global_fetch(request: &Request) -> Result { + let global = js_sys::global(); + let fetch = Reflect::get(&global, &JsValue::from_str("fetch"))?; + let fetch: Function = fetch + .dyn_into() + .map_err(|_| js_error("global fetch is unavailable in this runtime"))?; + fetch + .call1(&global, request)? + .dyn_into() + .map_err(JsValue::from) +} + +fn schedule_abort(controller: AbortController, timeout_ms: u32) -> Result { + let global = js_sys::global(); + let set_timeout: Function = Reflect::get(&global, &JsValue::from_str("setTimeout"))? + .dyn_into() + .map_err(|_| js_error("global setTimeout is unavailable in this runtime"))?; + let callback = Closure::once_into_js(move || controller.abort()); + set_timeout.call2( + &global, + &callback, + &JsValue::from_f64(f64::from(timeout_ms)), + ) +} + +fn clear_timeout(timer: &JsValue) { + let global = js_sys::global(); + if let Ok(value) = Reflect::get(&global, &JsValue::from_str("clearTimeout")) { + if let Ok(function) = value.dyn_into::() { + let _ = function.call1(&global, timer); + } + } +} + +async fn response_bytes( + response: &Response, + limit: u64, + overflow_code: &str, + description: &str, +) -> Result, JsValue> { + if let Ok(Some(declared)) = response.headers().get("content-length") { + if declared.parse::().is_ok_and(|length| length > limit) { + return Err(api_js_error( + 0, + overflow_code, + &format!("{description} exceeded {limit} bytes; refusing"), + )); + } + } + let buffer = JsFuture::from(response.array_buffer()?).await?; + let bytes = Uint8Array::new(&buffer).to_vec(); + if bytes.len() as u64 > limit { + return Err(api_js_error( + 0, + overflow_code, + &format!("{description} exceeded {limit} bytes; refusing"), + )); + } + Ok(bytes) +} + +struct TimedResponse { + response: Response, + timer: JsValue, +} + +#[wasm_bindgen] +pub struct ZedClient { + base: String, + token: Option, + token_error: Option, + timeout_ms: u32, + configuration_error: Option, +} + +#[wasm_bindgen] +impl ZedClient { + /// Invalid constructor input is retained and every operation fails before + /// transport, preserving the existing non-throwing constructor shape. + #[wasm_bindgen(constructor)] + pub fn new(base_url: Option) -> ZedClient { + let raw = base_url.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); + let (base, configuration_error) = match normalize_base(&raw) { + Ok(base) => (base, None), + Err(error) => (DEFAULT_REGISTRY_URL.to_string(), Some(error)), + }; + ZedClient { + base, + token: None, + token_error: None, + timeout_ms: DEFAULT_TIMEOUT_MS, + configuration_error, + } + } + + #[wasm_bindgen(js_name = withToken)] + pub fn with_token(&mut self, token: String) { + let trimmed = token.trim(); + if trimmed.is_empty() { + self.token = None; + self.token_error = None; + } else if trimmed.chars().any(char::is_control) { + self.token = None; + self.token_error = Some("token must not contain control characters".to_string()); + } else { + self.token = Some(trimmed.to_string()); + self.token_error = None; + } + } + + #[wasm_bindgen(js_name = withTimeoutMs)] + pub fn with_timeout_ms(&mut self, timeout_ms: u32) -> Result<(), JsValue> { + if timeout_ms == 0 { + return Err(api_js_error( + 0, + "invalid_timeout", + "timeout_ms must be positive", + )); + } + self.timeout_ms = timeout_ms; + Ok(()) + } + + fn ensure_configured(&self) -> Result<(), JsValue> { + if let Some(error) = &self.configuration_error { + return Err(api_js_error(0, "invalid_configuration", error)); + } + Ok(()) + } + + fn require_token(&self) -> Result<&str, JsValue> { + if let Some(error) = &self.token_error { + return Err(api_js_error(0, "invalid_token", error)); + } + self.token.as_deref().ok_or_else(|| { + api_js_error( + 0, + "missing_token", + "authenticated registry operation requires a nonblank bearer token", + ) + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + async fn send(&self, request: Request, authorized: bool) -> Result { + self.ensure_configured()?; + if authorized { + request.headers().set( + "authorization", + &format!("Bearer {}", self.require_token()?), + )?; + } + request.headers().set("accept", "application/json")?; + + let controller = AbortController::new()?; + let init = RequestInit::new(); + init.set_signal(Some(&controller.signal())); + init.set_redirect(RequestRedirect::Error); + let timed_request = Request::new_with_request_and_init(&request, &init)?; + let timer = schedule_abort(controller, self.timeout_ms)?; + let promise = match global_fetch(&timed_request) { + Ok(promise) => promise, + Err(error) => { + clear_timeout(&timer); + return Err(error); + } + }; + let response = match JsFuture::from(promise).await { + Ok(response) => match response.dyn_into::() { + Ok(response) => response, + Err(error) => { + clear_timeout(&timer); + return Err(error); + } + }, + Err(error) => { + clear_timeout(&timer); + return Err(error); + } + }; + Ok(TimedResponse { response, timer }) + } + + async fn check(&self, response: Response) -> Result { + if response.ok() { + return Ok(response); + } + let status = response.status(); + let bytes = match response_bytes( + &response, + MAX_ERROR_BODY_BYTES, + "error_body_too_large", + "registry error body", + ) + .await + { + Ok(bytes) => bytes, + Err(_) => { + return Err(api_js_error( + status, + &format!("http_{status}"), + "registry error body exceeded the client limit", + )); + } + }; + let text = String::from_utf8_lossy(&bytes).into_owned(); + let (code, message) = match serde_json::from_slice::(&bytes) { + Ok(error) if !error.code.trim().is_empty() => { + (error.code.trim().to_string(), error.message) + } + _ => (format!("http_{status}"), text), + }; + Err(api_js_error(status, &code, &message)) + } + + async fn json( + &self, + request: Request, + authorized: bool, + ) -> Result { + let TimedResponse { response, timer } = self.send(request, authorized).await?; + let result = async { + let response = self.check(response).await?; + let bytes = response_bytes( + &response, + MAX_JSON_RESPONSE_BYTES, + "response_too_large", + "registry JSON response", + ) + .await?; + let value: T = serde_json::from_slice(&bytes) + .map_err(|error| api_js_error(0, "invalid_response", &error.to_string()))?; + serde_wasm_bindgen::to_value(&value).map_err(JsValue::from) + } + .await; + clear_timeout(&timer); + result + } + + fn get_request(&self, path: &str) -> Result { + let init = RequestInit::new(); + init.set_method("GET"); + init.set_redirect(RequestRedirect::Error); + Request::new_with_str_and_init(&self.url(path), &init).map_err(JsValue::from) + } + + fn json_request(&self, method: &str, path: &str, body: &str) -> Result { + let init = RequestInit::new(); + init.set_method(method); + init.set_redirect(RequestRedirect::Error); + init.set_body(&JsValue::from_str(body)); + let request = Request::new_with_str_and_init(&self.url(path), &init)?; + request.headers().set("content-type", "application/json")?; + Ok(request) + } + + #[wasm_bindgen(js_name = getPackage)] + pub async fn get_package(&self, org: String, name: String) -> Result { + let path = registry::package_path( + &checked_segment(&org, "org").map_err(js_error)?, + &checked_segment(&name, "name").map_err(js_error)?, + ); + self.json::(self.get_request(&path)?, false) + .await + } + + #[wasm_bindgen(js_name = getVersion)] + pub async fn get_version( + &self, + org: String, + name: String, + version: String, + ) -> Result { + let path = registry::version_path( + &checked_segment(&org, "org").map_err(js_error)?, + &checked_segment(&name, "name").map_err(js_error)?, + &checked_segment(&version, "version").map_err(js_error)?, + ); + self.json::(self.get_request(&path)?, false) + .await + } + + pub async fn search(&self, query: String) -> Result { + let path = format!( + "{}?q={}", + registry::search_path(), + utf8_percent_encode(&query, SEGMENT) + ); + self.json::(self.get_request(&path)?, false) + .await + } + + #[wasm_bindgen(js_name = claimOrg)] + pub async fn claim_org(&self, slug: String) -> Result { + validate_segment(&slug, "slug").map_err(js_error)?; + self.require_token()?; + let body = serde_json::json!({ "slug": slug }).to_string(); + self.json::( + self.json_request("POST", ®istry::orgs_path(), &body)?, + true, + ) + .await + } + + #[wasm_bindgen(js_name = setYanked)] + pub async fn set_yanked( + &self, + org: String, + name: String, + version: String, + yanked: bool, + ) -> Result { + self.require_token()?; + let path = registry::yank_path( + &checked_segment(&org, "org").map_err(js_error)?, + &checked_segment(&name, "name").map_err(js_error)?, + &checked_segment(&version, "version").map_err(js_error)?, + ); + let body = serde_json::json!({ "yanked": yanked }).to_string(); + self.json::(self.json_request("POST", &path, &body)?, true) + .await + } + + pub async fn yank( + &self, + org: String, + name: String, + version: String, + yanked: bool, + ) -> Result { + self.set_yanked(org, name, version, yanked).await + } + + pub async fn restore( + &self, + org: String, + name: String, + version: String, + ) -> Result { + self.set_yanked(org, name, version, false).await + } + + #[wasm_bindgen(js_name = downloadArtifact)] + pub async fn download_artifact(&self, version: JsValue) -> Result { + self.ensure_configured()?; + let version: VersionMetadata = + serde_wasm_bindgen::from_value(version).map_err(JsValue::from)?; + let url = resolve_download_url(&version.download_url, &self.base, &version.sha256) + .map_err(js_error)?; + let init = RequestInit::new(); + init.set_method("GET"); + init.set_redirect(RequestRedirect::Error); + let request = Request::new_with_str_and_init(&url, &init)?; + let TimedResponse { response, timer } = self.send(request, false).await?; + let result = async { + let response = self.check(response).await?; + let bytes = response_bytes( + &response, + download_limit(version.size), + "artifact_too_large", + "artifact", + ) + .await?; + verify_sha256(&bytes, &version.sha256).map_err(js_error)?; + Ok(Uint8Array::from(bytes.as_slice())) + } + .await; + clear_timeout(&timer); + result + } + + pub async fn publish( + &self, + meta_json: String, + artifact: Uint8Array, + ) -> Result { + self.ensure_configured()?; + self.require_token()?; + if u64::from(artifact.length()) > MAX_ARTIFACT_BYTES { + return Err(api_js_error( + 0, + "artifact_too_large", + &format!("artifact exceeded {MAX_ARTIFACT_BYTES} bytes; refusing"), + )); + } + let meta: zed_interfaces::registry::PublishMeta = serde_json::from_str(&meta_json) + .map_err(|error| api_js_error(0, "invalid_publish_meta", &error.to_string()))?; + let package = &meta.manifest.package; + let org = checked_segment(&package.org, "meta.manifest.package.org").map_err(js_error)?; + let name = + checked_segment(&package.name, "meta.manifest.package.name").map_err(js_error)?; + let version = + checked_segment(&package.version, "meta.manifest.package.version").map_err(js_error)?; + let path = registry::version_path(&org, &name, &version); + let filename = safe_filename(&format!( + "{}-{}-{}.tar.gz", + package.org, package.name, package.version + )); + + let parts = Array::new(); + parts.push(&Uint8Array::from(artifact.to_vec().as_slice())); + let options = BlobPropertyBag::new(); + options.set_type("application/gzip"); + let blob = Blob::new_with_u8_array_sequence_and_options(&parts, &options)?; + + let form = FormData::new()?; + form.append_with_str(registry::PUBLISH_META_FIELD, &meta_json)?; + form.append_with_blob_and_filename(registry::PUBLISH_ARTIFACT_FIELD, &blob, &filename)?; + + let init = RequestInit::new(); + init.set_method("PUT"); + init.set_redirect(RequestRedirect::Error); + init.set_body(&form); + let request = Request::new_with_str_and_init(&self.url(&path), &init)?; + self.json::(request, true).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha_verification_is_case_insensitive() { + let bytes = b"zed"; + let good = hex::encode(Sha256::digest(bytes)); + assert!(verify_sha256(bytes, &good.to_uppercase()).is_ok()); + assert!(verify_sha256(bytes, "00").is_err()); + } + + #[test] + fn registry_base_and_segments_fail_closed() { + for invalid in [ + "relative/path", + "ftp://registry.test", + "https://user:secret@registry.test", + "https://registry.test?tenant=one", + "https://registry.test#fragment", + "https://registry.test/../admin", + "https://registry.test/%2e%2e/admin", + "https://registry.test/a%2Fb", + ] { + assert!(normalize_base(invalid).is_err(), "accepted {invalid}"); + } + for invalid in ["", " ", ".", "..", "line\nbreak"] { + assert!(validate_segment(invalid, "segment").is_err()); + } + assert_eq!(encode_segment(".."), "%2E%2E"); + } + + #[test] + fn path_segments_are_percent_encoded() { + assert_eq!(encode_segment("1.2.0"), "1.2.0"); + assert_eq!( + encode_segment("release candidate/1"), + "release%20candidate%2F1" + ); + assert_eq!( + registry::version_path( + &encode_segment("acme"), + &encode_segment("kit"), + &encode_segment("v/2?x"), + ), + "/v1/packages/acme/kit/versions/v%2F2%3Fx" + ); + } + + #[test] + fn download_limit_caps_at_ceiling() { + assert_eq!(download_limit(0), MAX_ARTIFACT_BYTES); + assert_eq!(download_limit(10), 10 + DOWNLOAD_SLACK); + assert_eq!(download_limit(MAX_ARTIFACT_BYTES), MAX_ARTIFACT_BYTES); + } + + #[test] + fn download_urls_reject_credentials_fragments_and_insecure_schemes() { + let base = "https://registry.zpkg.tech/gateway"; + for raw in [ + "http://evil.example/artifact", + "file:///etc/passwd", + "https://user:secret@cdn.example/a", + "https://cdn.example/a#fragment", + ] { + assert!(allowed_download_url(raw, base).is_err(), "accepted {raw}"); + } + assert!(allowed_download_url("http://127.0.0.1:8080/a", base).is_ok()); + assert!(allowed_download_url("http://localhost/a", base).is_ok()); + assert!(allowed_download_url("http://[::1]:9/a", base).is_ok()); + assert!(allowed_download_url("https://cdn.example/a", base).is_ok()); + assert_eq!( + resolve_download_url("artifacts/hash", base, "abc").unwrap(), + "https://registry.zpkg.tech/gateway/artifacts/hash" + ); + for invalid in [ + "../escape", + "%2e%2e/escape", + "a%2Fb", + "//evil.example/artifact", + "/absolute/artifact", + "\\authority-replacement", + ] { + assert!( + resolve_download_url(invalid, base, "abc").is_err(), + "accepted {invalid}" + ); + } + } + + #[test] + fn missing_token_and_invalid_constructor_state_fail_before_transport() { + let client = ZedClient::new(Some("relative/path".to_string())); + assert!(client.configuration_error.is_some()); + + let mut client = ZedClient::new(Some("https://registry.test".to_string())); + assert!(client.token.is_none()); + client.with_token(" ".to_string()); + assert!(client.token.is_none()); + client.with_token("token\r\nheader".to_string()); + assert!(client.token.is_none()); + assert!(client.token_error.is_some()); + } +} diff --git a/scripts/check-client-capability-drift.py b/scripts/check-client-capability-drift.py new file mode 100644 index 0000000..82cc41c --- /dev/null +++ b/scripts/check-client-capability-drift.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Fail closed when the documented SDK matrix drifts from reviewed code.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +README = ROOT / "README.md" + +EXPECTED_CLIENTS = { + "rust", + "wasm", + "typescript", + "python", + "go", + "dart", + "gleam", + "erlang", + "elixir", + "java", + "kotlin", + "ruby", + "php", + "swift", +} + +EXPECTED_TYPESCRIPT_RUNTIMES = {"nodejs", "deno", "bun", "edge"} + +HISTORICALLY_STALE_YANK_MARKERS = { + "typescript": (ROOT / "clients/typescript/src/client.ts", "yank("), + "python": (ROOT / "clients/python/zed_pkg_client/__init__.py", "def yank("), + "go": (ROOT / "clients/go/client.go", "func (c *Client) Yank("), + "rust": (ROOT / "clients/rust/src/lib.rs", "pub fn yank("), + "elixir": (ROOT / "clients/elixir/lib/zed_pkg_client.ex", "def yank("), + "kotlin": (ROOT / "clients/kotlin/src/main/kotlin/tech/zpkg/client/ZedClient.kt", "fun yankJson("), + "ruby": (ROOT / "clients/ruby/lib/zed_pkg_client.rb", "def yank("), + "php": (ROOT / "clients/php/src/Client.php", "function yank("), +} + + +def fail(message: str) -> None: + raise SystemExit(f"client capability drift: {message}") + + +def main() -> None: + readme = README.read_text(encoding="utf-8") + documented = re.findall(r"\[clients/([a-z0-9-]+)/\]\(clients/\1/\)", readme) + documented_set = set(documented) + + if len(documented) != len(documented_set): + fail(f"duplicate client table rows: {documented}") + if documented_set != EXPECTED_CLIENTS: + missing = sorted(EXPECTED_CLIENTS - documented_set) + extra = sorted(documented_set - EXPECTED_CLIENTS) + fail(f"README matrix mismatch; missing={missing}, extra={extra}") + + required_claim = "All fourteen clients\nimplement that core lifecycle." + if required_claim not in readme: + fail("README must state that all fourteen clients implement the core lifecycle") + + retired_claims = ( + "Completion of that operation in the older", + "remains tracked by the fleet-level parity issue", + ) + for claim in retired_claims: + if claim in readme: + fail(f"retired incomplete-parity claim returned: {claim!r}") + + for client, (path, marker) in HISTORICALLY_STALE_YANK_MARKERS.items(): + if not path.is_file(): + fail(f"{client} source is missing: {path.relative_to(ROOT)}") + if marker not in path.read_text(encoding="utf-8"): + fail(f"{client} lost yank/restore marker {marker!r}") + + package_json = json.loads((ROOT / "clients/typescript/package.json").read_text(encoding="utf-8")) + exports = package_json.get("exports", {}) + for runtime in EXPECTED_TYPESCRIPT_RUNTIMES: + path = ROOT / "clients/typescript/src/runtimes" / runtime / "index.ts" + if not path.is_file(): + fail(f"TypeScript runtime entry point is missing: {path.relative_to(ROOT)}") + if f"./{runtime}" not in exports: + fail(f"package.json export is missing for TypeScript runtime {runtime}") + + print("zed-clients README, runtime exports, and implemented core lifecycle are aligned") + + +if __name__ == "__main__": + main() diff --git a/tests/browser/wasm_expected_diagnostics_shim.mjs b/tests/browser/wasm_expected_diagnostics_shim.mjs new file mode 100644 index 0000000..2f3a0bb --- /dev/null +++ b/tests/browser/wasm_expected_diagnostics_shim.mjs @@ -0,0 +1,122 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const playwright = await import('playwright'); +const expectedDiagnostics = []; +const artifactRoot = process.env.BROWSER_ARTIFACT_DIR + ? path.resolve(process.env.BROWSER_ARTIFACT_DIR) + : null; + +const redirectPath = '/redirect-gateway/v1/orgs'; + +function expectedConsoleDiagnostic(message) { + if (message?.type?.() !== 'error') return null; + const text = String(message.text?.() ?? ''); + const location = message.location?.() ?? {}; + const url = String(location.url ?? ''); + + const exactNetworkErrors = new Set([ + 'Failed to load resource: net::ERR_FAILED', + 'Failed to load resource: the server responded with a status of 502 (Bad Gateway)', + 'Failed to load resource: the server responded with a status of 503 (Service Unavailable)', + ]); + if (exactNetworkErrors.has(text)) { + return { kind: 'console', text, url }; + } + + if ( + text.includes('Not allowed to follow a redirection while loading') && + text.includes(redirectPath) + ) { + return { kind: 'console', text, url }; + } + + return null; +} + +function expectedPageError(error) { + const message = String(error?.message ?? error ?? ''); + if ( + message.includes(redirectPath) && + message.includes('due to access control checks') + ) { + return { kind: 'pageerror', text: message, url: '' }; + } + return null; +} + +function wrapPage(page) { + if (page.__zedExpectedDiagnosticsWrapped) return page; + Object.defineProperty(page, '__zedExpectedDiagnosticsWrapped', { + value: true, + enumerable: false, + }); + + const originalOn = page.on.bind(page); + page.on = (eventName, listener) => { + if (typeof listener !== 'function') return originalOn(eventName, listener); + + if (eventName === 'console') { + return originalOn(eventName, (message, ...rest) => { + const expected = expectedConsoleDiagnostic(message); + if (expected) { + expectedDiagnostics.push(expected); + return undefined; + } + return listener(message, ...rest); + }); + } + + if (eventName === 'pageerror') { + return originalOn(eventName, (error, ...rest) => { + const expected = expectedPageError(error); + if (expected) { + expectedDiagnostics.push(expected); + return undefined; + } + return listener(error, ...rest); + }); + } + + return originalOn(eventName, listener); + }; + return page; +} + +function wrapContext(context) { + if (context.__zedExpectedDiagnosticsWrapped) return context; + Object.defineProperty(context, '__zedExpectedDiagnosticsWrapped', { + value: true, + enumerable: false, + }); + const originalNewPage = context.newPage.bind(context); + context.newPage = async (...args) => wrapPage(await originalNewPage(...args)); + return context; +} + +function wrapBrowser(browser) { + if (browser.__zedExpectedDiagnosticsWrapped) return browser; + Object.defineProperty(browser, '__zedExpectedDiagnosticsWrapped', { + value: true, + enumerable: false, + }); + const originalNewContext = browser.newContext.bind(browser); + browser.newContext = async (...args) => wrapContext(await originalNewContext(...args)); + return browser; +} + +const browserTypePrototype = Object.getPrototypeOf(playwright.chromium); +const originalLaunch = browserTypePrototype.launch; +browserTypePrototype.launch = async function launchWithExpectedDiagnostics(...args) { + return wrapBrowser(await originalLaunch.apply(this, args)); +}; + +process.on('exit', () => { + if (!artifactRoot) return; + mkdirSync(artifactRoot, { recursive: true }); + writeFileSync( + path.join(artifactRoot, 'expected-browser-diagnostics.json'), + `${JSON.stringify(expectedDiagnostics, null, 2)}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); +}); diff --git a/tests/browser/wasm_fixture_csp_shim.mjs b/tests/browser/wasm_fixture_csp_shim.mjs new file mode 100644 index 0000000..33c6c9e --- /dev/null +++ b/tests/browser/wasm_fixture_csp_shim.mjs @@ -0,0 +1,33 @@ +import http from 'node:http'; + +const originalWriteHead = http.ServerResponse.prototype.writeHead; + +/** + * wasm-bindgen's browser loader requires WebAssembly code generation, and the + * contract dispatcher intentionally evaluates only test-owned function source. + * Grant those capabilities solely on the in-process fixture response while + * retaining same-origin scripts/connect requests and every other CSP boundary. + */ +http.ServerResponse.prototype.writeHead = function writeHeadWithWasmFixtureCsp( + statusCode, + statusMessageOrHeaders, + maybeHeaders, +) { + const rewrite = (headers) => { + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return headers; + const copy = { ...headers }; + const key = Object.keys(copy).find((name) => name.toLowerCase() === 'content-security-policy'); + if (key && typeof copy[key] === 'string') { + copy[key] = copy[key].replace( + "script-src 'self'", + "script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'", + ); + } + return copy; + }; + + if (typeof statusMessageOrHeaders === 'string') { + return originalWriteHead.call(this, statusCode, statusMessageOrHeaders, rewrite(maybeHeaders)); + } + return originalWriteHead.call(this, statusCode, rewrite(statusMessageOrHeaders)); +}; diff --git a/tests/browser/wasm_transport_contract.mjs b/tests/browser/wasm_transport_contract.mjs new file mode 100644 index 0000000..e96ed11 --- /dev/null +++ b/tests/browser/wasm_transport_contract.mjs @@ -0,0 +1,482 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import test from 'node:test'; + +const browserName = process.env.BROWSER_ENGINE ?? 'chromium'; +const packageRoot = path.resolve(process.env.ZED_WASM_PKG ?? 'clients/wasm/pkg'); +const artifactRoot = path.resolve( + process.env.BROWSER_ARTIFACT_DIR ?? path.join('artifacts', 'wasm-transport', browserName), +); +const artifactBytes = Buffer.from('zed-browser-artifact', 'utf8'); +const artifactSha256 = createHash('sha256').update(artifactBytes).digest('hex'); +const maxJsonBytes = 16 * 1024 * 1024; +const maxErrorBytes = 16 * 1024; + +function contentType(file) { + if (file.endsWith('.js')) return 'text/javascript; charset=utf-8'; + if (file.endsWith('.wasm')) return 'application/wasm'; + if (file.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} + +async function readRequestBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +function packageMetadata() { + return { + org: 'acme', + name: 'sdk', + description: 'browser contract fixture', + vcs: 'git', + repo_url: 'https://github.com/acme/sdk', + latest: '1.0.0', + tags: ['browser'], + versions: ['1.0.0'], + }; +} + +function json(response, status, value, extraHeaders = {}) { + const body = Buffer.from(JSON.stringify(value)); + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'content-length': body.length, + ...extraHeaders, + }); + response.end(body); +} + +async function startFixture() { + const requests = []; + let origin = ''; + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const body = await readRequestBody(request); + requests.push({ + method: request.method ?? '', + path: url.pathname, + authorization: request.headers.authorization ?? null, + body, + }); + + if (url.pathname === '/') { + const html = `Zed WASM transport contract

Zed WASM transport contract

loading
`; + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-security-policy': "default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'none'; img-src 'self' data:; object-src 'none'; base-uri 'none'", + 'content-type': 'text/html; charset=utf-8', + 'x-content-type-options': 'nosniff', + }); + response.end(html); + return; + } + + if (url.pathname === '/contract-page.js') { + const source = `import init, { ZedClient } from '/pkg/zed_client_wasm.js';\nawait init('/pkg/zed_client_wasm_bg.wasm');\nglobalThis.ZedClient = ZedClient;\ndocument.querySelector('#state').textContent = 'ready';`; + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'text/javascript; charset=utf-8', + 'x-content-type-options': 'nosniff', + }); + response.end(source); + return; + } + + if (url.pathname.startsWith('/pkg/')) { + const relative = url.pathname.slice('/pkg/'.length); + const candidate = path.resolve(packageRoot, relative); + if (!candidate.startsWith(`${packageRoot}${path.sep}`)) { + response.writeHead(400).end('bad package path'); + return; + } + try { + const bytes = await readFile(candidate); + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': contentType(candidate), + 'content-length': bytes.length, + 'x-content-type-options': 'nosniff', + }); + response.end(bytes); + } catch (error) { + response.writeHead(error?.code === 'ENOENT' ? 404 : 500).end('package file unavailable'); + } + return; + } + + if (url.pathname === '/gateway/v1/packages/acme/sdk') { + json(response, 200, packageMetadata()); + return; + } + + if (url.pathname === '/gateway/v1/packages/error/sdk') { + json(response, 502, { code: 'provider_failure', message: 'provider-secret' }); + return; + } + + if (url.pathname === '/gateway/v1/packages/oversized/sdk') { + const oversized = Buffer.alloc(maxJsonBytes + 1, 0x20); + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'content-length': oversized.length, + }); + response.end(oversized); + return; + } + + if (url.pathname === '/gateway/v1/packages/large-error/sdk') { + const oversized = Buffer.alloc(maxErrorBytes + 1, 0x78); + response.writeHead(503, { + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + 'content-length': oversized.length, + }); + response.end(oversized); + return; + } + + if (url.pathname === '/slow-gateway/v1/packages/acme/sdk') { + setTimeout(() => json(response, 200, packageMetadata()), 500); + return; + } + + if (url.pathname === '/gateway/v1/orgs') { + json(response, 200, { slug: 'acme', created: true }); + return; + } + + if (url.pathname === '/redirect-gateway/v1/orgs') { + response.writeHead(302, { + location: `${origin}/redirect-target`, + 'cache-control': 'no-store', + }); + response.end(); + return; + } + + if (url.pathname === '/redirect-target') { + json(response, 200, { slug: 'redirected', created: true }); + return; + } + + if (url.pathname === '/gateway/v1/packages/acme/sdk/versions/1.0.0/yank') { + const parsed = JSON.parse(body || '{}'); + json(response, 200, { + org: 'acme', + name: 'sdk', + version: '1.0.0', + yanked: Boolean(parsed.yanked), + }); + return; + } + + if (url.pathname === '/gateway/artifacts/blob' || url.pathname === '/artifact-host/blob') { + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'application/octet-stream', + 'content-length': artifactBytes.length, + }); + response.end(artifactBytes); + return; + } + + if (url.pathname === '/gateway/artifacts/bad-digest') { + const bytes = Buffer.from('wrong-artifact', 'utf8'); + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-type': 'application/octet-stream', + 'content-length': bytes.length, + }); + response.end(bytes); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('not found'); + } catch (error) { + response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); + response.end(`fixture error: ${error.message}`); + } + }); + + server.requestTimeout = 5_000; + server.headersTimeout = 5_000; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert(address && typeof address === 'object'); + origin = `http://127.0.0.1:${address.port}`; + + return { + origin, + requests, + async close() { + if (!server.listening) return; + server.closeIdleConnections?.(); + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +function apiRequests(requests) { + return requests.filter((request) => !['/', '/contract-page.js'].includes(request.path) && !request.path.startsWith('/pkg/')); +} + +async function evaluateCapture(page, callback, argument) { + return page.evaluate( + async ({ source, argument }) => { + const capture = async (operation) => { + try { + const value = await operation(); + return { ok: true, value }; + } catch (error) { + return { + ok: false, + error: { + name: error?.name ?? null, + message: error?.message ?? String(error), + status: error?.status ?? null, + code: error?.code ?? null, + registryMessage: error?.registryMessage ?? null, + }, + }; + } + }; + const fn = (0, eval)(`(${source})`); + return capture(() => fn(globalThis.ZedClient, argument)); + }, + { source: callback.toString(), argument }, + ); +} + +function assertFailure(result, code = null) { + assert.equal(result.ok, false, JSON.stringify(result)); + if (code !== null) assert.equal(result.error.code, code, JSON.stringify(result)); + return result.error; +} + +async function runContract(page, fixture) { + const { origin, requests } = fixture; + const initialApiCount = apiRequests(requests).length; + + const valid = await evaluateCapture( + page, + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).getPackage('acme', 'sdk'), + origin, + ); + assert.equal(valid.ok, true, JSON.stringify(valid)); + assert.equal(valid.value.org, 'acme'); + assert.equal(valid.value.name, 'sdk'); + + const beforePreflight = apiRequests(requests).length; + for (const operation of [ + async (ZedClient) => new ZedClient('relative/path').getPackage('acme', 'sdk'), + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).getPackage('..', 'sdk'), + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).claimOrg('acme'), + async (ZedClient, origin) => { + const client = new ZedClient(`${origin}/gateway`); + client.withToken(' '); + return client.claimOrg('acme'); + }, + async (ZedClient, origin) => { + const client = new ZedClient(`${origin}/gateway`); + client.withToken('token\r\nheader'); + return client.claimOrg('acme'); + }, + ]) { + assertFailure(await evaluateCapture(page, operation, origin)); + } + assert.equal(apiRequests(requests).length, beforePreflight, 'preflight failures reached transport'); + + const authorized = await evaluateCapture( + page, + async (ZedClient, origin) => { + const client = new ZedClient(`${origin}/gateway`); + client.withToken(' bearer-token '); + const yanked = await client.setYanked('acme', 'sdk', '1.0.0', true); + const restored = await client.restore('acme', 'sdk', '1.0.0'); + return { yanked, restored }; + }, + origin, + ); + assert.equal(authorized.ok, true, JSON.stringify(authorized)); + assert.equal(authorized.value.yanked.yanked, true); + assert.equal(authorized.value.restored.yanked, false); + const yankRequests = requests.filter((request) => request.path.endsWith('/yank')); + assert.equal(yankRequests.length, 2); + assert(yankRequests.every((request) => request.authorization === 'Bearer bearer-token')); + assert.deepEqual(yankRequests.map((request) => JSON.parse(request.body).yanked), [true, false]); + + const redirect = await evaluateCapture( + page, + async (ZedClient, origin) => { + const client = new ZedClient(`${origin}/redirect-gateway`); + client.withToken('redirect-secret'); + return client.claimOrg('acme'); + }, + origin, + ); + assertFailure(redirect); + assert.equal(requests.filter((request) => request.path === '/redirect-target').length, 0); + const redirectSource = requests.find((request) => request.path === '/redirect-gateway/v1/orgs'); + assert.equal(redirectSource?.authorization, 'Bearer redirect-secret'); + + const structuredError = await evaluateCapture( + page, + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).getPackage('error', 'sdk'), + origin, + ); + const structured = assertFailure(structuredError, 'provider_failure'); + assert.equal(structured.status, 502); + assert.equal(structured.message, 'registry error 502: provider_failure'); + assert.equal(structured.registryMessage, 'provider-secret'); + assert(!structured.message.includes('provider-secret')); + + const oversized = await evaluateCapture( + page, + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).getPackage('oversized', 'sdk'), + origin, + ); + assertFailure(oversized, 'response_too_large'); + + const oversizedError = await evaluateCapture( + page, + async (ZedClient, origin) => new ZedClient(`${origin}/gateway`).getPackage('large-error', 'sdk'), + origin, + ); + const boundedError = assertFailure(oversizedError, 'http_503'); + assert(!boundedError.message.includes('xxxx')); + assert.match(boundedError.registryMessage, /exceeded the client limit/); + + const timedOut = await evaluateCapture( + page, + async (ZedClient, origin) => { + const client = new ZedClient(`${origin}/slow-gateway`); + client.withTimeoutMs(50); + return client.getPackage('acme', 'sdk'); + }, + origin, + ); + assertFailure(timedOut); + + const relativeDownload = await evaluateCapture( + page, + async (ZedClient, { origin, sha256 }) => { + const client = new ZedClient(`${origin}/gateway`); + client.withToken('registry-secret'); + const bytes = await client.downloadArtifact({ + org: 'acme', name: 'sdk', version: '1.0.0', sha256: sha256.toUpperCase(), + size: 20, vcs_tag: 'v1.0.0', download_url: 'artifacts/blob', + published_at: '2026-08-03T00:00:00Z', + }); + return { length: bytes.length, text: new TextDecoder().decode(bytes) }; + }, + { origin, sha256: artifactSha256 }, + ); + assert.equal(relativeDownload.ok, true, JSON.stringify(relativeDownload)); + assert.equal(relativeDownload.value.text, artifactBytes.toString('utf8')); + const relativeRequest = requests.find((request) => request.path === '/gateway/artifacts/blob'); + assert(relativeRequest, 'relative download did not preserve gateway prefix'); + assert.equal(relativeRequest.authorization, null); + + const absoluteDownload = await evaluateCapture( + page, + async (ZedClient, { origin, sha256 }) => { + const client = new ZedClient(`${origin}/gateway`); + client.withToken('registry-secret'); + const bytes = await client.downloadArtifact({ + org: 'acme', name: 'sdk', version: '1.0.0', sha256, + size: 20, vcs_tag: 'v1.0.0', download_url: `${origin}/artifact-host/blob`, + published_at: '2026-08-03T00:00:00Z', + }); + return bytes.length; + }, + { origin, sha256: artifactSha256 }, + ); + assert.equal(absoluteDownload.ok, true, JSON.stringify(absoluteDownload)); + const artifactHostRequest = requests.find((request) => request.path === '/artifact-host/blob'); + assert.equal(artifactHostRequest?.authorization, null); + + const beforeUnsafeDownloads = apiRequests(requests).length; + for (const downloadUrl of ['../escape', '%2e%2e/escape', 'a%2Fb', '//evil.example/blob', '/absolute/blob']) { + const result = await evaluateCapture( + page, + async (ZedClient, { origin, sha256, downloadUrl }) => + new ZedClient(`${origin}/gateway`).downloadArtifact({ + org: 'acme', name: 'sdk', version: '1.0.0', sha256, + size: 20, vcs_tag: 'v1.0.0', download_url: downloadUrl, + published_at: '2026-08-03T00:00:00Z', + }), + { origin, sha256: artifactSha256, downloadUrl }, + ); + assertFailure(result); + } + assert.equal(apiRequests(requests).length, beforeUnsafeDownloads, 'unsafe downloads reached transport'); + + const mismatch = await evaluateCapture( + page, + async (ZedClient, { origin, sha256 }) => + new ZedClient(`${origin}/gateway`).downloadArtifact({ + org: 'acme', name: 'sdk', version: '1.0.0', sha256, + size: 20, vcs_tag: 'v1.0.0', download_url: 'artifacts/bad-digest', + published_at: '2026-08-03T00:00:00Z', + }), + { origin, sha256: artifactSha256 }, + ); + const mismatchError = assertFailure(mismatch); + assert.match(mismatchError.message, /sha256 mismatch/i); + + assert(apiRequests(requests).length > initialApiCount); +} + +test('hardened browser WASM transport contract', { timeout: 120_000 }, async () => { + await mkdir(artifactRoot, { recursive: true }); + const fixture = await startFixture(); + const playwright = await import('playwright'); + const browserType = playwright[browserName]; + assert(browserType, `unsupported browser engine ${browserName}`); + const browser = await browserType.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + const browserErrors = []; + const externalRequests = []; + page.on('console', (message) => message.type() === 'error' && browserErrors.push(`console: ${message.text()}`)); + page.on('pageerror', (error) => browserErrors.push(`pageerror: ${error.message}`)); + page.on('request', (request) => { + if (!request.url().startsWith(fixture.origin) && !request.url().startsWith('data:')) { + externalRequests.push(request.url()); + } + }); + + try { + await page.goto(fixture.origin, { waitUntil: 'load', timeout: 20_000 }); + await page.waitForFunction(() => document.querySelector('#state')?.textContent === 'ready'); + await runContract(page, fixture); + await page.evaluate(() => { + document.querySelector('#result').textContent = 'all transport boundaries passed'; + }); + assert.deepEqual(browserErrors, []); + assert.deepEqual(externalRequests, []); + } finally { + await Promise.allSettled([ + page.screenshot({ path: path.join(artifactRoot, `${browserName}.png`), fullPage: true }), + writeFile(path.join(artifactRoot, 'requests.json'), `${JSON.stringify(fixture.requests, null, 2)}\n`), + writeFile(path.join(artifactRoot, 'browser-errors.json'), `${JSON.stringify(browserErrors, null, 2)}\n`), + writeFile(path.join(artifactRoot, 'external-requests.json'), `${JSON.stringify(externalRequests, null, 2)}\n`), + ]); + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + await fixture.close(); + } +}); diff --git a/tests/native-lock-consumer/Cargo.toml b/tests/native-lock-consumer/Cargo.toml new file mode 100644 index 0000000..2cb1b47 --- /dev/null +++ b/tests/native-lock-consumer/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "zed-native-lock-consumer" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +zed-interfaces = { path = "../../../zed-interfaces" } diff --git a/tests/native-lock-consumer/src/main.rs b/tests/native-lock-consumer/src/main.rs new file mode 100644 index 0000000..b70ec37 --- /dev/null +++ b/tests/native-lock-consumer/src/main.rs @@ -0,0 +1,146 @@ +use zed_interfaces::{ + ArtifactFormat, Lockfile, LockfileError, NativeArtifact, NativeDependencyLock, NativeRegistry, + NativeVersionCandidate, +}; + +fn artifact(digit: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat(digit).take(64).collect(), + size: 1024, + format: ArtifactFormat::TarGz, + } +} + +fn exact_lock( + registry: NativeRegistry, + package: &str, + declared: &str, + version: &str, + digit: char, +) -> NativeDependencyLock { + NativeDependencyLock::resolve( + registry, + package, + declared, + &[NativeVersionCandidate { + version: version.to_string(), + artifact: artifact(digit), + }], + ) + .expect("fixture must resolve") +} + +fn legacy_documents_remain_compatible() { + let legacy = r#" +version = 1 + +[[package]] +org = "zedtest" +name = "core" +version = "1.0.0" +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 128 +format = "tar.gz" +vcs_tag = "v1.0.0" +vcs_commit = "0123456789abcdef0123456789abcdef01234567" +source = "file:///tmp/registry" +"#; + + let lockfile = Lockfile::parse(legacy).expect("legacy lockfile must parse"); + assert!(lockfile.native_dependencies.is_empty()); + assert!(!lockfile.to_toml_string().unwrap().contains("native-dependency")); +} + +fn ordering_is_deterministic_and_sources_coexist() { + let npm = exact_lock(NativeRegistry::Npm, "core", "^1.2.3", "1.9.0", 'a'); + let cargo = exact_lock(NativeRegistry::Cargo, "core", "1.2.3", "1.9.0", 'b'); + + let mut forward = Lockfile::default(); + forward.upsert_native_dependency(cargo.clone()).unwrap(); + forward.upsert_native_dependency(npm.clone()).unwrap(); + + let mut reverse = Lockfile::default(); + reverse.upsert_native_dependency(npm).unwrap(); + reverse.upsert_native_dependency(cargo).unwrap(); + + let forward_toml = forward.to_toml_string().unwrap(); + assert_eq!(forward_toml, reverse.to_toml_string().unwrap()); + assert_eq!(forward_toml.matches("[[native-dependency]]").count(), 2); + + let parsed = Lockfile::parse(&forward_toml).unwrap(); + assert_eq!(parsed, forward); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Npm, "core") + .unwrap() + .artifact + .sha256, + "a".repeat(64) + ); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Cargo, "core") + .unwrap() + .artifact + .sha256, + "b".repeat(64) + ); +} + +fn upsert_replaces_one_source_identity() { + let old = exact_lock(NativeRegistry::Npm, "core", "1.2.3", "1.2.3", 'a'); + let new = exact_lock(NativeRegistry::Npm, "core", "1.2.4", "1.2.4", 'b'); + + let mut lockfile = Lockfile::default(); + lockfile.upsert_native_dependency(old).unwrap(); + lockfile.upsert_native_dependency(new).unwrap(); + + assert_eq!(lockfile.native_dependencies.len(), 1); + assert_eq!( + lockfile + .find_native_dependency(NativeRegistry::Npm, "core") + .unwrap() + .package + .version, + "1.2.4" + ); +} + +fn invalid_provenance_fails_closed() { + let first = exact_lock(NativeRegistry::Npm, "core", "1.2.3", "1.2.3", 'a'); + let second = exact_lock(NativeRegistry::Npm, "core", "1.2.4", "1.2.4", 'b'); + let duplicate = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: vec![first, second], + nix_adapters: Vec::new(), + }; + assert!(matches!( + duplicate.to_toml_string(), + Err(LockfileError::DuplicateNativeDependency(_)) + )); + + let mut drift = exact_lock(NativeRegistry::Npm, "core", "^1.2.3", "1.9.0", 'c'); + drift.requirement.canonical = "^1.3.0".to_string(); + let mut lockfile = Lockfile::default(); + assert!(matches!( + lockfile.upsert_native_dependency(drift), + Err(LockfileError::InvalidNativeDependency(_)) + )); + + let mut invalid_artifact = + exact_lock(NativeRegistry::Cargo, "core", "1.2.3", "1.9.0", 'd'); + invalid_artifact.artifact.sha256 = "0".repeat(64); + lockfile.native_dependencies.push(invalid_artifact); + assert!(matches!( + lockfile.to_toml_string(), + Err(LockfileError::InvalidNativeDependency(_)) + )); +} + +fn main() { + legacy_documents_remain_compatible(); + ordering_is_deterministic_and_sources_coexist(); + upsert_replaces_one_source_identity(); + invalid_provenance_fails_closed(); +}