From d4fd94f47ee3f130044c7e98760c57711dc692fa Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 14:53:00 +0100 Subject: [PATCH 1/6] feat(wrapper): decdn-sponsored with per-download throwaway keys Rename the onramp CLI to decdn-sponsored and replace `onramp ` with `decdn-sponsored pull [-o ]`, which delegates to `decdn bundle pull`. - Each download gets its own throwaway key and random password under ~/.decdn/sponsored/downloads//; the user never manages a keystore or password. The installer no longer generates a key. - The capability is saved beside the key and reused on re-run, so an interrupted pull resumes without a new captcha. A capability near expiry is replaced by a fresh key. State is deleted on success. - Hash-only input (b3: or hex); name resolution stays on the website. - decdn runs with inherited stdio; stderr classification is removed. - The installer passes its arguments through, so `curl .../decdn.sh | sh -s -- pull b3:` installs and downloads. - Capability defaults: $5 cap, 48h TTL. - Server moves from the removed decdn-client-pull crate to decdn-client so the workspace builds again. Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 +- Cargo.lock | 281 ++++++++++++++++++++---- Cargo.toml | 2 +- README.md | 94 ++++---- crates/server/Cargo.toml | 2 +- crates/server/assets/decdn.sh | 35 +-- crates/server/src/config.rs | 8 +- crates/server/src/issuer.rs | 6 +- crates/server/src/treasury.rs | 10 +- crates/server/tests/chain_treasury.rs | 2 +- crates/wrapper/Cargo.toml | 7 +- crates/wrapper/src/config.rs | 72 ++---- crates/wrapper/src/flow.rs | 112 +++++++--- crates/wrapper/src/lib.rs | 7 +- crates/wrapper/src/main.rs | 54 +++-- crates/wrapper/src/runner.rs | 232 ++++++++++--------- crates/wrapper/src/session.rs | 210 ++++++++++++++++++ crates/wrapper/tests/pull_flow.rs | 152 +++++++++++++ crates/wrapper/tests/runner_classify.rs | 26 --- 19 files changed, 952 insertions(+), 368 deletions(-) create mode 100644 crates/wrapper/src/session.rs create mode 100644 crates/wrapper/tests/pull_flow.rs delete mode 100644 crates/wrapper/tests/runner_classify.rs diff --git a/.env.example b/.env.example index 36df964..b42c4cb 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,10 @@ SPONSOR_TURNSTILE_SITEKEY=0x... SPONSOR_BIND=127.0.0.1:8080 SPONSOR_PUBLIC_URL=https://up.decdn.org SPONSOR_CHAIN_ID=421614 -# Cap baked into each issued capability, in micro-USDC (10_000_000 = $10) -SPONSOR_CAPABILITY_CAP_MICRO_USDC=10000000 -# How long an issued capability remains valid, in seconds (2_592_000 = 30d) -SPONSOR_CAPABILITY_TTL_SECS=2592000 +# Cap baked into each issued capability, in micro-USDC (5_000_000 = $5) +SPONSOR_CAPABILITY_CAP_MICRO_USDC=5000000 +# How long an issued capability remains valid, in seconds (172_800 = 48h) +SPONSOR_CAPABILITY_TTL_SECS=172800 # Treasury tops the pool up when its remaining balance drops below this, in micro-USDC ($20) SPONSOR_POOL_LOW_WATER_MICRO_USDC=20000000 # Amount the treasury tops the pool up by, in micro-USDC ($100) diff --git a/Cargo.lock b/Cargo.lock index a792939..1a6a41d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2531,6 +2531,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -2746,7 +2755,7 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "decdn-bao-range" -version = "0.1.1" +version = "0.0.0" dependencies = [ "bao-tree", "bytes", @@ -2756,7 +2765,7 @@ dependencies = [ [[package]] name = "decdn-cache" -version = "0.1.1" +version = "0.0.0" dependencies = [ "anyhow", "arc-swap", @@ -2770,6 +2779,7 @@ dependencies = [ "bao-tree", "bytes", "chacha20poly1305", + "dashmap", "decdn-bao-range", "decdn-config-types", "decdn-protocol", @@ -2789,8 +2799,8 @@ dependencies = [ ] [[package]] -name = "decdn-client-pull" -version = "0.1.1" +name = "decdn-client" +version = "0.0.0" dependencies = [ "alloy", "anyhow", @@ -2801,8 +2811,10 @@ dependencies = [ "decdn-common", "decdn-incentive", "decdn-protocol", + "futures-util", "iroh", "iroh-io", + "rand 0.10.2", "serde", "serde_json", "tempfile", @@ -2812,7 +2824,7 @@ dependencies = [ [[package]] name = "decdn-common" -version = "0.1.1" +version = "0.0.0" dependencies = [ "alloy", "anyhow", @@ -2822,6 +2834,7 @@ dependencies = [ "dirs", "iroh", "jsonrpsee", + "nix", "rand 0.10.2", "serde", "tokio", @@ -2832,7 +2845,7 @@ dependencies = [ [[package]] name = "decdn-config-types" -version = "0.1.1" +version = "0.0.0" dependencies = [ "anyhow", "serde", @@ -2841,14 +2854,14 @@ dependencies = [ [[package]] name = "decdn-e2e" -version = "0.1.1" +version = "0.0.0" dependencies = [ "alloy", "anyhow", "bao-tree", "decdn-bao-range", "decdn-cache", - "decdn-client-pull", + "decdn-client", "decdn-common", "decdn-config-types", "decdn-incentive", @@ -2866,7 +2879,7 @@ dependencies = [ [[package]] name = "decdn-incentive" -version = "0.1.1" +version = "0.0.0" dependencies = [ "alloy", "anyhow", @@ -2888,7 +2901,7 @@ dependencies = [ [[package]] name = "decdn-node" -version = "0.1.1" +version = "0.0.0" dependencies = [ "alloy", "anyhow", @@ -2897,9 +2910,11 @@ dependencies = [ "bao-tree", "bytes", "clap", + "crossbeam-queue", + "dashmap", "decdn-bao-range", "decdn-cache", - "decdn-client-pull", + "decdn-client", "decdn-common", "decdn-incentive", "decdn-protocol", @@ -2916,6 +2931,10 @@ dependencies = [ "iroh-io", "iroh-metrics", "jsonrpsee", + "noq-proto", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "postcard", "rand 0.10.2", "redb", @@ -2927,13 +2946,14 @@ dependencies = [ "tokio-util", "toml", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "url", ] [[package]] name = "decdn-protocol" -version = "0.1.1" +version = "0.0.0" dependencies = [ "postcard", "serde", @@ -2943,7 +2963,7 @@ dependencies = [ [[package]] name = "decdn-reputation" -version = "0.1.1" +version = "0.0.0" dependencies = [ "anyhow", "decdn-protocol", @@ -2954,6 +2974,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "decdn-sponsored" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "clap", + "decdn-incentive", + "getrandom 0.3.4", + "hex", + "reqwest 0.12.28", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "toml", + "wiremock", +] + [[package]] name = "der" version = "0.6.1" @@ -3083,9 +3123,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -4204,6 +4244,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -5519,6 +5572,18 @@ dependencies = [ "wmi", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -5804,25 +5869,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onramp" -version = "0.1.0" -dependencies = [ - "alloy", - "anyhow", - "clap", - "decdn-incentive", - "hex", - "reqwest 0.12.28", - "serde", - "serde_json", - "serial_test", - "tempfile", - "tokio", - "toml", - "wiremock", -] - [[package]] name = "opaque-debug" version = "0.3.1" @@ -5872,6 +5918,68 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.19", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http 1.5.0", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror 2.0.19", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.19", + "tokio", + "tokio-stream", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -6352,6 +6460,38 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quanta" version = "0.12.6" @@ -6610,9 +6750,9 @@ checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" [[package]] name = "redb" -version = "4.1.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" dependencies = [ "libc", ] @@ -7726,7 +7866,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "decdn-client-pull", + "decdn-client", "decdn-common", "decdn-e2e", "decdn-incentive", @@ -8222,6 +8362,54 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -8230,9 +8418,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -8312,6 +8503,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-serde" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index d178d12..0dc512c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = "0.3" -decdn-client-pull = { path = "../decdn/crates/client-pull" } +decdn-client = { path = "../decdn/crates/client" } decdn-incentive = { path = "../decdn/crates/incentive", features = ["redb"] } decdn-common = { path = "../decdn/crates/common" } diff --git a/README.md b/README.md index ee083e9..29120e0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# sponsor +# sponsord -`sponsor` is deCDN's sponsored on-ramp: a gateway (`sponsord`) that grants a +`sponsord` is deCDN's sponsored on-ramp: a gateway (`sponsord`) that grants a new client a zero-tx allowance against its own shared `PaymentPool` on the Arbitrum Sepolia testnet, so a new user can fetch content from the network without first acquiring testnet USDC, opening a channel, or setting up a @@ -9,18 +9,17 @@ via `decdn pool open` (its id given by `SPONSOR_POOL_ID`) — the gateway never opens anything per user. A captcha-gated `/fund` flow issues an owner-signed EIP-712 capability (serialized as a `dcap1:` token) authorizing the caller's key to redeem against that pool up to a per-capability cap: zero on-chain -transaction and zero locked deposit per user. A companion CLI wrapper -(`onramp`, in `crates/wrapper`) drives the actual `decdn` fetch using a -locally generated keystore, never handing that key to the gateway. -It is a wrapper around `decdn`'s own paid-pull path — see `../decdn` for the -protocol and contracts this all sits on top of. +transaction and zero locked deposit per user. A companion CLI +(`decdn-sponsored`, in `crates/wrapper`) gives each download a throwaway key, +obtains a capability for it, and hands the pull to the `decdn` binary. The +gateway never sees that key. See `../decdn` for the protocol and contracts +this all sits on top of. Capabilities are node-agnostic: issuance doesn't involve a content hash or node discovery, only an allowance against the shared pool. Registration of a signer against the pool is set-once on-chain — once a key first redeems, its -cap and expiry are frozen for that key. There's no per-key top-up; getting -more allowance means generating a fresh keystore key and requesting a fresh -capability. Anti-abuse is bounded by the captcha on `/fund`, the +cap and expiry are frozen for that key, which is why `decdn-sponsored` uses +one key per download. Anti-abuse is bounded by the captcha on `/fund`, the per-capability cap (`SPONSOR_CAPABILITY_CAP_MICRO_USDC`), and the shared pool's own balance — there's no per-signer monthly accumulator. @@ -29,10 +28,10 @@ pool's own balance — there's no per-signer monthly accumulator. - `crates/server` (binary `sponsord`) — the HTTP gateway: `/healthz`, `/decdn.sh` (templated installer), `/fund` (captcha page + capability issuance), `/capability` (poll for an issued capability). -- `crates/wrapper` (binary `onramp`) — the end-user CLI: reads - `~/.decdn/sponsor.toml` (written by the installer), talks to `sponsord` to - obtain a capability, then drives a real `decdn` paid fetch against the - shared pool with a local keystore. +- `crates/wrapper` (binary `decdn-sponsored`) — the end-user CLI: reads + `~/.decdn/sponsor.toml` (written by the installer), obtains a capability + for a per-download throwaway key, then runs `decdn bundle pull` against the + shared pool. ## Running the server @@ -71,8 +70,8 @@ pool up from the treasury whenever its remaining balance falls below | `SPONSOR_CAPACITY_BOND_ADDR` | **yes** | — | `CapacityBond` contract address (used for hash → node/provider discovery) | | `SPONSOR_TREASURY_KEYSTORE` | **yes** | — | Path to the treasury hot-wallet's encrypted keystore JSON | | `SPONSOR_TREASURY_PASSWORD` | **yes** | — | Password to decrypt `SPONSOR_TREASURY_KEYSTORE` (never logged, never written to disk elsewhere) | -| `SPONSOR_CAPABILITY_CAP_MICRO_USDC` | no | `10_000_000` ($10) | Spend cap baked into each issued capability | -| `SPONSOR_CAPABILITY_TTL_SECS` | no | `2_592_000` (30 days) | How long an issued capability remains valid | +| `SPONSOR_CAPABILITY_CAP_MICRO_USDC` | no | `5_000_000` ($5) | Spend cap baked into each issued capability | +| `SPONSOR_CAPABILITY_TTL_SECS` | no | `172_800` (48 hours) | How long an issued capability remains valid | | `SPONSOR_POOL_LOW_WATER_MICRO_USDC` | no | `20_000_000` ($20) | Balance threshold below which `pool_watch` tops the pool up from the treasury | | `SPONSOR_POOL_REFILL_MICRO_USDC` | no | `100_000_000` ($100) | Amount `pool_watch` tops the pool up by | | `SPONSOR_POOL_WATCH_INTERVAL_SECS` | no | `3600` | How often the pool-balance background task runs | @@ -80,34 +79,43 @@ pool up from the treasury whenever its remaining balance falls below | `SPONSOR_TURNSTILE_SITEKEY` | **yes** | — | Cloudflare Turnstile sitekey, interpolated into the `/fund` widget page | | `SPONSOR_DATA_DIR` | no | `./data` | Directory for the redb store (issuance bookkeeping) | -## The wrapper (`onramp`) flow - -1. A user runs the installer served at `GET {{gateway}}/decdn.sh` (see - `assets/decdn.sh`). It installs the `decdn` and `onramp` binaries, - writes `~/.decdn/sponsor.toml` with the gateway's contract addresses and - RPC URL already filled in, and generates a local client keystore - (`~/.decdn/client/keystore.json`) if one doesn't already exist. -2. The user sets `DECDN_KEYSTORE_PASSWORD` and runs `onramp -o - out.bin`. -3. `onramp` loads `~/.decdn/sponsor.toml` (schema: `crates/wrapper/src/config.rs`'s - `Profile`), reads its local signer's address, and asks the gateway for a - capability: it opens `GET /fund?client=` in the browser for the - captcha, then polls `GET /capability?client=` until it gets back a - `dcap1:` token (or `204` while still pending). It then drives `decdn - fetch --capability --payment-pool-address -o - out`, signing vouchers with the local keystore — the gateway never sees - or holds the client's private key, only its own treasury key. - -There's no top-up loop: a capability's cap and expiry are fixed at issuance, -and on-chain signer registration is set-once, so once a key's allowance is -exhausted, the only way to get more is to request a fresh capability under a -new keystore key. +## The `decdn-sponsored` flow + +The website shows one command per model, with the model's BLAKE3 hash from +`models.json`: + +```bash +curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull b3: +``` + +1. The installer served at `GET /decdn.sh` (`assets/decdn.sh`) installs the + `decdn` and `decdn-sponsored` binaries and writes `~/.decdn/sponsor.toml` + with the gateway's contract addresses and RPC URL filled in. Any + arguments are passed on to `decdn-sponsored`. Running it again is + harmless, and `decdn-sponsored pull ...` works on its own once installed. +2. `decdn-sponsored pull [-o ]` (output defaults to the current + directory) opens the state directory for that hash, `~/.decdn/sponsored/downloads//`, and generates a throwaway + voucher-signing key there with a random password stored beside it. The + user never sees a key, keystore, or password. +3. It polls `GET /capability?client=` and, while that answers `204`, + prints (and opens in the browser) `GET /fund?client=` for the + captcha. The issued `dcap1:` token is saved in the state directory. +4. It runs `decdn bundle pull --hash -o --capability-file ... + --keystore ... --data-dir ` with inherited stdio, so `decdn`'s + own progress and errors reach the user unchanged. The name-to-hash + mapping happens on the website; the CLI accepts only a hash, and `decdn` + verifies every byte against it. +5. On success the state directory is deleted. On failure it is kept: running + the same command again resumes with the same key and capability (no new + captcha), and `bundle pull` resumes from its `.partial` files. A saved + capability within five minutes of expiry is replaced by a fresh key and + a new captcha. The `~/.decdn/sponsor.toml` schema is a hard contract between the installer (`assets/decdn.sh`) and the wrapper (`crates/wrapper/src/config.rs`): field -names must match exactly. Current fields: `gateway_base`, `keystore_path`, -`decdn_bin`, `data_dir`, `rpc_url`, `payment_pool`, `capacity_bond` -(optional), `slash_judge` (optional), `chain_id`. +names must match exactly. Current fields: `gateway_base`, `decdn_bin`, +`data_dir`, `rpc_url`, `payment_pool`, `capacity_bond` (optional), +`slash_judge` (optional), `chain_id`. Unknown fields are ignored. ## Publish seam @@ -115,7 +123,7 @@ This repo currently depends on its sibling `decdn` checkout via path dependencies in the root `Cargo.toml`: ```toml -decdn-client-pull = { path = "../decdn/crates/client-pull" } +decdn-client = { path = "../decdn/crates/client" } decdn-incentive = { path = "../decdn/crates/incentive", features = ["redb"] } decdn-common = { path = "../decdn/crates/common" } ``` @@ -124,7 +132,7 @@ Before this repo goes public, swap those to git dependencies pinned to a tagged `decdn` release: ```toml -decdn-client-pull = { git = "https://github.com/decdn/decdn.git", tag = "vX.Y.Z" } +decdn-client = { git = "https://github.com/decdn/decdn.git", tag = "vX.Y.Z" } decdn-incentive = { git = "https://github.com/decdn/decdn.git", tag = "vX.Y.Z", features = ["redb"] } decdn-common = { git = "https://github.com/decdn/decdn.git", tag = "vX.Y.Z" } ``` diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 1a569df..475f113 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -14,7 +14,7 @@ anvil-e2e = [] tokio.workspace = true anyhow.workspace = true alloy.workspace = true -decdn-client-pull.workspace = true +decdn-client.workspace = true decdn-incentive.workspace = true decdn-common.workspace = true redb = "4" diff --git a/crates/server/assets/decdn.sh b/crates/server/assets/decdn.sh index 26120ec..361a248 100644 --- a/crates/server/assets/decdn.sh +++ b/crates/server/assets/decdn.sh @@ -1,8 +1,12 @@ #!/bin/sh -# decdn sponsor installer - served by sponsord at GET /decdn.sh, with the +# decdn-sponsored installer - served by sponsord at GET /decdn.sh, with the # placeholders below substituted server-side (see # crates/server/src/http/installer.rs) from ServerConfig, so nothing here # needs an environment variable to run. +# +# Arguments, when given, are passed to decdn-sponsored after installing, so +# one line installs and downloads: +# curl -fsSL /decdn.sh | sh -s -- pull b3: set -eu GATEWAY="{{GATEWAY_BASE}}" @@ -13,45 +17,44 @@ CHAIN_ID="{{CHAIN_ID}}" BINDIR="${HOME}/.local/bin" DECDN_DIR="${HOME}/.decdn" -CLIENT_DIR="${DECDN_DIR}/client" -KEYSTORE="${CLIENT_DIR}/keystore.json" -mkdir -p "$BINDIR" "$CLIENT_DIR" +mkdir -p "$BINDIR" "$DECDN_DIR" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" -# 1. Install the decdn and onramp binaries. +# 1. Install the decdn and decdn-sponsored binaries. # # NOTE: release hosting (GET /dl/--) is not wired up on the # gateway yet — this is the structure the installer will use once it is. # Until then this step will fail with a 404; that's expected pre-launch. -for bin in decdn onramp; do +for bin in decdn decdn-sponsored; do echo "Installing ${bin}..." curl -fsSL "${GATEWAY}/dl/${bin}-${OS}-${ARCH}" -o "${BINDIR}/${bin}" chmod +x "${BINDIR}/${bin}" done # 2. Write the wrapper's profile. Field names and shape MUST match -# crates/wrapper/src/config.rs's `Profile` struct exactly. +# crates/wrapper/src/config.rs's `Profile` struct exactly. Each download +# gets its own throwaway key under data_dir; there is no key to set up here. cat > "${DECDN_DIR}/sponsor.toml" < -o out.bin" +echo "decdn-sponsored is ready. Download with:" +echo " decdn-sponsored pull b3: [-o ]" +case ":${PATH}:" in + *":${BINDIR}:"*) ;; + *) echo "(add ${BINDIR} to your PATH first)" ;; +esac diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 25ccf99..cfafd01 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -51,8 +51,8 @@ impl ServerConfig { pool_id: B256::from_str(&env("SPONSOR_POOL_ID")?)?, capacity_bond: Address::from_str(&env("SPONSOR_CAPACITY_BOND_ADDR")?)?, treasury_keystore: PathBuf::from(env("SPONSOR_TREASURY_KEYSTORE")?), - capability_cap: MicroUsdc(env_u64("SPONSOR_CAPABILITY_CAP_MICRO_USDC", 10_000_000)?), - capability_ttl_secs: env_u64("SPONSOR_CAPABILITY_TTL_SECS", 2_592_000)?, + capability_cap: MicroUsdc(env_u64("SPONSOR_CAPABILITY_CAP_MICRO_USDC", 5_000_000)?), + capability_ttl_secs: env_u64("SPONSOR_CAPABILITY_TTL_SECS", 172_800)?, pool_low_water: MicroUsdc(env_u64("SPONSOR_POOL_LOW_WATER_MICRO_USDC", 20_000_000)?), pool_refill: MicroUsdc(env_u64("SPONSOR_POOL_REFILL_MICRO_USDC", 100_000_000)?), pool_watch_interval_secs: env_u64("SPONSOR_POOL_WATCH_INTERVAL_SECS", 3600)?, @@ -130,8 +130,8 @@ mod tests { } let cfg = ServerConfig::from_env().unwrap(); assert_eq!(cfg.chain_id, 421_614); - assert_eq!(cfg.capability_cap.0, 10_000_000); - assert_eq!(cfg.capability_ttl_secs, 2_592_000); + assert_eq!(cfg.capability_cap.0, 5_000_000); + assert_eq!(cfg.capability_ttl_secs, 172_800); assert_eq!(cfg.pool_low_water.0, 20_000_000); } } diff --git a/crates/server/src/issuer.rs b/crates/server/src/issuer.rs index 540b88e..f8e578f 100644 --- a/crates/server/src/issuer.rs +++ b/crates/server/src/issuer.rs @@ -3,7 +3,7 @@ //! Signing touches no chain — this is the zero-tx allowance. use alloy::dyn_abi::Eip712Domain; -use alloy::primitives::{Address, B256, U256}; +use alloy::primitives::{Address, B256}; use alloy::signers::local::PrivateKeySigner; use decdn_incentive::{Capability, CapabilityGrant}; @@ -50,7 +50,7 @@ impl Issuer { let expiry = now_unix.saturating_add(self.ttl_secs); let capability = Capability { signer: delegate, - spending_cap: U256::from(self.spending_cap), + spending_cap: self.spending_cap, pool_id: self.pool_id, expiry, }; @@ -91,7 +91,7 @@ mod tests { let grant = CapabilityGrant::from_token(&token).unwrap(); assert_eq!(grant.signer, delegate); assert_eq!(grant.pool_id, pool_id); - assert_eq!(grant.spending_cap, U256::from(10_000_000u64)); + assert_eq!(grant.spending_cap, 10_000_000u64); assert_eq!(grant.expiry, expiry); assert_eq!(grant.owner(&domain).unwrap(), owner); } diff --git a/crates/server/src/treasury.rs b/crates/server/src/treasury.rs index 16ba375..68bdd1c 100644 --- a/crates/server/src/treasury.rs +++ b/crates/server/src/treasury.rs @@ -7,8 +7,8 @@ use alloy::primitives::{Address, B256, U256}; use alloy::providers::Provider; use alloy::signers::local::PrivateKeySigner; use async_trait::async_trait; -use decdn_client_pull::buyer_pool::{ensure_allowance, top_up}; -use decdn_client_pull::provider::build_provider; +use decdn_client::buyer_pool::{ensure_allowance, top_up}; +use decdn_client::provider::build_provider; use decdn_incentive::payment_pool::PaymentPool; use crate::money::MicroUsdc; @@ -94,8 +94,10 @@ impl Treasury for DecdnTreasury

