diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..91ad07e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# The bindings crates' src/lib.rs is checked-in generated output (from +# `dpm-codegen-rust`). Mark it generated so GitHub keeps it out of the language +# stats and collapses it in PR diffs. +crates/canton-splice-*/src/lib.rs linguist-generated=true +crates/canton-quickstart-*/src/lib.rs linguist-generated=true + +# Vendored upstream schemas are verified byte-for-byte against SHA256SUMS, so +# they must not be line-ending-converted on checkout. Git on Windows defaults +# to `core.autocrlf=true`, which rewrites LF to CRLF and changes every hash — +# the tree looks unmodified and every checksum fails. `-text` pins the working +# copy to exactly what was vendored. +crates/canton-proto/proto/** -text + +# The fixture DAR is a zip archive; never treat it as text. +testdata/*.dar binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6528be..3f2da43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,18 +15,18 @@ jobs: fmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: { components: rustfmt } + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable, components: rustfmt } - run: cargo fmt --all --check clippy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: { components: clippy } - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable, components: clippy } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # --all-features so the ws / otel / server code paths are linted too. - run: cargo clippy --workspace --all-targets --all-features -- -D warnings @@ -37,27 +37,59 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Unit / in-process / TLS / ws tests need no node; live tests skip without # CANTON_TEST_ENDPOINT. --all-features covers the ws + otel surface. - run: cargo test --workspace --all-features + # The proposal's supported matrix is Tier-1 Rust on x86_64 and aarch64, plus + # musl for static container binaries. The `test` job covers x86_64 Linux and + # Windows and aarch64 macOS (which is what `macos-latest` now is); these are + # the two claimed targets nothing was checking. `check` rather than `test`, + # because running the binaries needs those machines — but a claim that the + # SDK builds for a target is a claim CI can hold. + cross-targets: + name: The claimed targets still compile + strategy: + fail-fast: false + matrix: + target: [aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable, targets: "${{ matrix.target }}" } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: { key: "${{ matrix.target }}" } + # The TLS stack compiles C for the target, so a cross toolchain is needed + # even for `cargo check`. + - run: sudo apt-get update && sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu + - run: cargo check --workspace --all-features --target ${{ matrix.target }} + env: + CC_x86_64_unknown_linux_musl: musl-gcc + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + msrv: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.88.0 # == workspace.package.rust-version - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + # == workspace.package.rust-version + with: { toolchain: 1.88.0 } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo check --workspace --all-features docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo doc --workspace --no-deps --all-features env: RUSTDOCFLAGS: "-D warnings" @@ -66,27 +98,186 @@ jobs: feature-matrix: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: { components: clippy } - - uses: taiki-e/install-action@cargo-hack - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable, components: clippy } + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: { tool: cargo-hack } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - run: cargo hack clippy --workspace --feature-powerset --no-dev-deps -- -D warnings deny: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 with: { command: check all } - # NOTE (post-first-release): enable cargo-semver-checks once a baseline is on - # crates.io — it guards the hand-written public API against accidental - # breaking changes (generated proto types are exempt per the stability - # policy in canton-proto). + # Manifest breakage (a bad `include`, a missing licence file, a path + # dependency without a version) otherwise surfaces only at `cargo publish`, + # when half the crates are already live and the order cannot be undone. + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Every publishable crate packages cleanly + run: | + for crate in canton-proto canton-core canton-auth canton-lf canton-daml \ + canton-ledger canton-admin canton-codegen canton-codegen-cli canton \ + canton-splice-amulet canton-splice-wallet canton-splice-wallet-payments; do + cargo package --no-verify --list -p "$crate" > /tmp/files.txt + grep -qx 'LICENSE' /tmp/files.txt || { echo "::error::$crate ships no LICENSE"; exit 1; } + grep -qx 'README.md' /tmp/files.txt || { echo "::error::$crate ships no README"; exit 1; } + # Integration tests read fixtures from the repo root, which is not in + # the package — shipping them gives a published crate a failing + # `cargo test`. + ! grep -q '^tests/' /tmp/files.txt || { echo "::error::$crate ships integration tests"; exit 1; } + done + + # The four committed binding crates prove that *some* past generator emitted + # compilable code. This proves the one on this commit does. + codegen-output: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Generate from the fixture DAR and compile the result + run: | + cargo run -p canton-codegen-cli -- \ + --dar testdata/splice-api-token-holding-v1-1.0.0.dar \ + --out "$RUNNER_TEMP/bindings" \ + --runtime-path "$GITHUB_WORKSPACE/crates/canton-daml" + # No Cargo.lock is generated for the output crate, so --locked could + # only ever fail here. + cd "$RUNNER_TEMP/bindings" && cargo check + - name: The generated crate compiles and round-trips against the runtime + env: + CODEGEN_COMPILE_TEST: "1" + run: | + # Gated on this variable because it spawns cargo, and nothing set it — + # so the one test that proves generated code *compiles and runs* + # skipped on every push, and went stale without anybody seeing it. + cargo test -p canton-codegen --test compile -- --nocapture | tee /tmp/compile.log + grep -qE 'test result: ok\. [1-9]' /tmp/compile.log || { + echo "::error::the codegen compile test did not run"; exit 1; } + + - name: The emitter still produces the committed fixture + run: | + # The four drift guards over the published bindings crates need a DAR + # from a Splice / cn-quickstart checkout, which this runner does not + # have, so they skip — and a skipped test is a passing one. This fifth + # guard uses the DAR in the repository and therefore actually runs; + # assert that it did, the way the conformance-oracle job does. + cargo test -p canton-codegen --test up_to_date -- --nocapture | tee /tmp/drift.log + grep -q 'emitter fixture agreement: ok' /tmp/drift.log || { + echo "::error::the emitter drift guard skipped instead of running"; exit 1; } + + # The native LF decoder claims to agree with the official JVM reader. That + # claim is only worth what a reviewer can re-run, so run it here rather than + # leaving it to a local machine. Its own job: it pulls a JVM and the Maven + # artifact, which the fast gates should not wait on. + lf-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '17' + - uses: VirtusLab/scala-cli-setup@42e5f90b7aed2c0478e8074e5c25a030e1db3af6 # v1 + - name: Native decoder agrees with daml-lf-archive-reader + env: + CANTON_LF_ORACLE_DAR: testdata/splice-api-token-holding-v1-1.0.0.dar + run: | + # The test skips silently when the DAR or scala-cli is absent, which + # here would mean a green job that verified nothing. + cargo test -p canton-lf --test oracle -- --nocapture | tee /tmp/oracle.log + grep -q 'oracle agreement:' /tmp/oracle.log || { + echo "::error::the oracle skipped instead of running"; exit 1; } + + # The three published bindings crates are checked-in generated code, and the + # tests that keep them in step with the emitter are gated on a DAR that lives + # in someone else's repository. Without one they skip — and a skipped test is + # reported as a passing one, so on every push so far nothing had checked that + # the emitter still produces what is on crates.io. + # + # cn-quickstart tracks those DARs in git, so they are fetched from a pinned + # commit and checked against their SHA-256 rather than vendored: no repo + # bloat, and the pin is what makes the run reproducible. Bumping the pin is a + # deliberate edit that comes with new checksums. # - # semver: - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: obi1kenobi/cargo-semver-checks-action@v2 + # canton-quickstart-licensing is absent on purpose: its DAR is built from + # source in cn-quickstart rather than committed, so there is nothing to pin. + # That guard stays local-only. + bindings-drift: + name: Committed bindings match the emitter + runs-on: ubuntu-latest + env: + QUICKSTART_COMMIT: 41f2d75cd16eff28aedfaf2e9a2278a881b1c71a + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: { toolchain: stable } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Fetch the DARs the committed crates were generated from + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/dars" + base="https://raw.githubusercontent.com/digital-asset/cn-quickstart/${QUICKSTART_COMMIT}/quickstart/daml/dars" + cat > "$RUNNER_TEMP/dars/SHA256SUMS" <<'SUMS' + 48aaa72dfc992c6c89d5bfa84aece9fd9dad992e281252674fe33e203b2a391b splice-amulet-0.1.14.dar + f5897dbe3a8c6da1ce1b25f46ab739777a70c0ed0e4057d6336c77cab9a7d6fc splice-wallet-0.1.14.dar + e12ab52b325f488e87adcd4143058e16341c001bb77eb0adc0715bf5badee457 splice-wallet-payments-0.1.14.dar + SUMS + sed -i 's/^ *//' "$RUNNER_TEMP/dars/SHA256SUMS" + cd "$RUNNER_TEMP/dars" + while read -r _ name; do curl -fsSL -o "$name" "$base/$name"; done < SHA256SUMS + # A DAR that is not the one the crate was generated from would make the + # guard compare against the wrong thing and pass for the wrong reason. + sha256sum -c SHA256SUMS + - name: The emitter still produces the committed crates + env: + CANTON_SPLICE_AMULET_DAR: ${{ runner.temp }}/dars/splice-amulet-0.1.14.dar + CANTON_SPLICE_WALLET_DAR: ${{ runner.temp }}/dars/splice-wallet-0.1.14.dar + CANTON_SPLICE_WALLET_PAYMENTS_DAR: ${{ runner.temp }}/dars/splice-wallet-payments-0.1.14.dar + run: | + set -euo pipefail + cargo test -p canton-codegen --test up_to_date -- --nocapture | tee /tmp/drift.log + # Assert each guard ran. Without this the job is green when a variable + # is misspelled, which is the failure mode it exists to remove. + for var in CANTON_SPLICE_AMULET_DAR CANTON_SPLICE_WALLET_DAR \ + CANTON_SPLICE_WALLET_PAYMENTS_DAR; do + grep -q "bindings agreement: ${var} ok" /tmp/drift.log || { + echo "::error::the ${var} drift guard did not run"; exit 1; } + done + ! grep -q 'skipping: set CANTON_SPLICE' /tmp/drift.log || { + echo "::error::a guard skipped despite its DAR being present"; exit 1; } + + # Guards the hand-written public API against a breaking change the version + # number does not admit to. The baseline is whatever is on crates.io, so this + # only became runnable once the first release was published — and a release + # that *does* break API passes by carrying the version bump that says so + # (pre-1.0, that is the minor). + # + # Generated proto types are exempt per the stability policy in canton-proto, + # which is why `canton-proto` is excluded rather than the job being advisory. + semver: + name: Public API breaks carry a version bump + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2 + with: + # The crates with a published baseline to compare against. A crate + # cannot be checked before its first release, so each M2/M3 crate + # joins this list in the release after it first publishes. + package: canton-core, canton-auth, canton-ledger, canton-admin, canton + feature-group: all-features diff --git a/.gitignore b/.gitignore index aaadf86..2164ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ # OS junk. .DS_Store Thumbs.db + +# scala-cli build caches (tools/lf-oracle). +.bsp/ +.scala-build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5371061..47214bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,506 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated protobuf types (the `canton-proto` crate and the `proto` re-exports) are **exempt from SemVer** — see the stability policy in `canton-proto`'s docs. +## [0.2.0] — unreleased + +All `canton-*` crates release in lockstep, so the M1 crates move to 0.2.0 with +the rest. Everything the 0.1.x line gained after the M1 submission — the read +request builders, the full `Commands` surface, Canton-native error +classification (see 0.1.2 below) and the documentation fixes of 0.1.3/0.1.4 — +is included. + +### Breaking, for code on 0.1.x + +Two signatures change, both because the old one could not express a correct +call. Everything else from 0.1.x compiles unchanged. + +- **`OidcConfig::auth0(domain, client_id, secret)` → `auth0(domain, audience, + client_id, secret)`.** Auth0 answers a client-credentials request without an + `audience` by issuing a token for its own userinfo endpoint, which a + participant rejects — so the old preset could not produce a working request. + The audience identifies your Auth0 API and cannot be derived from the domain. + + ```diff + - OidcConfig::auth0("my.eu.auth0.com", "client-id", "secret") + + OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "client-id", "secret") + ``` + +- **`CantonClient::await_completion(command_id, parties, offset, timeout)` → + `await_completion(&ChangeId, offset, timeout)`.** Canton identifies a command + by (user, acting parties, command id), and matching on the command id alone + can return another application's completion. The parties move inside the + change ID. + + ```diff + - client.await_completion(&command_id, vec![party.clone()], offset, timeout) + + client.await_completion(&ChangeId::new("", vec![party.clone()], &command_id), offset, timeout) + ``` + + Better still, take the identity from the submission rather than rebuilding it: + `let submission = client.submission(submit);` then + `submission.recover(offset, timeout)`. + +### Added — type-safe codegen from DARs + +- **`canton-lf`** — native Rust Daml-LF reader: DAR container (zip + + `MANIFEST.MF`) and an LF 2.x protobuf decoder (vendored `daml_lf2.proto`), + including the LF 2.dev additions — the explicit package-import table and + curried type application (`TApp`) — used by SDK 3.5 DARs. Decodes a DAR's whole + dependency closure (`decode_all`). +- **`canton-daml`** — the runtime generated code depends on (as `rt`): the Daml + primitive types (`Party`, `ContractId`, `Numeric`, `Timestamp`, `Date`, + `TextMap`, `GenMap`, `NestedOpt`, `Unit`), the `ToValue`/`FromValue` gRPC + codecs and serde JSON conventions (LF-JSON: `Int64` and `Numeric` as strings — + accepting a number on input — the nested-optional list form, `Unit` as `{}`, + adjacently-tagged variants, …), the `Contract`/`Template`/`Interface`/ + `WithKey`/`Choice` traits, and the `create_command`/`exercise_command`/ + `exercise_by_key_command` builders. +- **`canton-codegen`** — DAR → typed Rust: a decoder-agnostic IR, the documented + Daml-LF → Rust type mapping (see `docs/daml-lf-type-mapping.md`), and emission + of records, variants, enums, **templates** (payload + typed choices + on-ledger + id), **interfaces** (marker + view + choices), and **contract keys**, with JSON + and gRPC codecs. Output is a fully-qualified `pub mod` tree + (`crate::::::`) so cross-package references resolve and + names never collide; template ids use the SCU-friendly `#` form. + The package segment is the package **name** — a version bump does not rename + the paths a consumer imports — with the version appended only to separate two + packages that would otherwise share a module name. Each template's docs carry + its on-ledger id and its choices with their consuming flag. +- **`canton-codegen-cli`** — the `dpm-codegen-rust` binary (a `dpm codegen-rust` + component / build-script tool) that writes a self-contained bindings crate from + a DAR. +- **Pre-built bindings crates** (checked-in generated output, drift-guarded): + `canton-splice-amulet`, `canton-splice-wallet`, `canton-splice-wallet-payments`, + and `canton-quickstart-licensing`. +- **`canton-sample`** — reference app: builds a typed `AppInstallRequest` from + the bindings, round-trips both codecs, and runs the full verification loop + (submit → observe transaction → query ACS) over gRPC and JSON. +- **Verified** on a live cn-quickstart participant: the whole-DAR closure + compiles for all corpus DARs, and the sample's typed create commits and is read + back from the ACS on both transports. + +### Added — a local network needs no configuration in your program + +- **`canton_core::localnet`** reads a Splice LocalNet out of the environment — + the variables [canton-devkit](https://github.com/bitdynamics-ab/canton-devkit)'s + `localnet env` exports. After `eval "$(canton-devkit localnet env demo)"`, + `Config::from_env()` is a working gRPC configuration and + `JsonClient::from_env()` its JSON counterpart, with the participant's token + attached; `Config::from_env_for("app-user")` reaches the other participant, + and `localnet::party(alias)` the on-ledger id an application needs for + `act_as`. `CANTON_ENDPOINT` / `CANTON_TOKEN` override the lot for an + environment that is not a LocalNet. Runnable as the `localnet` example. +- **`Config` accepts a scheme-less `host:port`**, which is what a gRPC client + dials and therefore what tooling hands out — + `CANTON_GRPC_LEDGER_API_URL` is exactly this shape. Previously the missing + scheme surfaced at the first RPC as an unexplained transport error; the + scheme now defaults to the one TLS configuration implies. + +### Security + +- **`canton-lf` (zip bomb):** a DAR is now bounded **in total**, not only per + entry. Every `.dalf` is read into memory, so an archive of many entries each + legal on its own — a thousand at 256 MiB — passed every check and still asked + for hundreds of gigabytes; zeros DEFLATE at roughly 1000:1, so the file + carrying that request arrives small enough to go unnoticed. Each read is now + capped by whichever of the per-entry ceiling and the remaining archive budget + (2 GiB) is smaller, and the error says which limit was reached. The ceiling is + ~50× the entire available corpus (41 MiB across 18 DARs), so it cannot fire on + a real DAR. Found by reading canton-devkit's DAR reader, which has had the + aggregate cap all along. + +- **`canton-core`:** the mutual-TLS private key no longer reaches logs. + `TlsConfig` derived `Debug` and holds `client_identity_pem`, so + `format!("{config:?}")` — or one `tracing` field capturing a `Config` — + printed the key byte by byte; `Config` leaked it too, along with any + credentials in the endpoint URL. Both now have hand-written `Debug` that + reports presence and length. Five error paths across `canton-core`, + `canton-auth` and `canton-ledger` that quoted a URL verbatim now run it + through `canton_core::redact_url`. Two more types held the same secret behind + a `Debug` and were missed the first time: `JsonClient` derived one and printed + its `base_url` — userinfo included — and `OidcConfig` redacted its + `client_secret` field while printing a `token_url` that, for a provider taking + client credentials as basic auth, *is* the secret. `TokenResponse` no longer + derives `Debug` at all; it is one bearer token, and nothing should print it. +- **`canton-daml`:** a type mismatch no longer copies the offending value into + the error message. `mismatch()` formatted it with prost's `Debug`, so + decoding a payload as the wrong type put the whole record — parties, amounts, + free text — into a string that travels into the application's traces and + metrics. It now names the kind and stops. + +- **`canton-core` (bearer token in `Debug`):** `AuthInterceptor` derived `Debug` + over the token it injects into every gRPC request. The interceptor lives + inside every client that holds a channel, so one `{:?}` on client state — a + tracing field, a panic message, an error context — printed a live credential, + and `SECURITY.md`'s "Debug output is redacted" was untrue as written. It now + reports presence only. Reported privately by Equilibrium during their M1 + review, alongside the mutual-TLS key above. +- **`canton-core` (panic on a hostile retry hint):** `Error::retry_delay()` + handed a server-supplied number to `Duration::from_secs_f64`, which panics on + anything a `Duration` cannot hold — so a JSON error body carrying + `"retryInfo": "1e300 seconds"` aborted the caller *inside error + classification*, which the retry loop calls on every retriable failure. The + conversion is fallible now and an out-of-range hint reads as no + recommendation. + +### Fixed — from Equilibrium's independent M1 review + +An engineering review of the released 0.1.4 client by [Equilibrium](https://equilibrium.co), +carried out on the Development Fund milestone issue. Every finding is closed +here; two of them (the `Debug` leak and the retry-hint panic) are in +**Security** above. + +- **Ambiguous submissions are recoverable.** A submission whose response is + lost may still have committed, and the change ID is the only way back to the + outcome — but the SDK generated the command id *inside* the call that failed + and returned it only on success. `CantonClient::submission` and + `JsonClient::submission` fix the identity first and hand back a `Submission` + carrying its `ChangeId`, with `recover` reading the completion back. The + existing client methods are thin wrappers over the same object. +- **Recovery matches the whole change ID.** `await_completion` compared + `command_id` alone; Canton identifies a command by (user, acting parties, + command id), and two applications on one participant may each use `daily-run`. + It now takes a `&ChangeId`. A user id left to the bearer token is not + compared (the participant resolved it and the client cannot know it), and a + completion carrying no acting parties is not rejected on that ground. +- **The resumable update stream honours checkpoints.** `updates_with` dropped + `OffsetCheckpoint` frames before the resumable wrapper could see them, so the + resume point only advanced when a transaction arrived — on a quiet stream a + reconnect went back to where the caller started, which after pruning fails + outright. The resumable path now reads the unfiltered stream and filters for + itself. Subscribers see no change. +- **A spent reconnect budget reports the participant's failure**, not + `UnexpectedResponse("failed to resume after N reconnects")`, which threw away + the status, the details, the correlation id and the retriable classification + at the moment they were needed. Same fix in the resumable ACS read. +- **The JSON lane gained the four operations it was missing**: `submit` + (`/v2/commands/async/submit`), `submit_and_wait`, `events_by_contract_id`, + and recovery through `JsonClient::submission`. All four exist in Canton + 3.5.7's JSON API; this was the SDK stopping short. +- **`JsonClient::ws_active_contracts_resumable`** resubscribes from the last + `streamContinuationToken` rather than restarting the snapshot, which the gRPC + lane has done since M1. +- **The typed ACS read is lossless.** Every gRPC ACS method matched + `ActiveContract` and dropped the rest, so a reassignment in flight at the + snapshot offset — `IncompleteUnassigned` / `IncompleteAssigned` — vanished + from a multi-synchronizer application's view. `AcsEntry` and the `acs_page` / + `acs_entries` / `acs_entries_resumable` family are the lossless read; the + active-only methods keep their names and are now that read with + `into_active` applied. +- **The Auth0 and Okta presets produce their providers' normal requests.** + Auth0 needs an `audience` (without one it issues a token for its own userinfo + endpoint, which no participant accepts) — `auth0` now takes it, which is a + **breaking** signature change. Okta reads the credentials from an + `Authorization: Basic` header and rejects them in the body as + `invalid_client`; the preset selects that, and `ClientAuth` exposes the + choice for custom endpoints. +- **Telemetry covers a stream's life, not its opening.** `instrument_stream` + counts errors that arrive after a subscription opens — previously a stream + that failed an hour in had been recorded as a success and never revisited. + The WebSocket upgrade carries `traceparent` (the only request a WS stream + makes), structured events carry `trace_id`, and `otel::otlp_metrics` is a + supported OTLP path for the counters, recorder and all. + +- **A retry the participant de-duplicates is a success, not a failure.** This + is the other half of the finding, and the half that was still open after the + recovery handle was added. When the SDK retries a submission whose response + was lost, the participant refuses the second attempt as `DUPLICATE_COMMAND` — + because the first one was accepted. `submit` was reporting that rejection to + the caller, which says the command did not happen at the exact moment it + provably did. It now reports success, over both transports. A duplicate on + the *first* attempt is untouched: nothing of ours is at the participant, so + the caller reused a change id and needs to hear about it. + + The waiting variants cannot do this — their result is a transaction, and a + de-duplicated retry does not carry one — so they surface the rejection and + their documentation now says to recover through the handle rather than + describing a caveat and leaving it there. + +- **`examples/recover_a_submission.rs`** walks the finding's own scenario end + to end: submit, submit the same change ID again (what a retry after a lost + response looks like to the participant), watch it be rejected as + `DUPLICATE_COMMAND`, and recover the original outcome. Verified live — the + recovered `update_id` is the one the first submission committed. The README's + other examples are backed by compiled example files; this one was not, and + the newest API is the worst one to leave uncompiled. +- **The facade reaches what the documentation promises.** `canton::telemetry` + was not re-exported, so `cargo add canton` could not see the metric names, the + transport labels, or — now — the OTLP setup those metrics are meant to be + exported through. The `otel` feature also reached `canton-core` only by way of + `canton-ledger`, which was correct by accident. Both fixed, with tests that + name the paths a reader of the README would try. + +**From the same review's non-blocking list:** + +- Requests the participant would certainly refuse are refused locally: a + submission with no commands or no acting party, both minimum-ledger-time + forms at once, a negative offset, an inverted range, a subscription filtered + to nobody. +- `read_as` reaches the transaction filter of a submission's response, matching + the Ledger API's own default; filtering to `act_as` alone returned a + transaction quietly missing events. +- The idempotent reads — events-by-contract-id, the ACS page, the updates page + — take the configured retry policy, which had applied only to `version`, the + health check, `ledger_end` and submissions. +- Jitter never brings a retry back **before** a server-recommended delay; a + `RetryInfo` is a minimum, and coming back early spends an attempt on a + guaranteed rejection. +- The WebSocket streams take their reconnect budget and backoff from the + client's `RetryConfig` instead of a hardcoded five-at-250ms. +- `list_known_parties` fails on a repeated page token instead of returning a + prefix as if it were the whole list, and a topology response missing a + required field fails the read rather than shrinking it. +- The vendored `.proto` files carry a provenance record and per-file SHA-256s, + verified by a test, with `tools/vendor-protos.sh` for the refresh. +- `CANTON_TEST_REQUIRE_LIVE=1` turns a skipped live test into a failure, so a + live run's result is a claim about a participant rather than about an empty + environment. +- CI checks `aarch64-unknown-linux-gnu` and `x86_64-unknown-linux-musl`, and + the `cargo-semver-checks` job is enabled now that a baseline exists. +- ADR-0005 no longer claims mixed installs "fail to resolve"; with caret + requirements mixed *patch* versions resolve, which is intended. +- `canton-admin` documents that party management here is allocation and + discovery, and why updates are out of scope for M1. +- The reference app reads its committed transaction back independently and + matches the exact update id, on both transports. + +### Fixed — from an external review of the M1 client + +- **`canton-core` (message size):** the gRPC decode limit is raised off tonic's + 4 MiB default to 128 MiB, configurable via + `Config::with_max_decoding_message_size`. One ACS page arrives as a single + message and the participant permits page sizes up to 10 000, so the default + was reachable in ordinary use and surfaced as a client-side `OUT_OF_RANGE` + that reads like a server fault. Applied at all 24 service-client + constructions across `canton-ledger` and `canton-admin` through one macro per + crate, so a newly added RPC cannot pick the default back up. The WebSocket + lane had the same problem one layer down and no way to fix it: + `tungstenite`'s defaults cap a message at 64 MiB and a single **frame** at + 16 MiB. `JsonClient::with_max_decoding_message_size` now sets both, and + defaults them to the same 128 MiB, so one transport cannot quietly be + stricter than the other. (The HTTP lane needs nothing — `reqwest` puts no + limit on a response body.) +- **`canton-core` (errors):** `Error::resource_info()` exposes the + `google.rpc.ResourceInfo` Canton attaches to failures where "which one?" is + the first question — `CONTRACT_NOT_FOUND` names the contract id. A `Vec` + rather than an `Option`, because the JSON transport carries a list. +- **`canton-ledger` (unbounded waits):** the JSON lane had no timeout at all. + `reqwest` applies none unless asked, so a participant that accepted the + connection and then went quiet held the caller's task open for the life of + the process — while the gRPC channel beside it had bounded the same call at + 30s since M1. `JsonClient::with_timeout` sets it, defaulting to that same + 30s, applied per request so it holds whatever order the builders were called + in. The WebSocket **handshake** is bounded by the same value; the stream that + follows is a live tail and deliberately is not. +- **`canton-core` (transport parity):** `Error::error_info()` and + `Error::code()` now answer on the JSON transport too. Both returned `None` + there while returning the real thing over gRPC — so an application that + classified errors by error id, exactly as `error_info`'s documentation tells + it to, silently fell back to string-matching the display text the moment it + was pointed at the JSON lane. The body spells both (`code`/`context` and + `grpcCodeValue`); a redacted error's literal `"NA"` is reported as no error + id rather than as one. A live test now asserts that both lanes describe the + same failure identically, so this cannot drift apart again unnoticed. +- **`canton-daml` (party ids):** `Party::parse` / `"…".parse::()` + validate a party id a caller supplies — refusing empty, over-long, and + characters Canton does not use — while `Party::new` still takes a wire value + as-is, the same asymmetry `Numeric` already had. `FromStr` was `Infallible`, + so `"".parse::()` succeeded and the failure surfaced only at the + participant, as a `PermissionDenied` naming nothing. An empty party id is the + shape `std::env::var` returns for `PARTY=`, which is how it happens in + practice. Idea taken from zenith-network/canton-rs, which validates its + identifier types. +- **`canton-codegen` (submit vs read):** the two encoders a generated template + carries are now pinned to agree. The emitter writes the payload's field list + twice — in `ToValue` and in `Template::to_record` — and they are different + paths at runtime: `create_command` submits `to_record`, while a contract read + back arrives through `from_value`. A template whose two lists drifted would + write one shape to the ledger and expect another, and nothing compared them: + every round-trip test goes `to_value` → `from_value`, which is the half the + submit path does not use. +- **`canton-daml` (decode errors in containers):** a failure inside a `List`, + `TextMap` or `GenMap` now names the element. Generated records attach the + field name to every decode, but containers dropped everything below it, so + one bad entry in a list of five hundred holdings reported that the list was + bad and left the reader to find which. The paths compose: `holders.2`. A list + reports the index, a `TextMap` the key (which locates an entry better than a + position), a `GenMap` the position and which half of the entry — its keys are + arbitrary values, so there is no name to point at. +- **`canton-daml` (dead API removed before it froze):** `record_field` and + `record_value` are gone from the generated-code surface. The emitter never + emitted either, nothing in the workspace called them, and the crate is about + to publish — after which they would have to keep working forever. +- **`canton-codegen` (drift guard, published crates):** a CI job fetches the + three DARs the published bindings were generated from — cn-quickstart tracks + them in git — at a pinned commit, verifies their SHA-256, and runs the guards + against them, asserting each one ran rather than skipped. Nothing before this + checked on any push that the emitter still produces what is on crates.io. + (`canton-quickstart-licensing` stays local-only: its DAR is built from source + rather than committed, so there is nothing to pin.) +- **`canton-daml` (fixture drift):** the test fixture that claims to be written + "exactly as the generator emits" is now checked against the real emitter + instead of asserting it in a comment. It had drifted three times — the + `.at(label)` on each field decode, the serde derives, and the per-field + renames — and each drift quietly removed a path from coverage while every + test stayed green. +- **`canton-codegen` (phantom type parameters):** a generated codec bounds only + the type parameters it actually encodes. Daml permits a phantom parameter — + declared but used in no field — and the emitter required `T: ToValue` for + every declared one, so instantiating a phantom parameter with an **interface + marker** produced Rust that does not compile. Markers carry no codec by + design: they exist only as the tag of a `ContractId`. Valid Daml therefore + generated invalid Rust, with the error landing in code the reader did not + write. Four types across the published bindings carried the spurious bound + and have been regenerated without it. +- **`canton-codegen` (IR semver):** every public IR struct is + `#[non_exhaustive]`, with a constructor for each. The IR is documented as + something a caller lowers and then post-processes, its fields are public, and + it gained **forty fields during Milestone 2 alone** — so once the crate is on + crates.io, one more field would be a breaking change for anyone who wrote a + struct literal, and adding `#[non_exhaustive]` afterwards is itself breaking. + Fields stay public, so reading and mutating a lowered IR is unchanged; only + construction goes through `Record::new`, `Template::new` and the rest. +- **`canton-codegen` (a test that had stopped compiling):** the end-to-end test + that generates a crate, builds it and round-trips both codecs was gated on + `CODEGEN_COMPILE_TEST`, which nothing set — so it skipped on every push and + went stale when the runtime made `Numeric` and `GenMap`'s fields private. It + now builds its values through the public API, the way a consumer must, and CI + sets the variable and asserts the test ran. +- **`canton-codegen` (manifest injection):** the DAR's package version is + validated before it reaches the generated `Cargo.toml`. It was interpolated + raw, so a version of the form `0.1.0"` + newline + `[dependencies.evil]` + + `git = "…` closed the string and opened a table — an arbitrary git dependency + in a manifest the caller then compiles, which is code execution from an + archive. The archive-integrity guards do not stop it: the package id is the + hash of whatever payload its author chose, so an authored DAR passes them + all. The crate name beside it was already validated and the runtime path + beside it already escaped; the version was the third field in the same + manifest and the one that was missed. +- **`canton-codegen` (drift guard):** the guard that keeps the committed + bindings honest now runs in CI. The four existing ones need a DAR from a + Splice or cn-quickstart checkout, so on a machine without one they skip — and + a skipped test reads exactly like a passing one, which meant the property had + never been enforced anywhere but a developer's laptop, for three crates that + are published. A fifth guard regenerates from the DAR committed to this + repository and AST-compares against a committed fixture, and the CI job + asserts it ran rather than skipped, the way the conformance-oracle job + already did. +- **`canton-daml` (typed read on JSON):** `Template::from_json_created_event` + is the JSON counterpart of `from_created_event`, which was gRPC-only. Both + transports carry the same contract for the same bindings, so an application + may write over one and read over the other — but on the JSON lane a caller + had to reach into `event["CreatedEvent"]["createArgument"]` themselves, and + nothing then checked that the event was that template at all. A party's + stream carries every template it sees, and where two payloads share a field + shape, decoding one as the other succeeds and is wrong. Accepts the event + wrapped (`CreatedEvent` / `createdEvent`) or bare, and compares module and + entity but not the package id, matching the gRPC path under Smart Contract + Upgrade. +- **`canton-codegen` (shared packages):** a package already published as its own + crate can be **referenced** instead of re-generated — + `Options::with_external_package(name_or_id, crate)`, or `lower_dar_with` for + the library path. A DAR's dependency closure is shared, so + `splice-api-token-holding-v1` sits under amulet, wallet and wallet-payments + alike; generating it into each gave each crate its own `Holding`, and Rust + treats those as unrelated types. A program depending on two of the published + binding crates failed to compile with "expected `Holding`, found a different + `Holding`" — for the same interface, in the same package. Packages are keyed + by **name** as well as id, and the name is the one to prefer: an id is the + hash of one build, while the name survives a version bump, which is the point + of addressing packages by name under Smart Contract Upgrade. +- **`canton-codegen` (hostile DAR):** a type that resolves to itself is + refused instead of overflowing the stack. Interned types are a flat table of + indices, so `interned_types[0] = Interned(0)` — or two entries pointing at + each other — is not malformed to prost, which bounds nested *messages* and + has no view of the table. Following it recursed until the stack ended, and a + stack overflow aborts the process rather than failing the DAR: the one input + in this reader that still killed the caller after the zip-bomb, entry-size, + archive-size, package-id and LF-version guards. Bounded at 256 levels, where + the deepest type in the 648-package corpus resolves in 15. +- **`canton-lf` (archive integrity):** a package's payload is hashed and checked + against the id it declares before it is parsed, and a `hash_function` other + than SHA-256 is refused rather than assumed. That id is embedded in generated + bindings as `PACKAGE_ID` and is what cross-package references resolve + through, so a DAR whose id does not match its bytes previously produced + bindings naming a package its contents do not answer to. +- **`canton-lf` (LF version):** an archive declaring a Daml-LF 2.x minor this + build was not compiled against is refused instead of decoded. prost drops + fields from a schema it does not know, so a newer minor yielded a package + quietly missing template fields. Accepts `2.1` and `2.2`, which is what the + available corpus contains (648 packages across 18 DARs). The **major** + version is decided first: an LF 1 archive — every DAR a Daml 2.x SDK built — + was being reported as an unsupported *LF 2 minor*, telling the reader to + upgrade an SDK that will never read it. +- **`canton-codegen` (decode errors):** generated `FromValue` bodies attach the + field name to a failure, so a mismatch inside a nested record reports + `meta.values` instead of a bare "expected Text". `ValueError::at` existed and + was documented as being used by generated code; it was not. + +### Fixed — pre-release hardening (M2 quality audit) + +- **Recursion is now Rust-sound.** `Optional` self-recursion (`data Tree = Node + { left : Optional Tree }`), mutual recursion (including cross-module), and + recursion through a generic instantiation previously generated + infinitely-sized structs (E0072); a crate-wide containment-cycle breaker now + boxes every cycle-closing reference occurrence. +- **Record decode matches Canton's wire shapes.** Generated `FromValue` locates + fields by label *or* declaration index (non-verbose output omits labels) and + decodes an absent trailing `Optional` field as `None` (record normalization + under Smart Contract Upgrade drops them). +- **`Numeric` compares numerically**, not textually — the ledger echoes `"1.5"` + as `"1.5000000000"` and the two are now equal (`Eq`/`Ord`/`Hash` on the + canonical decimal; `Numeric::parse` validates early). +- **Typed read path:** `Template::from_created_event` decodes a `CreatedEvent` + into the payload struct (with a template-identity check); + `ContractId::retag` re-tags an id for interface exercise. +- **Codegen robustness on arbitrary DARs:** per-entry decompression cap + (zip-bomb guard), typed errors naming the offending file/package, `$`-named + GHC-internal types skip instead of panicking the emitter, snake-case field + collisions and duplicate package modules are detected, generated code spells + std types fully qualified so same-named Daml types cannot shadow them. +- **CLI safety:** refuses to overwrite files it did not generate (`--force` to + override); validates the crate name; the generated crate's version is the + DAR package's version; `--runtime-path` is absolutized and TOML-escaped. +- Always-on fixture tests: `testdata/splice-api-token-holding-v1-1.0.0.dar` + runs the full decode → lower → emit pipeline (plus determinism and CLI + contract checks) in every `cargo test`, no external setup. + +### Changed + +- **`canton-core` gains `Error::Payload`** — the one addition to the surface + published in 0.1.x. It carries a `canton-daml` codec failure as its `source`, + so a typed decode and a transport failure land in the same `Result` and an + application can stay on `canton::Error` end to end. `Error` is + `#[non_exhaustive]`, so the new variant does not break a `match`. + `canton-daml` supplies the bridge (`impl From for + canton_core::Error`), and `ValueError` is structured rather than a string: + it carries the field **path** it failed at and the message separately, so the + path survives into the error chain instead of being formatted away. +- The `canton` facade re-exports the codegen runtime as [`canton::daml`], so + `cargo add canton` gets a version-locked runtime for generated bindings. The + generator stays out of the facade on purpose: it is a build-time tool + (`cargo install canton-codegen-cli`, or depend on `canton-codegen` from a + build script). +- The codegen pipeline (`generate`, `Options`, `Runtime`, `Stats`) lives in + `canton-codegen`, not in the `-cli` crate, so a build script can call it + without pulling in a CLI. `canton-codegen-cli` is binary-only. +- Codegen errors are typed: `GenerateError` and `CodegenError` replace + `Box` and `syn::Error`, and `SkippedType` (with `module()` / + `reason()`) replaces a bare string. No `syn`/`proc-macro2` type appears in a + public signature. +- `Options` uses the same consuming-builder style as M1's `Config`, and + `Options`/`Stats`/`Runtime` are `#[non_exhaustive]`. +- Generated crates get a publishable `Cargo.toml` (description, no forced + `[workspace]` stanza) and their default name drops the DAR's version suffix, + so a DAR bump no longer renames the crate a caller depends on. +- Every published crate now ships the Apache-2.0 licence text, and integration + tests whose fixtures live outside the package are no longer packaged. + +> **Publish order:** `canton-proto` → `canton-core` → `canton-auth` → +> `canton-lf` → `canton-daml` → `canton-ledger` → `canton-admin` → +> `canton-codegen` → `canton-codegen-cli` → `canton` → the `canton-splice-*` +> bindings. `canton-sample` and `canton-quickstart-licensing` stay unpublished +> (reference material). ## [0.1.4] - 2026-08-05 Documentation and developer-experience fixes from the Canton Foundation review diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0401457..497814e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,16 @@ Guidelines: [`docs/adr/`](docs/adr/README.md) — add a new numbered record rather than editing an old one. - **Versioning**: all `canton-*` crates release in lockstep with one shared - version ([ADR-0005](docs/adr/0005-lockstep-versioning.md)). + version ([ADR-0005](docs/adr/0005-lockstep-versioning.md)). Publishing has an + order and a checklist — [RELEASING.md](RELEASING.md); a crates.io version + cannot be taken back, and half a family published in the wrong order leaves + the rest unpublishable until the next version. +- **CI actions are pinned to a commit**, with the human-readable version in a + trailing comment (`actions/checkout@11d5960… # v4`). A tag is mutable: whoever + controls the action's repository can repoint `v4` at anything, and it would + run with our checkout and our secrets. To bump one, resolve the new tag + (`gh api repos///git/ref/tags/ --jq .object.sha`) and update + both the SHA and the comment. ## Security issues diff --git a/Cargo.lock b/Cargo.lock index 17b3d23..69c6274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +23,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -144,17 +159,18 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "canton" -version = "0.1.4" +version = "0.2.0" dependencies = [ "canton-admin", "canton-auth", "canton-core", + "canton-daml", "canton-ledger", ] [[package]] name = "canton-admin" -version = "0.1.4" +version = "0.2.0" dependencies = [ "canton-auth", "canton-core", @@ -168,7 +184,7 @@ dependencies = [ [[package]] name = "canton-auth" -version = "0.1.4" +version = "0.2.0" dependencies = [ "canton-core", "reqwest", @@ -178,19 +194,46 @@ dependencies = [ "tracing", ] +[[package]] +name = "canton-codegen" +version = "0.2.0" +dependencies = [ + "canton-lf", + "heck", + "prettyplease", + "proc-macro2", + "quote", + "semver", + "syn", + "thiserror 2.0.18", +] + +[[package]] +name = "canton-codegen-cli" +version = "0.2.0" +dependencies = [ + "canton-codegen", + "thiserror 2.0.18", +] + [[package]] name = "canton-core" -version = "0.1.4" +version = "0.2.0" dependencies = [ + "async-stream", + "futures-core", "http", "metrics", "metrics-util", "opentelemetry", "opentelemetry-otlp", + "opentelemetry-proto", "opentelemetry_sdk", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-stream", + "tonic 0.12.3", "tonic 0.14.6", "tonic-types", "tracing", @@ -198,9 +241,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "canton-daml" +version = "0.2.0" +dependencies = [ + "canton-codegen", + "canton-core", + "canton-lf", + "canton-proto", + "serde", + "serde_json", + "time", +] + [[package]] name = "canton-ledger" -version = "0.1.4" +version = "0.2.0" dependencies = [ "async-stream", "canton-auth", @@ -208,6 +264,9 @@ dependencies = [ "canton-proto", "futures-core", "futures-util", + "http", + "opentelemetry", + "opentelemetry_sdk", "prost-types", "rcgen", "reqwest", @@ -222,16 +281,32 @@ dependencies = [ "tonic 0.14.6", "tonic-prost", "tracing", + "tracing-opentelemetry", + "tracing-subscriber", "uuid", ] +[[package]] +name = "canton-lf" +version = "0.2.0" +dependencies = [ + "prost 0.14.4", + "prost-build", + "protoc-bin-vendored", + "serde_json", + "sha2", + "thiserror 2.0.18", + "zip", +] + [[package]] name = "canton-proto" -version = "0.1.4" +version = "0.2.0" dependencies = [ "prost 0.14.4", "prost-types", "protoc-bin-vendored", + "sha2", "tokio", "tonic 0.14.6", "tonic-prost", @@ -239,6 +314,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "canton-quickstart-licensing" +version = "0.2.0" +dependencies = [ + "canton-daml", +] + +[[package]] +name = "canton-sample" +version = "0.2.0" +dependencies = [ + "canton-daml", + "canton-ledger", + "canton-quickstart-licensing", + "serde_json", + "tokio", + "tokio-stream", +] + +[[package]] +name = "canton-splice-amulet" +version = "0.2.0" +dependencies = [ + "canton-daml", +] + +[[package]] +name = "canton-splice-wallet" +version = "0.2.0" +dependencies = [ + "canton-daml", +] + +[[package]] +name = "canton-splice-wallet-payments" +version = "0.2.0" +dependencies = [ + "canton-daml", +] + [[package]] name = "cc" version = "1.2.67" @@ -306,6 +421,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -343,6 +467,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -389,7 +524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -410,6 +545,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -551,9 +696,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.15" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" dependencies = [ "atomic-waker", "bytes", @@ -595,6 +740,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.2" @@ -708,7 +859,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -953,6 +1104,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1054,9 +1215,11 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" dependencies = [ + "hex", "opentelemetry", "opentelemetry_sdk", "prost 0.13.5", + "serde", "tonic 0.12.3", ] @@ -1380,7 +1543,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -1418,9 +1581,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1676,7 +1839,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1780,6 +1943,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1846,6 +2015,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1861,6 +2041,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -1952,7 +2138,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2015,6 +2201,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2023,6 +2210,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2614,7 +2811,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2835,8 +3032,37 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 1ed5e5b..970d764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.4" +version = "0.2.0" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -16,17 +16,22 @@ keywords = ["canton", "grpc", "ledger", "blockchain", "daml"] [workspace.dependencies] # internal crates (path = local build, version = published requirement) -canton-proto = { path = "crates/canton-proto", version = "0.1.4" } -canton-core = { path = "crates/canton-core", version = "0.1.4" } -canton-auth = { path = "crates/canton-auth", version = "0.1.4" } -canton-ledger = { path = "crates/canton-ledger", version = "0.1.4" } -canton-admin = { path = "crates/canton-admin", version = "0.1.4" } +canton-proto = { path = "crates/canton-proto", version = "0.2.0" } +canton-core = { path = "crates/canton-core", version = "0.2.0" } +canton-auth = { path = "crates/canton-auth", version = "0.2.0" } +canton-ledger = { path = "crates/canton-ledger", version = "0.2.0" } +canton-admin = { path = "crates/canton-admin", version = "0.2.0" } +# M2 crates +canton-lf = { path = "crates/canton-lf", version = "0.2.0" } +canton-daml = { path = "crates/canton-daml", version = "0.2.0" } +canton-codegen = { path = "crates/canton-codegen", version = "0.2.0" } # external tonic = { version = "0.14", default-features = false } tonic-prost = { version = "0.14" } tonic-types = { version = "0.14" } prost = { version = "0.14" } +sha2 = { version = "0.10", default-features = false } prost-types = { version = "0.14" } tokio = { version = "1", default-features = false } tower = { version = "0.5" } @@ -48,8 +53,20 @@ reqwest = { version = "0.12", default-features = false } uuid = { version = "1", features = ["v4"] } # build-deps tonic-prost-build = { version = "0.14" } +prost-build = { version = "0.14" } protoc-bin-vendored = { version = "3" } walkdir = { version = "2" } +# codegen (M2): Rust source emission + formatting + case conversion +proc-macro2 = { version = "1" } +quote = { version = "1" } +syn = { version = "2", features = ["full", "parsing"] } +prettyplease = { version = "0.2" } +semver = { version = "1", default-features = false } +heck = { version = "0.5" } +# Daml-LF archive reader (M2): DAR is a JAR-style zip of .dalf packages +zip = { version = "2", default-features = false, features = ["deflate"] } +# generated-type JSON codec (M2): Timestamp/Date render as LF-JSON ISO strings +time = { version = "0.3", default-features = false, features = ["formatting", "parsing", "std", "macros"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 7a74e29..44d1eff 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,30 @@ A production-grade, async **Rust SDK for the [Canton Network](https://www.canton Built on `tonic`/`prost`/`tokio`. Talks the **Ledger API v2** over gRPC (primary) and JSON (HTTP + WebSocket), with correct change-ID de-duplication, command recovery, resilient/resumable streaming, TLS/mTLS on every transport, JWT/OIDC auth, and built-in telemetry. -> **Status: Milestone 1 released.** Every M1 deliverable is implemented, published on crates.io, and verified — no-node tests (unit, in-process gRPC/WS mock servers, TLS) plus a full live suite against a Canton **3.5.7** LocalNet participant, all green under `-D warnings` on every feature combination. Type-safe codegen from DAR packages (M2) is in progress. +> **Status:** the Ledger API client is **released** on crates.io (0.1.x); the type-safe DAR codegen is **code-complete and not yet published**. Everything here is verified against a Canton **3.5.7** participant: hermetic tests plus a live suite (submit, streaming, recovery, TLS/mTLS, auth), and an end-to-end typed loop — generate bindings from a DAR, submit a typed create, read it back, exercise a choice — over gRPC and JSON. CI holds the whole workspace to `-D warnings` on every feature combination. Token-standard support (CIP-56 / CIP-0112), a PQS client, and external signing are next. ## Crates | Crate | What it is | |---|---| -| `canton` | The SDK entry point: a thin facade re-exporting the whole family (`canton::ledger`, `canton::auth`, `canton::admin` + the shared `Config`/`Error` at the root) with the `ws`/`otel` features forwarded. `cargo add canton` gets everything below as one version-locked set. | +| `canton` | The SDK entry point: a thin facade re-exporting the whole family (`canton::ledger`, `canton::auth`, `canton::admin`, `canton::daml` + the shared `Config`/`Error` at the root) with the `ws`/`otel` features forwarded. `cargo add canton` gets everything below as one version-locked set. | | `canton-core` | Shared foundation: the `Error`/`Result` model (retriable classification, structured `ErrorInfo` details), the connection kernel (`Config`, `Auth`/`TokenSource`, `TlsConfig`, jittered retry with per-attempt timeouts), and telemetry (`tracing` spans + `metrics`, optional OTLP via `otel`). | | `canton-proto` | Generated gRPC types + client stubs from vendored protos (Ledger API v2, Canton admin API topology read, gRPC health), pinned to a Canton release. Internal. | -| `canton-auth` | JWT/OIDC authentication: client-credentials `TokenProvider` with caching + refresh + bounded fetch, and Keycloak/Auth0/Okta presets. | -| `canton-ledger` | The async Ledger API client. gRPC: `submit` / `submitAndWait` / `submitAndWaitForTransaction`, completions + recovery, ACS/update streaming (+ paging, reverse-order, event query, offset-resumable), request builders (bounded/filtered/shaped streams, completion `user_id`), node health. JSON: command submission, bounded reads, and WebSocket streaming (incl. resumable) behind the `ws` feature. | +| `canton-auth` | JWT/OIDC authentication: client-credentials `TokenProvider` with caching + refresh + bounded fetch, and Keycloak/Auth0/Okta presets that each produce their provider's normal token request (Auth0's `audience`, Okta's HTTP Basic credentials). | +| `canton-ledger` | The async Ledger API client, with the **same operations on both transports**. gRPC: `submit` / `submitAndWait` / `submitAndWaitForTransaction`, completions and change-ID recovery, ACS/update streaming (+ paging, reverse-order, event query, checkpoint-resumable), a lossless ACS read (`AcsEntry`, incomplete reassignments included), request builders (bounded/filtered/shaped streams, completion `user_id`), node health. JSON: the same submission set including fire-and-forget and recovery, event query, bounded reads, and WebSocket streaming — updates, completions and a resumable ACS — behind the `ws` feature. | | `canton-admin` | Admin surface: party allocation/management, user self-inspect, packages read, and topology read (party→participant mappings, namespace delegations, vetted packages) over the Canton admin API. | +| `canton-daml` | The runtime under generated bindings: Daml primitive types (`Party`, `ContractId`, `Numeric`, `Timestamp`, …), `Template`/`Interface`/`Choice` traits, command builders, and the JSON + gRPC value codecs. | +| `canton-codegen` / `canton-codegen-cli` | DAR → typed Rust. The CLI (`dpm-codegen-rust`, also `dpm codegen-rust`) writes a complete crate from any DAR; the library is the IR + emitter behind it. | +| `canton-lf` | Daml-LF archive reader/decoder (the codegen front-end), built on the official `daml-lf-archive` schema and held to the official JVM reader by a conformance oracle. Internal. | +| `canton-splice-amulet`, `canton-splice-wallet`, `canton-splice-wallet-payments` | Pre-built typed bindings for the Splice protocol DARs, regenerated per release ("DAR as a crate"). | +| `canton-quickstart-licensing` | The same, for the cn-quickstart licensing DAR. **Not published** — it backs the reference app and the end-to-end tests; generate your own with the CLI. | ## Compatibility | SDK version | Canton version | Ledger API | Rust (MSRV) | |---|---|---|---| -| 0.1.4 | 3.5.7 (pinned protos) | v2 | 1.88 | +| 0.1.4 (released) | 3.5.7 (pinned protos) | v2 | 1.88 | +| 0.2.x (this branch, unreleased) | 3.5.7 (pinned protos) | v2 | 1.88 | The vendored `.proto` files are pinned to the Canton release above; moving the supported Canton range re-vendors them in a new SDK minor (see the stability @@ -37,7 +43,7 @@ crates release in **lockstep** — mix only equal versions | `ws` | `canton-ledger` | WebSocket streaming for the JSON transport (`ws_updates`, `ws_active_contracts`, `ws_completions`, `ws_updates_resumable`), TLS-aware. | | `otel` | `canton-core`, `canton-ledger` | OTLP span export (`telemetry::otel::otlp_tracer`) and automatic W3C trace-context injection into outgoing gRPC metadata + JSON headers. | -The `canton` facade forwards both: `canton = { version = "0.1", features = ["ws", "otel"] }`. +The `canton` facade forwards both: `canton = { version = "0.2", features = ["ws", "otel"] }`. Telemetry follows the standard Rust model: the SDK **emits** (`tracing` spans, `metrics` counters labelled by method + transport); the application installs the subscriber/recorder of its choice. @@ -48,46 +54,47 @@ cargo add canton # the whole SDK, one crate # or pick pieces: cargo add canton-ledger canton-auth ``` -```rust +```rust,ignore use canton::ledger::{CantonClient, Config}; -# async fn run() -> canton::Result<()> { -let client = CantonClient::connect_lazy(Config::new("http://localhost:3901"))?; -println!("ledger api version: {}", client.version().await?); -println!("node health: {:?}", client.health_check().await?); -# Ok(()) -# } +#[tokio::main] +async fn main() -> canton::Result<()> { + let client = CantonClient::connect_lazy(Config::new("http://localhost:3901"))?; + println!("ledger api version: {}", client.version().await?); + println!("node health: {:?}", client.health_check().await?); + Ok(()) +} ``` With OIDC auth and a command: -```rust +```rust,ignore use canton::auth::{OidcConfig, TokenProvider}; use canton::ledger::{CantonClient, Config, Submit, create, identifier, record, value}; -# async fn run(party: &str, pkg: &str) -> canton::Result<()> { -let auth = TokenProvider::new(OidcConfig::keycloak( - "http://keycloak.localhost:8082", "AppProvider", "client-id", "client-secret", -)); -let client = CantonClient::connect_lazy( - Config::new("http://localhost:3901").with_oidc(auth), -)?; - -let tx = client - .submit_and_wait_for_transaction( - Submit::new(party).add_command(create( - identifier(pkg, "Licensing.AppInstall", "AppInstallRequest"), - record(vec![ - ("provider", value::party(party)), - ("user", value::party(party)), - ("meta", value::record(record(vec![("values", value::empty_text_map())]))), - ]), - )), - ) - .await?; -println!("committed {} at offset {}", tx.update_id, tx.offset); -# Ok(()) -# } +async fn submit(party: &str, pkg: &str) -> canton::Result<()> { + let auth = TokenProvider::new(OidcConfig::keycloak( + "http://keycloak.localhost:8082", "AppProvider", "client-id", "client-secret", + )); + let client = CantonClient::connect_lazy( + Config::new("http://localhost:3901").with_oidc(auth), + )?; + + let tx = client + .submit_and_wait_for_transaction( + Submit::new(party).add_command(create( + identifier(pkg, "Licensing.AppInstall", "AppInstallRequest"), + record(vec![ + ("provider", value::party(party)), + ("user", value::party(party)), + ("meta", value::record(record(vec![("values", value::empty_text_map())]))), + ]), + )), + ) + .await?; + println!("committed {} at offset {}", tx.update_id, tx.offset); + Ok(()) +} ``` Runnable examples: [`version_and_health`](crates/canton-ledger/examples/version_and_health.rs) (no auth, defaults to `http://localhost:3901`) and [`submit_and_read`](crates/canton-ledger/examples/submit_and_read.rs) (OIDC auth + a create). Both read the same `CANTON_TEST_*` variables as the live tests below, so one export set runs everything: @@ -97,8 +104,147 @@ cargo run -p canton-ledger --example version_and_health cargo run -p canton-ledger --example submit_and_read ``` +**When the outcome must not be lost.** A submission whose response never +arrives may still have committed, and the way back to it is the command's +identity — so take the identity *before* sending rather than from a call that +may fail: + +```rust,ignore +use std::time::Duration; + +// An offset from before the submission, to read completions from. +let offset = client.ledger_end().await?; +let submission = client.submission(Submit::new(party).add_command(command)); + +if submission.submit_and_wait().await.is_err() { + // Ambiguous — ask the ledger what actually happened. The match is on the + // whole change ID (user, acting parties, command id), not the command id + // alone, which is not unique across a participant's users. + let completion = submission.recover(offset, Duration::from_secs(30)).await?; + println!("committed after all: {}", completion.update_id); +} +``` + +`JsonClient::submission` is the same handle on the JSON transport, recovering +over the WebSocket. + See also the integration tests in [`crates/canton-ledger/tests/`](crates/canton-ledger/tests/) and [`crates/canton-admin/tests/`](crates/canton-admin/tests/). +## A local network, with no configuration in your program + +[canton-devkit](https://github.com/bitdynamics-ab/canton-devkit) runs a Splice +LocalNet — two participants and a super-validator — and exports it into the +environment. The SDK reads that export directly, so nothing in the program names +a host, a port, or a credential: + +```sh +canton-devkit localnet up demo # or: dpm localnet up demo +eval "$(canton-devkit localnet env demo)" +``` + +```rust,ignore +use canton::ledger::{CantonClient, JsonClient}; +use canton::{Config, localnet}; + +let grpc = CantonClient::connect_lazy(Config::from_env()?)?; // app-provider +let json = JsonClient::from_env()?; // same network +let user = Config::from_env_for("app-user")?; // the other participant +let party = localnet::party("app-provider"); // the id for `act_as` +``` + +Runnable: [`localnet`](crates/canton-ledger/examples/localnet.rs). + +```sh +cargo run -p canton-ledger --example localnet +``` + +Two details this handles for you, both of which otherwise fail late and +unhelpfully. The exported gRPC URL has **no scheme** (`host:port` is what a gRPC +client dials) — passed to a client unchanged it produced an unexplained +transport error at the first RPC. And the URLs are nginx **virtual-host names** +(`grpc-ledger-api.app-provider.demo.localhost`), so the name has to reach the +`:authority` / `Host` header rather than be resolved away; substituting +`127.0.0.1` reaches the port and is refused by the vhost. `*.localhost` resolves +to loopback on macOS and on Linux with systemd-resolved — elsewhere, add an +`/etc/hosts` entry. + +Nothing here is devkit-specific beyond the variable names, and `CANTON_ENDPOINT` +/ `CANTON_TOKEN` override them for an environment that is not a LocalNet. The +full contract is in [`canton_core::localnet`](crates/canton-core/src/localnet.rs). + +Verified against a live Canton 3.5.7 participant reached through those exported +shapes — scheme-less gRPC URL, vhost hostnames, ready-made bearer token — on +both transports. What that does *not* yet cover is a `localnet up` of our own: +the contract is read and exercised, the orchestration around it is the devkit +project's to vouch for. + +## Typed bindings from your DAR (codegen) + +Turn any DAR into a typed crate — templates become structs, choices become +typed exercise impls, with JSON and gRPC codecs on everything: + +```sh +cargo install canton-codegen-cli # provides `dpm-codegen-rust` +dpm-codegen-rust --dar path/to/my-app-0.1.0.dar --out my-app-bindings +``` + +The output is a self-contained crate (`Cargo.toml` + `src/lib.rs`). **Commit it** +and depend on it by path — that is how the `canton-splice-*` crates in this +repository are built, and it keeps the generated code reviewable in a diff: + +```toml +my-app-bindings = { path = "my-app-bindings" } +``` + +To keep it in step with the DAR, regenerate in CI and fail on a diff, rather +than generating during the build: + +```sh +dpm-codegen-rust --dar dars/my-app-0.1.0.dar --out my-app-bindings +git diff --exit-code my-app-bindings +``` + +No `--force` is needed to regenerate over the tool's own output; it is there to +overwrite files this tool did **not** write, which is a thing to do on purpose +and not a flag to carry around. + +Generating from a **build script** does not work, and it is worth saying why +rather than leaving it to be discovered: Cargo resolves path dependencies before +it runs build scripts, so on a clean checkout `my-app-bindings` does not exist +yet and the build fails before the script that would create it has run. The +`prost-build` arrangement — write into `OUT_DIR`, `include!` it — does not apply +either: the emitted tree spans several packages and refers between them by +`crate::`-qualified paths, which resolve to the *including* crate's root rather +than the module they were placed in. + +`canton_codegen::generate` is a library call for exactly the CI step above, and +for tooling that produces a crate directory. + +Then submit typed commands: + +```rust,ignore +use my_app_bindings::my_app::My_Module::{Asset, Asset_Transfer}; +use canton_daml as rt; +use rt::Template as _; + +let payload = Asset { owner: rt::Party::new(party), name: "gem".into() }; +let create = rt::create_command(&payload); // gRPC command +let created: Asset = Asset::from_created_event(&event)?; // typed read +let exercise = rt::exercise_command(&contract_id, &Asset_Transfer { + new_owner: rt::Party::new(other), +}); +``` + +Template ids use the upgrade-friendly `#package-name` form, so the participant +resolves the version vetted under Smart Contract Upgrade (the pinned package id +is also available as `Asset::PACKAGE_ID`). For the Splice DARs, skip codegen and +use the pre-built `canton-splice-*` crates. The full Daml-LF → Rust type mapping +is documented in [docs/daml-lf-type-mapping.md](docs/daml-lf-type-mapping.md); +regeneration on a DAR version bump in +[docs/scu-regeneration.md](docs/scu-regeneration.md). A complete runnable flow +(typed create → read back → exercise, on gRPC and JSON) is +[`crates/canton-sample`](crates/canton-sample/src/main.rs). + ## Testing **No-node tests** — unit tests, in-process gRPC/WebSocket mock servers, TLS @@ -112,6 +258,12 @@ cargo test --workspace --all-features below are set, and skip otherwise (so the command above stays green without a node). Every name is prefixed `CANTON_TEST_`: +A skipped test and a passing one are the same line in cargo's output, so set +**`CANTON_TEST_REQUIRE_LIVE=1`** whenever a run is meant to prove something: +each test that would step aside for a missing variable fails instead. That is +what makes "38 live tests passed" a claim about a participant rather than about +an empty environment. + | Variable | What it gates | Example (LocalNet App Provider) | |---|---|---| | `CANTON_TEST_ENDPOINT` | all gRPC live tests | `http://localhost:3901` | @@ -123,6 +275,7 @@ node). Every name is prefixed `CANTON_TEST_`: | `CANTON_TEST_ADMIN_ENDPOINT` | `canton-admin` topology reads | `http://localhost:3902` | | `CANTON_TEST_ADMIN_CLIENT_ID`, `CANTON_TEST_ADMIN_CLIENT_SECRET` | party-admin RPCs (need the `ParticipantAdmin` right) | `app-provider-validator`, … | | `CANTON_TEST_SYNC_ID` | optional: also assert vetted packages in the synchronizer store | | +| `CANTON_TEST_REQUIRE_LIVE` | turns every skip into a failure — set it on any run whose result is being reported | `1` | ```sh export CANTON_TEST_ENDPOINT=http://localhost:3901 @@ -131,12 +284,29 @@ export CANTON_TEST_TOKEN_URL=http://keycloak.localhost:8082/realms/AppProvider/p export CANTON_TEST_CLIENT_ID=app-provider-backend CANTON_TEST_CLIENT_SECRET=… export CANTON_TEST_PARTY='app_provider_quickstart-…::1220…' export CANTON_TEST_LICENSING_PKG='#quickstart-licensing' +export CANTON_TEST_REQUIRE_LIVE=1 # skipping is now a failure, not a pass cargo test -p canton-ledger --all-features --test live -- --nocapture ``` -**Bringing up a node.** Any Canton 3.5 participant works; three paths, least +**Credentials.** The suite takes whichever the environment offers: the OIDC +client-credentials flow where there is an issuer (`CANTON_TEST_TOKEN_URL` and +friends), otherwise a ready-made bearer token — which is what a Splice LocalNet +exports, having no issuer to exchange credentials with. Two tests genuinely need +an issuer (`ledger_end_with_oidc_auth`, and party management, which needs a +token carrying `ParticipantAdmin`); those say so when they step aside. The rest +run either way. + +**Bringing up a node.** Any Canton 3.5 participant works; four paths, least setup first: +- [canton-devkit](https://github.com/bitdynamics-ab/canton-devkit) — one binary: + `canton-devkit localnet up demo`, then `eval "$(canton-devkit localnet env + demo)"` exports endpoints, tokens and party ids under the names + [`canton_core::localnet`](crates/canton-core/src/localnet.rs) reads, so the + suite needs no `CANTON_TEST_*` at all. It allocates its own ports, so pass + `--port-base` or read the exported values rather than assuming `3901`. + Authentication is Splice's `unsafe-jwt-hmac-256`, so the two issuer-dependent + tests skip; `localnet dar upload` supplies a package for the rest. - [Canton Builder Tool](https://canton-network-devs.github.io/Canton-Builder-Tool/#part-builder) — the least to install: `canton builder start` brings up a LocalNet (its guide says about five minutes the first time, faster after), and @@ -149,9 +319,10 @@ setup first: (`3901` gRPC, `3902` admin, `3975` JSON), so `CANTON_TEST_ENDPOINT` and `CANTON_TEST_JSON_ENDPOINT` need no changes. It runs **unauthenticated** by default — its only other profile is `unsafe-jwt-hmac-256`, an HMAC secret you - sign tokens with yourself — so there is no OIDC token endpoint: the tests - gated on `CANTON_TEST_TOKEN_URL` skip, as do the command-submission ones, - which also want the licensing package. Use `cn-quickstart` for those. + sign tokens with yourself — so there is no OIDC token endpoint. Set the token + you signed as `CANTON_TOKEN` and everything except the two issuer-dependent + tests runs; the command-submission ones additionally want a package, which is + what `cn-quickstart` supplies. - [`cn-quickstart`](https://github.com/digital-asset/cn-quickstart) (`make setup && make build && make start`) — the same LocalNet plus the licensing sample app, which is where `CANTON_TEST_LICENSING_PKG` / @@ -171,7 +342,16 @@ MSRV. ## Roadmap -M1 (this milestone) is the core client. Coming next per the proposal: **M2** — type-safe code generation from DAR packages (`daml-lf-archive`-based, SCU-aware) with a `dpm codegen-rust` component and the first prebuilt `canton-splice-*` crates; **M3** — token-standard support (CIP-56 V1 + CIP-0112 V2), interactive submission with a pluggable signer, a typed PQS client, and the Ledger-Client-Standard conformance suite. +**Shipped:** the async Ledger API client (gRPC + JSON + WebSocket, auth, TLS, +retry, telemetry) and type-safe code generation from DAR packages — SCU-aware, +with a `dpm codegen-rust` component and prebuilt `canton-splice-*` crates. The +LF decoder is native Rust rather than a JVM wrapper around `daml-lf-archive` +([ADR-0008](docs/adr/0008-native-lf-decoder.md)); its output is held to the +official JVM reader by a conformance oracle. + +**Next:** token-standard support (CIP-56 V1 + CIP-0112 V2), interactive +submission with a pluggable signer, a typed PQS client, and the +Ledger-Client-Standard conformance suite. ## Contributing & security @@ -179,6 +359,23 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and [SECURITY.md](SECURITY.md) for private vulnerability reporting. Notable changes are tracked in [CHANGELOG.md](CHANGELOG.md). +## Acknowledgements + +Built on the Ledger API and Daml-LF work of the +[Canton](https://github.com/digital-asset/canton) and +[Splice](https://github.com/canton-network/splice) teams. + +The local-development path reads the environment +[canton-devkit](https://github.com/bitdynamics-ab/canton-devkit) exports, and +reading its DAR container taught us that a per-entry decompression cap bounds +nothing on its own — an archive is now bounded in total as well. + +[Equilibrium](https://equilibrium.co) reviewed the released M1 client from an +independent engineering perspective and reported a credential leak privately +before anything else. Their findings are closed in 0.2.0 and listed in the +[changelog](CHANGELOG.md); several are the kind that only a reader who does not +already know what the code meant to do would find. + ## License Apache-2.0. See [LICENSE](LICENSE). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c12ca93 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,66 @@ +# Releasing + +All `canton-*` crates release in lockstep on one version +([ADR-0005](docs/adr/0005-lockstep-versioning.md)). Publishing is the one step +here that cannot be undone — a crates.io version is permanent, and half a +family published in the wrong order leaves the rest unpublishable until the +next version. + +## Order + +`cargo publish` builds the package to verify it, which resolves **dev** +dependencies as well as normal ones, so a crate cannot go before anything it +depends on either way. This order is derived from the manifests, not from +memory: + +1. `canton-core` +2. `canton-lf` +3. `canton-proto` +4. `canton-auth` +5. `canton-codegen` +6. `canton-admin` ← dev-depends on `canton-auth`, which is why it is not third +7. `canton-codegen-cli` +8. `canton-daml` +9. `canton-ledger` +10. `canton` +11. `canton-splice-amulet` +12. `canton-splice-wallet` +13. `canton-splice-wallet-payments` + +`canton-quickstart-licensing` and `canton-sample` are `publish = false`: the +first is generated from a DAR built from source, the second is the reference +app. + +Re-derive the order after adding a crate: + +```sh +cargo metadata --format-version 1 --no-deps >/dev/null # manifests parse +tools/publish-order.sh # prints the list above +``` + +Each step waits for the index: `cargo publish -p ` then let it appear +before the next (crates.io is usually seconds; `cargo publish` will fail fast +with "no matching package" if it is not there yet). + +## Before publishing + +- `cargo fmt --all --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace --all-features` +- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features` +- `cargo deny check all` +- `cargo hack clippy --workspace --feature-powerset --no-dev-deps -- -D warnings` +- `cargo semver-checks -p canton-core -p canton-auth -p canton-ledger -p canton-admin -p canton --all-features` + (only crates with a published baseline; add each new one after its first release) +- The live suites against a participant, with skips made fatal: + `CANTON_TEST_REQUIRE_LIVE=1 cargo test -p canton-ledger --features ws --test live` + and the same for `-p canton-admin` +- The reference app end to end on both transports: `cargo run -p canton-sample` +- `CHANGELOG.md`: turn `— unreleased` into the date +- `README.md`: the status line names what is released +- The tree is clean and tagged `v` + +## After publishing + +- GitHub release on the tag, notes from the changelog section +- `docs/compatibility-matrix.md` (M3 onward) names the released version diff --git a/crates/canton-admin/Cargo.toml b/crates/canton-admin/Cargo.toml index be9ec6c..d8134ed 100644 --- a/crates/canton-admin/Cargo.toml +++ b/crates/canton-admin/Cargo.toml @@ -11,6 +11,7 @@ homepage = { workspace = true } categories = { workspace = true } keywords = { workspace = true } description = "Canton admin client: party management, user self-inspect, and topology read (gRPC)." +include = ["src/**/*", "README.md", "LICENSE"] [dependencies] canton-proto = { workspace = true } diff --git a/crates/canton-admin/LICENSE b/crates/canton-admin/LICENSE new file mode 100644 index 0000000..2ad0721 --- /dev/null +++ b/crates/canton-admin/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NODEJUMPER + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/canton-admin/src/client.rs b/crates/canton-admin/src/client.rs index e00d2fc..5c0c04c 100644 --- a/crates/canton-admin/src/client.rs +++ b/crates/canton-admin/src/client.rs @@ -26,6 +26,22 @@ pub struct AdminClient { config: Arc, } +/// Build a gRPC service client on the authenticated channel, with this +/// client's decode limit applied. +/// +/// A macro rather than a function because `max_decoding_message_size` is an +/// inherent method on each generated client — tonic exposes no trait for it — +/// so there is nothing to be generic over. Keeping every construction site +/// behind one expansion is the point: `tonic`'s 4 MiB default is small enough +/// that a real ACS page trips it, and a new RPC added later would otherwise +/// pick the default up silently. +macro_rules! service { + ($self:ident, $ctor:expr) => { + $ctor($self.intercepted().await?) + .max_decoding_message_size($self.config.max_decoding_message_size()) + }; +} + impl AdminClient { /// Build a lazily-connected client. Returns immediately; the TCP/TLS /// handshake happens on the first RPC. @@ -64,7 +80,7 @@ impl AdminClient { pub async fn participant_id(&self) -> Result { telemetry::instrument("participant_id", TRANSPORT_GRPC, async { self.with_retry(|| async { - let mut client = PartyManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PartyManagementServiceClient::new); Ok(client .get_participant_id(pb::GetParticipantIdRequest {}) .await? @@ -90,7 +106,7 @@ impl AdminClient { pub async fn allocate_party(&self, party_id_hint: Option<&str>) -> Result { let party_id_hint = party_id_hint.unwrap_or_default().to_string(); telemetry::instrument("allocate_party", TRANSPORT_GRPC, async move { - let mut client = PartyManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PartyManagementServiceClient::new); let response = client .allocate_party(pb::AllocatePartyRequest { party_id_hint, @@ -119,7 +135,7 @@ impl AdminClient { self.with_retry(|| { let page_token = page_token.clone().unwrap_or_default(); async move { - let mut client = PartyManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PartyManagementServiceClient::new); let response = client .list_known_parties(pb::ListKnownPartiesRequest { page_token, @@ -153,8 +169,7 @@ impl AdminClient { .with_retry(|| { let page_token = sent.clone(); async move { - let mut client = - PartyManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PartyManagementServiceClient::new); let response = client .list_known_parties(pb::ListKnownPartiesRequest { page_token, @@ -170,15 +185,17 @@ impl AdminClient { if next.is_empty() { break; } - // Guard against a server that never advances the token: without - // this a degenerate/buggy participant would loop forever. + // A server that never advances the token would loop forever. + // Stopping quietly is no better: the caller receives a prefix + // of the party list with nothing to say it is a prefix, and + // "which parties exist" is a question whose wrong answer looks + // exactly like a right one. So this fails. if next == sent { - tracing::warn!( - "list_known_parties: server returned an unchanged page token; \ - stopping pagination with {} parties collected", + return Err(Error::UnexpectedResponse(format!( + "the participant repeated the same page token after {} parties; \ + the list is incomplete and cannot be continued", all.len() - ); - break; + ))); } page_token = next; } @@ -197,7 +214,7 @@ impl AdminClient { self.with_retry(|| { let parties = parties.clone(); async move { - let mut client = PartyManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PartyManagementServiceClient::new); Ok(client .get_parties(pb::GetPartiesRequest { parties, @@ -235,7 +252,7 @@ impl AdminClient { self.with_retry(|| { let user_id = user_id.clone(); async move { - let mut client = UserManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, UserManagementServiceClient::new); let response = client .get_user(pb::GetUserRequest { user_id, @@ -273,7 +290,7 @@ impl AdminClient { self.with_retry(|| { let user_id = user_id.clone(); async move { - let mut client = UserManagementServiceClient::new(self.intercepted().await?); + let mut client = service!(self, UserManagementServiceClient::new); Ok(client .list_user_rights(pb::ListUserRightsRequest { user_id, @@ -299,7 +316,7 @@ impl AdminClient { pub async fn list_packages(&self) -> Result> { telemetry::instrument("list_packages", TRANSPORT_GRPC, async { self.with_retry(|| async { - let mut client = PackageServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PackageServiceClient::new); Ok(client .list_packages(lapi::ListPackagesRequest {}) .await? @@ -323,7 +340,7 @@ impl AdminClient { self.with_retry(|| { let package_id = package_id.clone(); async move { - let mut client = PackageServiceClient::new(self.intercepted().await?); + let mut client = service!(self, PackageServiceClient::new); let response = client .get_package_status(lapi::GetPackageStatusRequest { package_id }) .await? diff --git a/crates/canton-admin/src/lib.rs b/crates/canton-admin/src/lib.rs index 2d04f6d..fa454f6 100644 --- a/crates/canton-admin/src/lib.rs +++ b/crates/canton-admin/src/lib.rs @@ -17,6 +17,17 @@ //! Both build on the shared [`canton_core::Config`] (endpoint, auth, TLS, //! retry). //! +//! # Scope +//! +//! Party management here is **allocation and discovery**: allocate a party, +//! list the parties a participant knows, look specific ones up. Updating a +//! party's details (`UpdatePartyDetails`), the identity-provider fields, and +//! the participant-permission options of `AllocateParty` are deliberately not +//! wrapped — Milestone 1 covers the client surface an application needs to act +//! on the ledger, and party *administration* beyond that belongs to the +//! operator tooling rather than to an SDK's first release. Anything not +//! wrapped is still reachable through [`canton_proto`] on the same channel. +//! //! [`PartyManagementService`]: https://docs.daml.com //! [`UserManagementService`]: https://docs.daml.com //! diff --git a/crates/canton-admin/src/topology.rs b/crates/canton-admin/src/topology.rs index 2333f4a..59a926d 100644 --- a/crates/canton-admin/src/topology.rs +++ b/crates/canton-admin/src/topology.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use canton_core::auth::{self, Intercepted}; use canton_core::telemetry::{self, TRANSPORT_GRPC}; -use canton_core::{Config, Result}; +use canton_core::{Config, Error, Result}; use canton_proto::com::digitalasset::canton::protocol::v30 as protocol; use canton_proto::com::digitalasset::canton::topology::admin::v30 as topo; use tonic::transport::Channel; @@ -67,6 +67,22 @@ pub struct TopologyClient { config: Arc, } +/// Build a gRPC service client on the authenticated channel, with this +/// client's decode limit applied. +/// +/// A macro rather than a function because `max_decoding_message_size` is an +/// inherent method on each generated client — tonic exposes no trait for it — +/// so there is nothing to be generic over. Keeping every construction site +/// behind one expansion is the point: `tonic`'s 4 MiB default is small enough +/// that a real ACS page trips it, and a new RPC added later would otherwise +/// pick the default up silently. +macro_rules! service { + ($self:ident, $ctor:expr) => { + $ctor($self.intercepted().await?) + .max_decoding_message_size($self.config.max_decoding_message_size()) + }; +} + impl TopologyClient { /// Build a lazily-connected client for the admin API endpoint (e.g. /// `http://localhost:3902`). @@ -126,8 +142,7 @@ impl TopologyClient { filter_participant.clone(), ); async move { - let mut client = - TopologyManagerReadServiceClient::new(self.intercepted().await?); + let mut client = service!(self, TopologyManagerReadServiceClient::new); let response = client .list_party_to_participant(topo::ListPartyToParticipantRequest { base_query: Some(Self::base_query(&store)), @@ -136,9 +151,7 @@ impl TopologyClient { }) .await? .into_inner(); - Ok(collect_entries( - response.results.into_iter().map(|r| (r.context, r.item)), - )) + collect_entries(response.results.into_iter().map(|r| (r.context, r.item))) } }) .await @@ -167,8 +180,7 @@ impl TopologyClient { filter_target_key_fingerprint.clone(), ); async move { - let mut client = - TopologyManagerReadServiceClient::new(self.intercepted().await?); + let mut client = service!(self, TopologyManagerReadServiceClient::new); let response = client .list_namespace_delegation(topo::ListNamespaceDelegationRequest { base_query: Some(Self::base_query(&store)), @@ -177,9 +189,7 @@ impl TopologyClient { }) .await? .into_inner(); - Ok(collect_entries( - response.results.into_iter().map(|r| (r.context, r.item)), - )) + collect_entries(response.results.into_iter().map(|r| (r.context, r.item))) } }) .await @@ -206,8 +216,7 @@ impl TopologyClient { self.with_retry(|| { let (store, filter_participant) = (store.clone(), filter_participant.clone()); async move { - let mut client = - TopologyManagerReadServiceClient::new(self.intercepted().await?); + let mut client = service!(self, TopologyManagerReadServiceClient::new); let response = client .list_vetted_packages(topo::ListVettedPackagesRequest { base_query: Some(Self::base_query(&store)), @@ -215,9 +224,7 @@ impl TopologyClient { }) .await? .into_inner(); - Ok(collect_entries( - response.results.into_iter().map(|r| (r.context, r.item)), - )) + collect_entries(response.results.into_iter().map(|r| (r.context, r.item))) } }) .await @@ -227,20 +234,32 @@ impl TopologyClient { } /// Keep only results that carry both a context and an item, pairing them. +/// Assemble the entries of a topology response, refusing an incomplete one. +/// +/// Both halves of an entry are `Required` in the proto, so a row missing +/// either is a participant that answered something this client cannot read. +/// Dropping such a row silently is the dangerous shape: topology reads answer +/// questions like "which participants host this party", and a short answer is +/// indistinguishable from a true one — the caller acts on a view of the +/// network that is missing a member it was never told about. fn collect_entries( results: impl Iterator, Option)>, -) -> Vec> { +) -> Result>> { results - .filter_map(|(context, item)| { - Some(Entry { - context: context?, - item: item?, - }) + .map(|(context, item)| match (context, item) { + (Some(context), Some(item)) => Ok(Entry { context, item }), + (None, _) => Err(Error::UnexpectedResponse( + "a topology result carried no context; the response is incomplete".to_string(), + )), + (_, None) => Err(Error::UnexpectedResponse( + "a topology result carried no mapping; the response is incomplete".to_string(), + )), }) .collect() } #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { use super::*; @@ -279,15 +298,28 @@ mod tests { } #[test] - fn collect_entries_drops_partial_results() { + fn collect_entries_refuses_partial_results() { let ctx = topo::BaseResult::default(); + + // A complete response reads normally. let rows = vec![ (Some(ctx.clone()), Some(7u8)), - (None, Some(8u8)), // missing context -> dropped - (Some(ctx.clone()), None), // missing item -> dropped + (Some(ctx.clone()), Some(8u8)), ]; - let kept = collect_entries(rows.into_iter()); - assert_eq!(kept.len(), 1); - assert_eq!(kept[0].item, 7); + let kept = collect_entries(rows.into_iter()).expect("a complete response"); + assert_eq!(kept.len(), 2); + + // A row missing either half fails the whole read rather than shrinking + // it: a topology answer that is quietly short is worse than none. + let missing_context = vec![(Some(ctx.clone()), Some(7u8)), (None, Some(8u8))]; + assert!(matches!( + collect_entries(missing_context.into_iter()), + Err(Error::UnexpectedResponse(_)) + )); + let missing_item = vec![(Some(ctx.clone()), Some(7u8)), (Some(ctx), None::)]; + assert!(matches!( + collect_entries(missing_item.into_iter()), + Err(Error::UnexpectedResponse(_)) + )); } } diff --git a/crates/canton-admin/tests/inprocess.rs b/crates/canton-admin/tests/inprocess.rs index 4d14a0d..680bb5a 100644 --- a/crates/canton-admin/tests/inprocess.rs +++ b/crates/canton-admin/tests/inprocess.rs @@ -32,6 +32,9 @@ struct MockParty { allocate_calls: Arc, allocate_fails: bool, deny: bool, + /// Hand back the token that was sent, forever — a participant that never + /// advances its cursor. + repeat_page_token: bool, } fn party(n: usize) -> pb::PartyDetails { @@ -60,7 +63,14 @@ impl PartyManagementService for MockParty { if self.deny { return Err(Status::permission_denied("needs ParticipantAdmin")); } - let (party_details, next_page_token) = match request.into_inner().page_token.as_str() { + let sent = request.into_inner().page_token; + if self.repeat_page_token && !sent.is_empty() { + return Ok(Response::new(pb::ListKnownPartiesResponse { + party_details: vec![party(9)], + next_page_token: sent, + })); + } + let (party_details, next_page_token) = match sent.as_str() { "" => (vec![party(0), party(1)], "p1".to_string()), "p1" => (vec![party(2), party(3)], "p2".to_string()), "p2" => (vec![party(4)], String::new()), @@ -416,3 +426,31 @@ async fn acting_parties_keeps_only_can_act_as() { // And the full rights list is available for callers that need the wildcard. assert_eq!(client.current_user_rights().await.unwrap().len(), 4); } + +#[tokio::test] +async fn a_participant_that_repeats_its_page_token_is_an_error_not_a_short_list() { + let endpoint = start_party_server(MockParty { + repeat_page_token: true, + ..Default::default() + }) + .await; + let client = AdminClient::connect_lazy(Config::new(endpoint)).unwrap(); + + let error = client + .list_known_parties() + .await + .expect_err("a cursor that never advances cannot be followed"); + + // The alternative failure modes are both worse: looping forever, or + // returning a prefix of the party list with nothing to mark it as one. + // "Which parties exist" is a question whose wrong answer looks exactly + // like a right one. + assert!( + matches!(error, canton_admin::Error::UnexpectedResponse(_)), + "got {error:?}" + ); + assert!( + format!("{error}").contains("repeated the same page token"), + "{error}" + ); +} diff --git a/crates/canton-admin/tests/live.rs b/crates/canton-admin/tests/live.rs index 7e6ed86..abadc5d 100644 --- a/crates/canton-admin/tests/live.rs +++ b/crates/canton-admin/tests/live.rs @@ -18,8 +18,47 @@ use canton_admin::{AdminClient, Config, Store, TopologyClient}; use canton_auth::{OidcConfig, TokenProvider}; +/// Report a live test that could not run. +/// +/// A skipped test and a passing test are the same line in cargo's output, so a +/// live suite that reached no participant reads exactly like one that exercised +/// everything — which is how "28 live tests passed" can be true and mean +/// nothing. Set **`CANTON_TEST_REQUIRE_LIVE=1`**, as any run that claims to have +/// exercised a node should, and a missing environment fails here instead of +/// passing quietly. +macro_rules! skip { + ($($arg:tt)*) => {{ + let reason = format!($($arg)*); + assert!( + std::env::var("CANTON_TEST_REQUIRE_LIVE").is_err(), + "live test skipped while CANTON_TEST_REQUIRE_LIVE is set: {reason}" + ); + eprintln!("SKIP (no live environment): {reason}"); + }}; +} + fn endpoint() -> Option { - std::env::var("CANTON_TEST_ENDPOINT").ok() + std::env::var("CANTON_TEST_ENDPOINT") + .ok() + .or_else(|| canton_core::localnet::grpc_endpoint(None)) +} + +/// A client with whatever ordinary credentials the environment offers: the +/// OIDC client-credentials flow where there is an issuer (cn-quickstart runs +/// Keycloak), otherwise the ready-made token a Splice LocalNet exports. +/// +/// Deliberately **not** used for the party-management tests. Those need the +/// `ParticipantAdmin` right, which here comes from a second, differently +/// privileged OIDC client — and nothing establishes that a LocalNet's exported +/// token carries it. A test that runs and fails with `PermissionDenied` is +/// worse than one that says it is skipping. +fn ordinary_client() -> Option { + let config = Config::new(endpoint()?); + let config = match oidc() { + Some(oidc) => config.with_oidc(TokenProvider::new(oidc)), + None => config.with_token(canton_core::localnet::token(None)?), + }; + AdminClient::connect_lazy(config).ok() } fn admin_endpoint() -> Option { @@ -50,14 +89,10 @@ fn admin_client(config: OidcConfig) -> Option { #[tokio::test] async fn user_self_inspect_reports_the_authenticated_user() { - let (Some(oidc_config), Some(client)) = (oidc(), oidc().and_then(admin_client)) else { - eprintln!( - "skipping user_self_inspect: set CANTON_TEST_ENDPOINT + \ - CANTON_TEST_TOKEN_URL/CLIENT_ID/CLIENT_SECRET" - ); + let Some(client) = ordinary_client() else { + skip!("user_self_inspect: no endpoint or credentials in the environment"); return; }; - let _ = oidc_config; let user = client.current_user().await.expect("current_user"); assert!(!user.id.is_empty(), "authenticated user should have an id"); @@ -74,8 +109,8 @@ async fn user_self_inspect_reports_the_authenticated_user() { #[tokio::test] async fn participant_id_is_returned() { - let Some(client) = oidc().and_then(admin_client) else { - eprintln!("skipping participant_id_is_returned: set CANTON_TEST_ENDPOINT + token env"); + let Some(client) = ordinary_client() else { + skip!("participant_id_is_returned: no endpoint or credentials in the environment"); return; }; @@ -87,8 +122,8 @@ async fn participant_id_is_returned() { #[tokio::test] async fn party_admin_allocate_list_and_get() { let Some(client) = admin_oidc().and_then(admin_client) else { - eprintln!( - "skipping party_admin_allocate_list_and_get: set CANTON_TEST_ENDPOINT + \ + skip!( + "party_admin_allocate_list_and_get: set CANTON_TEST_ENDPOINT + \ CANTON_TEST_ADMIN_CLIENT_ID/CANTON_TEST_ADMIN_CLIENT_SECRET (ParticipantAdmin)" ); return; @@ -146,9 +181,7 @@ async fn party_admin_allocate_list_and_get() { #[tokio::test] async fn topology_reads_return_mappings() { let Some(admin_ep) = admin_endpoint() else { - eprintln!( - "skipping topology_reads_return_mappings: set CANTON_TEST_ADMIN_ENDPOINT (:3902)" - ); + skip!("topology_reads_return_mappings: set CANTON_TEST_ADMIN_ENDPOINT (:3902)"); return; }; @@ -214,8 +247,8 @@ fn uuid_like() -> String { async fn packages_read_lists_and_reports_status() { use canton_admin::PackageStatus; - let Some(client) = oidc().and_then(admin_client) else { - eprintln!("skipping packages_read_lists_and_reports_status: set endpoint + token env"); + let Some(client) = ordinary_client() else { + skip!("packages_read_lists_and_reports_status: no endpoint or credentials"); return; }; diff --git a/crates/canton-auth/Cargo.toml b/crates/canton-auth/Cargo.toml index 224b332..09b3201 100644 --- a/crates/canton-auth/Cargo.toml +++ b/crates/canton-auth/Cargo.toml @@ -11,6 +11,7 @@ homepage = { workspace = true } categories = { workspace = true } keywords = { workspace = true } description = "JWT/OIDC authentication for the Canton Rust SDK (client-credentials, caching, refresh)." +include = ["src/**/*", "README.md", "LICENSE"] [dependencies] canton-core = { workspace = true } @@ -21,7 +22,7 @@ tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } [dev-dependencies] -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util"] } [lints] workspace = true diff --git a/crates/canton-auth/LICENSE b/crates/canton-auth/LICENSE new file mode 100644 index 0000000..2ad0721 --- /dev/null +++ b/crates/canton-auth/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NODEJUMPER + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/canton-auth/src/lib.rs b/crates/canton-auth/src/lib.rs index e4ab659..496489e 100644 --- a/crates/canton-auth/src/lib.rs +++ b/crates/canton-auth/src/lib.rs @@ -74,6 +74,28 @@ pub struct OidcConfig { // `Debug`), so it cannot leak via `println!`/serialization by accident. client_secret: String, scope: Option, + client_auth: ClientAuth, + audience: Option, +} + +/// How the client credentials are presented to the token endpoint. +/// +/// OAuth 2.0 defines both and RFC 6749 §2.3.1 says a server *must* support the +/// `Basic` form; providers differ in what they accept, and picking the wrong +/// one is rejected as `invalid_client` — which reads like a wrong secret. The +/// provider presets choose for you; this is here for a custom endpoint that +/// wants the other one. `#[non_exhaustive]`: a provider may need a third form +/// (a signed assertion, say) without that being a breaking change. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum ClientAuth { + /// `client_secret_post` — id and secret in the request body. The default, + /// and what Keycloak and Auth0 accept. + #[default] + Post, + /// `client_secret_basic` — id and secret in an HTTP `Authorization: Basic` + /// header. Okta's default for a confidential client. + Basic, } impl OidcConfig { @@ -89,6 +111,8 @@ impl OidcConfig { client_id: client_id.into(), client_secret: client_secret.into(), scope: None, + client_auth: ClientAuth::Post, + audience: None, } } @@ -99,6 +123,40 @@ impl OidcConfig { self } + /// Choose how the client credentials are presented ([`ClientAuth`]). + /// + /// The provider presets set this themselves; reach for it when pointing + /// [`OidcConfig::new`] at an endpoint that wants the other form. + #[must_use] + pub fn with_client_auth(mut self, client_auth: ClientAuth) -> Self { + self.client_auth = client_auth; + self + } + + /// Set the `audience` parameter of the token request — the API the token + /// is being requested *for*. + /// + /// Auth0 requires it (without one it issues an opaque token for its own + /// userinfo endpoint, which a participant cannot verify). Most other + /// providers ignore it. + #[must_use] + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// How the client credentials are presented to the token endpoint. + #[must_use] + pub fn client_auth(&self) -> ClientAuth { + self.client_auth + } + + /// The `audience` this configuration requests a token for, if any. + #[must_use] + pub fn audience(&self) -> Option<&str> { + self.audience.as_deref() + } + /// The OAuth2 token endpoint URL. #[must_use] pub fn token_url(&self) -> &str { @@ -131,10 +189,18 @@ impl OidcConfig { ) } - /// Preset for **Auth0**: builds the `https://{domain}/oauth/token` endpoint. + /// Preset for **Auth0**: builds the `https://{domain}/oauth/token` + /// endpoint and requests a token for `audience`. + /// + /// The audience is the identifier of the Auth0 API the participant + /// validates against — it cannot be derived from the domain, and Auth0 + /// answers a client-credentials request without one by issuing a token for + /// its own userinfo endpoint, which the participant will reject. Asking + /// for it here is what makes this preset produce Auth0's normal request. #[must_use] pub fn auth0( domain: impl AsRef, + audience: impl Into, client_id: impl Into, client_secret: impl Into, ) -> Self { @@ -144,11 +210,17 @@ impl OidcConfig { client_id, client_secret, ) + .with_audience(audience) } /// Preset for **Okta**: builds the /// `https://{domain}/oauth2/{auth_server}/v1/token` endpoint (use - /// `"default"` for the default authorization server). + /// `"default"` for the default authorization server) and presents the + /// credentials as HTTP Basic, which is Okta's default for a confidential + /// client. + /// + /// An Okta app can be configured to accept them in the body instead; say + /// so with `.with_client_auth(ClientAuth::Post)`. #[must_use] pub fn okta( domain: impl AsRef, @@ -162,21 +234,30 @@ impl OidcConfig { client_id, client_secret, ) + .with_client_auth(ClientAuth::Basic) } } impl fmt::Debug for OidcConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OidcConfig") - .field("token_url", &self.token_url) + // Redacted for the same reason the secret is: an identity provider + // that takes client credentials as basic auth is configured as + // `https://id:secret@idp/token`, which puts the secret in the URL. + .field("token_url", &canton_core::redact_url(&self.token_url)) .field("client_id", &self.client_id) .field("client_secret", &"") .field("scope", &self.scope) + .field("client_auth", &self.client_auth) + .field("audience", &self.audience) .finish() } } -#[derive(Debug, Deserialize)] +/// No `Debug`: the struct is one bearer token and a number, so deriving it +/// would leave a `{resp:?}` one edit away from putting a live credential in a +/// log line. Nothing prints it today, and nothing should need to. +#[derive(Deserialize)] struct TokenResponse { access_token: String, #[serde(default)] @@ -274,29 +355,36 @@ impl TokenProvider { async fn fetch(&self) -> Result { let config = &self.inner.config; - let mut params = vec![ - ("grant_type", "client_credentials"), - ("client_id", config.client_id.as_str()), - ("client_secret", config.client_secret.as_str()), - ]; + let mut params = vec![("grant_type", "client_credentials")]; + if config.client_auth == ClientAuth::Post { + params.push(("client_id", config.client_id.as_str())); + params.push(("client_secret", config.client_secret.as_str())); + } if let Some(scope) = &config.scope { params.push(("scope", scope.as_str())); } + if let Some(audience) = &config.audience { + params.push(("audience", audience.as_str())); + } // A send failure means the IdP was unreachable — retriable transport, // not a credential rejection. The per-request timeout bounds the fetch // even if the client was built without one. - let response = self + let mut request = self .inner .http .post(&config.token_url) .timeout(FETCH_TIMEOUT) - .form(¶ms) - .send() - .await - .map_err(|e| { - Error::Connection(format!("token request to {} failed: {e}", config.token_url)) - })?; + .form(¶ms); + if config.client_auth == ClientAuth::Basic { + request = request.basic_auth(&config.client_id, Some(&config.client_secret)); + } + let response = request.send().await.map_err(|e| { + Error::Connection(format!( + "token request to {} failed: {e}", + canton_core::redact_url(&config.token_url) + )) + })?; // A credential rejection (401/403, e.g. `invalid_client`) is a definite // auth failure; other non-success statuses keep their code so 5xx/429 @@ -377,6 +465,28 @@ mod tests { assert!(rendered.contains("my-client")); } + /// The dedicated `client_secret` field is not the only way a secret gets + /// into this type. Providers that take client credentials as basic auth are + /// configured as `https://id:secret@idp/token`, and that URL was being + /// printed whole. + #[test] + fn debug_redacts_a_secret_carried_in_the_token_url() { + let config = OidcConfig::new( + "https://my-client:URL-EMBEDDED-SECRET@idp.example/realms/r/token", + "my-client", + "TOP-SECRET-VALUE", + ); + let rendered = format!("{config:?}"); + assert!( + !rendered.contains("URL-EMBEDDED-SECRET"), + "leaked via the token url: {rendered}" + ); + assert!( + rendered.contains("idp.example"), + "should keep the provider host: {rendered}" + ); + } + #[test] fn provider_debug_does_not_leak_the_secret() { let rendered = format!("{:?}", TokenProvider::new(sample_config())); @@ -390,7 +500,7 @@ mod tests { "http://kc:8082/realms/AppProvider/protocol/openid-connect/token" ); assert_eq!( - OidcConfig::auth0("my.eu.auth0.com", "c", "s").token_url, + OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "c", "s").token_url, "https://my.eu.auth0.com/oauth/token" ); assert_eq!( diff --git a/crates/canton-auth/tests/preset_requests.rs b/crates/canton-auth/tests/preset_requests.rs new file mode 100644 index 0000000..16eed2c --- /dev/null +++ b/crates/canton-auth/tests/preset_requests.rs @@ -0,0 +1,124 @@ +//! What each advertised provider preset actually puts on the wire. +//! +//! A preset's job is to produce its provider's *normal* client-credentials +//! request. The token URL is only part of that: Okta expects the credentials in +//! an `Authorization: Basic` header and rejects them in the body as +//! `invalid_client`, while Auth0 needs an `audience` or it issues a token for +//! its own userinfo endpoint that no participant will accept. Neither shows up +//! in an assertion about the URL. +//! +//! Each test builds the preset, carries its settings onto a local capture +//! server (a preset's own `https://` host cannot be pointed at one), and reads +//! the request off the socket. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use canton_auth::{ClientAuth, OidcConfig, TokenProvider}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; + +const SECRET: &str = "s"; + +/// A token endpoint that answers one request and hands back what it received. +async fn capture_endpoint() -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(None)); + let captured = seen.clone(); + tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut buf = vec![0u8; 4096]; + let read = socket.read(&mut buf).await.unwrap_or(0); + *captured.lock().unwrap() = Some(String::from_utf8_lossy(&buf[..read]).into_owned()); + let body = r#"{"access_token":"t","expires_in":300}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.shutdown().await; + }); + tokio::time::sleep(Duration::from_millis(100)).await; + (format!("http://localhost:{port}/token"), seen) +} + +/// The preset's request, aimed at a local endpoint: same credential placement, +/// same audience, different host. If a preset stops setting one of those, the +/// request asserted below changes with it. +async fn request_of(preset: &OidcConfig) -> String { + let (url, seen) = capture_endpoint().await; + let mut config = + OidcConfig::new(url, preset.client_id(), SECRET).with_client_auth(preset.client_auth()); + if let Some(audience) = preset.audience() { + config = config.with_audience(audience); + } + TokenProvider::new(config) + .token() + .await + .expect("the capture endpoint issues a token"); + let request = seen.lock().unwrap().clone(); + request.expect("the endpoint saw a request") +} + +#[tokio::test] +async fn keycloak_sends_its_credentials_in_the_body() { + let preset = OidcConfig::keycloak("http://kc:8082/", "AppProvider", "c", SECRET); + assert_eq!( + preset.token_url(), + "http://kc:8082/realms/AppProvider/protocol/openid-connect/token" + ); + assert_eq!(preset.client_auth(), ClientAuth::Post); + + let request = request_of(&preset).await; + assert!(request.contains("client_id=c"), "{request}"); + assert!(request.contains("client_secret=s"), "{request}"); + assert!( + !request.to_lowercase().contains("authorization:"), + "the body form is not accompanied by a Basic header: {request}" + ); +} + +#[tokio::test] +async fn auth0_asks_for_the_audience_it_was_given() { + let preset = OidcConfig::auth0("my.eu.auth0.com", "https://ledger.example", "c", SECRET); + assert_eq!(preset.token_url(), "https://my.eu.auth0.com/oauth/token"); + assert_eq!(preset.audience(), Some("https://ledger.example")); + + let request = request_of(&preset).await; + assert!( + request.contains("audience=https%3A%2F%2Fledger.example"), + "Auth0's request carries the audience, url-encoded: {request}" + ); + assert!(request.contains("client_secret=s"), "{request}"); +} + +#[tokio::test] +async fn okta_presents_its_credentials_as_basic_auth() { + let preset = OidcConfig::okta("my.okta.com", "default", "c", SECRET); + assert_eq!( + preset.token_url(), + "https://my.okta.com/oauth2/default/v1/token" + ); + assert_eq!(preset.client_auth(), ClientAuth::Basic); + + let request = request_of(&preset).await; + // base64("c:s") — spelled out rather than computed, so the test fails if + // the header stops being what Okta reads. + assert!( + request.contains("authorization: Basic Yzpz") + || request.contains("Authorization: Basic Yzpz"), + "Okta's credentials belong in the header: {request}" + ); + assert!( + !request.contains("client_secret="), + "and must not also be in the body: {request}" + ); + assert!( + request.contains("grant_type=client_credentials"), + "{request}" + ); +} diff --git a/crates/canton-codegen-cli/Cargo.toml b/crates/canton-codegen-cli/Cargo.toml new file mode 100644 index 0000000..1dab60e --- /dev/null +++ b/crates/canton-codegen-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "canton-codegen-cli" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +readme = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +categories = ["development-tools", "command-line-utilities"] +keywords = ["canton", "daml", "codegen", "dar", "dpm"] +description = "dpm codegen-rust: generate a typed Rust crate of bindings from a DAR." +include = ["src/**/*", "README.md", "LICENSE"] + +# A binary-only crate: the codegen pipeline itself is the `canton-codegen` +# library (so a build script can call it without pulling in a CLI). +# Invoked as `dpm codegen-rust` (dpm's git/cargo-style subcommand convention), +# or standalone as `dpm-codegen-rust`. +[[bin]] +name = "dpm-codegen-rust" +path = "src/main.rs" + +[dependencies] +canton-codegen = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/crates/canton-codegen-cli/LICENSE b/crates/canton-codegen-cli/LICENSE new file mode 100644 index 0000000..2ad0721 --- /dev/null +++ b/crates/canton-codegen-cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NODEJUMPER + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/canton-codegen-cli/src/main.rs b/crates/canton-codegen-cli/src/main.rs new file mode 100644 index 0000000..cfc8d95 --- /dev/null +++ b/crates/canton-codegen-cli/src/main.rs @@ -0,0 +1,170 @@ +//! `dpm-codegen-rust` — generate a typed Rust crate from a DAR. +//! +//! Invoked as `dpm codegen-rust` (dpm resolves subcommands to `dpm-` +//! binaries, like git/cargo) or standalone. The pipeline itself lives in the +//! `canton-codegen` library, so a build script can call it directly the way +//! `prost-build` is used, without depending on this CLI. + +use std::path::PathBuf; +use std::process::ExitCode; + +use canton_codegen::{GenerateError, Options, Runtime, generate}; + +const USAGE: &str = "\ +dpm-codegen-rust — generate a typed Rust crate from a DAR + +USAGE: + dpm-codegen-rust --dar --out [OPTIONS] + +OPTIONS: + --dar Input .dar (with its dependency closure) + --out Output crate directory (created if absent) + --name Generated crate name [default: derived from the DAR] + --runtime-path Depend on canton-daml by path (default: by version) + --runtime-version canton-daml version requirement [default: matches this tool] + --force Overwrite output files not generated by this tool + -h, --help Print this help + -V, --version Print the version +"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + // Print the whole cause chain: the top-level message names the + // operation, the source names the underlying failure. + eprint!("error: {error}"); + let mut cause = std::error::Error::source(&*error); + while let Some(error) = cause { + eprint!(": {error}"); + cause = error.source(); + } + eprintln!(); + ExitCode::FAILURE + } + } +} + +/// A CLI-level failure: bad arguments, or a generation error. +#[derive(Debug, thiserror::Error)] +enum CliError { + #[error("{0}\n\n{USAGE}")] + Usage(String), + #[error(transparent)] + Generate(#[from] GenerateError), +} + +fn run() -> Result<(), Box> { + let mut dar: Option = None; + let mut out: Option = None; + let mut name: Option = None; + let mut runtime_path: Option = None; + let mut runtime_version: Option = None; + let mut force = false; + + let usage = |message: String| Box::new(CliError::Usage(message)); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + // Accept both `--flag value` and `--flag=value`. + let (flag, mut inline) = match arg.split_once('=') { + Some((flag, value)) => (flag.to_string(), Some(value.to_string())), + None => (arg, None), + }; + let mut value = |args: &mut dyn Iterator, flag: &str| match inline.take() { + Some(inline) => Ok(inline), + None => args + .next() + .ok_or_else(|| usage(format!("{flag} needs a value"))), + }; + match flag.as_str() { + "-h" | "--help" | "-V" | "--version" => { + if let Some(value) = inline.take() { + return Err(usage(format!("{flag} takes no value (got `={value}`)"))); + } + if flag == "-h" || flag == "--help" { + print!("{USAGE}"); + } else { + println!("dpm-codegen-rust {}", env!("CARGO_PKG_VERSION")); + } + return Ok(()); + } + // A boolean flag takes no value: `--force=false` must not be read + // as "enable force" (it would destroy the caller's files). + "--force" => { + if let Some(value) = inline.take() { + return Err(usage(format!( + "--force is a flag and takes no value (got `={value}`)" + ))); + } + force = true; + } + "--dar" => dar = Some(PathBuf::from(value(&mut args, "--dar")?)), + "--out" => out = Some(PathBuf::from(value(&mut args, "--out")?)), + "--name" => name = Some(value(&mut args, "--name")?), + "--runtime-path" => { + runtime_path = Some(PathBuf::from(value(&mut args, "--runtime-path")?)); + } + "--runtime-version" => { + runtime_version = Some(value(&mut args, "--runtime-version")?); + } + other => return Err(usage(format!("unexpected argument `{other}`"))), + } + } + + let dar = dar.ok_or_else(|| usage("missing required --dar".to_string()))?; + let out = out.ok_or_else(|| usage("missing required --out".to_string()))?; + + let mut options = Options::new(dar, out.clone()).with_force(force); + if let Some(name) = name { + options = options.with_crate_name(name); + } + // A path dependency and a version requirement are two answers to one + // question. Taking the path and dropping the version silently would write a + // manifest the user did not ask for and give them no way to notice. + if runtime_path.is_some() && runtime_version.is_some() { + return Err(usage( + "--runtime-path and --runtime-version both set the `canton-daml` dependency; pass one" + .to_string(), + )); + } + let by_version = runtime_path.is_none(); + if let Some(path) = runtime_path { + options = options.with_runtime(Runtime::Path(path)); + } else if let Some(requirement) = runtime_version { + options = options.with_runtime(Runtime::Version(requirement)); + } + let crate_name = options.crate_name(); + + let stats = generate(&options) + .map_err(CliError::from) + .map_err(Box::new)?; + + eprintln!( + "generated `{crate_name}` in {}: {} packages / {} modules / {} items / {} KB", + out.display(), + stats.packages, + stats.modules, + stats.items, + stats.bytes / 1024, + ); + if by_version { + eprintln!( + "note: the crate depends on `canton-daml` from crates.io; \ + if it fails to resolve, re-run with --runtime-path /crates/canton-daml" + ); + } + if !stats.skipped.is_empty() { + eprintln!( + "warning: {} declaration(s) could not be lowered and were skipped:", + stats.skipped.len() + ); + for skipped in stats.skipped.iter().take(20) { + eprintln!(" - {skipped}"); + } + if stats.skipped.len() > 20 { + eprintln!(" … and {} more", stats.skipped.len() - 20); + } + } + Ok(()) +} diff --git a/crates/canton-codegen-cli/tests/cli.rs b/crates/canton-codegen-cli/tests/cli.rs new file mode 100644 index 0000000..c981342 --- /dev/null +++ b/crates/canton-codegen-cli/tests/cli.rs @@ -0,0 +1,219 @@ +//! Tests that run the actual `dpm-codegen-rust` binary — argument parsing, +//! exit codes, and the guarantees a user relies on at the command line. +//! +//! The generation pipeline itself is tested in `canton-codegen`; what is +//! covered here is only reachable by executing the binary. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// The DAR fixture, and a scratch directory unique to each test. +fn fixture() -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../testdata/splice-api-token-holding-v1-1.0.0.dar" + )) +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dpm-codegen-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_dpm-codegen-rust")) + .args(args) + .output() + .expect("run the binary") +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// A directory holding a file the user wrote, which the tool must not destroy. +fn with_user_file(dir: &Path) -> PathBuf { + let lib = dir.join("src/lib.rs"); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write(&lib, "fn user_wrote_this() {}").unwrap(); + std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"mine\"\n").unwrap(); + lib +} + +#[test] +fn boolean_flags_reject_an_inline_value() { + // Regression: `--force=false` was parsed as "enable --force", so a user + // trying to *disable* it had their files overwritten instead. + let dir = scratch("force-eq"); + let user_file = with_user_file(&dir); + + let dar = fixture(); + let output = run(&[ + "--dar", + dar.to_str().unwrap(), + "--out", + dir.to_str().unwrap(), + "--force=false", + ]); + + assert!(!output.status.success(), "must fail, not silently force"); + assert!( + stderr(&output).contains("takes no value"), + "{}", + stderr(&output) + ); + assert_eq!( + std::fs::read_to_string(&user_file).unwrap(), + "fn user_wrote_this() {}", + "the user's file must be untouched" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn refuses_to_clobber_a_foreign_crate_then_obeys_force() { + let dir = scratch("clobber"); + let user_file = with_user_file(&dir); + let dar = fixture(); + let args = [ + "--dar", + dar.to_str().unwrap(), + "--out", + dir.to_str().unwrap(), + "--runtime-path", + concat!(env!("CARGO_MANIFEST_DIR"), "/../canton-daml"), + ]; + + let refused = run(&args); + assert!(!refused.status.success()); + assert!( + stderr(&refused).contains("not generated by this tool"), + "{}", + stderr(&refused) + ); + assert_eq!( + std::fs::read_to_string(&user_file).unwrap(), + "fn user_wrote_this() {}" + ); + + // With --force the same invocation proceeds. + let mut forced: Vec<&str> = args.to_vec(); + forced.push("--force"); + let output = run(&forced); + assert!(output.status.success(), "{}", stderr(&output)); + assert!( + std::fs::read_to_string(&user_file) + .unwrap() + .contains("Typed Rust bindings generated from a Daml archive") + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn generates_and_is_idempotent() { + let dir = scratch("generate"); + let dar = fixture(); + let args = [ + "--dar", + dar.to_str().unwrap(), + "--out", + dir.to_str().unwrap(), + "--runtime-path", + concat!(env!("CARGO_MANIFEST_DIR"), "/../canton-daml"), + ]; + + let first = run(&args); + assert!(first.status.success(), "{}", stderr(&first)); + assert!(stderr(&first).contains("generated"), "{}", stderr(&first)); + let source = std::fs::read_to_string(dir.join("src/lib.rs")).unwrap(); + + // Re-running over the tool's own output needs no --force and is stable. + let second = run(&args); + assert!(second.status.success(), "{}", stderr(&second)); + assert_eq!( + source, + std::fs::read_to_string(dir.join("src/lib.rs")).unwrap() + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn usage_errors_are_actionable_and_exit_nonzero() { + // Missing arguments, unknown flags, and a flag with no value all print the + // usage text rather than a bare message. + for args in [ + vec!["--dar", "x.dar"], // missing --out + vec!["--out", "x"], // missing --dar + vec!["--nonsense"], // unknown flag + vec!["--dar"], // flag with no value + ] { + let output = run(&args); + assert!(!output.status.success(), "{args:?} should fail"); + let message = stderr(&output); + assert!(message.contains("USAGE:"), "{args:?}: {message}"); + } +} + +#[test] +fn two_ways_of_naming_the_runtime_dependency_is_a_usage_error() { + // Both flags answer the same question. Honouring the path and dropping the + // version — the same silent swallow of an explicit intent that `--force=false` + // was — writes a manifest the user did not ask for, with nothing to notice. + let dir = scratch("runtime-conflict"); + let output = run(&[ + "--dar", + fixture().to_str().unwrap(), + "--out", + dir.join("out").to_str().unwrap(), + "--runtime-path", + "/somewhere/canton-daml", + "--runtime-version", + "0.2", + ]); + assert!(!output.status.success(), "should be rejected"); + let message = stderr(&output); + assert!(message.contains("--runtime-path"), "{message}"); + assert!(message.contains("--runtime-version"), "{message}"); + assert!( + !dir.join("out").exists(), + "nothing should be written when the arguments are rejected" + ); +} + +#[test] +fn help_and_version_succeed() { + let help = run(&["--help"]); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("--runtime-path")); + + let version = run(&["-V"]); + assert!(version.status.success()); + assert!( + String::from_utf8_lossy(&version.stdout).contains(env!("CARGO_PKG_VERSION")), + "version output should name the crate version" + ); +} + +#[test] +fn a_non_dar_input_fails_with_the_path_and_a_hint() { + let dir = scratch("nondar"); + let fake = dir.join("fake.dar"); + std::fs::write(&fake, "not a zip").unwrap(); + + let output = run(&[ + "--dar", + fake.to_str().unwrap(), + "--out", + dir.join("out").to_str().unwrap(), + ]); + assert!(!output.status.success()); + let message = stderr(&output); + // The whole cause chain is printed: what we were doing, and why it failed. + assert!(message.contains("fake.dar"), "{message}"); + assert!(message.contains("daml build"), "{message}"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/canton-codegen/Cargo.toml b/crates/canton-codegen/Cargo.toml new file mode 100644 index 0000000..feb58ac --- /dev/null +++ b/crates/canton-codegen/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "canton-codegen" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +readme = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +categories = ["development-tools", "api-bindings"] +keywords = ["canton", "daml", "codegen", "dar", "bindings"] +description = "Type-safe Rust code generation from Daml packages (DAR to Rust)." +include = ["src/**/*", "examples/**/*", "README.md", "LICENSE"] + +[dependencies] +thiserror = { workspace = true } +canton-lf = { workspace = true } +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } +prettyplease = { workspace = true } +heck = { workspace = true } +semver = { workspace = true } + +[dev-dependencies] +# The drift guard compares regenerated vs committed bindings at the AST level. +canton-lf = { workspace = true } + +[lints] +workspace = true diff --git a/crates/canton-codegen/LICENSE b/crates/canton-codegen/LICENSE new file mode 100644 index 0000000..2ad0721 --- /dev/null +++ b/crates/canton-codegen/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 NODEJUMPER + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/canton-codegen/src/emit.rs b/crates/canton-codegen/src/emit.rs new file mode 100644 index 0000000..d63c68f --- /dev/null +++ b/crates/canton-codegen/src/emit.rs @@ -0,0 +1,809 @@ +//! Emit Rust source items from the [`crate::ir`] types. + +use heck::{ToSnakeCase, ToUpperCamelCase}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; + +use crate::ir::{ + Choice, Crate, DamlType, DataType, Enum, Interface, Module, NamedModule, PackageModule, Record, + Template, Variant, +}; +use crate::map::rust_type; + +/// Emit the Rust item(s) for a named data type (record, variant, or enum). +#[must_use] +pub(crate) fn data_type(data_type: &DataType) -> TokenStream { + match data_type { + DataType::Record(record) => record_items(record), + DataType::Variant(variant) => variant_items(variant), + DataType::Enum(enumeration) => enum_items(enumeration), + DataType::InterfaceMarker(name) => interface_marker(name), + } +} + +/// Emit an interface **marker**: a phantom tag `struct` that only ever appears +/// as the type argument of a `ContractId` (which is unconditional in its tag), +/// so it needs no derives or codecs of its own. +#[must_use] +fn interface_marker(name: &str) -> TokenStream { + let name = type_ident(name); + let doc = format!("Marker for the Daml interface `{name}` (held via `ContractId`)."); + quote! { + #[doc = #doc] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct #name; + } +} + +/// Emit every item of a module — its data types, then its templates. +#[must_use] +pub(crate) fn module_items(module: &Module) -> TokenStream { + let data_types = module.data_types.iter().map(data_type); + let templates = module.templates.iter().map(template); + let interfaces = module.interfaces.iter().map(interface); + quote! { + #(#data_types)* + #(#templates)* + #(#interfaces)* + } +} + +/// Emit the module tree for a whole generated crate: one `pub mod` per package, +/// one submodule per Daml module. Cross-module and cross-package references +/// resolve through the `crate::::::` paths the lowering +/// produced, and names from different modules cannot collide. +#[must_use] +pub(crate) fn crate_items(krate: &Crate) -> TokenStream { + let packages = krate.packages.iter().map(package_module); + quote! { + #(#packages)* + } +} + +/// Emit one package as a `pub mod`, wrapping its Daml modules. +fn package_module(package: &PackageModule) -> TokenStream { + let name = ident(&package.name); + let modules = package.modules.iter().map(named_module); + quote! { + pub mod #name { + #(#modules)* + } + } +} + +/// Emit one Daml module as a `pub mod`, aliasing the runtime as `rt` so the +/// module's `rt::…` references resolve locally. +fn named_module(module: &NamedModule) -> TokenStream { + let name = ident(&module.name); + let items = module_items(&module.module); + quote! { + pub mod #name { + use canton_daml as rt; + + #items + } + } +} + +/// A doc line for a generated item, but only where the Rust identifier had to +/// be spelled differently from the Daml one. +/// +/// Where the two agree the doc would only restate the identifier next to it — +/// noise on every field of every record on docs.rs. The `#[serde(rename)]` +/// beside it records the wire label either way. +fn renamed_doc(rust_name: &Ident, daml_name: &str, kind: &str) -> TokenStream { + if rust_name.to_string().trim_start_matches("r#") == daml_name { + return TokenStream::new(); + } + let doc = format!("Daml {kind} `{daml_name}`."); + quote!(#[doc = #doc]) +} + +/// Generate the `struct` for a record data type (also used for template +/// payloads). Field names are snake-cased for Rust; the original Daml label is +/// pinned on the wire by the emitted codecs (the gRPC `Record` labels, and +/// `serde(rename)` for JSON) and appears in a doc only where the two differ. +#[must_use] +pub(crate) fn record_struct(record: &Record) -> TokenStream { + let name = type_ident(&record.name); + let generics = generics(&record.type_params); + let fields = record.fields.iter().map(|field| { + let field_name = field_ident(&field.label); + let ty = rust_type(&field.ty); + let label = &field.label; + let doc = renamed_doc(&field_name, label, "field"); + quote! { + #doc + #[serde(rename = #label)] + pub #field_name: #ty, + } + }); + let phantom = phantom_field(&record.type_params, record.fields.iter().map(|f| &f.ty)); + + quote! { + #[derive(Clone, Debug, PartialEq, Eq, rt::serde::Serialize, rt::serde::Deserialize)] + #[serde(crate = "rt::serde")] + pub struct #name #generics { + #(#fields)* + #phantom + } + } +} + +/// A hidden `PhantomData` field binding any type parameters that a generic +/// record declares but never uses in its fields (Daml permits such phantom +/// parameters; Rust rejects an unused type parameter). Empty when every +/// parameter is used. The field is `#[serde(skip)]` and ignored by the `Value` +/// codec, so it never touches the wire form. +fn phantom_field<'a>( + type_params: &'a [String], + field_types: impl Iterator, +) -> TokenStream { + let unused = unused_params(type_params, field_types); + if unused.is_empty() { + return quote!(); + } + let params = unused.iter().map(|param| type_var_ident(param)); + quote! { + #[doc(hidden)] + #[serde(skip)] + pub _phantom: ::core::marker::PhantomData<(#(#params,)*)>, + } +} + +/// The `_phantom` field initializer for a record's `FromValue`/constructor form, +/// or empty when the record has no phantom parameters. +fn phantom_init<'a>( + type_params: &'a [String], + field_types: impl Iterator, +) -> TokenStream { + if unused_params(type_params, field_types).is_empty() { + quote!() + } else { + quote! { _phantom: ::core::marker::PhantomData, } + } +} + +/// The type parameters a generic type declares but never uses in the given field +/// types (Daml's phantom parameters). +fn unused_params<'a>( + type_params: &'a [String], + field_types: impl Iterator, +) -> Vec<&'a String> { + let mut used = std::collections::BTreeSet::new(); + for ty in field_types { + collect_type_vars(ty, &mut used); + } + type_params + .iter() + .filter(|param| !used.contains(*param)) + .collect() +} + +/// Collect the names of every type variable referenced anywhere in `ty`. +fn collect_type_vars(ty: &DamlType, out: &mut std::collections::BTreeSet) { + match ty { + DamlType::Var(name) => { + out.insert(name.clone()); + } + DamlType::ContractId(inner) + | DamlType::List(inner) + | DamlType::Optional(inner) + | DamlType::TextMap(inner) + | DamlType::Boxed(inner) => collect_type_vars(inner, out), + DamlType::GenMap(key, value) => { + collect_type_vars(key, out); + collect_type_vars(value, out); + } + DamlType::Ref(reference) => { + for arg in &reference.args { + collect_type_vars(arg, out); + } + } + _ => {} + } +} + +/// Emit a record's `struct` together with its `ToValue`/`FromValue` codecs. +#[must_use] +pub(crate) fn record_items(record: &Record) -> TokenStream { + let structure = record_struct(record); + let codecs = record_codecs(record); + quote! { + #structure + #codecs + } +} + +/// Emit the `ToValue`/`FromValue` impls mapping a record to a Ledger API +/// `Record` value (each field keyed by its Daml label). Generic records get the +/// impls too, bounded by the trait on every type parameter. +/// +/// Decoding is robust to the two shapes Canton legitimately produces: fields +/// are located by label *or* declaration index (non-verbose output omits +/// labels), and an absent `Optional` field decodes as `None` (normalized +/// records omit trailing empty optionals under Smart Contract Upgrade). +fn record_codecs(record: &Record) -> TokenStream { + let name = type_ident(&record.name); + let to_fields = record.fields.iter().map(|field| { + let label = &field.label; + let ident = field_ident(&field.label); + quote! { (#label, rt::ToValue::to_value(&self.#ident)), } + }); + let from_fields = record.fields.iter().enumerate().map(|(index, field)| { + let label = &field.label; + let ident = field_ident(&field.label); + // `.at(label)` on the *decode* of the field, not on locating it: + // `required_field` already names the field it could not find, while a + // failure inside `from_value` — the wrong type three records down — + // otherwise arrives as a bare "expected Text" with no way to tell which + // field it came from. Each layer prepends its own label as the error + // travels up, so the path reads `owner.address.city`. + if matches!(field.ty, DamlType::Optional(_)) { + // Absent (normalized-away) optional fields decode as `None`. + quote! { #ident: rt::optional_field(value, #index, #label).map_err(|e| e.at(#label))?, } + } else { + quote! { + #ident: rt::FromValue::from_value(rt::required_field(value, #index, #label)?) + .map_err(|e| e.at(#label))?, + } + } + }); + + let used = used_params(record.fields.iter().map(|f| &f.ty)); + let (impl_generics, ty, to_where) = + codec_header(&name, &record.type_params, &used, "e!(rt::ToValue)); + let (_, _, from_where) = + codec_header(&name, &record.type_params, &used, "e!(rt::FromValue)); + let phantom = phantom_init(&record.type_params, record.fields.iter().map(|f| &f.ty)); + // A record with no fields never reads the wire value. + let value_binding = ident(if record.fields.is_empty() { + "_value" + } else { + "value" + }); + quote! { + impl #impl_generics rt::ToValue for #ty #to_where { + fn to_value(&self) -> rt::Value { + rt::record(::std::vec![#(#to_fields)*]) + } + } + impl #impl_generics rt::FromValue for #ty #from_where { + fn from_value(#value_binding: &rt::Value) -> ::core::result::Result { + ::core::result::Result::Ok(Self { #(#from_fields)* #phantom }) + } + } + } +} + +/// The `(impl-generics, Self-type, where-clause)` for a codec `impl` on a type +/// that may be generic: `impl Trait for Name where A: Trait, ...`. +/// A non-generic type yields empty generics and no `where`. +fn codec_header( + name: &Ident, + type_params: &[String], + used: &std::collections::BTreeSet, + trait_bound: &TokenStream, +) -> (TokenStream, TokenStream, TokenStream) { + if type_params.is_empty() { + return (TokenStream::new(), quote!(#name), TokenStream::new()); + } + let params = type_params + .iter() + .map(|param| type_var_ident(param)) + .collect::>(); + let impl_generics = quote!(<#(#params),*>); + let ty = quote!(#name<#(#params),*>); + // Bound only the parameters the codec actually touches. Daml permits a + // phantom parameter — declared but used in no field — and bounding one + // would demand a codec from a type this impl never encodes. Interface + // markers are exactly such a type: they carry no codec by design, so + // `Wrapper Holding` for a phantom `Wrapper a` would generate Rust that does + // not compile. + let bounded = type_params + .iter() + .filter(|param| used.contains(*param)) + .map(|param| type_var_ident(param)) + .collect::>(); + let where_clause = if bounded.is_empty() { + TokenStream::new() + } else { + quote!(where #(#bounded: #trait_bound),*) + }; + (impl_generics, ty, where_clause) +} + +/// The type variables a set of field/payload types actually mentions. +fn used_params<'a>( + types: impl Iterator, +) -> std::collections::BTreeSet { + let mut used = std::collections::BTreeSet::new(); + for ty in types { + collect_type_vars(ty, &mut used); + } + used +} + +/// Emit a variant (sum) type as a Rust `enum` — one variant per constructor, +/// carrying the constructor's payload type (or nothing for a nullary one). +#[must_use] +pub(crate) fn variant_enum(variant: &Variant) -> TokenStream { + let name = type_ident(&variant.name); + let generics = generics(&variant.type_params); + let constructors = variant.constructors.iter().map(|ctor| { + let ctor_name = type_ident(&ctor.name); + let label = &ctor.name; + let doc = renamed_doc(&ctor_name, label, "constructor"); + // A nullary constructor carries `Unit`, not nothing: the LF-JSON variant + // form always has a `value`, and a nullary one is `Unit` (`{}`). Emitting + // it as a bare unit variant would serialize to `{"tag":}`, which the + // Ledger API's `{"tag":,"value":{}}` neither matches nor parses. + let payload = ctor + .payload + .as_ref() + .map_or_else(|| quote!(rt::Unit), rust_type); + quote! { + #doc + #[serde(rename = #label)] + #ctor_name(#payload), + } + }); + + // The LF-JSON variant form is `{"tag": , "value": }` — + // serde's adjacently-tagged representation. + quote! { + #[derive(Clone, Debug, PartialEq, Eq, rt::serde::Serialize, rt::serde::Deserialize)] + #[serde(crate = "rt::serde", tag = "tag", content = "value")] + pub enum #name #generics { + #(#constructors)* + } + } +} + +/// Emit a variant type together with its `ToValue`/`FromValue` codecs. +#[must_use] +pub(crate) fn variant_items(variant: &Variant) -> TokenStream { + let structure = variant_enum(variant); + let codecs = variant_codecs(variant); + quote! { + #structure + #codecs + } +} + +/// The gRPC `Value` codecs for a variant (a proto `Variant` — constructor name +/// plus the payload value; a nullary constructor carries `Unit`). +fn variant_codecs(variant: &Variant) -> TokenStream { + let name = type_ident(&variant.name); + let type_name = &variant.name; + // Every constructor is a newtype variant (a nullary one carries `rt::Unit`), + // so both codecs treat the payload uniformly. + let to_arms = variant.constructors.iter().map(|ctor| { + let ctor_name = type_ident(&ctor.name); + let label = &ctor.name; + quote! { #name::#ctor_name(inner) => rt::variant_value(#label, rt::ToValue::to_value(inner)), } + }); + let from_arms = variant.constructors.iter().map(|ctor| { + let ctor_name = type_ident(&ctor.name); + let label = &ctor.name; + quote! { + #label => ::core::result::Result::Ok(#name::#ctor_name( + rt::FromValue::from_value(payload).map_err(|e| e.at(#label))?, + )), + } + }); + + let used = used_params( + variant + .constructors + .iter() + .filter_map(|c| c.payload.as_ref()), + ); + let (impl_generics, ty, to_where) = + codec_header(&name, &variant.type_params, &used, "e!(rt::ToValue)); + let (_, _, from_where) = + codec_header(&name, &variant.type_params, &used, "e!(rt::FromValue)); + quote! { + impl #impl_generics rt::ToValue for #ty #to_where { + fn to_value(&self) -> rt::Value { + match self { + #(#to_arms)* + } + } + } + impl #impl_generics rt::FromValue for #ty #from_where { + fn from_value(value: &rt::Value) -> ::core::result::Result { + let (constructor, payload) = rt::variant_parts(value)?; + match constructor { + #(#from_arms)* + other => ::core::result::Result::Err(rt::unexpected_constructor(#type_name, other)), + } + } + } + } +} + +/// Emit an enumeration as a C-like Rust `enum` (constructors carry no data). +#[must_use] +pub(crate) fn enum_type(enumeration: &Enum) -> TokenStream { + let name = type_ident(&enumeration.name); + let constructors = enumeration.constructors.iter().map(|ctor| { + let ctor_name = type_ident(ctor); + let label = ctor; + let doc = renamed_doc(&ctor_name, label, "constructor"); + quote! { + #doc + #[serde(rename = #label)] + #ctor_name, + } + }); + + // An enum's LF-JSON form is just its constructor name (a string), which is + // serde's default for a fieldless enum. + quote! { + #[derive( + Clone, Copy, Debug, PartialEq, Eq, rt::serde::Serialize, rt::serde::Deserialize, + )] + #[serde(crate = "rt::serde")] + pub enum #name { + #(#constructors)* + } + } +} + +/// Emit an enum type together with its `ToValue`/`FromValue` codecs. +#[must_use] +pub(crate) fn enum_items(enumeration: &Enum) -> TokenStream { + let structure = enum_type(enumeration); + let codecs = enum_codecs(enumeration); + quote! { + #structure + #codecs + } +} + +/// The gRPC `Value` codecs for an enum (a proto `Enum` — the constructor name). +fn enum_codecs(enumeration: &Enum) -> TokenStream { + let name = type_ident(&enumeration.name); + let type_name = &enumeration.name; + let to_arms = enumeration.constructors.iter().map(|ctor| { + let ctor_name = type_ident(ctor); + let label = ctor; + quote! { #name::#ctor_name => #label, } + }); + let from_arms = enumeration.constructors.iter().map(|ctor| { + let ctor_name = type_ident(ctor); + let label = ctor; + quote! { #label => ::core::result::Result::Ok(#name::#ctor_name), } + }); + + quote! { + impl rt::ToValue for #name { + fn to_value(&self) -> rt::Value { + rt::enum_value(match self { #(#to_arms)* }) + } + } + impl rt::FromValue for #name { + fn from_value(value: &rt::Value) -> ::core::result::Result { + match rt::enum_constructor(value)? { + #(#from_arms)* + other => ::core::result::Result::Err(rt::unexpected_constructor(#type_name, other)), + } + } + } + } +} + +/// The doc on a template's payload struct: its on-ledger identity and the +/// choices exercisable on it. +/// +/// rustdoc has no reverse index for `impl Choice for That`, so without +/// this a reader on docs.rs sees a payload struct and no way to discover what +/// can be done with it. +/// +/// One `#[doc]` attribute per line: a single multi-line string is rendered as a +/// `/** … */` block whose continuation lines are indented to match the item, +/// and rustdoc reads a four-space indent as a code block — which would turn the +/// prose into a failing doctest in the user's crate. +fn template_doc(template: &Template) -> TokenStream { + let mut lines = vec![ + format!( + "The Daml template `{}:{}`.", + template.module_name, template.name + ), + String::new(), + format!( + "Submit with `rt::create_command`; its on-ledger id is `#{}:{}:{}`.", + template.package_name, template.module_name, template.name, + ), + ]; + if !template.choices.is_empty() { + lines.extend([ + String::new(), + "# Choices".to_string(), + String::new(), + "Exercise with `rt::exercise_command`:".to_string(), + String::new(), + ]); + for choice in &template.choices { + let consuming = if choice.consuming { + "consuming" + } else { + "non-consuming" + }; + lines.push(format!("- `{}` — {consuming}", choice.name)); + } + } + if template.key.is_some() { + lines.extend([ + String::new(), + "Keyed: also exercisable with `rt::exercise_by_key_command`.".to_string(), + ]); + } + quote!(#(#[doc = #lines])*) +} + +/// Emit a template: its payload `struct`, its on-ledger identity +/// (`rt::Contract` + `rt::Template`), an `rt::WithKey` impl when it is keyed, and +/// a typed `rt::Choice` impl per choice. +#[must_use] +pub(crate) fn template(template: &Template) -> TokenStream { + let payload = record_items(&Record { + name: template.name.clone(), + type_params: Vec::new(), + fields: template.fields.clone(), + }); + let self_ty = type_ident(&template.name); + let doc = template_doc(template); + + let contract_impl = contract_impl( + &self_ty, + &template.package_id, + &template.package_name, + &template.module_name, + &template.name, + ); + + // A keyed template exposes its key type so contracts can be exercised by key. + let key_impl = template.key.as_ref().map_or_else(TokenStream::new, |key| { + let key_ty = rust_type(key); + quote! { + impl rt::WithKey for #self_ty { + type Key = #key_ty; + } + } + }); + + let choices = choice_impls(&self_ty, &template.name, &template.choices); + + // `to_record` mirrors the payload's `ToValue`, but returns the bare + // `Record` a create command carries — so the runtime never has to unwrap a + // `Value` it merely assumes is a record. + let record_fields = template.fields.iter().map(|field| { + let label = &field.label; + let ident = field_ident(&field.label); + quote! { (#label, rt::ToValue::to_value(&self.#ident)), } + }); + + quote! { + #doc + #payload + #contract_impl + impl rt::Template for #self_ty { + fn to_record(&self) -> rt::Record { + rt::record_fields(::std::vec![#(#record_fields)*]) + } + } + #key_impl + #choices + } +} + +/// Emit an interface's impls on its marker type: its on-ledger identity +/// (`rt::Contract` + `rt::Interface`, carrying the view type) and a typed +/// `rt::Choice` impl per interface choice, so a `ContractId` can be +/// exercised without the concrete template. The marker `struct` itself is +/// emitted from the interface's data type (`interface_marker`). +#[must_use] +pub(crate) fn interface(interface: &Interface) -> TokenStream { + let self_ty = type_ident(&interface.name); + let contract_impl = contract_impl( + &self_ty, + &interface.package_id, + &interface.package_name, + &interface.module_name, + &interface.name, + ); + // The view is a record (or `Unit` for an empty view). + let view_ty = interface + .view + .as_ref() + .map_or_else(|| quote!(rt::Unit), rust_type); + let choices = choice_impls(&self_ty, &interface.name, &interface.choices); + + quote! { + #contract_impl + impl rt::Interface for #self_ty { + type View = #view_ty; + } + #choices + } +} + +/// Emit the `rt::Contract` impl carrying a template's/interface's on-ledger +/// identity (package id + name, module, entity). +fn contract_impl( + self_ty: &Ident, + package_id: &str, + package_name: &str, + module_name: &str, + entity_name: &str, +) -> TokenStream { + quote! { + impl rt::Contract for #self_ty { + const PACKAGE_ID: &'static str = #package_id; + const PACKAGE_NAME: &'static str = #package_name; + const MODULE_NAME: &'static str = #module_name; + const ENTITY_NAME: &'static str = #entity_name; + } + } +} + +/// Emit a typed `rt::Choice` impl for each choice (shared by templates and +/// interfaces), linking the choice-argument type to its owner and return type. +fn choice_impls(self_ty: &Ident, owner: &str, choices: &[Choice]) -> TokenStream { + let impls = choices.iter().map(|choice| { + let argument = rust_type(&choice.argument); + let returns = rust_type(&choice.returns); + let choice_name = &choice.name; + let consuming = choice.consuming; + let doc = format!( + "The `{}` choice on [`{}`] ({}).", + choice.name, + owner, + if choice.consuming { + "consuming" + } else { + "non-consuming" + } + ); + quote! { + #[doc = #doc] + impl rt::Choice<#self_ty> for #argument { + type Return = #returns; + const NAME: &'static str = #choice_name; + const CONSUMING: bool = #consuming; + } + } + }); + quote! { #(#impls)* } +} + +/// The generic parameter list `` for a type's parameters, or empty tokens +/// when the type is not generic. +fn generics(type_params: &[String]) -> TokenStream { + if type_params.is_empty() { + return TokenStream::new(); + } + let params = type_params.iter().map(|param| type_var_ident(param)); + quote!(<#(#params),*>) +} + +/// A Rust identifier for a Daml type or constructor **name**. Daml type names +/// are already valid Rust identifiers, so they are used as-is (keywords +/// escaped) — not case-converted, which would mangle names containing `_`. +#[must_use] +pub(crate) fn type_ident(name: &str) -> Ident { + ident(name) +} + +/// A Rust identifier for a Daml **type variable** (`a` → `A`), upper-camel-cased +/// to follow Rust's generic-parameter naming convention. +#[must_use] +pub(crate) fn type_var_ident(name: &str) -> Ident { + ident(&name.to_upper_camel_case()) +} + +/// Render a type reference path (`["crate", "m", "Type"]` → `crate::m::Type`), +/// keyword-escaping each named segment. The path keywords `crate` / `self` / +/// `super` / `Self` pass through unescaped. +#[must_use] +pub(crate) fn type_path(segments: &[String]) -> TokenStream { + let parts = segments.iter().map(|segment| match segment.as_str() { + "crate" | "self" | "super" | "Self" => segment.parse::().unwrap_or_default(), + // The marker for a path into another crate. Emitting nothing here is + // what produces the leading `::`, since the segments below are joined + // with `::` — so `["::", "c", "M", "T"]` becomes `::c::M::T`, an + // absolute path that a same-named local module cannot shadow. + crate::lower::EXTERNAL_CRATE_ROOT => TokenStream::new(), + other => { + let id = ident(other); + quote!(#id) + } + }); + quote!(#(#parts)::*) +} + +/// A Rust identifier for a record field: the Daml label, snake-cased, with Rust +/// keywords escaped so labels like `type` stay valid. +fn field_ident(label: &str) -> Ident { + ident(&label.to_snake_case()) +} + +/// Build an [`Ident`], escaping Rust keywords. Most keywords become raw +/// identifiers (`r#type`); the four that cannot be raw are suffixed with `_`. +fn ident(name: &str) -> Ident { + match name { + "crate" | "self" | "Self" | "super" => Ident::new(&format!("{name}_"), Span::call_site()), + _ if is_keyword(name) => Ident::new_raw(name, Span::call_site()), + // A name that is empty or starts with a digit is not a valid Rust + // identifier — for example a tuple field `_1`, whose snake_case drops + // the leading underscore to `1`. Prefix `_` to make it valid. + _ if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) => { + Ident::new(&format!("_{name}"), Span::call_site()) + } + _ => Ident::new(name, Span::call_site()), + } +} + +/// Whether `name` is a Rust keyword (strict + reserved) that must be escaped. +fn is_keyword(name: &str) -> bool { + matches!( + name, + "as" | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + | "async" + | "await" + | "abstract" + | "become" + | "box" + | "do" + | "final" + | "macro" + | "override" + | "priv" + | "typeof" + | "unsized" + | "virtual" + | "yield" + | "try" + // Reserved by the 2024 edition. A generated crate declares 2021, so + // a Daml field named `gen` compiles there today — but the same file + // in a 2024 crate would not, and `r#gen` is valid in both. The + // `serde(rename)` beside it keeps the wire label either way. + | "gen" + ) +} diff --git a/crates/canton-codegen/src/generate.rs b/crates/canton-codegen/src/generate.rs new file mode 100644 index 0000000..84d4a4c --- /dev/null +++ b/crates/canton-codegen/src/generate.rs @@ -0,0 +1,772 @@ +//! The end-to-end pipeline: a `.dar` in, a self-contained bindings **crate** +//! out (`Cargo.toml` + `src/lib.rs`). +//! +//! This is what the `dpm-codegen-rust` binary runs, and what a build script +//! should call. For finer control (emit a single module, post-process the IR +//! first), use [`lower_dar`](crate::lower_dar) and +//! [`generate_crate`](crate::generate_crate) directly. + +use std::fs; +use std::path::{Path, PathBuf}; + +use canton_lf::{Dar, DarError, DecodeError, decode_main_package, package_version}; + +use crate::{CodegenError, SkippedType, generate_crate}; + +/// The marker written into a generated `Cargo.toml`, and looked for before +/// overwriting one: a file without it was not written by this tool. +const CARGO_TOML_MARKER: &str = "# generated by dpm-codegen-rust — do not edit by hand"; + +/// The marker in a generated `lib.rs` (the first line of its crate docs), used +/// to recognize this tool's own output before overwriting. Kept in sync with +/// `crate_docs` — the test below fails if the emitted header stops carrying it. +const LIB_RS_MARKER: &str = "Typed Rust bindings generated from a Daml archive"; + +/// Writing a bindings crate failed. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum GenerateError { + /// The `.dar` could not be read. + #[error("reading {path}")] + ReadDar { + /// The DAR that could not be read. + path: PathBuf, + /// The underlying container error. + #[source] + source: DarError, + }, + /// The `.dar` could not be decoded. + #[error("decoding {path}")] + DecodeDar { + /// The DAR that could not be decoded. + path: PathBuf, + /// The underlying decode error. + #[source] + source: DecodeError, + }, + /// The archive held no Daml-LF packages to generate from. + #[error("{path} contains no Daml-LF packages — is it a DAR built with `daml build`?")] + EmptyDar { + /// The offending archive. + path: PathBuf, + }, + /// The requested crate name is not a valid Cargo package name. + #[error( + "`{name}` is not a valid Cargo package name (ASCII letters, digits, `-`/`_`; \ + must not start with a digit)" + )] + InvalidCrateName { + /// The rejected name. + name: String, + }, + /// The DAR's package version is not usable as a Cargo version. + /// + /// It is refused rather than sanitised because the version reaches the + /// generated `Cargo.toml`, and a DAR is not necessarily something the + /// caller wrote. + #[error( + "the DAR declares the package version `{version}`, which is not a semantic \ + version Cargo will accept (`MAJOR.MINOR.PATCH`, optionally `-pre` and \ + `+build`). Rebuild the DAR with a plain version" + )] + InvalidCrateVersion { + /// The rejected version, as the DAR declared it. + version: String, + }, + /// The `canton-daml` version requirement is not one Cargo can parse. + #[error( + "`{requirement}` is not a Cargo version requirement (e.g. `0.2`, `^0.2`, \ + `>=0.2, <0.4`)" + )] + InvalidRuntimeRequirement { + /// The rejected requirement. + requirement: String, + }, + /// An output file exists and was not written by this tool. + #[error( + "{path} already exists and was not generated by this tool; \ + choose an empty output directory or force the overwrite" + )] + WouldClobber { + /// The file that would have been overwritten. + path: PathBuf, + }, + /// The generated tokens were not valid Rust — a bug in this crate. + #[error(transparent)] + Codegen(#[from] CodegenError), + /// Writing the output crate failed. + #[error("writing {path}")] + Write { + /// The path being written. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, +} + +/// How the generated crate depends on the `canton-daml` runtime. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Runtime { + /// A published version requirement (e.g. `0.2`). + Version(String), + /// A local path — for a monorepo, or to try bindings before publishing. + Path(PathBuf), +} + +/// What to generate and where. Build with [`Options::new`], then the +/// `with_*` methods. +/// +/// ```no_run +/// use canton_codegen::{Options, Runtime}; +/// +/// let options = Options::new("my-app-0.1.0.dar", "my-app-bindings") +/// .with_crate_name("my-app-bindings") +/// .with_runtime(Runtime::Version("0.2".to_string())); +/// ``` +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Options { + dar: PathBuf, + out: PathBuf, + crate_name: Option, + runtime: Runtime, + force: bool, + external: crate::lower::ExternalPackages, +} + +impl Options { + /// Generate bindings for `dar` into the crate directory `out` (created if + /// absent). The crate name defaults to one derived from the DAR file name + /// and the runtime to the matching published `canton-daml`. + pub fn new(dar: impl Into, out: impl Into) -> Self { + Self { + dar: dar.into(), + out: out.into(), + crate_name: None, + runtime: Runtime::Version(DEFAULT_RUNTIME_REQ.to_string()), + force: false, + external: crate::lower::ExternalPackages::new(), + } + } + + /// Set the generated crate's package name (default: derived from the DAR + /// file name — see [`default_crate_name`]). + #[must_use] + pub fn with_crate_name(mut self, name: impl Into) -> Self { + self.crate_name = Some(name.into()); + self + } + + /// Set how the generated crate depends on the `canton-daml` runtime. + #[must_use] + pub fn with_runtime(mut self, runtime: Runtime) -> Self { + self.runtime = runtime; + self + } + + /// Reference a package that is **already published as its own crate** + /// instead of generating it, keyed by package name (preferred) or id. + /// + /// A DAR's dependency closure is shared — `splice-api-token-holding-v1` + /// sits under amulet, wallet and wallet-payments alike — and generating it + /// into each crate gives each its own `Holding`, which Rust treats as + /// unrelated types. A `ContractId` from one then does not + /// typecheck against the other, though both name the same interface in the + /// same package. + /// + /// ```no_run + /// # use canton_codegen::Options; + /// let options = Options::new("splice-amulet.dar", "amulet-bindings") + /// .with_external_package("splice-api-token-holding-v1", "canton_splice_api_token_holding_v1"); + /// ``` + #[must_use] + pub fn with_external_package( + mut self, + package: impl Into, + crate_name: impl Into, + ) -> Self { + self.external = self.external.with(package, crate_name); + self + } + + /// Overwrite output files even if they were not generated by this tool. + #[must_use] + pub fn with_force(mut self, force: bool) -> Self { + self.force = force; + self + } + + /// The crate name that will be used: the explicit one, or the default + /// derived from the DAR path. + #[must_use] + pub fn crate_name(&self) -> String { + self.crate_name + .clone() + .unwrap_or_else(|| default_crate_name(&self.dar)) + } +} + +/// The `canton-daml` version requirement written into a generated crate when +/// the caller does not choose one. Kept in lockstep with this crate's own +/// version, since the SDK crates release together. +const DEFAULT_RUNTIME_REQ: &str = concat!( + env!("CARGO_PKG_VERSION_MAJOR"), + ".", + env!("CARGO_PKG_VERSION_MINOR") +); + +/// A summary of a successful generation. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Stats { + /// Packages in the DAR closure that produced modules. + pub packages: usize, + /// Generated Rust submodules. + pub modules: usize, + /// Generated named items (data types + templates + interfaces). + pub items: usize, + /// Size of the generated `lib.rs`, in bytes. + pub bytes: usize, + /// Declarations that could not be lowered (best-effort; surface these as + /// warnings). + pub skipped: Vec, +} + +/// Decode `opts.dar`, lower its whole closure, and write a bindings crate +/// (`Cargo.toml` + `src/lib.rs`) into the output directory. +/// +/// Refuses to overwrite an existing `Cargo.toml` / `src/lib.rs` that this tool +/// did not generate, unless [`Options::with_force`] is set — a wrong output +/// directory must not clobber a hand-written crate. +/// +/// # Errors +/// See [`GenerateError`]. +pub fn generate(opts: &Options) -> Result { + let crate_name = opts.crate_name(); + validate_crate_name(&crate_name)?; + + let dar = Dar::open(&opts.dar).map_err(|source| GenerateError::ReadDar { + path: opts.dar.clone(), + source, + })?; + let (krate, skipped) = + crate::lower::lower_dar_with(&dar, &opts.external).map_err(|source| { + GenerateError::DecodeDar { + path: opts.dar.clone(), + source, + } + })?; + if krate.packages.is_empty() { + return Err(GenerateError::EmptyDar { + path: opts.dar.clone(), + }); + } + let source = generate_crate(&krate)?; + + let modules = krate.packages.iter().map(|p| p.modules.len()).sum(); + let items = krate + .packages + .iter() + .flat_map(|p| &p.modules) + .map(|m| m.module.data_types.len() + m.module.templates.len() + m.module.interfaces.len()) + .sum(); + + let cargo_toml_path = opts.out.join("Cargo.toml"); + let lib_rs_path = opts.out.join("src").join("lib.rs"); + if !opts.force { + refuse_foreign(&cargo_toml_path, CARGO_TOML_MARKER)?; + refuse_foreign(&lib_rs_path, LIB_RS_MARKER)?; + } + + let src_dir = opts.out.join("src"); + fs::create_dir_all(&src_dir).map_err(|source| GenerateError::Write { + path: src_dir, + source, + })?; + let version = crate_version(&dar); + validate_crate_version(&version)?; + if let Runtime::Version(requirement) = &opts.runtime { + validate_runtime_requirement(requirement)?; + } + let manifest = cargo_toml(opts, &crate_name, &version); + fs::write(&cargo_toml_path, manifest).map_err(|source| GenerateError::Write { + path: cargo_toml_path, + source, + })?; + fs::write(&lib_rs_path, &source).map_err(|source| GenerateError::Write { + path: lib_rs_path, + source, + })?; + + Ok(Stats { + packages: krate.packages.len(), + modules, + items, + bytes: source.len(), + skipped, + }) +} + +/// Error if `path` exists but does not carry `marker` in its first lines — +/// i.e. it is someone's own file, not a previous run's output. +fn refuse_foreign(path: &Path, marker: &str) -> Result<(), GenerateError> { + match fs::read_to_string(path) { + Ok(existing) => { + let head: String = existing.lines().take(5).collect::>().join("\n"); + if head.contains(marker) { + Ok(()) // ours from a previous run — safe to regenerate over + } else { + Err(GenerateError::WouldClobber { + path: path.to_path_buf(), + }) + } + } + Err(_) => Ok(()), // absent (or unreadable, in which case the write errors) + } +} + +/// The generated crate's version: the DAR main package's version (so +/// `splice-amulet 0.1.14` yields a `0.1.14` crate), or `0.0.0` when the +/// package carries none. +fn crate_version(dar: &Dar) -> String { + decode_main_package(dar) + .ok() + .and_then(|(package, _)| package_version(&package).map(str::to_string)) + .map_or_else(|| "0.0.0".to_string(), |version| pad_to_semver(&version)) +} + +/// Complete a short but otherwise numeric version: `2.0` becomes `2.0.0`. +/// +/// Cargo requires all three components — it rejects `2.0`, `0.1` and `1` — and +/// Daml does not. Refusing the whole DAR over that would leave the caller +/// stuck, since the version belongs to somebody else's package and they cannot +/// change it. Anything that is not simply *short* is left exactly as it is, for +/// [`validate_crate_version`] to refuse. +fn pad_to_semver(version: &str) -> String { + let parts: Vec<&str> = version.split('.').collect(); + let numeric = parts.len() < 3 + && !parts.is_empty() + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())); + if numeric { + let mut padded = parts.join("."); + for _ in parts.len()..3 { + padded.push_str(".0"); + } + padded + } else { + version.to_string() + } +} + +/// The `Cargo.toml` for the generated crate: a publishable manifest depending +/// on the `canton-daml` runtime. +fn cargo_toml(opts: &Options, crate_name: &str, version: &str) -> String { + let dependency = match &opts.runtime { + // Escaped for the same reason the path below is: both are interpolated + // into a quoted TOML string, and only one of them used to be. + Runtime::Version(requirement) => { + format!("canton-daml = \"{}\"", toml_escape(requirement)) + } + Runtime::Path(path) => { + // A relative path would silently re-anchor at the *output* crate; + // resolve it against the invoking directory instead. Escaped as a + // TOML basic string (Windows separators, quotes). + let absolute = std::path::absolute(path).unwrap_or_else(|_| path.clone()); + format!( + "canton-daml = {{ path = \"{}\" }}", + toml_escape(&absolute.display().to_string()) + ) + } + }; + // A referenced crate has to be a dependency, or the paths the emitter wrote + // do not resolve. The version is left to the caller to pin: this tool knows + // the crate's name, not which release of it matches the DAR. + let externals = opts + .external + .crate_names() + .iter() + .fold(String::new(), |mut out, name| { + use std::fmt::Write as _; + let _ = writeln!(out, "{} = \"*\" # pin this", name.replace('_', "-")); + out + }); + format!( + "{CARGO_TOML_MARKER}\n\ + [package]\n\ + name = \"{crate_name}\"\n\ + version = \"{version}\"\n\ + edition = \"2021\"\n\ + description = \"Typed Rust bindings generated from a Daml archive (DAR).\"\n\ + # Fill these in before publishing the generated crate.\n\ + # license = \"Apache-2.0\"\n\ + # repository = \"\"\n\ + \n\ + [dependencies]\n{dependency}\n{externals}" + ) +} + +/// Escape a string for a double-quoted TOML basic string. +fn toml_escape(raw: &str) -> String { + // A quote is not the only way out of a basic string. A raw newline ends it + // too — TOML forbids one inside — so escaping only `"` left an input able + // to put its own text on the next line of the manifest. Control characters + // are escaped as TOML spells them. + raw.chars() + .fold(String::with_capacity(raw.len()), |mut out, c| { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04X}", c as u32); + } + c => out.push(c), + } + out + }) +} + +/// Error unless `name` is a valid Cargo package name: non-empty ASCII +/// alphanumerics, `-` or `_`, not starting with a digit. +/// Refuse a package version that cannot go into a `Cargo.toml` verbatim. +/// +/// The version comes out of the DAR, and a DAR is not necessarily something the +/// caller wrote — the package id is the hash of whatever payload its author +/// chose, so every integrity guard upstream still passes one that was authored +/// rather than built. Interpolated raw, a version of the form +/// `0.1.0"` + newline + `[dependencies.evil]` + `git = "…` closed the string +/// and opened a table: an arbitrary dependency in a manifest the caller then +/// compiles. +/// +/// The name beside it was already validated and the runtime path beside it +/// already escaped, for this reason. This is the third field in the same +/// manifest and the one that was missed. +/// +/// Parsed as a semantic version rather than screened by charset, because +/// blocking the injection is only half the job: a charset wide enough to admit +/// every real version also admits `hello`, `1..2` and `1.0_bad`, and those +/// write a manifest that generation calls a success and every later `cargo` +/// command fails to parse. `semver` is the crate Cargo itself uses, so this +/// accepts exactly what a `package.version` may be. +fn validate_crate_version(version: &str) -> Result<(), GenerateError> { + semver::Version::parse(version) + .map(|_| ()) + .map_err(|_| GenerateError::InvalidCrateVersion { + version: version.to_string(), + }) +} + +/// The `canton-daml` version requirement, checked the same way and for the same +/// two reasons. +/// +/// It reaches the manifest through the identical `format!`, so a quote or a +/// newline in it opens a table exactly as the package version did. And a +/// requirement that is merely mistyped — `--runtime-version 0..2` — otherwise +/// produces a crate that generates cleanly and cannot be built. +/// +/// Unlike a version, a *requirement* may carry an operator (`^0.2`, `>=0.2, +/// <0.4`), so it parses as `VersionReq`. +fn validate_runtime_requirement(requirement: &str) -> Result<(), GenerateError> { + semver::VersionReq::parse(requirement) + .map(|_| ()) + .map_err(|_| GenerateError::InvalidRuntimeRequirement { + requirement: requirement.to_string(), + }) +} + +fn validate_crate_name(name: &str) -> Result<(), GenerateError> { + let valid = !name.is_empty() + && !name.starts_with(|c: char| c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if valid { + Ok(()) + } else { + Err(GenerateError::InvalidCrateName { + name: name.to_string(), + }) + } +} + +/// Derive a crate name from a DAR path: its file stem with the trailing +/// version dropped, sanitised to a valid Cargo package name +/// (`splice-amulet-0.1.14.dar` → `splice-amulet`). +/// +/// The version is left out on purpose: a DAR version bump should not rename the +/// crate a caller depends on (the *crate version* carries it instead). +#[must_use] +pub fn default_crate_name(dar: &Path) -> String { + let stem = dar + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("bindings"); + let stem = strip_trailing_version(stem); + let name: String = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect(); + let name = name.trim_matches('-').to_string(); + // A Cargo package name cannot be empty or start with a digit. + if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { + format!("bindings-{name}").trim_end_matches('-').to_string() + } else { + name + } +} + +/// Drop a trailing `-` (dot-separated digits) from a DAR file stem. +fn strip_trailing_version(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((head, tail)) + if !tail.is_empty() + && tail + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) => + { + head + } + _ => stem, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn default_crate_names_drop_the_dar_version_and_stay_valid() { + let cases = [ + // A DAR version bump must not rename the crate. + ("splice-amulet-0.1.14.dar", "splice-amulet"), + ("splice-amulet-0.1.15.dar", "splice-amulet"), + ("quickstart-licensing-0.0.1.dar", "quickstart-licensing"), + ("quickstart licensing.dar", "quickstart-licensing"), + // Not a version suffix — kept. + ("my-app-v2.dar", "my-app-v2"), + // Degenerate: a name that is only a version keeps its digits + // behind the `bindings-` prefix (a Cargo name cannot lead with one). + ("0.1.14.dar", "bindings-0-1-14"), + ]; + for (input, expected) in cases { + let name = default_crate_name(Path::new(input)); + assert_eq!(name, expected, "for {input}"); + assert!(validate_crate_name(&name).is_ok(), "{name}"); + } + } + + #[test] + fn invalid_crate_names_are_rejected_with_a_typed_error() { + for bad in ["", "1abc", "has space", "naïve"] { + assert!(matches!( + validate_crate_name(bad), + Err(GenerateError::InvalidCrateName { .. }) + )); + } + assert!(validate_crate_name("fine-name_2").is_ok()); + } + + #[test] + fn cargo_toml_is_publishable_and_escapes_windows_paths() { + let options = Options::new("x.dar", "out") + .with_runtime(Runtime::Path(PathBuf::from(r"C:\Users\dev\canton-daml"))); + let toml = cargo_toml(&options, "x", "0.0.0"); + // Backslashes must be escaped or the TOML string is invalid. + assert!(toml.contains(r"C:\\Users\\dev\\canton-daml"), "{toml}"); + assert!(toml.starts_with(CARGO_TOML_MARKER), "{toml}"); + // A description is present, and no `[workspace]` stanza is forced on + // the caller's layout. + assert!(toml.contains("description = "), "{toml}"); + assert!(!toml.contains("[workspace]"), "{toml}"); + } + + #[test] + fn the_default_runtime_requirement_tracks_this_crate() { + // Lockstep: a generated crate asks for the runtime that matches the + // codegen that wrote it. + assert_eq!( + DEFAULT_RUNTIME_REQ, + concat!( + env!("CARGO_PKG_VERSION_MAJOR"), + ".", + env!("CARGO_PKG_VERSION_MINOR") + ) + ); + assert!(matches!( + Options::new("x.dar", "o").runtime, + Runtime::Version(_) + )); + } + + /// The clobber guard recognises this tool's own output only if the emitted + /// header actually contains the marker — so assert against a real emission, + /// not against the constant. + #[test] + fn the_lib_rs_marker_matches_what_the_generator_emits() { + let emitted = crate::generate_crate(&crate::ir::Crate::default()).unwrap(); + assert!( + emitted.contains(LIB_RS_MARKER), + "generated header must carry the marker, got:\n{emitted}" + ); + } + + #[test] + fn refuses_to_overwrite_a_foreign_file() { + let dir = std::env::temp_dir().join(format!("codegen-clobber-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Cargo.toml"); + + std::fs::write(&file, "[package]\nname = \"users-own-crate\"\n").unwrap(); + assert!(matches!( + refuse_foreign(&file, CARGO_TOML_MARKER), + Err(GenerateError::WouldClobber { .. }) + )); + + // Our own previous output is fine to regenerate over, as is an absent file. + std::fs::write(&file, format!("{CARGO_TOML_MARKER}\n[package]\n")).unwrap(); + assert!(refuse_foreign(&file, CARGO_TOML_MARKER).is_ok()); + assert!(refuse_foreign(&dir.join("absent.toml"), CARGO_TOML_MARKER).is_ok()); + + std::fs::remove_dir_all(&dir).ok(); + } + /// The version reaches `Cargo.toml`, and it comes out of the DAR — which + /// the caller did not necessarily write. Every integrity guard upstream + /// still passes an *authored* archive, because the package id is the hash + /// of whatever payload its author chose. + /// + /// Interpolated raw, this closed the string and opened a table: an + /// arbitrary git dependency in a manifest the caller then compiles. The + /// name beside it was already validated and the runtime path beside it + /// already escaped; this was the third field in the same manifest. + #[test] + fn a_package_version_cannot_inject_into_the_manifest() { + let hostile = "0.1.0\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo"; + let error = validate_crate_version(hostile).expect_err("must be refused"); + assert!( + matches!(error, GenerateError::InvalidCrateVersion { .. }), + "{error}" + ); + // The message shows what was rejected, so the reader can go and look at + // the DAR rather than guess. + assert!(error.to_string().contains("0.1.0"), "{error}"); + + // Neither can a newline on its own, which is all it takes to start a + // new key once the quote is out. + assert!(validate_crate_version("0.1.0\nx = 1").is_err()); + assert!(validate_crate_version("").is_err()); + + // Cargo itself rejects a two-component version, so this must too — + // `2.0` is completed to `2.0.0` upstream, in `crate_version`, not + // waved through here. + assert!(validate_crate_version("2.0").is_err()); + assert_eq!(pad_to_semver("2.0"), "2.0.0"); + assert_eq!(pad_to_semver("1"), "1.0.0"); + assert_eq!( + pad_to_semver("0.1.14"), + "0.1.14", + "a complete version is untouched" + ); + assert_eq!( + pad_to_semver("hello"), + "hello", + "padding never rescues a non-version" + ); + + // Everything a real package carries still passes. + for good in [ + "0.0.0", + "1.2.3", + "0.1.14", + "1.0.0-SNAPSHOT", + "1.0.0-rc.1+build.5", + ] { + assert!( + validate_crate_version(good).is_ok(), + "{good} should be accepted" + ); + } + } + /// Both fields that reach the manifest as text, checked the way Cargo + /// checks them. + /// + /// A charset was enough to stop the injection and not enough to stop a + /// broken manifest: it admitted `hello` and `1..2`, which generate cleanly + /// and then fail every later `cargo` command with a parse error nowhere + /// near its cause. + #[test] + fn a_manifest_is_never_written_with_a_version_cargo_cannot_read() { + // Rejected: not versions at all. + for bad in ["hello", "1..2", "1.0_bad", "", "1.0", "v1.0.0"] { + assert!( + validate_crate_version(bad).is_err(), + "`{bad}` is not a Cargo package version" + ); + } + // Accepted: every shape a real DAR carries, plus the semver extras. + for good in [ + "0.0.1", + "0.1.14", + "1.0.0", + "1.0.6", + "1.0.0-SNAPSHOT", + "1.0.0-rc.1+build.5", + ] { + assert!(validate_crate_version(good).is_ok(), "{good}"); + } + + // The requirement is a *requirement*, so operators are legal here and + // a bare `0.2` — what this crate emits by default — must pass. + for good in [DEFAULT_RUNTIME_REQ, "0.2", "^0.2", ">=0.2, <0.4", "*"] { + assert!(validate_runtime_requirement(good).is_ok(), "{good}"); + } + for bad in ["0..2", "hello", "", ">>0.2"] { + assert!(validate_runtime_requirement(bad).is_err(), "`{bad}`"); + } + } + + /// The two arms of one `match` disagreed: the path was escaped, the version + /// requirement three lines above it was not. Both reach the same quoted + /// TOML string. + #[test] + fn a_runtime_requirement_cannot_inject_into_the_manifest() { + let hostile = "0.2\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo"; + assert!( + validate_runtime_requirement(hostile).is_err(), + "must be refused before it reaches the manifest" + ); + + // And if it somehow did, the escape is now there too — no unescaped + // quote survives into the file. + let opts = Options::new("x.dar", "out").with_runtime(Runtime::Version(hostile.to_string())); + let manifest = cargo_toml(&opts, "bindings", "1.0.0"); + // The text may survive as *content* of the escaped string — harmless. + // What must not happen is a line of the manifest starting with it, + // which is what a table header is. + assert!( + !manifest + .lines() + .any(|l| l.trim_start().starts_with('[') && l.contains("evil")), + "escaped output opened a table:\n{manifest}" + ); + // And nothing may break out of the dependency line at all. + let dependency = manifest + .lines() + .find(|l| l.starts_with("canton-daml")) + .expect("the dependency line"); + assert!(!dependency.contains('\n')); + } +} diff --git a/crates/canton-codegen/src/ir.rs b/crates/canton-codegen/src/ir.rs new file mode 100644 index 0000000..2510e5d --- /dev/null +++ b/crates/canton-codegen/src/ir.rs @@ -0,0 +1,416 @@ +//! A decoder-agnostic intermediate representation (IR) of Daml types. +//! +//! The IR is the seam between "decode Daml-LF" (`canton-lf`) and "emit Rust" +//! (this crate's generator). Neither side knows about the other: a decoder +//! produces this IR, the generator consumes it — so the LF-decoder choice stays +//! isolated to lowering, the one step that reads the LF AST +//! ([`lower_dar`](crate::lower_dar)). + +/// A Daml type — a primitive, a container, or a reference to a named data type. +/// +/// This is the type a record field, choice argument, or contract key can take. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DamlType { + /// The `Unit` type `()`. + Unit, + /// `Bool`. + Bool, + /// `Int64`. + Int64, + /// `Numeric n` — a fixed-scale decimal; the value is the scale (decimals). + Numeric(u8), + /// `Text`. + Text, + /// `Timestamp` (microseconds since the Unix epoch, UTC). + Timestamp, + /// `Date` (days since the Unix epoch). + Date, + /// `Party`. + Party, + /// `ContractId t` — a handle to a contract of the referenced payload type. + ContractId(Box), + /// `List t` / `[t]`. + List(Box), + /// `Optional t`. + Optional(Box), + /// `TextMap t` — a map keyed by `Text`. + TextMap(Box), + /// `GenMap k v` — a map with arbitrary key type. + GenMap(Box, Box), + /// A reference to a named data type (record / variant / enum). + Ref(TypeRef), + /// A type parameter (`a`, `b`, …) inside a generic data type. + Var(String), + /// A type behind a `Box`, used to give recursive types the indirection Rust + /// requires (Daml allows a type to contain itself directly; Rust does not). + Boxed(Box), +} + +/// A reference to a named Daml data type, with any applied type arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct TypeRef { + /// The Rust path to the referenced type, as segments. A local reference is a + /// single segment (`["Foo"]`); a qualified one carries its full path + /// (`["crate", "splice_amulet", "Amulet"]`), which is how cross-module and + /// cross-package references are disambiguated. + pub path: Vec, + /// Applied type arguments, if the referenced type is generic. + pub args: Vec, +} + +impl TypeRef { + /// A local (single-segment) reference to `name`, no path qualification. + #[must_use] + pub fn local(name: impl Into, args: Vec) -> Self { + Self { + path: vec![name.into()], + args, + } + } +} + +/// One field of a record. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Field { + /// The Daml field label, in its source casing (usually camelCase). + pub label: String, + /// The field's type. + pub ty: DamlType, +} + +/// A record data type. Template payloads are records too, so this is reused for +/// both a plain `data … = … with` record and a `template … with` payload. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Record { + /// The type name (PascalCase, as in Daml). + pub name: String, + /// Type parameters, in order, if the record is generic. + pub type_params: Vec, + /// The fields, in declaration order. + pub fields: Vec, +} + +/// A named data type declared in a module: a record, a variant, or an enum. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DataType { + /// A record (product) type. + Record(Record), + /// A variant (sum) type. + Variant(Variant), + /// An enumeration (constructors carrying no payload). + Enum(Enum), + /// An interface **marker**: a phantom tag emitted so references to the + /// interface (always `ContractId`) resolve. The interface itself is not + /// serializable and carries no data of its own — its view and choices are + /// emitted separately, from [`Interface`]. The `String` is the name. + InterfaceMarker(String), +} + +/// A variant (sum) type: named constructors, each optionally carrying a payload. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Variant { + /// The type name (PascalCase). + pub name: String, + /// Type parameters, in order, if generic. + pub type_params: Vec, + /// The constructors, in declaration order. + pub constructors: Vec, +} + +/// One constructor of a [`Variant`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct VariantConstructor { + /// The constructor name (PascalCase). + pub name: String, + /// The payload type, or `None` for a constructor that carries no data. + pub payload: Option, +} + +/// An enumeration: named constructors that carry no payload. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Enum { + /// The type name (PascalCase). + pub name: String, + /// The constructor names, in declaration order. + pub constructors: Vec, +} + +/// A template: its payload fields, its choices, and an optional contract key. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Template { + /// The template name (PascalCase) — also the Rust payload struct name. + pub name: String, + /// The Daml module the template is defined in, dotted (e.g. `Splice.Amulet`). + /// Part of the on-ledger template id. + pub module_name: String, + /// The id (hash) of the package the template is defined in. Pins the exact + /// template version in an on-ledger template id. + pub package_id: String, + /// The Daml package **name** (e.g. `splice-amulet`), used for the + /// upgrade-friendly `#` template-id form so the participant + /// resolves the vetted version under Smart Contract Upgrade. + pub package_name: String, + /// The payload fields, in declaration order. + pub fields: Vec, + /// The choices exercisable on a contract of this template. + pub choices: Vec, + /// The contract key type, if the template declares a key. + pub key: Option, +} + +/// A choice on a [`Template`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Choice { + /// The choice name (PascalCase). + pub name: String, + /// Whether exercising the choice archives the contract. + pub consuming: bool, + /// The choice argument type (usually a reference to a record). + pub argument: DamlType, + /// The type the choice returns. + pub returns: DamlType, +} + +/// A module's worth of generated declarations: its data types, templates, and +/// interfaces. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +#[non_exhaustive] +pub struct Module { + /// The named data types (records, variants, enums). + pub data_types: Vec, + /// The templates. + pub templates: Vec