{ Some(amount), ) .await?; - let credited = top_up(&self.contract, pool_id, amount).await?; - Ok(MicroUsdc(u64::try_from(credited).unwrap_or(u64::MAX))) + let topped = top_up(&self.contract, pool_id, amount).await?; + Ok(MicroUsdc( + u64::try_from(topped.credited).unwrap_or(u64::MAX), + )) } async fn pool_owner(&self, pool_id: B256) -> anyhow::Result

{ diff --git a/crates/server/tests/chain_treasury.rs b/crates/server/tests/chain_treasury.rs index 9f9ad6f..6181be9 100644 --- a/crates/server/tests/chain_treasury.rs +++ b/crates/server/tests/chain_treasury.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use alloy::primitives::U256; use alloy::signers::local::PrivateKeySigner; -use decdn_client_pull::buyer_pool::{ensure_allowance, open_pool}; +use decdn_client::buyer_pool::{ensure_allowance, open_pool}; use decdn_e2e::chain::ChainFixture; use decdn_incentive::payment_pool::PaymentPool; use decdn_incentive::voucher_domain; diff --git a/crates/wrapper/Cargo.toml b/crates/wrapper/Cargo.toml index 56ca97a..3c9ac35 100644 --- a/crates/wrapper/Cargo.toml +++ b/crates/wrapper/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "onramp" +name = "decdn-sponsored" version = "0.1.0" edition = "2024" publish = false [[bin]] -name = "onramp" +name = "decdn-sponsored" path = "src/main.rs" [lib] -name = "onramp" +name = "decdn_sponsored" path = "src/lib.rs" [lints] @@ -30,6 +30,7 @@ decdn-incentive.workspace = true serde.workspace = true serde_json.workspace = true hex = "0.4" +getrandom = "0.3" reqwest = { version = "0.12", features = ["json"] } toml = "1" clap = { version = "4", features = ["derive"] } diff --git a/crates/wrapper/src/config.rs b/crates/wrapper/src/config.rs index 3972174..391bd00 100644 --- a/crates/wrapper/src/config.rs +++ b/crates/wrapper/src/config.rs @@ -1,30 +1,21 @@ -//! Wrapper configuration: loaded from the installer-written profile file -//! (`~/.decdn/sponsor.toml`) plus one environment variable for the keystore -//! password (never written to disk). +//! Wrapper configuration, loaded from the installer-written profile file +//! (`~/.decdn/sponsor.toml`). //! -//! Field names here are the contract Task 18's `decdn.sh` installer must -//! write to `~/.decdn/sponsor.toml` — keep them in sync if either side -//! changes. +//! Field names here are the contract the `decdn.sh` installer writes — keep +//! them in sync if either side changes. Unknown fields are ignored. use std::path::{Path, PathBuf}; use alloy::primitives::Address; use serde::Deserialize; -/// Env var holding the keystore decryption password. Never written to the -/// profile file on disk. -const PASSWORD_ENV: &str = "DECDN_KEYSTORE_PASSWORD"; - /// Default location of the installer-written profile, relative to `$HOME`. const DEFAULT_PROFILE_REL: &str = ".decdn/sponsor.toml"; -/// On-disk shape of `~/.decdn/sponsor.toml`, written by the Task 18 -/// installer. Everything except the keystore password (kept out of the -/// file, supplied via `DECDN_KEYSTORE_PASSWORD`) lives here. +/// On-disk shape of `~/.decdn/sponsor.toml`. #[derive(Debug, Clone, Deserialize)] struct Profile { gateway_base: String, - keystore_path: PathBuf, decdn_bin: String, data_dir: PathBuf, rpc_url: String, @@ -34,14 +25,12 @@ struct Profile { chain_id: u64, } -/// Fully resolved wrapper configuration: the on-disk profile plus the -/// password from the environment. +/// Fully resolved wrapper configuration. #[derive(Debug, Clone)] pub struct WrapperConfig { pub gateway_base: String, - pub keystore_path: PathBuf, - pub keystore_password: String, pub decdn_bin: String, + /// Root for per-download state (`/downloads//`). pub data_dir: PathBuf, pub rpc_url: String, pub payment_pool: Address, @@ -71,15 +60,13 @@ fn expand_home(path: &Path) -> anyhow::Result { } impl WrapperConfig { - /// Load the profile written by the Task 18 installer at - /// `~/.decdn/sponsor.toml` (path overridable via `DECDN_SPONSOR_PROFILE` - /// for tests/dev), and pull the keystore password from - /// `DECDN_KEYSTORE_PASSWORD`. + /// Load the installer-written profile at `~/.decdn/sponsor.toml` (path + /// overridable via `DECDN_SPONSOR_PROFILE` for tests/dev). /// /// # Errors /// - /// Returns an error if `$HOME` can't be resolved, the profile file - /// can't be read or parsed, or `DECDN_KEYSTORE_PASSWORD` isn't set. + /// Returns an error if `$HOME` can't be resolved or the profile file + /// can't be read or parsed. pub fn load() -> anyhow::Result { let profile_path = match std::env::var("DECDN_SPONSOR_PROFILE") { Ok(p) => PathBuf::from(p), @@ -95,21 +82,16 @@ impl WrapperConfig { Self::from_toml_str(&text) } - /// Parse a profile from an in-memory TOML string (used by `load` and - /// directly by tests, to avoid touching the real filesystem/`$HOME`). + /// Parse a profile from an in-memory TOML string. /// /// # Errors /// - /// Returns an error if the TOML doesn't parse into `Profile`, a `~` - /// path can't be expanded, or `DECDN_KEYSTORE_PASSWORD` isn't set. + /// Returns an error if the TOML doesn't parse into `Profile` or a `~` + /// path can't be expanded. pub fn from_toml_str(text: &str) -> anyhow::Result { let profile: Profile = toml::from_str(text)?; - let keystore_password = std::env::var(PASSWORD_ENV) - .map_err(|_| anyhow::anyhow!("missing env {PASSWORD_ENV}"))?; Ok(Self { gateway_base: profile.gateway_base, - keystore_path: expand_home(&profile.keystore_path)?, - keystore_password, decdn_bin: profile.decdn_bin, data_dir: expand_home(&profile.data_dir)?, rpc_url: profile.rpc_url, @@ -128,10 +110,9 @@ mod tests { use serial_test::serial; const SAMPLE: &str = r#" - gateway_base = "https://sponsor.example.com" - keystore_path = "~/.decdn/keystore.json" + gateway_base = "https://gateway.example.com" decdn_bin = "decdn" - data_dir = "~/.decdn/data" + data_dir = "~/.decdn/sponsored" rpc_url = "https://sepolia-rollup.arbitrum.io/rpc" payment_pool = "0x0000000000000000000000000000000000000001" capacity_bond = "0x0000000000000000000000000000000000000002" @@ -142,31 +123,22 @@ mod tests { #[serial] fn parses_profile_and_expands_home() { unsafe { - std::env::set_var("DECDN_KEYSTORE_PASSWORD", "pw"); std::env::set_var("HOME", "/home/testuser"); } let cfg = WrapperConfig::from_toml_str(SAMPLE).unwrap(); - assert_eq!(cfg.gateway_base, "https://sponsor.example.com"); + assert_eq!(cfg.gateway_base, "https://gateway.example.com"); assert_eq!( - cfg.keystore_path, - PathBuf::from("/home/testuser/.decdn/keystore.json") + cfg.data_dir, + PathBuf::from("/home/testuser/.decdn/sponsored") ); - assert_eq!(cfg.data_dir, PathBuf::from("/home/testuser/.decdn/data")); - assert_eq!(cfg.keystore_password, "pw"); assert_eq!(cfg.chain_id, 421_614); assert!(cfg.slash_judge.is_none()); - unsafe { - std::env::remove_var("DECDN_KEYSTORE_PASSWORD"); - } } #[test] #[serial] - fn missing_password_env_errors() { - unsafe { - std::env::remove_var("DECDN_KEYSTORE_PASSWORD"); - } - let res = WrapperConfig::from_toml_str(SAMPLE); - assert!(res.is_err()); + fn ignores_unknown_fields() { + let with_extra = format!("{SAMPLE}\nkeystore_path = \"~/.decdn/client/keystore.json\"\n"); + assert!(WrapperConfig::from_toml_str(&with_extra).is_ok()); } } diff --git a/crates/wrapper/src/flow.rs b/crates/wrapper/src/flow.rs index 4f83ed9..4005b64 100644 --- a/crates/wrapper/src/flow.rs +++ b/crates/wrapper/src/flow.rs @@ -1,57 +1,97 @@ -//! The wrapper flow: get/poll a capability (browser captcha), then run one -//! `decdn fetch --capability`. There is no money top-up — an exhausted cap or -//! drained pool is terminal (issue a fresh capability with a new key). +//! The pull flow: open this download's state, make sure its throwaway key +//! holds an unexpired capability (browser captcha when it doesn't), then run +//! `decdn bundle pull`. A successful pull deletes the state; a failed one +//! keeps it, so re-running the same command resumes without a new captcha. +use std::io::IsTerminal; use std::path::Path; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::api::Api; use crate::config::WrapperConfig; -use crate::runner::{self, FetchArgs, FetchOutcome}; +use crate::runner::{self, PullArgs}; +use crate::session::{self, Session}; /// How long to wait for the browser captcha flow to produce a capability. -const CAPABILITY_POLL_TIMEOUT: Duration = Duration::from_secs(300); +const CAPABILITY_POLL_TIMEOUT: Duration = Duration::from_secs(600); + +/// A saved capability this close to expiry is replaced before pulling. +const EXPIRY_MARGIN_SECS: u64 = 300; /// # Errors -/// Keystore unreadable, sponsor unreachable / no capability within the poll -/// timeout, or `decdn fetch` fails (including a terminal cap/pool exhaustion). -pub async fn get(hash: &str, out: &Path, cfg: &WrapperConfig) -> anyhow::Result<()> { - let client = crate::keystore::read_address(&cfg.keystore_path)?; - let api = Api { - base: cfg.gateway_base.clone(), - http: reqwest::Client::new(), - }; +/// Invalid hash, state dir or key failure, sponsor unreachable / no +/// capability within the poll timeout, or `decdn bundle pull` failing. +pub async fn pull(hash: &str, output: &Path, cfg: &WrapperConfig) -> anyhow::Result<()> { + let hash = session::normalize_hash(hash)?; + let api = Api::new(cfg.gateway_base.clone()); - let info = match api.get_capability(client).await? { - Some(info) => info, - None => { - println!( - "No capability yet. Open this link and solve the captcha:\n {}", - api.fund_url(client) - ); - api.poll_capability(client, CAPABILITY_POLL_TIMEOUT).await? + let mut session = Session::open(&cfg.data_dir, &hash)?; + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let has_live_capability = match session.capability()? { + Some(grant) if grant.expiry > now.saturating_add(EXPIRY_MARGIN_SECS) => true, + Some(_) => { + // A key's cap and expiry are frozen on-chain at its first + // redemption, so an expired capability means a fresh key. + session.discard()?; + session = Session::open(&cfg.data_dir, &hash)?; + false } + None => false, }; - let args = FetchArgs { - hash: hash.to_string(), - output: out.display().to_string(), - capability: info.token, - payment_pool_address: cfg.payment_pool.to_string(), + let client = session.ensure_key()?; + if !has_live_capability { + let info = match api.get_capability(client).await? { + Some(info) => info, + None => { + let url = api.fund_url(client); + println!("Solve the captcha to start the download:\n {url}"); + open_in_browser(&url); + api.poll_capability(client, CAPABILITY_POLL_TIMEOUT).await? + } + }; + session.save_capability(&info.token)?; + } + + let args = PullArgs { + hash, + output: output.to_path_buf(), + capability_file: session.capability_path(), + keystore: session.keystore_path(), + password_file: session.password_path(), + data_dir: session.dir().to_path_buf(), rpc_url: cfg.rpc_url.clone(), + payment_pool_address: cfg.payment_pool.to_string(), capacity_bond_address: cfg.capacity_bond.map(|a| a.to_string()), slash_judge_address: cfg.slash_judge.map(|a| a.to_string()), chain_id: cfg.chain_id, - keystore: cfg.keystore_path.display().to_string(), - data_dir: cfg.data_dir.display().to_string(), }; - match runner::run_fetch(&cfg.decdn_bin, &args).await? { - FetchOutcome::Complete => Ok(()), - FetchOutcome::Exhausted => anyhow::bail!( - "sponsored allowance exhausted (cap or pool drained). Generate a new keystore key \ - and request a fresh capability — an already-used key's cap cannot be raised on-chain." - ), - FetchOutcome::Failed(e) => anyhow::bail!("fetch failed: {e}"), + let status = runner::run_pull(&cfg.decdn_bin, &args).await?; + if !status.success() { + anyhow::bail!( + "download did not finish. Re-run the same command to resume: downloaded \ + bytes are kept, and no new captcha is needed while the capability is valid." + ); } + session.discard() +} + +/// Best-effort: open `url` in the default browser when a user is at the +/// terminal. The link is always printed too, so a failure here is silent. +fn open_in_browser(url: &str) { + if !std::io::stdout().is_terminal() { + return; + } + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + let _ = std::process::Command::new(opener) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); } diff --git a/crates/wrapper/src/lib.rs b/crates/wrapper/src/lib.rs index ebd08c7..96b0d93 100644 --- a/crates/wrapper/src/lib.rs +++ b/crates/wrapper/src/lib.rs @@ -1,9 +1,10 @@ -//! `onramp`: the decdn-sponsor CLI wrapper. Wraps a plain `decdn` node -//! process, buys/tops-up a payment channel on the operator's behalf, and -//! (in later tasks) proxies requests through the sponsor's channel. +//! `decdn-sponsored`: the walletless client for the sponsord gateway. Gives +//! each download a throwaway key, obtains a sponsor capability for it via a +//! browser captcha, and delegates the pull itself to the `decdn` binary. pub mod api; pub mod config; pub mod flow; pub mod keystore; pub mod runner; +pub mod session; diff --git a/crates/wrapper/src/main.rs b/crates/wrapper/src/main.rs index 464fc9c..6caeca2 100644 --- a/crates/wrapper/src/main.rs +++ b/crates/wrapper/src/main.rs @@ -1,39 +1,55 @@ -//! `onramp`: fetch a content-addressed blob through the decdn-sponsor -//! gateway. Wraps `decdn fetch` with fund/poll/top-up handling — see -//! `flow::get`. +//! `decdn-sponsored`: download a content-addressed bundle through the +//! sponsord gateway, with no wallet. See `flow::pull`. use std::path::PathBuf; +use std::process::ExitCode; -use clap::Parser; -use onramp::config::WrapperConfig; -use onramp::flow; +use clap::{Parser, Subcommand}; +use decdn_sponsored::config::WrapperConfig; +use decdn_sponsored::flow; -/// Fetch a blob through the decdn-sponsor gateway. +/// Download from deCDN, paid for by the sponsor. You solve one captcha per +/// download; there is no wallet, key, or password to manage. #[derive(Parser, Debug)] -#[command(name = "onramp")] +#[command(name = "decdn-sponsored")] struct Cli { - /// BLAKE3 hash of the blob to fetch. - hash: String, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Pull a bundle by its BLAKE3 hash. + Pull { + /// BLAKE3 hash of the bundle manifest (`b3:` or bare hex). + hash: String, - /// Destination path for the fetched blob. - #[arg(short, long)] - output: PathBuf, + /// Directory the bundle's files are written under. + #[arg(short, long, default_value = ".")] + output: PathBuf, + }, } #[tokio::main] -async fn main() { +async fn main() -> ExitCode { let cli = Cli::parse(); let cfg = match WrapperConfig::load() { Ok(cfg) => cfg, Err(e) => { - eprintln!("onramp: failed to load config: {e}"); - std::process::exit(1); + eprintln!("decdn-sponsored: failed to load config: {e}"); + return ExitCode::FAILURE; } }; - if let Err(e) = flow::get(&cli.hash, &cli.output, &cfg).await { - eprintln!("onramp: {e}"); - std::process::exit(1); + let result = match &cli.command { + Command::Pull { hash, output } => flow::pull(hash, output, &cfg).await, + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("decdn-sponsored: {e:#}"); + ExitCode::FAILURE + } } } diff --git a/crates/wrapper/src/runner.rs b/crates/wrapper/src/runner.rs index 0806904..5020d93 100644 --- a/crates/wrapper/src/runner.rs +++ b/crates/wrapper/src/runner.rs @@ -1,149 +1,147 @@ -//! Spawns `decdn fetch` as a child process and classifies its outcome. +//! Spawns `decdn bundle pull` against a sponsor-issued capability. //! -//! `decdn fetch` is the paying pull of a single content-addressed blob -//! (see `decdn/crates/common/src/cli/fetch.rs`). This wrapper runs it -//! unattended: stdout (the progress bar) is inherited so the operator still -//! sees liveness, stderr is captured so a cap/pool-exhaustion error can be -//! told apart from any other failure. -//! -//! Fetches draw against a sponsor-funded capability and its backing -//! `PaymentPool` (`--capability` / `--payment-pool-address`). When the -//! capability's cap or the pool's balance is exhausted mid-fetch, `decdn -//! fetch` cannot top up and fails terminally with an actionable message -//! (`decdn/crates/cli/src/commands/fetch.rs:907-909`). That is the one -//! failure mode this wrapper must tell apart from a generic error, because -//! the caller (Task 16's flow) responds to it by asking the sponsor to -//! raise the cap or refill the pool rather than treating the fetch as a -//! hard failure. +//! `decdn` owns the pull end to end: discovery, payment, BLAKE3 verification, +//! progress, and resuming from `.partial` files. The child inherits all +//! stdio, so the user sees `decdn`'s own progress and error messages +//! unchanged; this module only builds its argument vector. -use std::process::{ExitStatus, Stdio}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::ExitStatus; use tokio::process::Command; -/// Outcome of one `decdn fetch` invocation. -#[derive(Debug)] -pub enum FetchOutcome { - /// The fetch completed successfully; the blob is at `FetchArgs::output`. - Complete, - /// The capability's cap or the backing `PaymentPool`'s balance was - /// exhausted mid-fetch. Terminal for this key — there is no top-up - /// path from here; the caller must ask the sponsor to raise the cap or - /// refill the pool. - Exhausted, - /// Any other non-zero exit. Carries the last stderr line, or a generic - /// message if stderr was empty. - Failed(String), -} - -/// Stable substring of the terminal error `decdn fetch` prints when a -/// capability's cap or its backing `PaymentPool` is exhausted -/// (`decdn/crates/cli/src/commands/fetch.rs:907-909`). -const EXHAUSTED_MARKER: &str = "higher-cap capability"; - -/// Classify a finished `decdn fetch` child process from its exit status and -/// captured stderr. Pure — does no I/O — so it is unit-testable without -/// spawning a process. -#[must_use] -pub fn classify_exit(status: ExitStatus, stderr: &str) -> FetchOutcome { - if status.success() { - return FetchOutcome::Complete; - } - if stderr.contains(EXHAUSTED_MARKER) { - return FetchOutcome::Exhausted; - } - FetchOutcome::Failed(stderr.lines().last().unwrap_or("fetch failed").to_string()) -} +/// `decdn` reads this before `--keystore-password-file`; it is removed from +/// the child's environment so the per-download password file always wins. +const PASSWORD_ENV: &str = "DECDN_KEYSTORE_PASSWORD"; -/// Arguments for one `decdn fetch` invocation. A plain data struct: this -/// task only builds and classifies the child process; Task 16's flow -/// populates and reuses these across fetches in a session. +/// Arguments for one `decdn bundle pull` invocation. #[derive(Debug, Clone)] -pub struct FetchArgs { - /// `--hash`: BLAKE3 hash of the blob to fetch. +pub struct PullArgs { + /// `--hash`: BLAKE3 hash (64 hex) of the bundle manifest. pub hash: String, - /// `-o`/`--output`: destination path for the fetched blob. - pub output: String, - /// `--capability`: the sponsor-issued capability id to draw the fetch's - /// payment against. - pub capability: String, + /// `-o`/`--output`: directory the bundle's files are written under. + pub output: PathBuf, + /// `--capability-file`: the sponsor-issued `dcap1:` token, read from a + /// file to keep it off the process table. + pub capability_file: PathBuf, + /// `--keystore`: this download's throwaway voucher-signing key. + pub keystore: PathBuf, + /// `--keystore-password-file`: that key's random password. + pub password_file: PathBuf, + /// `--data-dir`: this download's state dir (buyer-channel store). + pub data_dir: PathBuf, /// `--rpc-url`: JSON-RPC endpoint for on-chain reads. pub rpc_url: String, - /// `--payment-pool-address`: `PaymentPool` contract address backing the - /// capability. + /// `--payment-pool-address`: the sponsor's `PaymentPool` contract. pub payment_pool_address: String, - /// `--capacity-bond-address`: `CapacityBond` contract address. Used by - /// node auto-discovery under `--capability`: the fetch has no explicit - /// node argument, so it reads `CapacityBond` to find nodes to fetch - /// from. + /// `--capacity-bond-address`: read for node auto-discovery. pub capacity_bond_address: Option, /// `--slash-judge-address`: `SlashJudge` contract address. pub slash_judge_address: Option, /// `--chain-id`: EIP-712 `chainId`. pub chain_id: u64, - /// `--keystore`: path to the voucher-signing keystore. - pub keystore: String, - /// `--data-dir`: data dir holding the persistent buyer-channel store. - pub data_dir: String, } -impl FetchArgs { - /// Build the `decdn fetch` argument vector (everything after the - /// `fetch` subcommand). - fn to_args(&self) -> Vec { - let mut args = vec![ - "fetch".to_string(), - "--capability".to_string(), - self.capability.clone(), - "--hash".to_string(), - self.hash.clone(), - "-o".to_string(), - self.output.clone(), - "--rpc-url".to_string(), - self.rpc_url.clone(), - "--payment-pool-address".to_string(), - self.payment_pool_address.clone(), - "--chain-id".to_string(), - self.chain_id.to_string(), - "--keystore".to_string(), - self.keystore.clone(), - "--data-dir".to_string(), - self.data_dir.clone(), +impl PullArgs { + /// The `decdn` argument vector, starting at the `bundle` subcommand. + #[must_use] + pub fn to_args(&self) -> Vec { + let mut args: Vec = vec![ + "bundle".into(), + "pull".into(), + "--hash".into(), + self.hash.clone().into(), + "-o".into(), + self.output.clone().into(), + "--capability-file".into(), + self.capability_file.clone().into(), + "--keystore".into(), + self.keystore.clone().into(), + "--keystore-password-file".into(), + self.password_file.clone().into(), + "--data-dir".into(), + self.data_dir.clone().into(), + "--rpc-url".into(), + self.rpc_url.clone().into(), + "--payment-pool-address".into(), + self.payment_pool_address.clone().into(), + "--chain-id".into(), + self.chain_id.to_string().into(), ]; if let Some(addr) = &self.capacity_bond_address { - args.push("--capacity-bond-address".to_string()); - args.push(addr.clone()); + args.push("--capacity-bond-address".into()); + args.push(addr.clone().into()); } if let Some(addr) = &self.slash_judge_address { - args.push("--slash-judge-address".to_string()); - args.push(addr.clone()); + args.push("--slash-judge-address".into()); + args.push(addr.clone().into()); } args } } -/// Spawn `decdn fetch` with `args`, letting stdout (progress bar) pass -/// through to the wrapper's own stdout while capturing stderr, then -/// classify the result. +/// Run `decdn bundle pull` to completion with inherited stdio. /// /// # Errors /// -/// Returns an error if the child process cannot be spawned or awaited -/// (e.g. `decdn_bin` is not found). A non-zero exit from `decdn fetch` -/// itself is not an `Err` here — it is reported as `FetchOutcome::Exhausted` -/// or `FetchOutcome::Failed` so the caller can distinguish cap/pool -/// exhaustion from every other failure. -pub async fn run_fetch(decdn_bin: &str, args: &FetchArgs) -> anyhow::Result { - // `Command::output()` forces both stdout and stderr to piped, which - // would swallow the progress bar the operator is meant to see. Spawn - // with explicit per-stream stdio instead — stdout inherited, stderr - // piped — then `wait_with_output` drains only the piped stream - // (stdout comes back empty since it was never captured). - let child = Command::new(decdn_bin) +/// Returns an error if `decdn_bin` cannot be spawned or awaited. A non-zero +/// exit is returned as the `ExitStatus`, not as an `Err`. +pub async fn run_pull(decdn_bin: &str, args: &PullArgs) -> anyhow::Result { + let status = Command::new(decdn_bin) .args(args.to_args()) - .stdout(Stdio::inherit()) - .stderr(Stdio::piped()) - .spawn()?; - let output = child.wait_with_output().await?; - let stderr = String::from_utf8_lossy(&output.stderr); - Ok(classify_exit(output.status, &stderr)) + .env_remove(PASSWORD_ENV) + .status() + .await + .map_err(|e| anyhow::anyhow!("failed to run {decdn_bin}: {e}"))?; + Ok(status) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn sample() -> PullArgs { + PullArgs { + hash: "ab".repeat(32), + output: PathBuf::from("out"), + capability_file: PathBuf::from("/s/capability"), + keystore: PathBuf::from("/s/keystore.json"), + password_file: PathBuf::from("/s/password"), + data_dir: PathBuf::from("/s"), + rpc_url: "http://rpc".into(), + payment_pool_address: "0x01".into(), + capacity_bond_address: Some("0x02".into()), + slash_judge_address: None, + chain_id: 421_614, + } + } + + fn value_after(args: &[OsString], flag: &str) -> Option { + let i = args.iter().position(|a| a == flag)?; + args.get(i + 1).map(|v| v.to_string_lossy().into_owned()) + } + + #[test] + fn builds_bundle_pull_with_capability_file() { + let args = sample().to_args(); + assert_eq!(args[0], "bundle"); + assert_eq!(args[1], "pull"); + assert_eq!(value_after(&args, "--hash").unwrap(), "ab".repeat(32)); + assert_eq!( + value_after(&args, "--capability-file").unwrap(), + "/s/capability" + ); + assert_eq!( + value_after(&args, "--keystore-password-file").unwrap(), + "/s/password" + ); + assert_eq!( + value_after(&args, "--capacity-bond-address").unwrap(), + "0x02" + ); + assert!(!args.iter().any(|a| a == "--capability")); + assert!(!args.iter().any(|a| a == "--slash-judge-address")); + } } diff --git a/crates/wrapper/src/session.rs b/crates/wrapper/src/session.rs new file mode 100644 index 0000000..5ab5a2f --- /dev/null +++ b/crates/wrapper/src/session.rs @@ -0,0 +1,210 @@ +//! Per-download state: a throwaway voucher-signing key and the capability +//! the sponsor issued to it, kept under `/downloads//`. +//! +//! Each download gets its own key, so the user never manages a wallet: the +//! key holds no funds, is never shown, and its password is random and stored +//! beside it. The directory survives an interrupted pull, so re-running the +//! same command resumes with the same key and capability (no new captcha), +//! and is deleted once the pull succeeds. It is also the `--data-dir` handed +//! to `decdn`, so the buyer-channel store for this key goes with it. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use alloy::primitives::Address; +use anyhow::Context; +use decdn_incentive::CapabilityGrant; +use decdn_incentive::eth_identity; + +const PASSWORD_FILE: &str = "password"; +const CAPABILITY_FILE: &str = "capability"; + +/// Normalize a BLAKE3 hash as the website prints it (`b3:`, `0x`, +/// or bare hex) to 64 lowercase hex characters. +/// +/// # Errors +/// +/// Returns an error unless the remainder is exactly 64 hex characters. +pub fn normalize_hash(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + let hex = trimmed + .strip_prefix("b3:") + .or_else(|| trimmed.strip_prefix("0x")) + .unwrap_or(trimmed) + .to_ascii_lowercase(); + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + anyhow::bail!( + "not a BLAKE3 hash (expected 64 hex characters, optionally b3:-prefixed): {raw}" + ); + } + Ok(hex) +} + +/// One download's state directory. +#[derive(Debug)] +pub struct Session { + dir: PathBuf, +} + +impl Session { + /// Open (creating with mode `0700` if needed) the state directory for + /// `hash` under `root`. `hash` must already be normalized. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be created or secured. + pub fn open(root: &Path, hash: &str) -> anyhow::Result { + let dir = root.join("downloads").join(hash); + create_private_dir(&dir)?; + Ok(Self { dir }) + } + + /// The directory itself; `decdn`'s `--data-dir` for this download. + #[must_use] + pub fn dir(&self) -> &Path { + &self.dir + } + + #[must_use] + pub fn keystore_path(&self) -> PathBuf { + eth_identity::keystore_path(&self.dir) + } + + #[must_use] + pub fn password_path(&self) -> PathBuf { + self.dir.join(PASSWORD_FILE) + } + + #[must_use] + pub fn capability_path(&self) -> PathBuf { + self.dir.join(CAPABILITY_FILE) + } + + /// Return this download's key address, generating the key (and its + /// random password) on first use. + /// + /// # Errors + /// + /// Returns an error if the key cannot be generated, written, or read. + pub fn ensure_key(&self) -> anyhow::Result
{ + let keystore = self.keystore_path(); + if keystore.exists() { + return crate::keystore::read_address(&keystore); + } + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).map_err(|e| anyhow::anyhow!("read OS randomness: {e}"))?; + let password = hex::encode(secret); + write_private(&self.password_path(), password.as_bytes())?; + eth_identity::generate_and_persist(&self.dir, &password, false) + .context("generate throwaway download key") + } + + /// The capability saved for this download, if any. An unreadable or + /// malformed file reads as `None`, so the caller requests a fresh one. + /// + /// # Errors + /// + /// Returns an error only if the file exists but cannot be read. + pub fn capability(&self) -> anyhow::Result> { + let path = self.capability_path(); + if !path.exists() { + return Ok(None); + } + let token = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + Ok(CapabilityGrant::from_token(token.trim()).ok()) + } + + /// Persist the capability token issued to this download's key. + /// + /// # Errors + /// + /// Returns an error if the file cannot be written. + pub fn save_capability(&self, token: &str) -> anyhow::Result<()> { + write_private(&self.capability_path(), token.as_bytes()) + } + + /// Delete this download's state: the key, its password, its capability, + /// and the buyer-channel store. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be removed. + pub fn discard(self) -> anyhow::Result<()> { + std::fs::remove_dir_all(&self.dir).with_context(|| format!("remove {}", self.dir.display())) + } +} + +fn create_private_dir(dir: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("chmod 0700 {}", dir.display()))?; + } + Ok(()) +} + +fn write_private(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts + .open(path) + .with_context(|| format!("open {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("write {}", path.display())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + const HEX: &str = "9194000d7b356650e6924a7746ec4afb0b705838b65913c0e46cba6b15af69e5"; + + #[test] + fn normalize_accepts_website_forms() { + assert_eq!(normalize_hash(&format!("b3:{HEX}")).unwrap(), HEX); + assert_eq!(normalize_hash(&format!("0x{HEX}")).unwrap(), HEX); + assert_eq!(normalize_hash(&HEX.to_uppercase()).unwrap(), HEX); + } + + #[test] + fn normalize_rejects_non_hashes() { + assert!(normalize_hash("mistral-7b").is_err()); + assert!(normalize_hash(&format!("b3:{}", &HEX[..63])).is_err()); + assert!(normalize_hash(&format!("b3:{}g", &HEX[..63])).is_err()); + } + + #[test] + fn key_is_generated_once_and_reused() { + let root = tempfile::tempdir().unwrap(); + let session = Session::open(root.path(), HEX).unwrap(); + let first = session.ensure_key().unwrap(); + let again = Session::open(root.path(), HEX) + .unwrap() + .ensure_key() + .unwrap(); + assert_eq!(first, again); + let pw = std::fs::read_to_string(session.password_path()).unwrap(); + assert_eq!(pw.len(), 64); + } + + #[test] + fn malformed_capability_reads_as_none_and_discard_removes_state() { + let root = tempfile::tempdir().unwrap(); + let session = Session::open(root.path(), HEX).unwrap(); + assert!(session.capability().unwrap().is_none()); + session.save_capability("dcap1:not-a-token").unwrap(); + assert!(session.capability().unwrap().is_none()); + let dir = session.dir().to_path_buf(); + session.discard().unwrap(); + assert!(!dir.exists()); + } +} diff --git a/crates/wrapper/tests/pull_flow.rs b/crates/wrapper/tests/pull_flow.rs new file mode 100644 index 0000000..fbfbfa1 --- /dev/null +++ b/crates/wrapper/tests/pull_flow.rs @@ -0,0 +1,152 @@ +//! End-to-end `flow::pull` against a mock gateway and a stub `decdn` script +//! that records its arguments and exits with a chosen status. +#![cfg(unix)] +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy::primitives::{Address, B256}; +use decdn_incentive::CapabilityGrant; +use decdn_sponsored::config::WrapperConfig; +use decdn_sponsored::flow; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const HASH: &str = "9194000d7b356650e6924a7746ec4afb0b705838b65913c0e46cba6b15af69e5"; + +fn token(expiry: u64) -> String { + CapabilityGrant { + pool_id: B256::repeat_byte(0x11), + signer: Address::repeat_byte(0x22), + spending_cap: 5_000_000, + expiry, + owner_signature: vec![0u8; 65], + } + .to_token() +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +/// A stub `decdn` that appends its argv to `/calls` and exits `code`. +fn stub_decdn(dir: &Path, code: i32) -> PathBuf { + let bin = dir.join(format!("decdn-{code}")); + let log = dir.join("calls"); + std::fs::write( + &bin, + format!( + "#!/bin/sh\necho \"$@\" >> '{}'\nexit {code}\n", + log.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +fn config(gateway: &str, decdn_bin: &Path, data_dir: &Path) -> WrapperConfig { + WrapperConfig { + gateway_base: gateway.to_string(), + decdn_bin: decdn_bin.display().to_string(), + data_dir: data_dir.to_path_buf(), + rpc_url: "http://rpc.invalid".into(), + payment_pool: Address::repeat_byte(0x01), + capacity_bond: Some(Address::repeat_byte(0x02)), + slash_judge: None, + chain_id: 421_614, + } +} + +fn state_dir(data_dir: &Path) -> PathBuf { + data_dir.join("downloads").join(HASH) +} + +async fn gateway_with_capability(expiry: u64, expected_calls: u64) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/capability")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "token": token(expiry) + }))) + .expect(expected_calls) + .mount(&server) + .await; + server +} + +#[tokio::test] +async fn success_runs_bundle_pull_and_discards_state() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("sponsored"); + let gateway = gateway_with_capability(now() + 3600, 1).await; + let cfg = config(&gateway.uri(), &stub_decdn(tmp.path(), 0), &data); + + flow::pull(&format!("b3:{HASH}"), &tmp.path().join("out"), &cfg) + .await + .unwrap(); + + let calls = std::fs::read_to_string(tmp.path().join("calls")).unwrap(); + assert!(calls.starts_with(&format!("bundle pull --hash {HASH} -o "))); + assert!(calls.contains("--capability-file")); + assert!(calls.contains("--keystore-password-file")); + assert!(!state_dir(&data).exists()); +} + +#[tokio::test] +async fn failure_keeps_state_and_rerun_reuses_capability() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("sponsored"); + let gateway = gateway_with_capability(now() + 3600, 1).await; + + let failing = config(&gateway.uri(), &stub_decdn(tmp.path(), 1), &data); + let err = flow::pull(HASH, &tmp.path().join("out"), &failing) + .await + .unwrap_err(); + assert!(err.to_string().contains("Re-run the same command")); + let key_before = std::fs::read(state_dir(&data).join("keystore.json")).unwrap(); + + // The mock allows exactly one GET /capability, so this run must reuse + // the saved capability and key rather than asking the gateway again. + let ok = config(&gateway.uri(), &stub_decdn(tmp.path(), 0), &data); + flow::pull(HASH, &tmp.path().join("out"), &ok) + .await + .unwrap(); + let calls = std::fs::read_to_string(tmp.path().join("calls")).unwrap(); + assert_eq!(calls.lines().count(), 2); + assert!(!state_dir(&data).exists()); + assert!(!key_before.is_empty()); +} + +#[tokio::test] +async fn expired_capability_rotates_to_a_fresh_key() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("sponsored"); + + // First run leaves state behind holding an already-expired capability. + let stale = gateway_with_capability(now().saturating_sub(10), 1).await; + let failing = config(&stale.uri(), &stub_decdn(tmp.path(), 1), &data); + flow::pull(HASH, &tmp.path().join("out"), &failing) + .await + .unwrap_err(); + let old_key = std::fs::read(state_dir(&data).join("keystore.json")).unwrap(); + + // Second run must throw the old key away and fetch a new capability. + let fresh = gateway_with_capability(now() + 3600, 1).await; + let failing_again = config(&fresh.uri(), &stub_decdn(tmp.path(), 1), &data); + flow::pull(HASH, &tmp.path().join("out"), &failing_again) + .await + .unwrap_err(); + let new_key = std::fs::read(state_dir(&data).join("keystore.json")).unwrap(); + assert_ne!(old_key, new_key); +} diff --git a/crates/wrapper/tests/runner_classify.rs b/crates/wrapper/tests/runner_classify.rs deleted file mode 100644 index 3f7789e..0000000 --- a/crates/wrapper/tests/runner_classify.rs +++ /dev/null @@ -1,26 +0,0 @@ -use onramp::runner::{FetchOutcome, classify_exit}; - -fn exit(code: i32) -> std::process::ExitStatus { - use std::os::unix::process::ExitStatusExt; - std::process::ExitStatus::from_raw((code & 0xff) << 8) -} - -#[test] -fn exhausted_marker_classifies_as_exhausted() { - let out = classify_exit( - exit(1), - "error: capability exhausted: ask the sponsor for a higher-cap capability or to refill the pool", - ); - assert!(matches!(out, FetchOutcome::Exhausted)); -} - -#[test] -fn success_is_complete() { - assert!(matches!(classify_exit(exit(0), ""), FetchOutcome::Complete)); -} - -#[test] -fn other_error_is_failed() { - let out = classify_exit(exit(1), "some other error"); - assert!(matches!(out, FetchOutcome::Failed(_))); -} From cc411998baba3654bc37a7209aad0c04711a934d Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 15:03:54 +0100 Subject: [PATCH 2/6] fix(wrapper): private dirs from creation; recover a key missing its password - Create the per-download state dirs with mode 0700 via DirBuilder, so the directory holding key material is never briefly world-readable. - A keystore whose password file is missing cannot sign: replace it, together with the capability bound to it, instead of letting `decdn bundle pull` fail with a low-signal error. The flow now checks for a capability after ensure_key so the replacement is picked up. - Correct Session::capability's doc: only malformed contents read as None; an unreadable file is an error. Co-Authored-By: Claude Opus 5.5 --- crates/wrapper/src/flow.rs | 22 ++++++------ crates/wrapper/src/session.rs | 66 +++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/wrapper/src/flow.rs b/crates/wrapper/src/flow.rs index 4005b64..5ba4e41 100644 --- a/crates/wrapper/src/flow.rs +++ b/crates/wrapper/src/flow.rs @@ -27,20 +27,18 @@ pub async fn pull(hash: &str, output: &Path, cfg: &WrapperConfig) -> anyhow::Res let mut session = Session::open(&cfg.data_dir, &hash)?; let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let has_live_capability = match session.capability()? { - Some(grant) if grant.expiry > now.saturating_add(EXPIRY_MARGIN_SECS) => true, - Some(_) => { - // A key's cap and expiry are frozen on-chain at its first - // redemption, so an expired capability means a fresh key. - session.discard()?; - session = Session::open(&cfg.data_dir, &hash)?; - false - } - None => false, - }; + if session + .capability()? + .is_some_and(|grant| grant.expiry <= now.saturating_add(EXPIRY_MARGIN_SECS)) + { + // A key's cap and expiry are frozen on-chain at its first + // redemption, so an expired capability means a fresh key. + session.discard()?; + session = Session::open(&cfg.data_dir, &hash)?; + } let client = session.ensure_key()?; - if !has_live_capability { + if session.capability()?.is_none() { let info = match api.get_capability(client).await? { Some(info) => info, None => { diff --git a/crates/wrapper/src/session.rs b/crates/wrapper/src/session.rs index 5ab5a2f..4bf36b9 100644 --- a/crates/wrapper/src/session.rs +++ b/crates/wrapper/src/session.rs @@ -81,7 +81,9 @@ impl Session { } /// Return this download's key address, generating the key (and its - /// random password) on first use. + /// random password) on first use. A key whose password file is missing + /// cannot sign, so it is replaced, together with the capability bound to + /// it, instead of being handed to `decdn` to fail on. /// /// # Errors /// @@ -89,7 +91,11 @@ impl Session { pub fn ensure_key(&self) -> anyhow::Result
{ let keystore = self.keystore_path(); if keystore.exists() { - return crate::keystore::read_address(&keystore); + if self.password_path().is_file() { + return crate::keystore::read_address(&keystore); + } + remove_if_present(&keystore)?; + remove_if_present(&self.capability_path())?; } let mut secret = [0u8; 32]; getrandom::fill(&mut secret).map_err(|e| anyhow::anyhow!("read OS randomness: {e}"))?; @@ -99,12 +105,13 @@ impl Session { .context("generate throwaway download key") } - /// The capability saved for this download, if any. An unreadable or - /// malformed file reads as `None`, so the caller requests a fresh one. + /// The capability saved for this download, if any. A file whose contents + /// are not a `dcap1:` token reads as `None`, so the caller requests a + /// fresh one. /// /// # Errors /// - /// Returns an error only if the file exists but cannot be read. + /// Returns an error if the file exists but cannot be read. pub fn capability(&self) -> anyhow::Result> { let path = self.capability_path(); if !path.exists() { @@ -135,8 +142,20 @@ impl Session { } } +/// Create `dir` (and missing parents) as mode `0700` from the start, so the +/// directory holding key material is never briefly world-readable. An +/// existing `dir` is tightened to `0700` as well. fn create_private_dir(dir: &Path) -> anyhow::Result<()> { - std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(dir) + .with_context(|| format!("create {}", dir.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -146,6 +165,15 @@ fn create_private_dir(dir: &Path) -> anyhow::Result<()> { Ok(()) } +fn remove_if_present(path: &Path) -> anyhow::Result<()> { + match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Err(e).with_context(|| format!("remove {}", path.display())) + } + _ => Ok(()), + } +} + fn write_private(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); @@ -196,6 +224,32 @@ mod tests { assert_eq!(pw.len(), 64); } + #[test] + fn key_without_password_is_replaced_with_its_capability() { + let root = tempfile::tempdir().unwrap(); + let session = Session::open(root.path(), HEX).unwrap(); + let first = session.ensure_key().unwrap(); + session.save_capability("dcap1:bound-to-first").unwrap(); + std::fs::remove_file(session.password_path()).unwrap(); + + let second = session.ensure_key().unwrap(); + assert_ne!(first, second); + assert!(session.password_path().is_file()); + assert!(!session.capability_path().exists()); + } + + #[cfg(unix)] + #[test] + fn state_dirs_are_created_private() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let session = Session::open(root.path(), HEX).unwrap(); + for dir in [session.dir(), root.path().join("downloads").as_path()] { + let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{}", dir.display()); + } + } + #[test] fn malformed_capability_reads_as_none_and_discard_removes_state() { let root = tempfile::tempdir().unwrap(); From 9a7ea7440180c0941612da6887c33604ef4706d1 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 15:58:52 +0100 Subject: [PATCH 3/6] feat: Windows x64 and ARM64 support for decdn-sponsored - installer: GET /decdn.ps1 serves a PowerShell twin of decdn.sh, templated from the same config. It installs decdn.exe and decdn-sponsored.exe to %LOCALAPPDATA%\decdn\bin, puts that on PATH (for the user and the running session), and writes the same profile with forward-slash paths and no BOM. It picks the OS architecture, so x64 PowerShell under emulation on ARM64 still installs ARM64 binaries, and runs in one script block so `irm | iex` leaves nothing behind. - decdn.sh names architectures x86_64/aarch64 like the Windows script, so /dl/-- is uniform. - wrapper: resolve the home directory with std::env::home_dir (HOME is unset on Windows) and open the captcha link through `rundll32 url.dll,FileProtocolHandler` there. - tests: the pull-flow tests run on Windows with a .cmd stub for decdn; a contract test covers GET /decdn.ps1. - ci: first workflow. Linux runs the workspace (fmt, clippy, tests); macOS and Windows x64 lint and test the client; Windows ARM64 lints it. decdn/decdn is checked out beside sponsord for the path dependencies. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 87 +++++++++++++++++++++++++ README.md | 19 ++++-- crates/server/assets/decdn.ps1 | 95 ++++++++++++++++++++++++++++ crates/server/assets/decdn.sh | 12 +++- crates/server/src/http/installer.rs | 61 +++++++++++------- crates/server/src/http/mod.rs | 5 +- crates/server/tests/http_contract.rs | 24 +++++++ crates/wrapper/src/config.rs | 30 +++++---- crates/wrapper/src/flow.rs | 19 ++++-- crates/wrapper/tests/pull_flow.rs | 23 ++++++- 10 files changed, 323 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 crates/server/assets/decdn.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d5c5d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +# This workspace path-depends on its sibling `decdn` checkout +# (`../decdn/crates/*`), so every job checks both repos out side by side: +# `sponsord/` and `decdn/` under the workspace root. +jobs: + linux: + name: linux (workspace) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: sponsord + - uses: actions/checkout@v4 + with: + repository: decdn/decdn + path: decdn + - uses: dtolnay/rust-toolchain@1.95 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: sponsord + - working-directory: sponsord + run: cargo fmt --all --check + - working-directory: sponsord + run: cargo clippy --workspace --all-targets -- -D warnings + - working-directory: sponsord + run: cargo test --workspace + + # `decdn-sponsored` is the end-user CLI, installed by `decdn.sh` on macOS and + # Linux and by `decdn.ps1` on Windows x64 and ARM64. The server (`sponsord`) + # runs only on Linux, so these legs cover the client crate alone. + client: + name: client (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-latest + target: aarch64-apple-darwin + test: true + - runner: windows-latest + target: x86_64-pc-windows-msvc + test: true + # Cross-compiled on the x64 runner, so it is linted, not run. + - runner: windows-latest + target: aarch64-pc-windows-msvc + test: false + steps: + - uses: actions/checkout@v4 + with: + path: sponsord + - uses: actions/checkout@v4 + with: + repository: decdn/decdn + path: decdn + - uses: dtolnay/rust-toolchain@1.95 + with: + components: clippy + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: sponsord + key: ${{ matrix.target }} + - working-directory: sponsord + run: cargo clippy -p decdn-sponsored --all-targets --target ${{ matrix.target }} -- -D warnings + - if: matrix.test + working-directory: sponsord + run: cargo test -p decdn-sponsored --target ${{ matrix.target }} diff --git a/README.md b/README.md index 29120e0..d14150c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ pool's own balance — there's no per-signer monthly accumulator. ## Crates - `crates/server` (binary `sponsord`) — the HTTP gateway: `/healthz`, - `/decdn.sh` (templated installer), `/fund` (captcha page + capability + `/decdn.sh` and `/decdn.ps1` (templated installers for macOS/Linux and + Windows), `/fund` (captcha page + capability issuance), `/capability` (poll for an issued capability). - `crates/wrapper` (binary `decdn-sponsored`) — the end-user CLI: reads `~/.decdn/sponsor.toml` (written by the installer), obtains a capability @@ -62,7 +63,7 @@ pool up from the treasury whenever its remaining balance falls below | Variable | Required | Default | Purpose | |---|---|---|---| | `SPONSOR_BIND` | no | `127.0.0.1:8080` | Address the HTTP server listens on | -| `SPONSOR_PUBLIC_URL` | no | `https://up.decdn.org` | This gateway's own public base URL; baked into the `/decdn.sh` installer as `{{GATEWAY_BASE}}` | +| `SPONSOR_PUBLIC_URL` | no | `https://up.decdn.org` | This gateway's own public base URL; baked into the `/decdn.sh` and `/decdn.ps1` installers as `{{GATEWAY_BASE}}` | | `SPONSOR_RPC_URL` | **yes** | — | Arbitrum Sepolia RPC endpoint | | `SPONSOR_CHAIN_ID` | no | `421614` | Chain id (Arbitrum Sepolia) | | `SPONSOR_PAYMENT_POOL_ADDR` | **yes** | — | `PaymentPool` contract address | @@ -82,13 +83,20 @@ pool up from the treasury whenever its remaining balance falls below ## The `decdn-sponsored` flow The website shows one command per model, with the model's BLAKE3 hash from -`models.json`: +`models.json`. On macOS and Linux: ```bash curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull b3: ``` -1. The installer served at `GET /decdn.sh` (`assets/decdn.sh`) installs the +On Windows (x64 and ARM64), in PowerShell: + +```powershell +irm https://up.decdn.org/decdn.ps1 | iex; decdn-sponsored pull b3: +``` + +1. The installer served at `GET /decdn.sh` (`assets/decdn.sh`), or its + PowerShell twin at `GET /decdn.ps1` (`assets/decdn.ps1`), installs the `decdn` and `decdn-sponsored` binaries and writes `~/.decdn/sponsor.toml` with the gateway's contract addresses and RPC URL filled in. Any arguments are passed on to `decdn-sponsored`. Running it again is @@ -112,7 +120,8 @@ curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull b3: a new captcha. The `~/.decdn/sponsor.toml` schema is a hard contract between the installer -(`assets/decdn.sh`) and the wrapper (`crates/wrapper/src/config.rs`): field +(`assets/decdn.sh`, `assets/decdn.ps1`) and the wrapper +(`crates/wrapper/src/config.rs`): field names must match exactly. Current fields: `gateway_base`, `decdn_bin`, `data_dir`, `rpc_url`, `payment_pool`, `capacity_bond` (optional), `slash_judge` (optional), `chain_id`. Unknown fields are ignored. diff --git a/crates/server/assets/decdn.ps1 b/crates/server/assets/decdn.ps1 new file mode 100644 index 0000000..92793cb --- /dev/null +++ b/crates/server/assets/decdn.ps1 @@ -0,0 +1,95 @@ +# decdn-sponsored installer for Windows - served by sponsord at GET /decdn.ps1, +# with the placeholders below substituted server-side (see +# crates/server/src/http/installer.rs) from ServerConfig. The POSIX twin is +# assets/decdn.sh; the two write the same profile. +# +# Install, then download: +# irm /decdn.ps1 | iex; decdn-sponsored pull b3: +# Or pass the arguments through in one call: +# & ([scriptblock]::Create((irm /decdn.ps1))) pull b3: +# +# Everything runs inside one script block, so `iex` leaves no variables or +# preference changes behind in the caller's session, and nothing here calls +# `exit` (which would close the caller's window). +& { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest's progress bar slows downloads sharply in Windows + # PowerShell 5.1. + $ProgressPreference = 'SilentlyContinue' + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + $Gateway = '{{GATEWAY_BASE}}' + $RpcUrl = '{{RPC_URL}}' + $PaymentPool = '{{PAYMENT_POOL}}' + $CapacityBond = '{{CAPACITY_BOND}}' + $ChainId = '{{CHAIN_ID}}' + + $BinDir = Join-Path $env:LOCALAPPDATA 'decdn\bin' + $DecdnDir = Join-Path $HOME '.decdn' + New-Item -ItemType Directory -Force -Path $BinDir, $DecdnDir | Out-Null + + # The OS architecture, not the process's: x64 PowerShell under emulation on + # an ARM64 machine reports AMD64 in PROCESSOR_ARCHITECTURE. + $OsArch = try { + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + $env:PROCESSOR_ARCHITECTURE + } + $Arch = switch ($OsArch) { + { $_ -in 'X64', 'AMD64' } { 'x86_64' } + { $_ -in 'Arm64', 'ARM64' } { 'aarch64' } + default { throw "decdn: unsupported Windows architecture: $OsArch" } + } + + # 1. Install the decdn and decdn-sponsored binaries. + # + # NOTE: release hosting (GET /dl/--) is not wired up on the + # gateway yet; until it is, this step fails with a 404. + foreach ($bin in 'decdn', 'decdn-sponsored') { + Write-Host "Installing $bin..." + Invoke-WebRequest -UseBasicParsing -Uri "$Gateway/dl/$bin-windows-$Arch.exe" ` + -OutFile (Join-Path $BinDir "$bin.exe") + } + + # 2. Put the binaries on PATH: permanently for the user, and right away for + # this session so the next command on the same line finds them. + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not (($UserPath -split ';') -contains $BinDir)) { + $NewPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir } + [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') + } + if (-not (($env:Path -split ';') -contains $BinDir)) { + $env:Path = "$BinDir;$env:Path" + } + + # 3. Write the wrapper's profile. Field names and shape MUST match + # crates/wrapper/src/config.rs's `Profile` struct exactly. Paths use + # forward slashes, which Windows accepts and TOML strings need no + # escaping for. Each download gets its own throwaway key under data_dir. + $Fwd = { param($p) $p.Replace('\', '/') } + $DecdnBin = & $Fwd (Join-Path $BinDir 'decdn.exe') + $DataDir = & $Fwd (Join-Path $DecdnDir 'sponsored') + $ProfileToml = @" +gateway_base = "$Gateway" +decdn_bin = "$DecdnBin" +data_dir = "$DataDir" +rpc_url = "$RpcUrl" +payment_pool = "$PaymentPool" +capacity_bond = "$CapacityBond" +chain_id = $ChainId +"@ + # UTF-8 without a byte-order mark: Windows PowerShell 5.1's `-Encoding UTF8` + # writes one, and a BOM is not valid TOML. + [IO.File]::WriteAllText((Join-Path $DecdnDir 'sponsor.toml'), $ProfileToml, + (New-Object System.Text.UTF8Encoding $false)) + + if ($args.Count -gt 0) { + & (Join-Path $BinDir 'decdn-sponsored.exe') @args + return + } + + Write-Host '' + Write-Host 'decdn-sponsored is ready. Download with:' + Write-Host ' decdn-sponsored pull b3: [-o ]' +} @args diff --git a/crates/server/assets/decdn.sh b/crates/server/assets/decdn.sh index 361a248..3e8faed 100644 --- a/crates/server/assets/decdn.sh +++ b/crates/server/assets/decdn.sh @@ -1,8 +1,10 @@ #!/bin/sh -# decdn-sponsored installer - served by sponsord at GET /decdn.sh, with the +# decdn-sponsored installer for macOS/Linux - served by sponsord at +# GET /decdn.sh, with the # placeholders below substituted server-side (see # crates/server/src/http/installer.rs) from ServerConfig, so nothing here -# needs an environment variable to run. +# needs an environment variable to run. The Windows twin is assets/decdn.ps1; +# the two write the same profile. # # Arguments, when given, are passed to decdn-sponsored after installing, so # one line installs and downloads: @@ -21,7 +23,11 @@ DECDN_DIR="${HOME}/.decdn" mkdir -p "$BINDIR" "$DECDN_DIR" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" -ARCH="$(uname -m)" +case "$(uname -m)" in + x86_64 | amd64) ARCH=x86_64 ;; + arm64 | aarch64) ARCH=aarch64 ;; + *) echo "decdn: unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac # 1. Install the decdn and decdn-sponsored binaries. # diff --git a/crates/server/src/http/installer.rs b/crates/server/src/http/installer.rs index 62b8621..65c6107 100644 --- a/crates/server/src/http/installer.rs +++ b/crates/server/src/http/installer.rs @@ -1,33 +1,50 @@ -//! `GET /decdn.sh`: the templated POSIX installer script. Embeds -//! `assets/decdn.sh` at compile time (`include_str!`) and substitutes the -//! `{{...}}` placeholders with values from `ServerConfig`, so end users never -//! set an env var themselves — the contract addresses and RPC URL are baked -//! in server-side. +//! `GET /decdn.sh` and `GET /decdn.ps1`: the templated installer scripts for +//! macOS/Linux and Windows. Each embeds its `assets/` file at compile time +//! (`include_str!`) and substitutes the `{{...}}` placeholders with values +//! from `ServerConfig`, so end users never set an env var themselves — the +//! contract addresses and RPC URL are baked in server-side. use axum::extract::State; use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; +use crate::config::ServerConfig; use crate::state::AppState; -/// The raw installer script, embedded at compile time. +/// The POSIX installer script, embedded at compile time. const DECDN_SH_TEMPLATE: &str = include_str!("../../assets/decdn.sh"); -/// Renders the installer script with `state.cfg`'s values substituted for -/// the `{{GATEWAY_BASE}}`, `{{RPC_URL}}`, `{{PAYMENT_POOL}}`, -/// `{{CAPACITY_BOND}}`, and `{{CHAIN_ID}}` placeholders. -pub async fn get(State(state): State) -> Response { - let script = DECDN_SH_TEMPLATE - .replace("{{GATEWAY_BASE}}", &state.cfg.public_url) - .replace("{{RPC_URL}}", &state.cfg.rpc_url) - .replace("{{PAYMENT_POOL}}", &state.cfg.payment_pool.to_string()) - .replace("{{CAPACITY_BOND}}", &state.cfg.capacity_bond.to_string()) - .replace("{{CHAIN_ID}}", &state.cfg.chain_id.to_string()); - - let mut resp = (StatusCode::OK, script).into_response(); - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/x-shellscript; charset=utf-8"), - ); +/// The PowerShell installer script, embedded at compile time. +const DECDN_PS1_TEMPLATE: &str = include_str!("../../assets/decdn.ps1"); + +/// Substitute `cfg`'s values for the `{{GATEWAY_BASE}}`, `{{RPC_URL}}`, +/// `{{PAYMENT_POOL}}`, `{{CAPACITY_BOND}}`, and `{{CHAIN_ID}}` placeholders. +fn render(template: &str, cfg: &ServerConfig) -> String { + template + .replace("{{GATEWAY_BASE}}", &cfg.public_url) + .replace("{{RPC_URL}}", &cfg.rpc_url) + .replace("{{PAYMENT_POOL}}", &cfg.payment_pool.to_string()) + .replace("{{CAPACITY_BOND}}", &cfg.capacity_bond.to_string()) + .replace("{{CHAIN_ID}}", &cfg.chain_id.to_string()) +} + +fn script(body: String, content_type: &'static str) -> Response { + let mut resp = (StatusCode::OK, body).into_response(); + resp.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); resp } + +pub async fn sh(State(state): State) -> Response { + script( + render(DECDN_SH_TEMPLATE, &state.cfg), + "text/x-shellscript; charset=utf-8", + ) +} + +pub async fn ps1(State(state): State) -> Response { + script( + render(DECDN_PS1_TEMPLATE, &state.cfg), + "text/plain; charset=utf-8", + ) +} diff --git a/crates/server/src/http/mod.rs b/crates/server/src/http/mod.rs index 3bd81ac..fc4d41b 100644 --- a/crates/server/src/http/mod.rs +++ b/crates/server/src/http/mod.rs @@ -1,4 +1,4 @@ -//! HTTP surface: `/healthz`, `/decdn.sh`, `/fund` (captcha page + issue), +//! HTTP surface: `/healthz`, `/decdn.sh` + `/decdn.ps1`, `/fund` (captcha page + issue), //! `/capability` (poll for the issued token). Handlers live in the sibling //! `fund`/`capability`/`installer` modules and share the helpers below. @@ -20,7 +20,8 @@ use crate::state::AppState; pub fn router(state: AppState) -> Router { Router::new() .route("/healthz", get(healthz)) - .route("/decdn.sh", get(installer::get)) + .route("/decdn.sh", get(installer::sh)) + .route("/decdn.ps1", get(installer::ps1)) .route("/fund", get(fund::page).post(fund::submit)) .route("/capability", get(capability::get)) .with_state(state) diff --git a/crates/server/tests/http_contract.rs b/crates/server/tests/http_contract.rs index 7facba1..31da712 100644 --- a/crates/server/tests/http_contract.rs +++ b/crates/server/tests/http_contract.rs @@ -237,3 +237,27 @@ async fn decdn_sh_templated_with_payment_pool() { "installer writes payment_pool" ); } + +#[tokio::test] +async fn decdn_ps1_templated_with_payment_pool() { + let state = test_support::app_state_with_fakes(); + let app = sponsord::http::router(state); + let resp = app + .oneshot(Request::get("/decdn.ps1").body(Body::empty()).expect("req")) + .await + .expect("resp"); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(bytes.to_vec()).expect("utf8"); + assert!(!body.contains("{{"), "no placeholder should remain"); + assert!( + body.contains("payment_pool ="), + "installer writes payment_pool" + ); + assert!( + body.contains("-windows-$Arch.exe"), + "installer downloads the Windows binaries" + ); +} diff --git a/crates/wrapper/src/config.rs b/crates/wrapper/src/config.rs index 391bd00..ff72776 100644 --- a/crates/wrapper/src/config.rs +++ b/crates/wrapper/src/config.rs @@ -39,22 +39,32 @@ pub struct WrapperConfig { pub chain_id: u64, } -/// Expand a leading `~` (or `~/...`) to `$HOME`. Any other path (including -/// one with no leading `~`) is returned unchanged. +/// The user's home directory: `$HOME` on Unix, the profile folder +/// (`%USERPROFILE%`) on Windows. /// /// # Errors /// -/// Returns an error if the path starts with `~` but `$HOME` isn't set. +/// Returns an error if the platform reports no home directory. +fn home() -> anyhow::Result { + std::env::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine the home directory")) +} + +/// Expand a leading `~` (or `~/...`) to the home directory. Any other path +/// (including one with no leading `~`) is returned unchanged. +/// +/// # Errors +/// +/// Returns an error if the path starts with `~` but there is no home +/// directory. fn expand_home(path: &Path) -> anyhow::Result { let Some(s) = path.to_str() else { return Ok(path.to_path_buf()); }; if s == "~" || s.starts_with("~/") { - let home = std::env::var("HOME") - .map_err(|_| anyhow::anyhow!("path {s} starts with ~ but $HOME is not set"))?; + let home = home()?; let rest = s.strip_prefix('~').unwrap_or(s); let rest = rest.strip_prefix('/').unwrap_or(rest); - return Ok(PathBuf::from(home).join(rest)); + return Ok(home.join(rest)); } Ok(path.to_path_buf()) } @@ -65,16 +75,12 @@ impl WrapperConfig { /// /// # Errors /// - /// Returns an error if `$HOME` can't be resolved or the profile file + /// Returns an error if the home directory can't be resolved or the profile file /// can't be read or parsed. pub fn load() -> anyhow::Result { let profile_path = match std::env::var("DECDN_SPONSOR_PROFILE") { Ok(p) => PathBuf::from(p), - Err(_) => { - let home = std::env::var("HOME") - .map_err(|_| anyhow::anyhow!("$HOME is not set; cannot locate sponsor.toml"))?; - PathBuf::from(home).join(DEFAULT_PROFILE_REL) - } + Err(_) => home()?.join(DEFAULT_PROFILE_REL), }; let text = std::fs::read_to_string(&profile_path).map_err(|e| { anyhow::anyhow!("failed to read profile {}: {e}", profile_path.display()) diff --git a/crates/wrapper/src/flow.rs b/crates/wrapper/src/flow.rs index 5ba4e41..a8270f3 100644 --- a/crates/wrapper/src/flow.rs +++ b/crates/wrapper/src/flow.rs @@ -81,13 +81,22 @@ fn open_in_browser(url: &str) { if !std::io::stdout().is_terminal() { return; } - let opener = if cfg!(target_os = "macos") { - "open" + // `rundll32 url.dll,FileProtocolHandler` hands the URL to the default + // browser without passing it through `cmd`'s metacharacter parsing. + let mut command = if cfg!(windows) { + let mut c = std::process::Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]); + c } else { - "xdg-open" + let mut c = std::process::Command::new(if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }); + c.arg(url); + c }; - let _ = std::process::Command::new(opener) - .arg(url) + let _ = command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) diff --git a/crates/wrapper/tests/pull_flow.rs b/crates/wrapper/tests/pull_flow.rs index fbfbfa1..23d6629 100644 --- a/crates/wrapper/tests/pull_flow.rs +++ b/crates/wrapper/tests/pull_flow.rs @@ -1,6 +1,6 @@ //! End-to-end `flow::pull` against a mock gateway and a stub `decdn` script -//! that records its arguments and exits with a chosen status. -#![cfg(unix)] +//! that records its arguments and exits with a chosen status: a shell script +//! on Unix, a `.cmd` batch file on Windows. #![allow( clippy::unwrap_used, clippy::expect_used, @@ -8,7 +8,6 @@ clippy::indexing_slicing )] -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -40,7 +39,9 @@ fn now() -> u64 { } /// A stub `decdn` that appends its argv to `/calls` and exits `code`. +#[cfg(unix)] fn stub_decdn(dir: &Path, code: i32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; let bin = dir.join(format!("decdn-{code}")); let log = dir.join("calls"); std::fs::write( @@ -55,6 +56,22 @@ fn stub_decdn(dir: &Path, code: i32) -> PathBuf { bin } +/// A stub `decdn` that appends its argv to `/calls` and exits `code`. +#[cfg(windows)] +fn stub_decdn(dir: &Path, code: i32) -> PathBuf { + let bin = dir.join(format!("decdn-{code}.cmd")); + let log = dir.join("calls"); + std::fs::write( + &bin, + format!( + "@echo off\r\necho %*>> \"{}\"\r\nexit /b {code}\r\n", + log.display() + ), + ) + .unwrap(); + bin +} + fn config(gateway: &str, decdn_bin: &Path, data_dir: &Path) -> WrapperConfig { WrapperConfig { gateway_base: gateway.to_string(), From 994ed9a284a47d038e99a17b2cbc32a1ecd92b45 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 16:16:59 +0100 Subject: [PATCH 4/6] fix: add the CI target on the pinned toolchain; restore TLS setting - ci: `rustup target add` from inside the checkout, so the Windows ARM64 target lands on the toolchain rust-toolchain.toml pins (the action's `targets:` went to its own toolchain, leaving no aarch64 std). - decdn.ps1: the TLS protocol list is process-wide, so save it and restore it in a `finally` instead of leaving it changed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- crates/server/assets/decdn.ps1 | 126 +++++++++++++++++---------------- 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d5c5d6..318e802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,10 @@ jobs: - uses: dtolnay/rust-toolchain@1.95 with: components: clippy - targets: ${{ matrix.target }} + # From inside the checkout, so the target lands on the toolchain that + # `rust-toolchain.toml` pins rather than on the action's own. + - working-directory: sponsord + run: rustup target add ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 with: workspaces: sponsord diff --git a/crates/server/assets/decdn.ps1 b/crates/server/assets/decdn.ps1 index 92793cb..f005a11 100644 --- a/crates/server/assets/decdn.ps1 +++ b/crates/server/assets/decdn.ps1 @@ -10,67 +10,70 @@ # # Everything runs inside one script block, so `iex` leaves no variables or # preference changes behind in the caller's session, and nothing here calls -# `exit` (which would close the caller's window). +# `exit` (which would close the caller's window). The one process-wide +# setting it touches, the TLS protocol list, is restored on the way out. & { $ErrorActionPreference = 'Stop' # Invoke-WebRequest's progress bar slows downloads sharply in Windows # PowerShell 5.1. $ProgressPreference = 'SilentlyContinue' + $PriorProtocol = [Net.ServicePointManager]::SecurityProtocol [Net.ServicePointManager]::SecurityProtocol = - [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $PriorProtocol -bor [Net.SecurityProtocolType]::Tls12 + try { - $Gateway = '{{GATEWAY_BASE}}' - $RpcUrl = '{{RPC_URL}}' - $PaymentPool = '{{PAYMENT_POOL}}' - $CapacityBond = '{{CAPACITY_BOND}}' - $ChainId = '{{CHAIN_ID}}' + $Gateway = '{{GATEWAY_BASE}}' + $RpcUrl = '{{RPC_URL}}' + $PaymentPool = '{{PAYMENT_POOL}}' + $CapacityBond = '{{CAPACITY_BOND}}' + $ChainId = '{{CHAIN_ID}}' - $BinDir = Join-Path $env:LOCALAPPDATA 'decdn\bin' - $DecdnDir = Join-Path $HOME '.decdn' - New-Item -ItemType Directory -Force -Path $BinDir, $DecdnDir | Out-Null + $BinDir = Join-Path $env:LOCALAPPDATA 'decdn\bin' + $DecdnDir = Join-Path $HOME '.decdn' + New-Item -ItemType Directory -Force -Path $BinDir, $DecdnDir | Out-Null - # The OS architecture, not the process's: x64 PowerShell under emulation on - # an ARM64 machine reports AMD64 in PROCESSOR_ARCHITECTURE. - $OsArch = try { - [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - } catch { - $env:PROCESSOR_ARCHITECTURE - } - $Arch = switch ($OsArch) { - { $_ -in 'X64', 'AMD64' } { 'x86_64' } - { $_ -in 'Arm64', 'ARM64' } { 'aarch64' } - default { throw "decdn: unsupported Windows architecture: $OsArch" } - } + # The OS architecture, not the process's: x64 PowerShell under emulation on + # an ARM64 machine reports AMD64 in PROCESSOR_ARCHITECTURE. + $OsArch = try { + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + $env:PROCESSOR_ARCHITECTURE + } + $Arch = switch ($OsArch) { + { $_ -in 'X64', 'AMD64' } { 'x86_64' } + { $_ -in 'Arm64', 'ARM64' } { 'aarch64' } + default { throw "decdn: unsupported Windows architecture: $OsArch" } + } - # 1. Install the decdn and decdn-sponsored binaries. - # - # NOTE: release hosting (GET /dl/--) is not wired up on the - # gateway yet; until it is, this step fails with a 404. - foreach ($bin in 'decdn', 'decdn-sponsored') { - Write-Host "Installing $bin..." - Invoke-WebRequest -UseBasicParsing -Uri "$Gateway/dl/$bin-windows-$Arch.exe" ` - -OutFile (Join-Path $BinDir "$bin.exe") - } + # 1. Install the decdn and decdn-sponsored binaries. + # + # NOTE: release hosting (GET /dl/--) is not wired up on the + # gateway yet; until it is, this step fails with a 404. + foreach ($bin in 'decdn', 'decdn-sponsored') { + Write-Host "Installing $bin..." + Invoke-WebRequest -UseBasicParsing -Uri "$Gateway/dl/$bin-windows-$Arch.exe" ` + -OutFile (Join-Path $BinDir "$bin.exe") + } - # 2. Put the binaries on PATH: permanently for the user, and right away for - # this session so the next command on the same line finds them. - $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') - if (-not (($UserPath -split ';') -contains $BinDir)) { - $NewPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir } - [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') - } - if (-not (($env:Path -split ';') -contains $BinDir)) { - $env:Path = "$BinDir;$env:Path" - } + # 2. Put the binaries on PATH: permanently for the user, and right away for + # this session so the next command on the same line finds them. + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not (($UserPath -split ';') -contains $BinDir)) { + $NewPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir } + [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') + } + if (-not (($env:Path -split ';') -contains $BinDir)) { + $env:Path = "$BinDir;$env:Path" + } - # 3. Write the wrapper's profile. Field names and shape MUST match - # crates/wrapper/src/config.rs's `Profile` struct exactly. Paths use - # forward slashes, which Windows accepts and TOML strings need no - # escaping for. Each download gets its own throwaway key under data_dir. - $Fwd = { param($p) $p.Replace('\', '/') } - $DecdnBin = & $Fwd (Join-Path $BinDir 'decdn.exe') - $DataDir = & $Fwd (Join-Path $DecdnDir 'sponsored') - $ProfileToml = @" + # 3. Write the wrapper's profile. Field names and shape MUST match + # crates/wrapper/src/config.rs's `Profile` struct exactly. Paths use + # forward slashes, which Windows accepts and TOML strings need no + # escaping for. Each download gets its own throwaway key under data_dir. + $Fwd = { param($p) $p.Replace('\', '/') } + $DecdnBin = & $Fwd (Join-Path $BinDir 'decdn.exe') + $DataDir = & $Fwd (Join-Path $DecdnDir 'sponsored') + $ProfileToml = @" gateway_base = "$Gateway" decdn_bin = "$DecdnBin" data_dir = "$DataDir" @@ -79,17 +82,20 @@ payment_pool = "$PaymentPool" capacity_bond = "$CapacityBond" chain_id = $ChainId "@ - # UTF-8 without a byte-order mark: Windows PowerShell 5.1's `-Encoding UTF8` - # writes one, and a BOM is not valid TOML. - [IO.File]::WriteAllText((Join-Path $DecdnDir 'sponsor.toml'), $ProfileToml, - (New-Object System.Text.UTF8Encoding $false)) + # UTF-8 without a byte-order mark: Windows PowerShell 5.1's `-Encoding UTF8` + # writes one, and a BOM is not valid TOML. + [IO.File]::WriteAllText((Join-Path $DecdnDir 'sponsor.toml'), $ProfileToml, + (New-Object System.Text.UTF8Encoding $false)) - if ($args.Count -gt 0) { - & (Join-Path $BinDir 'decdn-sponsored.exe') @args - return - } + if ($args.Count -gt 0) { + & (Join-Path $BinDir 'decdn-sponsored.exe') @args + return + } - Write-Host '' - Write-Host 'decdn-sponsored is ready. Download with:' - Write-Host ' decdn-sponsored pull b3: [-o ]' + Write-Host '' + Write-Host 'decdn-sponsored is ready. Download with:' + Write-Host ' decdn-sponsored pull b3: [-o ]' + } finally { + [Net.ServicePointManager]::SecurityProtocol = $PriorProtocol + } } @args From cd235e530d9cb8f1e22d58a1ba5148d30470946f Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 16:22:53 +0100 Subject: [PATCH 5/6] ci: let a manual run build against a decdn branch Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 318e802..19945c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + decdn_ref: + description: decdn/decdn branch, tag or SHA to build against + default: main permissions: contents: read @@ -18,7 +22,8 @@ env: # This workspace path-depends on its sibling `decdn` checkout # (`../decdn/crates/*`), so every job checks both repos out side by side: -# `sponsord/` and `decdn/` under the workspace root. +# `sponsord/` and `decdn/` under the workspace root. `decdn` is `main` unless a +# manual run names another ref. jobs: linux: name: linux (workspace) @@ -30,6 +35,7 @@ jobs: - uses: actions/checkout@v4 with: repository: decdn/decdn + ref: ${{ inputs.decdn_ref || 'main' }} path: decdn - uses: dtolnay/rust-toolchain@1.95 with: @@ -71,6 +77,7 @@ jobs: - uses: actions/checkout@v4 with: repository: decdn/decdn + ref: ${{ inputs.decdn_ref || 'main' }} path: decdn - uses: dtolnay/rust-toolchain@1.95 with: From a534280fdb352e3d40ee4995fa3c41d8136d5f2c Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Thu, 24 Sep 2026 16:41:32 +0100 Subject: [PATCH 6/6] test(wrapper): compare the expanded path with the platform's home The test faked the home directory by setting HOME, which Windows does not consult (home_dir reads the profile folder there), so it failed on the Windows runner while the code was right. Compare against home() instead, and drop the now-unused serial_test dev-dependency. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 25 +++++++++++-------------- crates/wrapper/Cargo.toml | 1 - crates/wrapper/src/config.rs | 14 ++++---------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a6a41d..f6dfebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2832,9 +2832,9 @@ dependencies = [ "decdn-config-types", "decdn-protocol", "dirs", + "fs4", "iroh", "jsonrpsee", - "nix", "rand 0.10.2", "serde", "tokio", @@ -2987,7 +2987,6 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "serial_test", "tempfile", "tokio", "toml", @@ -3611,6 +3610,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -5572,18 +5581,6 @@ dependencies = [ "wmi", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" diff --git a/crates/wrapper/Cargo.toml b/crates/wrapper/Cargo.toml index 3c9ac35..9b09b90 100644 --- a/crates/wrapper/Cargo.toml +++ b/crates/wrapper/Cargo.toml @@ -38,4 +38,3 @@ clap = { version = "4", features = ["derive"] } [dev-dependencies] tempfile = "3" wiremock = "0.6" -serial_test = "3" diff --git a/crates/wrapper/src/config.rs b/crates/wrapper/src/config.rs index ff72776..c7a1085 100644 --- a/crates/wrapper/src/config.rs +++ b/crates/wrapper/src/config.rs @@ -113,7 +113,6 @@ impl WrapperConfig { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use serial_test::serial; const SAMPLE: &str = r#" gateway_base = "https://gateway.example.com" @@ -125,24 +124,19 @@ mod tests { chain_id = 421614 "#; + /// Compared against the platform's own home directory (`$HOME` on Unix, + /// the profile folder on Windows) rather than a faked `HOME`, which + /// Windows does not consult. #[test] - #[serial] fn parses_profile_and_expands_home() { - unsafe { - std::env::set_var("HOME", "/home/testuser"); - } let cfg = WrapperConfig::from_toml_str(SAMPLE).unwrap(); assert_eq!(cfg.gateway_base, "https://gateway.example.com"); - assert_eq!( - cfg.data_dir, - PathBuf::from("/home/testuser/.decdn/sponsored") - ); + assert_eq!(cfg.data_dir, home().unwrap().join(".decdn/sponsored")); assert_eq!(cfg.chain_id, 421_614); assert!(cfg.slash_judge.is_none()); } #[test] - #[serial] fn ignores_unknown_fields() { let with_extra = format!("{SAMPLE}\nkeystore_path = \"~/.decdn/client/keystore.json\"\n"); assert!(WrapperConfig::from_toml_str(&with_extra).is_ok());