diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 9d7257b..870b283 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -22,22 +22,19 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install Rust stable toolchain + - name: Install Rust nightly toolchain run: | - rustup toolchain install stable --profile minimal --component clippy - rustup default stable - - - name: Install Rust nightly for formatting - run: rustup toolchain install nightly --profile minimal --component rustfmt + rustup toolchain install nightly --profile minimal --component rustfmt --component clippy + rustup default nightly - name: Check formatting run: cargo +nightly fmt -- --check - name: Run clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test --workspace --all-targets --all-features + run: cargo +nightly test --workspace --all-targets --all-features - name: Plan crates.io publish packages id: publish_plan @@ -87,7 +84,7 @@ jobs: exit 0 fi - cargo metadata --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" + cargo +nightly metadata --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" package_versions="$( PACKAGES="$(printf '%s\n' "${packages[@]}")" RUNNER_TEMP="$RUNNER_TEMP" python3 - <<'PY' import json @@ -160,10 +157,10 @@ jobs: fi if [[ "$mode" == "dry-run" ]]; then - publish_args=(cargo publish --dry-run --locked) + publish_args=(cargo +nightly publish --dry-run --locked) echo "dry-run publish packages: ${packages_to_publish[*]}" else - publish_args=(cargo publish --locked) + publish_args=(cargo +nightly publish --locked) echo "publish packages: ${packages_to_publish[*]}" fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7568ea..82c58d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ concurrency: env: CARGO_TERM_COLOR: always XTASK_RELEASE_S3_ENDPOINT_URL: ${{ vars.XTASK_RELEASE_S3_ENDPOINT_URL }} + DHTTP_ROOT_CA_PEM: ${{ secrets.DHTTP_ROOT_CA_PEM }} + DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem DHTTP_STUN_SERVER: ${{ vars.DHTTP_STUN_SERVER }} DHTTP_H3_DNS_SERVER: ${{ vars.DHTTP_H3_DNS_SERVER }} DHTTP_HTTP_DNS_SERVER: ${{ vars.DHTTP_HTTP_DNS_SERVER }} @@ -89,10 +91,6 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 240 env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - APT_SUITE: genmeta - APT_PREFIX: ppa/genmeta LINUX_TARGETS: x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu armv7-unknown-linux-gnueabihf i686-unknown-linux-gnu defaults: run: @@ -105,11 +103,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly - name: Cache cargo downloads uses: actions/cache@v5 @@ -139,6 +152,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -191,7 +205,7 @@ jobs: XTASK_RELEASE_S3_SECRET_ACCESS_KEY: ${{ secrets.XTASK_RELEASE_S3_SECRET_ACCESS_KEY }} XTASK_RELEASE_APT_SIGNING_KEY: ${{ secrets.XTASK_RELEASE_APT_SIGNING_KEY }} XTASK_RELEASE_APT_SIGNING_PASSPHRASE: ${{ secrets.XTASK_RELEASE_APT_SIGNING_PASSPHRASE }} - APT_SIGNING_FINGERPRINT: ${{ steps.apt_key.outputs.fingerprint }} + XTASK_RELEASE_APT_SIGNING_FINGERPRINT: ${{ steps.apt_key.outputs.fingerprint }} run: | set -euo pipefail export XTASK_RELEASE_APT_SIGNING_PASSPHRASE @@ -199,14 +213,11 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - deb --prefix "$APT_PREFIX" --suite "$APT_SUITE" --fingerprint "$APT_SIGNING_FINGERPRINT" + "${publish_cmd[@]}" deb - name: Upload deb packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -247,9 +258,6 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 240 env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - RPM_PREFIX: rpm/gmutils # Fedora 40 RPM repositories no longer provide armhfp/armv7hl metadata. RPM_TARGETS: x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu i686-unknown-linux-gnu defaults: @@ -263,11 +271,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly - name: Cache cargo downloads uses: actions/cache@v5 @@ -294,6 +317,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -334,14 +358,11 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - rpm --prefix "$RPM_PREFIX" + "${publish_cmd[@]}" rpm - name: Upload rpm packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -381,11 +402,6 @@ jobs: name: Linux scoop packages and S3 publish runs-on: ubuntu-24.04 timeout-minutes: 240 - env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - SCOOP_PREFIX: scoop/gmutils - SCOOP_PUBLIC_BASE_URL: https://download.dhttp.net/scoop/gmutils defaults: run: shell: bash @@ -397,11 +413,26 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly rustup target add x86_64-pc-windows-msvc i686-pc-windows-msvc - name: Cache cargo downloads @@ -436,6 +467,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -469,14 +501,11 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - scoop --prefix "$SCOOP_PREFIX" --public-base-url "$SCOOP_PUBLIC_BASE_URL" + "${publish_cmd[@]}" scoop - name: Upload scoop packages to GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') @@ -516,13 +545,6 @@ jobs: name: Homebrew package and S3 publish runs-on: macos-15 timeout-minutes: 120 - env: - DHTTP_ROOT_CA: ${{ github.workspace }}/gmutils/keychain/root.crt - RELEASE_BUCKET: download - BREW_PREFIX: brew/gmutils - BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils - HOMEBREW_TAP_REPOSITORY: genmeta/homebrew-genmeta - HOMEBREW_TAP_BASE_BRANCH: main defaults: run: shell: bash @@ -534,6 +556,35 @@ jobs: path: gmutils persist-credentials: false + - name: Materialize DHTTP root CA + run: | + set -euo pipefail + if [ -z "${DHTTP_ROOT_CA_PEM:-}" ]; then + echo "missing required release configuration: DHTTP_ROOT_CA_PEM" >&2 + exit 1 + fi + mkdir -p "$(dirname "$DHTTP_ROOT_CA")" + python3 - <<'PY' > "$DHTTP_ROOT_CA" + import os + + pem = os.environ["DHTTP_ROOT_CA_PEM"] + print(pem.replace("\\n", "\n"), end="" if pem.endswith("\n") else "\n") + PY + + - name: Read Homebrew tap destination + id: homebrew_destination + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import tomllib + from pathlib import Path + + destination = tomllib.loads(Path("xtask/release.toml").read_text()) + tap = destination["destination"]["brew"]["tap"] + print(f"repository={tap['repository']}") + print(f"base_branch={tap['base_branch']}") + PY + - name: Validate release configuration env: XTASK_RELEASE_S3_ACCESS_KEY_ID: ${{ secrets.XTASK_RELEASE_S3_ACCESS_KEY_ID }} @@ -544,6 +595,7 @@ jobs: missing=0 required=( XTASK_RELEASE_S3_ENDPOINT_URL + DHTTP_ROOT_CA DHTTP_STUN_SERVER DHTTP_H3_DNS_SERVER DHTTP_HTTP_DNS_SERVER @@ -565,15 +617,15 @@ jobs: if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') uses: actions/checkout@v6 with: - repository: ${{ env.HOMEBREW_TAP_REPOSITORY }} - ref: ${{ env.HOMEBREW_TAP_BASE_BRANCH }} + repository: ${{ steps.homebrew_destination.outputs.repository }} + ref: ${{ steps.homebrew_destination.outputs.base_branch }} path: homebrew-tap token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - name: Install Rust run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install nightly --profile minimal + rustup default nightly rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Cache cargo downloads @@ -603,32 +655,30 @@ jobs: if [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then mode=publish fi - publish_cmd=(cargo xtask publish s3) + publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3) if [[ "$mode" == "dry-run" ]]; then publish_cmd+=(--dry-run) fi - "${publish_cmd[@]}" \ - --endpoint-url "$XTASK_RELEASE_S3_ENDPOINT_URL" \ - --bucket "$RELEASE_BUCKET" \ - brew --prefix "$BREW_PREFIX" --public-base-url "$BREW_PUBLIC_BASE_URL" + "${publish_cmd[@]}" brew - name: Create Homebrew tap pull request if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + HOMEBREW_TAP_REPOSITORY: ${{ steps.homebrew_destination.outputs.repository }} + HOMEBREW_TAP_BASE_BRANCH: ${{ steps.homebrew_destination.outputs.base_branch }} FORMULA_NAME: gmutils.rb run: | set -euo pipefail tap_dir="$GITHUB_WORKSPACE/homebrew-tap" formula_source="$PWD/target/common/brew/$FORMULA_NAME" - formula_dest="$tap_dir/Formula/$FORMULA_NAME" + formula_dest="$tap_dir/$FORMULA_NAME" test -f "$formula_source" - mkdir -p "$tap_dir/Formula" cp "$formula_source" "$formula_dest" cd "$tap_dir" - if git diff --quiet -- "Formula/$FORMULA_NAME"; then + if [[ -z "$(git status --porcelain -- "$FORMULA_NAME")" ]]; then echo "homebrew tap formula is unchanged" exit 0 fi @@ -637,7 +687,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" - git add "Formula/$FORMULA_NAME" + git add "$FORMULA_NAME" git commit -m "brew: update $FORMULA_NAME" git push origin "$branch" gh pr create \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cb748..7cdd6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] - 2026-06-24 + +### Added + +- CLI tools now support scoped dhttp home selection across identity, access, + curl, nslookup, NAT, proxy, and SSH flows. +- Release packaging now reads its build and destination contract from + `xtask/release.toml`, including per-target build environment bindings. + +### Changed + +- Identity flows use explicit apply targets, replacement-aware default prompts, + and selected-home local-state behavior for missing identity homes. +- Homebrew and S3/R2 package generation use the normalized manifest-first + packaging contract. + +### Fixed + +- Identity `default` and `renew` commands now report missing selected-home state + through user-facing business errors instead of raw filesystem/profile errors. +- CLI and package integration are aligned with the scoped dhttp home rollout. + +### Dependencies + +- Release manifests now target `h3x` v0.5.0, `dhttp` v0.4.0, + `dhttp-access` v0.3.0, `dshell` v0.5.0, `dyns` v0.5.0, and `rankey` v0.2.1. + +### Components + +- `genmeta` v0.6.1 +- `genmeta-curl` v0.6.0 +- `genmeta-ssh` v0.6.1 +- `genmeta-access` v0.3.0 +- `genmeta-identity` v0.3.0 +- `genmeta-proxy` v0.3.0 +- `genmeta-discover` v0.3.1 +- `genmeta-doctor` v0.3.1 +- `genmeta-nat` v0.4.0 +- `genmeta-nslookup` v0.4.0 + ## [0.6.0] - 2026-06-15 This release brings the command-line tool family onto the public DHTTP diff --git a/Cargo.lock b/Cargo.lock index 3fa2585..c8c7b9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -730,7 +730,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tower-service", @@ -900,7 +900,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -915,6 +915,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1516,9 +1522,9 @@ dependencies = [ [[package]] name = "dhttp" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff511421b0de59123d2f7dac10743709adf48a991620d4782f1f71f4d8e20966" +checksum = "25cac718ccb8db7ba6069fcc92adb7a3b5a24267ce704969bcf8336ecf29e822" dependencies = [ "bon", "bytes", @@ -1539,9 +1545,9 @@ dependencies = [ [[package]] name = "dhttp-access" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73da9a414cc0264c4511c4a9ba31f275d9d4ae861ace65ec96206e3ac4b38ad1" +checksum = "a5dee91d9ec4bf5f8fbd49a2761b1c5454aa9b088f6c53fc278f7b7934de9f38" dependencies = [ "chrono", "clap", @@ -1562,9 +1568,9 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecafb614c068796adf0c96af474356d26edec72e0f0ed8d6b7e96489df1e485f" +checksum = "81393cfab5863c0f1b7994c5da57c0a1e941cb62886e558c4832cfea37d1ea68" dependencies = [ "dhttp-identity", "dirs", @@ -1689,9 +1695,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c9472020223c058801106d9d738634ee732476796e6a6345257105c5b036a8" +checksum = "1d8837fb8f07c5915a23b2e2ebd2ec594c0ccf2036815598c57fb998b8480347" dependencies = [ "arc-swap", "dashmap", @@ -1699,7 +1705,7 @@ dependencies = [ "qconnection", "qresolve", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -1707,9 +1713,9 @@ dependencies = [ [[package]] name = "dshell" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "210e6b448354575feab2fa1f5245a7702c651004a57a0888d4607edc9b7c1bdd" +checksum = "10c9c1969d89ebe8ebccccbfe345ac98bc6171f4d279adebdd24d763266988ec" dependencies = [ "base64", "bytes", @@ -1735,9 +1741,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3c560fa266a789403c88224d3d3ca0a2a50fdce255db43765484ec6c5ba953d" +checksum = "d6cf3d0f58906886609f22154cba85632af0df9661517b2380b0b740fc6f89d3" dependencies = [ "base64", "bitfield-struct", @@ -2148,7 +2154,7 @@ dependencies = [ [[package]] name = "genmeta" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "genmeta-access", @@ -2167,7 +2173,7 @@ dependencies = [ [[package]] name = "genmeta-access" -version = "0.2.0" +version = "0.3.0" dependencies = [ "clap", "dhttp", @@ -2182,7 +2188,7 @@ dependencies = [ [[package]] name = "genmeta-curl" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-compression", "bytes", @@ -2199,7 +2205,7 @@ dependencies = [ [[package]] name = "genmeta-discover" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "dhttp", @@ -2213,7 +2219,7 @@ dependencies = [ [[package]] name = "genmeta-doctor" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "genmeta-nat", @@ -2224,7 +2230,7 @@ dependencies = [ [[package]] name = "genmeta-identity" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", "bytes", @@ -2235,6 +2241,7 @@ dependencies = [ "http 1.4.2", "indicatif", "inquire", + "nix 0.31.3", "p384", "pkcs8 0.11.0-rc.11", "rankey", @@ -2255,7 +2262,7 @@ dependencies = [ [[package]] name = "genmeta-nat" -version = "0.3.0" +version = "0.4.0" dependencies = [ "clap", "dhttp", @@ -2269,7 +2276,7 @@ dependencies = [ [[package]] name = "genmeta-nslookup" -version = "0.3.0" +version = "0.4.0" dependencies = [ "clap", "dhttp", @@ -2283,7 +2290,7 @@ dependencies = [ [[package]] name = "genmeta-proxy" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "clap", @@ -2303,7 +2310,7 @@ dependencies = [ [[package]] name = "genmeta-ssh" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "crossterm", @@ -2442,9 +2449,9 @@ dependencies = [ [[package]] name = "h3x" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbadb89a6ea8f80675b01720854d756e867f35a0e45b438d932ee1e9194ce07a" +checksum = "6ec18423b1a2995c1bbdae48684d12e26886775fe7299e11849d9b60215684f3" dependencies = [ "arc-swap", "async-channel", @@ -2761,7 +2768,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -3003,6 +3010,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -3012,10 +3035,10 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -3033,6 +3056,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -3287,19 +3319,22 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "netdev" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bacaf873ee4eab5646f99b381b271ec75e716902a67cf962c0f328c5eb5bfb" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" dependencies = [ "block2", "dispatch2", "dlopen2", "ipnet", + "jni 0.21.1", "libc", "mac-addr", + "ndk-context", "netlink-packet-core", "netlink-packet-route", "netlink-sys", + "objc2", "objc2-core-foundation", "objc2-core-wlan", "objc2-foundation", @@ -3320,9 +3355,9 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ "bitflags", "libc", @@ -3348,7 +3383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85950142187da505903b9167147149ef0c71273f2dd92252039691413ce68e1c" dependencies = [ "android-build", - "jni", + "jni 0.22.4", "ndk-context", "nix 0.31.3", "windows", @@ -3540,7 +3575,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", "objc2", "objc2-core-foundation", ] @@ -3562,11 +3596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ "bitflags", - "dispatch2", - "libc", - "objc2", "objc2-core-foundation", - "objc2-security", ] [[package]] @@ -3977,9 +4007,9 @@ dependencies = [ [[package]] name = "qbase" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "814882a54bd00217746c83254e1a4171ec1688b5967881a8ef7c2ec9efa0ef8f" +checksum = "d008096ac877ed51917bec865eb512113df9e9cc0d99ade74285060b695bea5c" dependencies = [ "bitflags", "bytes", @@ -3993,30 +4023,30 @@ dependencies = [ "rustls 0.23.40", "serde", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "qcongestion" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cebe0c237cd0272ab17881c8f6e4eec2c4d988716aa9ffcdd36dd3c0cb3eb656" +checksum = "92ff2c0dd91cf0e4cacca9e3b1ab8c2bd4bff93c17ba39f509e53f238a1fda42" dependencies = [ "qbase", "qevent", "rand 0.10.1", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "qconnection" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c78f0a8879438e1b301d913e1d79fb493eb7527aeb88a6ffe35e147ca6410894" +checksum = "f77febb7882e4bb2cab5fa85c145ebdd3a7350816545155dc44e9e9900595b40" dependencies = [ "bytes", "dashmap", @@ -4033,7 +4063,7 @@ dependencies = [ "qtraversal", "ring", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4042,9 +4072,9 @@ dependencies = [ [[package]] name = "qdatagram" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab3beebe7085ce1ce977a3cb4fd9feb674a2b3c72791cb781a80ebacd6e2594" +checksum = "4d891c28529949b435a5eb4ec0e4eb67e33d514dc993138e2da36bbff5c3f77a" dependencies = [ "bytes", "qbase", @@ -4054,9 +4084,9 @@ dependencies = [ [[package]] name = "qevent" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a8df4e7d964ab6551451cc27fe0c0e588ec38bfcff8a81afee678692be0b59" +checksum = "13122bb529c62512d876c08681d57f2ab0be4b5e9943657a19b74c0c83298638" dependencies = [ "bytes", "derive_builder", @@ -4073,9 +4103,9 @@ dependencies = [ [[package]] name = "qinterface" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aa66f19a792f2fd42df5c86eb5c8f09033c34a9d5182fd63ea81b68e80103cd" +checksum = "b3d6859183d5d4547470fd3ea641d700b79e416269f229b03b0c8709e2c98575" dependencies = [ "bytes", "dashmap", @@ -4089,7 +4119,7 @@ dependencies = [ "qevent", "qudp", "rustls 0.23.40", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4109,25 +4139,25 @@ dependencies = [ [[package]] name = "qrecovery" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5e4cc4b0c3947ca0383c1d0134315c2a12a4828ea1ebe5c7729489f111c4b3" +checksum = "4adfff2dbe1f591662695e3d91a4a0c3159b22a6c7a86aa754f07894c9e946da" dependencies = [ "bytes", "derive_more", "futures", "qbase", "qevent", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "qresolve" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782eaf7f1c47b007519997f93827f1dd3516547516d52343da1709979be87819" +checksum = "1919a853d70998ed4b230e94883dfbe2bd065c3eb5e5fe058540a8489956519d" dependencies = [ "futures", "qbase", @@ -4136,9 +4166,9 @@ dependencies = [ [[package]] name = "qtraversal" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77d06edaf1113b528c7dcc00f26c395ba971aa5577b761ece68869d2f04d44f" +checksum = "9846e09295d5d5977be88a3c8a478c264acd91cbbc86deeca6d9fc0d71d2ace8" dependencies = [ "bon", "bytes", @@ -4152,7 +4182,7 @@ dependencies = [ "rand 0.10.1", "smallvec", "snafu 0.9.1", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -4160,9 +4190,9 @@ dependencies = [ [[package]] name = "qudp" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94767eb1de257b50c7762f46286d14112a1b9611794b0aa57189d702ba5f06a8" +checksum = "1ce9009cdd2de1ef116833beeccf1a5426a0a8f426ee836fe6c05cba4322b915" dependencies = [ "bytes", "cfg-if", @@ -4340,7 +4370,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4701,7 +4731,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.40", @@ -4848,7 +4878,7 @@ dependencies = [ "serde_json", "sqlx", "strum", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4948,7 +4978,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -5452,7 +5482,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -5540,7 +5570,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5583,7 +5613,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -5610,7 +5640,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -5757,13 +5787,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6059,7 +6109,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -6665,6 +6715,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -6692,6 +6751,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -6732,6 +6806,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6744,6 +6824,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6756,6 +6842,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6774,6 +6866,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6786,6 +6884,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6798,6 +6902,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6810,6 +6920,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6968,7 +7084,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 1bce35c..4e3068f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ indicatif = "0.18" whoami = "2" # network -h3x = "0.4.0" +h3x = "0.5.0" http = "1" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["server", "tokio"] } @@ -85,6 +85,7 @@ dirs = { version = "6" } sea-orm = { version = "1", features = ["runtime-tokio-rustls"] } snafu = "0.9" socket2 = "0.6" +nix = { version = "0.31", default-features = false, features = ["user"] } time = "0.3" async-compression = { version = "0.4", features = [ "tokio", @@ -94,20 +95,20 @@ async-compression = { version = "0.4", features = [ ] } # workspace -dhttp-access = "0.2.0" -genmeta-access = { path = "genmeta-access", version = "0.2.0" } -dhttp = "0.2.0" -genmeta-curl = { path = "genmeta-curl", version = "0.5.0" } -genmeta-discover = { path = "genmeta-discover", version = "0.3.0" } -genmeta-doctor = { path = "genmeta-doctor", version = "0.3.0" } -genmeta-identity = { path = "genmeta-identity", version = "0.2.0" } -genmeta-nat = { path = "genmeta-nat", version = "0.3.0" } -genmeta-nslookup = { path = "genmeta-nslookup", version = "0.3.0" } -genmeta-ssh = { path = "genmeta-ssh", version = "0.6.0" } -genmeta-proxy = { path = "genmeta-proxy", version = "0.2.0" } +dhttp-access = "0.3.0" +genmeta-access = { path = "genmeta-access", version = "0.3.0" } +dhttp = "0.4.0" +genmeta-curl = { path = "genmeta-curl", version = "0.6.0" } +genmeta-discover = { path = "genmeta-discover", version = "0.3.1" } +genmeta-doctor = { path = "genmeta-doctor", version = "0.3.1" } +genmeta-identity = { path = "genmeta-identity", version = "0.3.0" } +genmeta-nat = { path = "genmeta-nat", version = "0.4.0" } +genmeta-nslookup = { path = "genmeta-nslookup", version = "0.4.0" } +genmeta-ssh = { path = "genmeta-ssh", version = "0.6.1" } +genmeta-proxy = { path = "genmeta-proxy", version = "0.3.0" } # DShell -dshell = { version = "0.4.0", features = [ +dshell = { version = "0.5.0", features = [ "config", ] } diff --git a/genmeta-access/Cargo.toml b/genmeta-access/Cargo.toml index e96c530..684f5f7 100644 --- a/genmeta-access/Cargo.toml +++ b/genmeta-access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-access" description = "access control rule management" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-access/src/cli.rs b/genmeta-access/src/cli.rs index 39a2c82..16a9d8a 100644 --- a/genmeta-access/src/cli.rs +++ b/genmeta-access/src/cli.rs @@ -47,6 +47,13 @@ where after_help = "Examples:\n genmeta access \"/\" allow luffy.pilot\n genmeta access \"/\" list\n genmeta access list --wide\n genmeta access --identity reimu.pilot \"/\" deny \"*?\"" )] pub struct Options { + #[arg( + long, + global = true, + help = "use the global dhttp home instead of the default user home" + )] + global: bool, + #[arg( long, value_name = "NAME", @@ -59,6 +66,14 @@ pub struct Options { } impl Options { + pub(crate) fn home_scope(&self) -> dhttp::home::HomeScope { + if self.global { + dhttp::home::HomeScope::Global + } else { + dhttp::home::HomeScope::User + } + } + pub(crate) fn into_parts(self) -> Result<(Option>, Command), ParseCommandError> { let identity = self.identity.map(|ReportFromStr(identity)| identity); let command = self.command.try_into()?; @@ -122,6 +137,23 @@ pub(crate) enum Command { }, } +impl Command { + pub(crate) fn writes_store(&self, db_exists: bool) -> bool { + match self { + Self::Print { .. } => false, + Self::List { .. } => !db_exists, + Self::RemovePaths { .. } => true, + Self::Path { operation, .. } => match operation { + PathOperation::List => !db_exists, + PathOperation::Remove { .. } + | PathOperation::Clear + | PathOperation::Allow { .. } + | PathOperation::Deny { .. } => true, + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) enum PathOperation { List, diff --git a/genmeta-access/src/lib.rs b/genmeta-access/src/lib.rs index be57bd6..627a9fa 100644 --- a/genmeta-access/src/lib.rs +++ b/genmeta-access/src/lib.rs @@ -18,7 +18,7 @@ use dhttp::{ }, }, }, - home::{DhttpHome, LocateDhttpHomeError, identity::settings::LoadDhttpSettingsError}, + home::{DhttpHome, LoadDhttpHomeError, identity::settings::LoadDhttpSettingsError}, }; use snafu::{IntoError, OptionExt, ResultExt, Snafu}; use tracing_subscriber::prelude::*; @@ -33,8 +33,8 @@ pub enum Error { #[snafu(transparent)] ParseCommand { source: ParseCommandError }, - #[snafu(display("failed to locate DHTTP_CONFIG"))] - LocateHome { source: LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadHome { source: LoadDhttpHomeError }, #[snafu(display("failed to load default identity config"))] LoadDefaultIdentityConfig { source: LoadDhttpSettingsError }, @@ -96,7 +96,7 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard { pub async fn run(options: Options) -> Result<(), Error> { let _guard = init_tracing(); - let home = DhttpHome::load_from_environment().context(error::LocateHomeSnafu)?; + let home = DhttpHome::load(options.home_scope()).context(error::LoadHomeSnafu)?; let output = run_for_home(&home, options).await?; if !output.is_empty() { @@ -106,6 +106,7 @@ pub async fn run(options: Options) -> Result<(), Error> { } pub async fn run_for_home(home: &DhttpHome, options: Options) -> Result { + let global = matches!(options.home_scope(), dhttp::home::HomeScope::Global); let (identity, command) = options.into_parts()?; if let Command::Print { output } = command { return Ok(output); @@ -114,7 +115,16 @@ pub async fn run_for_home(home: &DhttpHome, options: Options) -> Result Result< Ok(String::new()) } + +#[cfg(test)] +mod tests { + use super::{Command, PathOperation}; + + #[test] + fn command_writes_store_when_store_is_missing() { + assert!(Command::List { wide: false }.writes_store(false)); + assert!(!Command::List { wide: false }.writes_store(true)); + assert!( + Command::RemovePaths { + patterns: Vec::new() + } + .writes_store(true) + ); + } + + #[test] + fn path_command_writes_store_only_for_mutations() { + assert!( + !Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(true) + ); + assert!( + Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::List, + } + .writes_store(false) + ); + assert!( + Command::Path { + pattern: "/".parse().unwrap(), + operation: PathOperation::Remove { + all: true, + sequence: Vec::new(), + }, + } + .writes_store(true) + ); + } +} diff --git a/genmeta-access/tests/cli.rs b/genmeta-access/tests/cli.rs index dd35613..97a540f 100644 --- a/genmeta-access/tests/cli.rs +++ b/genmeta-access/tests/cli.rs @@ -247,6 +247,18 @@ fn help_output_shows_inline_usage() { assert!(help.contains("genmeta access \"/\" allow luffy.pilot")); } +#[test] +fn global_flag_parses_and_is_described_in_help() { + assert!(Options::try_parse_from(["access", "--global", "list"]).is_ok()); + + let mut help = Vec::new(); + Options::command().write_long_help(&mut help).unwrap(); + let help = String::from_utf8(help).unwrap(); + + assert!(help.contains("--global")); + assert!(help.contains("global dhttp home")); +} + #[tokio::test] async fn bare_word_is_rejected_as_location_pattern() { let test_home = TestHome::new("bare-word"); diff --git a/genmeta-curl/Cargo.toml b/genmeta-curl/Cargo.toml index a334542..8ec4481 100644 --- a/genmeta-curl/Cargo.toml +++ b/genmeta-curl/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-curl" description = "curl-like DHTTP/3 client" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-curl/src/lib.rs b/genmeta-curl/src/lib.rs index 355408e..6544897 100644 --- a/genmeta-curl/src/lib.rs +++ b/genmeta-curl/src/lib.rs @@ -98,6 +98,10 @@ pub struct Options { #[arg(short, long, value_name = "client_identity")] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -131,6 +135,16 @@ pub struct Options { show_error: bool, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(module)] pub enum Error { @@ -145,8 +159,8 @@ pub enum Error { #[snafu(display("failed to construct normalized request uri"))] ConstructRequestUri { source: http::uri::InvalidUriParts }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -424,16 +438,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -907,6 +921,8 @@ pub async fn run(mut options: Options) -> Result<(), Error> { mod tests { use std::time::Duration; + use clap::Parser; + use super::*; #[test] @@ -927,4 +943,12 @@ mod tests { assert_eq!(normalized.to_string(), "https://reimu.pilot.dhttp.net/"); } + + #[test] + fn options_accept_global_flag() { + let options = + Options::try_parse_from(["genmeta-curl", "--global", "https://example.com/"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } } diff --git a/genmeta-discover/Cargo.toml b/genmeta-discover/Cargo.toml index 5c748cb..a4de053 100644 --- a/genmeta-discover/Cargo.toml +++ b/genmeta-discover/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-discover" description = "mdns discover services" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-doctor/Cargo.toml b/genmeta-doctor/Cargo.toml index e0b0a17..6ccd29e 100644 --- a/genmeta-doctor/Cargo.toml +++ b/genmeta-doctor/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-doctor" description = "diagnosing and fixing environment issues" -version = "0.3.0" +version = "0.3.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-identity/Cargo.toml b/genmeta-identity/Cargo.toml index 665897c..718250d 100644 --- a/genmeta-identity/Cargo.toml +++ b/genmeta-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-identity" description = "managing identities for genmeta network" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -48,3 +48,6 @@ cli = [ "dep:crossterm", "dep:whoami", ] + +[target.'cfg(unix)'.dependencies] +nix = { workspace = true } diff --git a/genmeta-identity/src/cli.rs b/genmeta-identity/src/cli.rs index ac52f43..8aca28e 100644 --- a/genmeta-identity/src/cli.rs +++ b/genmeta-identity/src/cli.rs @@ -8,7 +8,7 @@ use clap::Parser; use dhttp::{ certificate::CertificateChainKey, home::{ - DhttpHome, + DhttpHome, HomeScope, LoadDhttpHomeError, identity::{ settings::{DhttpSettingsFile, LoadDhttpSettingsError, SaveDhttpSettingsError}, ssl::{ @@ -19,6 +19,7 @@ use dhttp::{ }, name::DhttpName as Name, }; +pub use flow::welcome::WelcomeServiceError; use indicatif::ProgressStyle; use rankey::EncodePem; use snafu::{ResultExt, Snafu, Whatever, whatever}; @@ -47,6 +48,11 @@ pub enum Error { LoadDefaultConfig { source: LoadDhttpSettingsError }, #[snafu(transparent)] SaveDefaultConfig { source: SaveDhttpSettingsError }, + #[snafu(display("failed to create dhttp home directory at {}", path.display()))] + CreateDhttpHomeDir { + path: std::path::PathBuf, + source: io::Error, + }, #[snafu(transparent)] ListIdentities { source: ListIdentityProfilesError }, #[snafu(transparent)] @@ -66,6 +72,8 @@ pub enum Error { source: flow::kind::ParseIdentityKindError, }, #[snafu(transparent)] + WelcomeService { source: WelcomeServiceError }, + #[snafu(transparent)] LocalIdentity { source: crate::local_identity::Error, }, @@ -85,10 +93,8 @@ pub enum Error { source: Box, }, - #[snafu(transparent)] - LocateDhttpHome { - source: dhttp::home::LocateDhttpHomeError, - }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: LoadDhttpHomeError }, #[snafu(transparent)] Whatever { source: Whatever }, @@ -175,6 +181,14 @@ async fn load_current_settings(dhttp_home: &DhttpHome) -> Result Result<(), Error> { + if let Some(parent) = default_config.path().parent() { + tokio::fs::create_dir_all(parent) + .await + .context(CreateDhttpHomeDirSnafu { + path: parent.to_path_buf(), + })?; + } + let path = default_config.path().display(); tracing::Span::current().pb_set_message(&format!("Saving default configuration to {path}...")); default_config.save().await?; @@ -306,8 +320,6 @@ pub struct Create { pub struct Apply { #[arg(value_name = "IDENTITY")] pub name: Option, - #[arg(long = "default", conflicts_with = "name")] - pub use_default: bool, #[arg(long)] pub kind: Option, #[arg(long)] @@ -353,8 +365,13 @@ pub struct Default { } impl Default { - pub async fn run(&self, dhttp_home: &DhttpHome, cert_server: &CertServer) -> Result<(), Error> { - flow::default_identity::run(self, dhttp_home, cert_server).await + pub async fn run( + &self, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + ) -> Result<(), Error> { + flow::default_identity::run(self, dhttp_home, home_scope, cert_server).await } } @@ -382,7 +399,7 @@ impl List { ) .await?; if inventory.groups.is_empty() { - flow::transcript::print_line("No local identities found"); + flow::transcript::print_line("No identities found here"); } else { flow::transcript::print_block(&flow::output::render_inventory( &inventory, @@ -422,12 +439,19 @@ impl Info { ), }, }; - let summary = flow::local::load_summary( + let Some(summary) = flow::local::try_load_summary( dhttp_home, name.borrow(), default_name.as_ref().map(|default| default.borrow()), ) - .await?; + .await? + else { + whatever!( + "{} is not saved here.\n\nTo inspect it here, apply {} here first.", + name.as_partial(), + name.as_partial(), + ); + }; flow::transcript::print_block(&flow::output::format_info( &summary, std::io::stdout().is_terminal(), @@ -436,6 +460,31 @@ impl Info { Ok(()) } } + +#[derive(Parser, Debug, Clone)] +#[command(version, about, disable_help_flag = true, disable_version_flag = true)] +pub struct Cli { + #[arg( + long, + global = true, + help = "use the global dhttp home instead of the default user home" + )] + pub global: bool, + + #[command(subcommand)] + pub options: Options, +} + +impl Cli { + pub fn home_scope(&self) -> HomeScope { + if self.global { + HomeScope::Global + } else { + HomeScope::User + } + } +} + #[derive(Parser, Debug, Clone)] #[command(about, disable_help_flag = true, disable_version_flag = true)] pub enum Options { @@ -449,12 +498,28 @@ pub enum Options { } impl Options { - pub async fn run(&self, dhttp_home: &DhttpHome, cert_server: &CertServer) -> Result<(), Error> { + pub fn writes_home(&self) -> bool { + matches!( + self, + Self::Create(_) | Self::Apply(_) | Self::Renew(_) | Self::Default(_) + ) + } + + pub async fn run( + &self, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + ) -> Result<(), Error> { match self { - Options::Create(cmd) => flow::run_create(cmd, dhttp_home, cert_server).await, - Options::Apply(cmd) => flow::run_apply(cmd, dhttp_home, cert_server).await, + Options::Create(cmd) => { + flow::run_create(cmd, dhttp_home, home_scope, cert_server).await + } + Options::Apply(cmd) => flow::run_apply(cmd, dhttp_home, home_scope, cert_server).await, Options::Renew(cmd) => flow::run_renew(cmd, dhttp_home, cert_server).await, - Options::Default(cmd) => flow::run_default(cmd, dhttp_home, cert_server).await, + Options::Default(cmd) => { + flow::run_default(cmd, dhttp_home, home_scope, cert_server).await + } Options::Info(cmd) => flow::run_info(cmd, dhttp_home, cert_server).await, Options::List(cmd) => flow::run_list(cmd, dhttp_home, cert_server).await, Options::Version {} => { @@ -495,26 +560,60 @@ fn cert_server_base_url() -> &'static str { CERT_SERVER_BASE_URL } -pub async fn run(options: Options) -> Result<(), Error> { +pub async fn run(options: Cli) -> Result<(), Error> { init_tracing(); - let dhttp_home = DhttpHome::load_from_environment()?; + let home_scope = options.home_scope(); + let dhttp_home = DhttpHome::load(home_scope).context(LoadDhttpHomeSnafu)?; + + if options.global && options.options.writes_home() { + tracing::warn!( + path = %dhttp_home.as_path().display(), + "using the global dhttp home; this operation may require elevated privileges" + ); + } _ = rustls::crypto::ring::default_provider().install_default(); let cert_server = CertServer::new(cert_server_base_url())?; - options.run(&dhttp_home, &cert_server).await + options + .options + .run(&dhttp_home, home_scope, &cert_server) + .await } #[cfg(test)] mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use clap::{CommandFactory, Parser}; - use dhttp::{identity::Identity, name::Name}; + use dhttp::{ + home::{DhttpHome, HomeScope}, + identity::Identity, + name::{DhttpName, Name}, + }; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; - use super::{Create, Options, cert_server_base_url, certificate_chain_key_from_identity}; + use super::{ + Cli, Create, Default, Info, Options, cert_server_base_url, + certificate_chain_key_from_identity, + }; use crate::CERT_SERVER_BASE_URL; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-cli-{test_name}-{}-{nonce}", + std::process::id() + )) + } + #[test] fn cert_server_base_url_uses_compile_time_bootstrap_url() { let url = cert_server_base_url(); @@ -531,6 +630,11 @@ mod tests { ) } + fn dummy_cert_server() -> crate::cert_server::CertServer { + _ = rustls::crypto::ring::default_provider().install_default(); + crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() + } + #[test] fn certificate_chain_key_from_identity_reads_dhttp_ski() { let identity = local_identity_with_dhttp_ski(); @@ -596,6 +700,15 @@ mod tests { ); } + #[test] + fn helper_style_read_and_write_subcommands_are_rejected() { + for command in ["read", "write"] { + let error = Options::try_parse_from(["genmeta", command]).unwrap_err(); + let rendered = error.to_string(); + assert!(rendered.contains(command), "{rendered}"); + } + } + #[test] fn renew_rejects_kind_and_sequence_flags() { for (flag, value) in [("--kind", "primary"), ("--sequence", "1")] { @@ -642,11 +755,12 @@ mod tests { } #[test] - fn apply_and_renew_accept_default_flag() { - assert!( - Options::try_parse_from(["genmeta", "apply", "--default", "--kind", "primary",]) - .is_ok() - ); + fn apply_rejects_default_flag_while_renew_keeps_it() { + let apply_error = + Options::try_parse_from(["genmeta", "apply", "--default", "--kind", "primary"]) + .unwrap_err(); + assert!(apply_error.to_string().contains("--default")); + assert!(Options::try_parse_from(["genmeta", "renew", "--default"]).is_ok()); } @@ -687,4 +801,118 @@ mod tests { assert!(rendered.contains("--send-code"), "{rendered}"); assert!(rendered.contains("--verify-code"), "{rendered}"); } + + #[tokio::test] + async fn save_settings_creates_missing_home_directory() { + let home_path = unique_test_home_path("save-settings"); + let dhttp_home = DhttpHome::new(home_path.clone()); + let mut settings = dhttp_home.new_settings(); + settings + .settings_mut() + .set_default_identity_name(DhttpName::try_from("alice.smith").unwrap()); + + super::save_settings(&settings).await.unwrap(); + + assert!(home_path.join("settings.toml").exists()); + } + + #[tokio::test] + async fn info_reports_unsaved_identity_with_business_message() { + let home_path = unique_test_home_path("info-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Info { + name: Some("alice.smith".to_string()), + }; + + let error = command + .run(&dhttp_home, &dummy_cert_server()) + .await + .unwrap_err(); + let rendered = error.to_string(); + + assert!( + rendered.contains("alice.smith is not saved here"), + "{rendered}" + ); + assert!( + rendered.contains("apply alice.smith here first"), + "{rendered}" + ); + } + + #[tokio::test] + async fn default_reports_unsaved_identity_non_interactively() { + let home_path = unique_test_home_path("default-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Default { + name: Some("alice.smith".to_string()), + allow_nonready: false, + }; + + let error = command + .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) + .await + .unwrap_err(); + let rendered = error.to_string(); + + assert!( + rendered.contains("alice.smith is not saved here"), + "{rendered}" + ); + } + + #[tokio::test] + async fn default_named_saved_identity_sets_default_non_interactively() { + let home_path = unique_test_home_path("default-saved-noninteractive"); + let dhttp_home = DhttpHome::new(home_path.clone()); + let name = DhttpName::try_from("alice.smith").unwrap(); + let profile = dhttp_home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + + let command = Default { + name: Some("alice.smith".to_string()), + allow_nonready: true, + }; + + command + .run(&dhttp_home, HomeScope::User, &dummy_cert_server()) + .await + .unwrap(); + + let settings = dhttp_home.load_settings().await.unwrap(); + assert_eq!( + settings + .settings() + .default_identity_name() + .map(|name| name.as_partial()), + Some("alice.smith") + ); + + tokio::fs::remove_dir_all(home_path).await.unwrap(); + } + + #[test] + fn cli_accepts_global_before_and_after_subcommand() { + let before = Cli::try_parse_from(["genmeta", "--global", "list"]).unwrap(); + let after = Cli::try_parse_from(["genmeta", "list", "--global"]).unwrap(); + + assert_eq!(before.home_scope(), dhttp::home::HomeScope::Global); + assert_eq!(after.home_scope(), dhttp::home::HomeScope::Global); + } + + #[test] + fn write_commands_are_marked_for_global_warning() { + for argv in [ + ["genmeta", "create", "alice.smith", "--kind", "primary"].as_slice(), + ["genmeta", "apply", "alice.smith", "--kind", "primary"].as_slice(), + ["genmeta", "renew", "alice.smith"].as_slice(), + ["genmeta", "default", "alice.smith"].as_slice(), + ] { + let cli = Cli::try_parse_from(argv).unwrap(); + assert!(cli.options.writes_home()); + } + + let info = Cli::try_parse_from(["genmeta", "info", "alice.smith"]).unwrap(); + assert!(!info.options.writes_home()); + } } diff --git a/genmeta-identity/src/cli/flow.rs b/genmeta-identity/src/cli/flow.rs index 1dcd709..a1542c5 100644 --- a/genmeta-identity/src/cli/flow.rs +++ b/genmeta-identity/src/cli/flow.rs @@ -13,8 +13,9 @@ pub(crate) mod recovery; pub(crate) mod renew; pub(crate) mod target; pub(crate) mod transcript; +pub(crate) mod welcome; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use crate::{ cert_server::CertServer, @@ -24,17 +25,19 @@ use crate::{ pub(crate) async fn run_create( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - create::run(command, dhttp_home, cert_server).await + create::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_apply( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - apply::run(command, dhttp_home, cert_server).await + apply::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_renew( @@ -48,9 +51,10 @@ pub(crate) async fn run_renew( pub(crate) async fn run_default( command: &Default, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { - default_identity::run(command, dhttp_home, cert_server).await + default_identity::run(command, dhttp_home, home_scope, cert_server).await } pub(crate) async fn run_info( diff --git a/genmeta-identity/src/cli/flow/apply.rs b/genmeta-identity/src/cli/flow/apply.rs index d58117e..a7b330f 100644 --- a/genmeta-identity/src/cli/flow/apply.rs +++ b/genmeta-identity/src/cli/flow/apply.rs @@ -1,13 +1,13 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use tracing::{Instrument, info_span}; use super::{ approval, kind::IdentityKind, - local::{self, InteractiveInventoryChoice, LocalIdentityStatus, LocalIdentitySummary}, + local::{self, LocalIdentityStatus, LocalIdentitySummary}, target::{IdentityLevel, IdentityTarget}, }; use crate::{ @@ -107,6 +107,12 @@ pub(crate) enum ApplyRunOutcome { ReturnedToCaller, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApplyPostSavePolicy { + ManageDefaultSuggestion, + SkipDefaultSuggestion, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ApplyVerifyCodeAction { ResendVerificationCode, @@ -373,63 +379,30 @@ async fn resolve_approval_plan( } fn apply_identity_name_opening() -> &'static str { - "Apply an existing identity to this device.\n\nThis will create a new certificate chain for an existing identity\nand save it on this device.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" + "Apply an existing identity here.\n\nThis will create a new certificate chain for an existing identity\nand save it here.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo apply a sub-identity, add one more name before it:\n phone.alice.smith" } -async fn resolve_target( +fn explicit_target_from_command( command: &Apply, - dhttp_home: &DhttpHome, -) -> Result, Error> { - if command.use_default { - return cli::resolve_default_target_name(dhttp_home).await; - } +) -> Result>, Error> { + command + .name + .as_deref() + .map(cli::parse_identity_name) + .transpose() +} - match command.name.as_deref() { - Some(name) => cli::parse_identity_name(name), - None => { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_apply_inventory_choices(&inventory); - if choices.is_empty() { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - return cli::parse_identity_name(&identity); - } - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to apply to this device:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), - InteractiveInventoryChoice::Organization { target } => Ok(target.into_dhttp_name()), - InteractiveInventoryChoice::EnterAnotherIdentity => { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - cli::parse_identity_name(&identity) - } - } - } +async fn prompt_apply_target() -> Result, Error> { + let identity = crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) + .await + .require_interactive("IDENTITY")?; + cli::parse_identity_name(&identity) +} + +async fn resolve_target(command: &Apply) -> Result, Error> { + match explicit_target_from_command(command)? { + Some(name) => Ok(name), + None => prompt_apply_target().await, } } @@ -531,6 +504,7 @@ async fn resolve_apply_candidate( async fn run_helper_apply_action( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, auth_domain: &str, action: approval::ApprovalHelperAction, @@ -540,7 +514,6 @@ async fn run_helper_apply_action( approval::ApprovalHelperAction::Apply | approval::ApprovalHelperAction::Reapply => { let command = Apply { name: Some(auth_domain.to_string()), - use_default: false, kind: None, replace_local: matches!(action, approval::ApprovalHelperAction::Reapply), device_name: None, @@ -552,6 +525,7 @@ async fn run_helper_apply_action( match Box::pin(run_interactive( &command, dhttp_home, + home_scope, cert_server, return_to, )) @@ -625,75 +599,55 @@ async fn prompt_apply_approval_menu_action( .whatever_context::<_, Error>("selected apply approval action is unavailable") } -pub(crate) async fn run_interactive( +async fn run_post_save_epilogue( + post_save: ApplyPostSavePolicy, + dhttp_home: &DhttpHome, + domain: dhttp::name::DhttpName<'_>, + default_identity_when_command_started: Option>, + interactive: bool, + welcome: Option<&super::welcome::WelcomeServiceCreated>, +) -> Result<(), Error> { + match post_save { + ApplyPostSavePolicy::ManageDefaultSuggestion => { + crate::cli::flow::epilogue::run_lifecycle_epilogue( + dhttp_home, + domain, + default_identity_when_command_started, + interactive, + super::output::SavedIdentityAction::Applied, + welcome, + ) + .await + } + ApplyPostSavePolicy::SkipDefaultSuggestion => { + crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain, + super::output::SavedIdentityAction::Applied, + welcome, + ) + .await + } + } +} + +async fn run_interactive_with_policy( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, return_to: Option<&str>, + post_save: ApplyPostSavePolicy, ) -> Result { let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let initial_target = if command.use_default { - Some(cli::resolve_default_target_name(dhttp_home).await?) - } else { - command - .name - .as_deref() - .map(cli::parse_identity_name) - .transpose()? - }; + let initial_target = explicit_target_from_command(command)?; let mut state = InteractiveApplyState::from_command(command, initial_target)?; loop { if state.target.is_none() { - let default_name = cli::load_current_settings(dhttp_home) - .await? - .and_then(|config| config.settings().default_identity_name().cloned()); - let inventory = - local::load_inventory(dhttp_home, default_name.as_ref().map(|name| name.borrow())) - .await?; - let choices = local::build_apply_inventory_choices(&inventory); - if choices.is_empty() { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - state.target = Some(cli::parse_identity_name(&identity)?); - } else { - let labels: Vec = choices - .iter() - .map(|choice| { - super::output::render_choice_label(choice, std::io::stdout().is_terminal()) - }) - .collect(); - let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to apply to this device:", - labels.clone(), - ) - .await - .require_interactive("IDENTITY")?; - let choice = choices - .into_iter() - .zip(labels) - .find_map(|(choice, label)| (label == selected).then_some(choice)) - .whatever_context::<_, Error>("selected identity choice is unavailable")?; - match choice { - InteractiveInventoryChoice::Saved(summary) => { - state.target = Some(summary.target.into_dhttp_name()); - } - InteractiveInventoryChoice::Organization { target } => { - state.target = Some(target.into_dhttp_name()); - } - InteractiveInventoryChoice::EnterAnotherIdentity => { - let identity = - crate::cli::prompt::prompt_identity_name(apply_identity_name_opening()) - .await - .require_interactive("IDENTITY")?; - state.target = Some(cli::parse_identity_name(&identity)?); - } - } - } + state.target = Some(prompt_apply_target().await?); continue; } @@ -769,8 +723,15 @@ pub(crate) async fn run_interactive( action, } = approval_plan.clone() { - if !run_helper_apply_action(dhttp_home, cert_server, &auth_domain, action, return_to) - .await? + if !run_helper_apply_action( + dhttp_home, + home_scope, + cert_server, + &auth_domain, + action, + return_to, + ) + .await? { state.approval_plan = None; state.revisit_verification_method(); @@ -963,25 +924,59 @@ pub(crate) async fn run_interactive( ) .instrument(info_span!("save_identity")) .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, domain.borrow(), home_scope) + .await?; + run_post_save_epilogue( + post_save, dhttp_home, domain.borrow(), default_identity_when_command_started.clone(), std::io::stdin().is_terminal(), + welcome.as_ref(), ) .await?; return Ok(ApplyRunOutcome::Applied); } } -pub(crate) async fn run( +pub(crate) async fn run_interactive( command: &Apply, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, + return_to: Option<&str>, +) -> Result { + run_interactive_with_policy( + command, + dhttp_home, + home_scope, + cert_server, + return_to, + ApplyPostSavePolicy::ManageDefaultSuggestion, + ) + .await +} + +pub(crate) async fn run_with_policy( + command: &Apply, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + post_save: ApplyPostSavePolicy, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); if is_interactive && !command.send_code { - return match run_interactive(command, dhttp_home, cert_server, None).await? { + return match run_interactive_with_policy( + command, + dhttp_home, + home_scope, + cert_server, + None, + post_save, + ) + .await? + { ApplyRunOutcome::Applied => Ok(()), ApplyRunOutcome::ReturnedToCaller => whatever!("apply was cancelled"), }; @@ -989,7 +984,7 @@ pub(crate) async fn run( let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? .and_then(|config| config.settings().default_identity_name().cloned()); - let domain = resolve_target(command, dhttp_home).await?; + let domain = resolve_target(command).await?; let target = IdentityTarget::parse(domain.as_partial())?; let kind = resolve_kind(command).await?; let device_name = super::device::resolve_device_name(command.device_name.as_deref()); @@ -1067,11 +1062,32 @@ pub(crate) async fn run( ) .instrument(info_span!("save_identity")) .await?; - crate::cli::flow::epilogue::run_lifecycle_epilogue( + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, domain.borrow(), home_scope) + .await?; + run_post_save_epilogue( + post_save, dhttp_home, domain.borrow(), default_identity_when_command_started, is_interactive, + welcome.as_ref(), + ) + .await +} + +pub(crate) async fn run( + command: &Apply, + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, +) -> Result<(), Error> { + run_with_policy( + command, + dhttp_home, + home_scope, + cert_server, + ApplyPostSavePolicy::ManageDefaultSuggestion, ) .await } @@ -1082,7 +1098,7 @@ mod tests { ApplyApprovalMenuAction, ApplyApprovalPlan, ApplyEmailAction, ApplyVerifyCodeAction, InteractiveApplyState, apply_approval_menu_actions, apply_email_actions, apply_identity_name_opening, apply_verification_options, apply_verify_code_actions, - approval_plan_from_selection, build_apply_approval_options, + approval_plan_from_selection, build_apply_approval_options, explicit_target_from_command, resolve_non_interactive_approval_plan, }; use crate::{ @@ -1098,7 +1114,6 @@ mod tests { let mut state = InteractiveApplyState::from_command( &Apply { name: Some("alice.smith".to_string()), - use_default: false, kind: Some("primary".to_string()), replace_local: false, device_name: None, @@ -1127,7 +1142,6 @@ mod tests { let mut state = InteractiveApplyState::from_command( &Apply { name: Some("alice.smith".to_string()), - use_default: false, kind: Some("primary".to_string()), replace_local: false, device_name: None, @@ -1152,6 +1166,23 @@ mod tests { assert!(state.verify_code.is_none()); } + #[test] + fn explicit_target_from_command_returns_none_without_name() { + let target = explicit_target_from_command(&Apply { + name: None, + kind: None, + replace_local: false, + device_name: None, + email: None, + send_code: false, + verify_code: None, + auth: None, + }) + .unwrap(); + + assert!(target.is_none()); + } + #[test] fn root_apply_without_local_auth_defaults_to_email_non_interactively() { assert_eq!( @@ -1210,7 +1241,7 @@ mod tests { #[test] fn apply_identity_name_opening_matches_spec_copy() { let opening = apply_identity_name_opening(); - assert!(opening.contains("Apply an existing identity to this device.")); + assert!(opening.contains("Apply an existing identity here.")); assert!(opening.contains(".")); assert!(opening.contains("alice.smith")); assert!(opening.contains("phone.alice.smith")); @@ -1231,7 +1262,7 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } @@ -1252,8 +1283,8 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Renew alice.smith on this device, then verify with alice.smith".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Renew alice.smith here, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/approval.rs b/genmeta-identity/src/cli/flow/approval.rs index 2df8d9b..aee0918 100644 --- a/genmeta-identity/src/cli/flow/approval.rs +++ b/genmeta-identity/src/cli/flow/approval.rs @@ -163,15 +163,15 @@ impl ApprovalHelperOption { pub(crate) fn label(&self) -> String { match self.action { ApprovalHelperAction::Apply => format!( - "Apply {} to this device, then verify with {}", + "Apply {} here, then verify with {}", self.short_name, self.short_name ), ApprovalHelperAction::Reapply => format!( - "Re-apply {} to this device, then verify with {}", + "Re-apply {} here, then verify with {}", self.short_name, self.short_name ), ApprovalHelperAction::Renew => format!( - "Renew {} on this device, then verify with {}", + "Renew {} here, then verify with {}", self.short_name, self.short_name ), } @@ -342,8 +342,8 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Renew alice.smith on this device, then verify with alice.smith".to_string(), - "Re-apply alice.smith to this device, then verify with alice.smith".to_string(), + "Renew alice.smith here, then verify with alice.smith".to_string(), + "Re-apply alice.smith here, then verify with alice.smith".to_string(), ] ); } @@ -354,7 +354,7 @@ mod tests { assert_eq!( option.label(), - "Apply alice.smith to this device, then verify with alice.smith" + "Apply alice.smith here, then verify with alice.smith" ); } } diff --git a/genmeta-identity/src/cli/flow/create.rs b/genmeta-identity/src/cli/flow/create.rs index 453c024..bca7426 100644 --- a/genmeta-identity/src/cli/flow/create.rs +++ b/genmeta-identity/src/cli/flow/create.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{FromString, OptionExt, whatever}; use tracing::{Instrument, info_span}; @@ -315,7 +315,7 @@ fn resolve_non_interactive_approval_plan( Some(AuthMethod::Identity) => { let Some(ready_parent_identity) = ready_parent_identity else { whatever!( - "creating {} with --auth identity requires a ready local parent identity on this device", + "creating {} with --auth identity requires a ready parent identity saved here", target.short_name() ); }; @@ -434,7 +434,7 @@ fn ensure_non_interactive_sub_identity_checkout_not_required( } fn create_identity_name_opening() -> &'static str { - "Create a new identity for this device.\n\nThis will create a new identity or sub-identity, complete the required verification,\nand save it on this device.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo create a sub-identity, add one more name before it:\n phone.alice.smith" + "Create a new identity here.\n\nThis will create a new identity or sub-identity, complete the required verification,\nand save it here.\n\nUse a dotted name:\n .\n\nFor example:\n alice.smith\n\nTo create a sub-identity, add one more name before it:\n phone.alice.smith" } async fn prompt_create_email_action( @@ -598,7 +598,7 @@ async fn ensure_parent_identity_ready( match summary.status { LocalIdentityStatus::Ready { .. } => Ok(parent), _ => whatever!( - "creating {} with --auth identity requires a ready local parent identity at {}", + "creating {} with --auth identity requires a ready parent identity saved here at {}", target.short_name(), summary.saved_at.display() ), @@ -659,6 +659,7 @@ async fn resolve_parent_candidate( async fn run_helper_apply_parent( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, parent_identity: &str, @@ -669,14 +670,13 @@ async fn run_helper_apply_parent( .unwrap_or_else(|_| parent_identity.to_string()); let verb = if replace_local { "re-apply" } else { "apply" }; crate::cli::flow::transcript::print_block(&format!( - "This command needs {short_parent_identity} available on this device first. + "This command needs {short_parent_identity} saved here first. -To continue creating {}, it will {verb} {short_parent_identity} on this device, then return here and continue verification.", +To continue creating {}, it will {verb} {short_parent_identity} here, then return here and continue verification.", target.short_name() )); let command = crate::cli::Apply { name: Some(parent_identity.to_string()), - use_default: false, kind: None, replace_local, device_name: None, @@ -688,6 +688,7 @@ To continue creating {}, it will {verb} {short_parent_identity} on this device, match super::apply::run_interactive( &command, dhttp_home, + home_scope, cert_server, Some(&format!("create {}", target.short_name())), ) @@ -700,6 +701,7 @@ To continue creating {}, it will {verb} {short_parent_identity} on this device, async fn run_helper_parent_action( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, parent_identity: &str, @@ -707,10 +709,26 @@ async fn run_helper_parent_action( ) -> Result { match action { approval::ApprovalHelperAction::Apply => { - run_helper_apply_parent(dhttp_home, cert_server, target, parent_identity, false).await + run_helper_apply_parent( + dhttp_home, + home_scope, + cert_server, + target, + parent_identity, + false, + ) + .await } approval::ApprovalHelperAction::Reapply => { - run_helper_apply_parent(dhttp_home, cert_server, target, parent_identity, true).await + run_helper_apply_parent( + dhttp_home, + home_scope, + cert_server, + target, + parent_identity, + true, + ) + .await } approval::ApprovalHelperAction::Renew => { super::renew::run_helper_for_verification( @@ -917,6 +935,7 @@ async fn create_sub_identity_with_email_interactively( async fn run_interactive( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let default_identity_when_command_started = cli::load_current_settings(dhttp_home) @@ -1033,8 +1052,15 @@ async fn run_interactive( .parent_identity .as_deref() .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action(dhttp_home, cert_server, &target, parent_identity, action) - .await? + if !run_helper_parent_action( + dhttp_home, + home_scope, + cert_server, + &target, + parent_identity, + action, + ) + .await? { state.approval_plan = None; state.reset_after_approval_change(); @@ -1390,11 +1416,19 @@ async fn run_interactive( } } + let welcome = super::welcome::maybe_create_welcome_service( + dhttp_home, + target.dhttp_name(), + home_scope, + ) + .await?; crate::cli::flow::epilogue::run_lifecycle_epilogue( dhttp_home, target.dhttp_name(), default_identity_when_command_started.clone(), std::io::stdin().is_terminal(), + super::output::SavedIdentityAction::Created, + welcome.as_ref(), ) .await?; return Ok(()); @@ -1404,11 +1438,12 @@ async fn run_interactive( pub(crate) async fn run( command: &Create, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let is_interactive = std::io::stdin().is_terminal(); if is_interactive && !command.send_code { - return run_interactive(command, dhttp_home, cert_server).await; + return run_interactive(command, dhttp_home, home_scope, cert_server).await; } let default_identity_when_command_started = cli::load_current_settings(dhttp_home) .await? @@ -1443,8 +1478,15 @@ pub(crate) async fn run( .parent_identity .as_deref() .whatever_context::<_, Error>("helper apply path is missing its parent identity")?; - if !run_helper_parent_action(dhttp_home, cert_server, &target, parent_identity, action) - .await? + if !run_helper_parent_action( + dhttp_home, + home_scope, + cert_server, + &target, + parent_identity, + action, + ) + .await? { whatever!("create was cancelled"); } @@ -1605,11 +1647,16 @@ pub(crate) async fn run( } } + let welcome = + super::welcome::maybe_create_welcome_service(dhttp_home, target.dhttp_name(), home_scope) + .await?; crate::cli::flow::epilogue::run_lifecycle_epilogue( dhttp_home, target.dhttp_name(), default_identity_when_command_started, is_interactive, + super::output::SavedIdentityAction::Created, + welcome.as_ref(), ) .await } @@ -1777,7 +1824,7 @@ mod tests { .unwrap_err(); let rendered = error.to_string(); assert!( - rendered.contains("ready local parent identity"), + rendered.contains("ready parent identity saved here"), "{rendered}" ); assert!(rendered.contains("phone.alice.smith"), "{rendered}"); @@ -1838,7 +1885,7 @@ mod tests { #[test] fn create_identity_name_opening_matches_spec_copy() { let opening = super::create_identity_name_opening(); - assert!(opening.contains("Create a new identity for this device.")); + assert!(opening.contains("Create a new identity here.")); assert!(opening.contains(".")); assert!(opening.contains("alice.smith")); assert!(opening.contains("phone.alice.smith")); @@ -1877,7 +1924,7 @@ mod tests { .collect::>(), vec![ "Verify with email".to_string(), - "Apply alice.smith to this device, then verify with alice.smith".to_string(), + "Apply alice.smith here, then verify with alice.smith".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/default_identity.rs b/genmeta-identity/src/cli/flow/default_identity.rs index e96646f..206cdc0 100644 --- a/genmeta-identity/src/cli/flow/default_identity.rs +++ b/genmeta-identity/src/cli/flow/default_identity.rs @@ -1,6 +1,6 @@ use std::io::IsTerminal; -use dhttp::home::DhttpHome; +use dhttp::home::{DhttpHome, HomeScope}; use snafu::{OptionExt, whatever}; use super::{ @@ -24,7 +24,7 @@ fn default_organization_actions(target: &str) -> Vec { .map(|target| target.short_name().to_string()) .unwrap_or_else(|_| target.to_string()); vec![ - format!("Apply {short_name} to this device"), + format!("Apply {short_name} here"), "Choose another identity".to_string(), ] } @@ -49,27 +49,10 @@ fn organization_action_from_selection( } async fn set_default_summary( - command: &Default, dhttp_home: &DhttpHome, current_config: Option, summary: LocalIdentitySummary, ) -> Result<(), Error> { - if !summary.status.is_ready() && !command.allow_nonready { - let confirmed = cli::prompt::sync({ - let message = format!( - "{} is {}. Set it as the default identity anyway?", - summary.target.short_name(), - summary.status.label() - ); - move || inquire::Confirm::new(&message).with_default(false).prompt() - }) - .await - .require_interactive("--allow-nonready")?; - if !confirmed { - whatever!("default identity was not changed"); - } - } - let mut current_config = current_config.unwrap_or_else(|| { dhttp::home::identity::settings::DhttpSettingsFile::new(dhttp_home.settings_path()) }); @@ -79,19 +62,80 @@ async fn set_default_summary( cli::save_settings(¤t_config).await } +async fn confirm_default_target( + command: &Default, + summary: &LocalIdentitySummary, + current_default: Option<&super::epilogue::CurrentDefaultSummary>, + ansi: bool, + stdin_is_terminal: bool, +) -> Result<(), Error> { + if !summary.status.is_ready() && !command.allow_nonready { + let message = format!( + "{} is {}. Set it as the default identity anyway?", + summary.target.short_name(), + summary.status.label() + ); + let confirmed = + cli::prompt::sync(move || inquire::Confirm::new(&message).with_default(false).prompt()) + .await + .require_interactive("--allow-nonready")?; + if !confirmed { + whatever!("default identity was not changed"); + } + return Ok(()); + } + + // Non-interactive default changes must go through the explicit + // `genmeta identity default ` command, not through create/apply + // side effects. When the user has already named the target here, treat + // that explicit command as sufficient confirmation. + if command.name.is_some() && !stdin_is_terminal { + return Ok(()); + } + + if let Some(suggestion) = + super::epilogue::suggest_default_change(summary.target.short_name(), current_default, ansi) + { + let accepted = cli::prompt::sync(move || { + inquire::Confirm::new(&suggestion.prompt) + .with_default(suggestion.default) + .prompt() + }) + .await + .require_interactive("IDENTITY")?; + if !accepted { + whatever!("default identity was not changed"); + } + } + + Ok(()) +} + async fn run_helper_apply( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, target: &IdentityTarget, ) -> Result<(), Error> { crate::cli::flow::transcript::print_block(&format!( - "{} is not saved on this device.\n\nTo use it as the default identity, this command will first apply {} to this device, then return here and set it as the default identity.", + "{} is not saved here.\n\nTo use it as the default identity, this command will first apply {} here, then return here and set it as the default identity.", target.short_name(), target.short_name() )); - let command = crate::cli::Apply { + let command = helper_apply_command(target); + super::apply::run_with_policy( + &command, + dhttp_home, + home_scope, + cert_server, + super::apply::ApplyPostSavePolicy::SkipDefaultSuggestion, + ) + .await +} + +fn helper_apply_command(target: &IdentityTarget) -> crate::cli::Apply { + crate::cli::Apply { name: Some(target.short_name().to_string()), - use_default: false, kind: None, replace_local: false, device_name: None, @@ -99,12 +143,43 @@ async fn run_helper_apply( send_code: false, verify_code: None, auth: None, - }; - super::apply::run(&command, dhttp_home, cert_server).await + } +} + +async fn summary_for_named_default_target( + dhttp_home: &DhttpHome, + home_scope: HomeScope, + cert_server: &CertServer, + target: &IdentityTarget, + configured_default_name: Option>, +) -> Result { + if let Some(summary) = local::try_load_summary( + dhttp_home, + target.dhttp_name(), + configured_default_name.clone(), + ) + .await? + { + return Ok(summary); + } + + if !std::io::stdin().is_terminal() { + whatever!( + "{} is not saved here.\n\nTo use it as the default identity, apply {} here first or rerun this command interactively.", + target.short_name(), + target.short_name(), + ); + } + + run_helper_apply(dhttp_home, home_scope, cert_server, target).await?; + local::try_load_summary(dhttp_home, target.dhttp_name(), configured_default_name) + .await? + .whatever_context::<_, Error>("helper apply did not save the requested identity") } async fn select_interactive_default_summary( dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, configured_default_name: Option>, ) -> Result { @@ -112,7 +187,7 @@ async fn select_interactive_default_summary( let inventory = local::load_inventory(dhttp_home, configured_default_name.clone()).await?; let choices = local::build_default_inventory_choices(&inventory); if choices.is_empty() { - whatever!("No local identities found"); + whatever!("No identities found here"); } let ansi = std::io::stdout().is_terminal(); @@ -121,7 +196,7 @@ async fn select_interactive_default_summary( .map(|choice| output::render_choice_label(choice, ansi)) .collect::>(); let selected = crate::cli::prompt::prompt_select_string( - "Select an identity to set as the default on this device:", + "Select an identity to set as the default here:", labels.clone(), ) .await @@ -137,7 +212,7 @@ async fn select_interactive_default_summary( let options = default_organization_actions(target.full_name()); let selected = crate::cli::prompt::prompt_select_string( &format!( - "{} is not saved on this device. Choose what to do next:", + "{} is not saved here. Choose what to do next:", target.short_name() ), options.clone(), @@ -146,10 +221,11 @@ async fn select_interactive_default_summary( .require_interactive("IDENTITY")?; match organization_action_from_selection(&options, &selected)? { DefaultOrganizationAction::ApplyToLocalDevice => { - run_helper_apply(dhttp_home, cert_server, &target).await?; - return local::load_summary( + return summary_for_named_default_target( dhttp_home, - target.dhttp_name(), + home_scope, + cert_server, + &target, configured_default_name, ) .await; @@ -157,9 +233,6 @@ async fn select_interactive_default_summary( DefaultOrganizationAction::ChooseAnotherIdentity => continue, } } - InteractiveInventoryChoice::EnterAnotherIdentity => { - whatever!("default identity selection does not support free-form identity entry") - } } } } @@ -167,12 +240,16 @@ async fn select_interactive_default_summary( pub(crate) async fn run( command: &Default, dhttp_home: &DhttpHome, + home_scope: HomeScope, cert_server: &CertServer, ) -> Result<(), Error> { let current_config = cli::load_current_settings(dhttp_home).await?; let configured_default_name = current_config .as_ref() .and_then(|config| config.settings().default_identity_name().cloned()); + let current_default = super::epilogue::current_default_summary(dhttp_home).await?; + let ansi = std::io::stdout().is_terminal(); + let stdin_is_terminal = std::io::stdin().is_terminal(); match command.name.as_ref() { None => { @@ -195,12 +272,12 @@ pub(crate) async fn run( std::io::stdout().is_terminal(), )); - if !std::io::stdin().is_terminal() { + if !stdin_is_terminal { return Ok(()); } let switch_default = cli::prompt::sync(|| { - inquire::Confirm::new("Change the default identity on this device?") + inquire::Confirm::new("Change the default identity here?") .with_default(false) .prompt() }) @@ -212,25 +289,64 @@ pub(crate) async fn run( let selected_summary = select_interactive_default_summary( dhttp_home, + home_scope, cert_server, configured_default_name .as_ref() .map(|default| default.borrow()), ) .await?; - set_default_summary(command, dhttp_home, current_config, selected_summary).await + confirm_default_target( + command, + &selected_summary, + current_default.as_ref(), + ansi, + stdin_is_terminal, + ) + .await?; + set_default_summary(dhttp_home, current_config, selected_summary.clone()).await?; + let block = super::epilogue::default_block( + configured_default_name + .as_ref() + .map(|default| default.as_partial()), + Some(selected_summary.target.short_name()), + ); + crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( + &block, + )); + Ok(()) } Some(name) => { - let name = cli::parse_identity_name(name)?; - let summary = local::load_summary( + let target = IdentityTarget::parse(name)?; + let summary = summary_for_named_default_target( dhttp_home, - name.borrow(), + home_scope, + cert_server, + &target, configured_default_name .as_ref() .map(|default| default.borrow()), ) .await?; - set_default_summary(command, dhttp_home, current_config, summary).await + confirm_default_target( + command, + &summary, + current_default.as_ref(), + ansi, + stdin_is_terminal, + ) + .await?; + set_default_summary(dhttp_home, current_config, summary.clone()).await?; + let block = super::epilogue::default_block( + configured_default_name + .as_ref() + .map(|default| default.as_partial()), + Some(summary.target.short_name()), + ); + crate::cli::flow::transcript::print_line(output::format_default_identity_sentence( + &block, + )); + Ok(()) } } } @@ -238,15 +354,17 @@ pub(crate) async fn run( #[cfg(test)] mod tests { use super::{ - DefaultOrganizationAction, default_organization_actions, organization_action_from_selection, + DefaultOrganizationAction, default_organization_actions, helper_apply_command, + organization_action_from_selection, }; + use crate::cli::flow::target::IdentityTarget; #[test] fn organization_action_menu_matches_spec_copy() { assert_eq!( default_organization_actions("alice.smith.dhttp.net"), vec![ - "Apply alice.smith to this device".to_string(), + "Apply alice.smith here".to_string(), "Choose another identity".to_string(), ] ); @@ -257,8 +375,7 @@ mod tests { let options = default_organization_actions("alice.smith.dhttp.net"); assert_eq!( - organization_action_from_selection(&options, "Apply alice.smith to this device") - .unwrap(), + organization_action_from_selection(&options, "Apply alice.smith here").unwrap(), DefaultOrganizationAction::ApplyToLocalDevice, ); } @@ -272,4 +389,12 @@ mod tests { DefaultOrganizationAction::ChooseAnotherIdentity, ); } + + #[test] + fn helper_apply_command_uses_explicit_name_without_default_lookup() { + let command = helper_apply_command(&IdentityTarget::parse("alice.smith").unwrap()); + + assert_eq!(command.name.as_deref(), Some("alice.smith")); + assert!(command.kind.is_none()); + } } diff --git a/genmeta-identity/src/cli/flow/epilogue.rs b/genmeta-identity/src/cli/flow/epilogue.rs index f0417a0..f3ce19f 100644 --- a/genmeta-identity/src/cli/flow/epilogue.rs +++ b/genmeta-identity/src/cli/flow/epilogue.rs @@ -5,6 +5,12 @@ use dhttp::{home::DhttpHome, name::DhttpName}; use super::{local, output, transcript}; use crate::cli::{self, Error, prompt::InquireResultExt}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CurrentDefaultSummary { + pub(crate) name: String, + pub(crate) status: local::LocalIdentityStatus, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DefaultSuggestion { pub(crate) prompt: String, @@ -13,17 +19,21 @@ pub(crate) struct DefaultSuggestion { pub(crate) fn suggest_default_change( saved_name: &str, - current_default: Option<&str>, + current_default: Option<&CurrentDefaultSummary>, + ansi: bool, ) -> Option { match current_default { - Some(current) if current == saved_name => None, - Some(_) => Some(DefaultSuggestion { - prompt: format!("Set {saved_name} as the default identity on this device?"), - default: true, + Some(current) if current.name == saved_name => None, + Some(current) => Some(DefaultSuggestion { + prompt: format!( + "Set {saved_name} as the default here? {}", + output::format_current_default_suffix(¤t.name, ¤t.status, ansi) + ), + default: false, }), None => Some(DefaultSuggestion { - prompt: format!("Set {saved_name} as the default identity on this device?"), - default: false, + prompt: format!("Set {saved_name} as the default here?"), + default: true, }), } } @@ -53,6 +63,26 @@ async fn current_default_name(dhttp_home: &DhttpHome) -> Result Result, Error> { + let Some(name) = current_default_name(dhttp_home).await? else { + return Ok(None); + }; + + let status = match local::try_load_summary(dhttp_home, name.borrow(), None).await? { + Some(summary) => summary.status, + None => local::LocalIdentityStatus::Invalid { + detail: "identity is not saved here".to_string(), + }, + }; + + Ok(Some(CurrentDefaultSummary { + name: name.as_partial().to_string(), + status, + })) +} + async fn save_default_name( dhttp_home: &DhttpHome, name: DhttpName<'_>, @@ -73,9 +103,12 @@ pub(crate) async fn run_lifecycle_epilogue( name: DhttpName<'_>, default_at_start: Option>, interactive: bool, + action: output::SavedIdentityAction, + welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); let mut default_after = current_default_name(dhttp_home).await?; + let current_default = current_default_summary(dhttp_home).await?; let summary = local::load_summary( dhttp_home, name.clone(), @@ -83,14 +116,14 @@ pub(crate) async fn run_lifecycle_epilogue( ) .await?; - transcript::print_block(&output::format_info(&summary, ansi)); + transcript::print_block(&output::format_saved_identity_result( + action, &summary, ansi, + )); transcript::print_line(output::format_safekeeping_reminder(ansi)); if interactive - && let Some(suggestion) = suggest_default_change( - name.as_partial(), - default_after.as_ref().map(|default| default.as_partial()), - ) + && let Some(suggestion) = + suggest_default_change(name.as_partial(), current_default.as_ref(), ansi) { let accepted = crate::cli::prompt::sync(move || { inquire::Confirm::new(&suggestion.prompt) @@ -111,13 +144,18 @@ pub(crate) async fn run_lifecycle_epilogue( .map(|default| default.as_partial()), default_after.as_ref().map(|default| default.as_partial()), ); - transcript::print_line(output::format_default_identity_block(&block)); + transcript::print_line(output::format_default_identity_sentence(&block)); + if let Some(welcome) = welcome { + transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); + } Ok(()) } pub(crate) async fn run_local_epilogue( dhttp_home: &DhttpHome, name: DhttpName<'_>, + action: output::SavedIdentityAction, + welcome: Option<&super::welcome::WelcomeServiceCreated>, ) -> Result<(), Error> { let ansi = std::io::stdout().is_terminal(); let default_name = current_default_name(dhttp_home).await?; @@ -127,8 +165,13 @@ pub(crate) async fn run_local_epilogue( default_name.as_ref().map(|default| default.borrow()), ) .await?; - transcript::print_block(&output::format_info(&summary, ansi)); + transcript::print_block(&output::format_saved_identity_result( + action, &summary, ansi, + )); transcript::print_line(output::format_safekeeping_reminder(ansi)); + if let Some(welcome) = welcome { + transcript::print_block(&super::welcome::format_welcome_service_created(welcome)); + } Ok(()) } @@ -142,8 +185,8 @@ mod tests { use dhttp::{home::DhttpHome, name::DhttpName}; use tokio::fs; - use super::{DefaultSuggestion, default_block, suggest_default_change}; - use crate::cli::flow::output::DefaultIdentityBlock; + use super::{CurrentDefaultSummary, DefaultSuggestion, default_block, suggest_default_change}; + use crate::cli::flow::{local::LocalIdentityStatus, output::DefaultIdentityBlock}; fn unique_test_home_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() @@ -157,24 +200,37 @@ mod tests { } #[test] - fn suggest_new_default_uses_yes_by_default_when_another_default_exists() { - let suggestion = suggest_default_change("alice.smith", Some("meng.lin")).unwrap(); + fn suggest_fill_empty_default_uses_yes_by_default() { + let suggestion = suggest_default_change("alice.smith", None, false).unwrap(); + + assert!(suggestion.default); + assert_eq!(suggestion.prompt, "Set alice.smith as the default here?"); + } + + #[test] + fn suggest_replacing_default_uses_no_by_default_and_shows_current_status() { + let suggestion = suggest_default_change( + "alice.smith", + Some(&CurrentDefaultSummary { + name: "meng.lin".to_string(), + status: LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }, + }), + false, + ) + .unwrap(); assert_eq!( suggestion, DefaultSuggestion { - prompt: "Set alice.smith as the default identity on this device?".to_string(), - default: true, + prompt: "Set alice.smith as the default here? (current: meng.lin [invalid])" + .to_string(), + default: false, } ); } - #[test] - fn suggest_fill_empty_default_uses_no_by_default() { - let suggestion = suggest_default_change("alice.smith", None).unwrap(); - assert!(!suggestion.default); - } - #[test] fn default_block_reports_changed_identity() { assert_eq!( @@ -194,9 +250,16 @@ mod tests { let profile = dhttp_home.identity_profile(name.borrow()); fs::create_dir_all(profile.ssl_dir()).await.unwrap(); - super::run_lifecycle_epilogue(&dhttp_home, name.borrow(), None, false) - .await - .unwrap(); + super::run_lifecycle_epilogue( + &dhttp_home, + name.borrow(), + None, + false, + crate::cli::flow::output::SavedIdentityAction::Created, + None, + ) + .await + .unwrap(); assert!( super::current_default_name(&dhttp_home) diff --git a/genmeta-identity/src/cli/flow/kind.rs b/genmeta-identity/src/cli/flow/kind.rs index 86c4970..faa6105 100644 --- a/genmeta-identity/src/cli/flow/kind.rs +++ b/genmeta-identity/src/cli/flow/kind.rs @@ -10,8 +10,7 @@ pub(crate) enum IdentityKind { } impl IdentityKind { - pub(crate) const SELECT_PROMPT: &str = - "Choose how this device should be used for this identity."; + pub(crate) const SELECT_PROMPT: &str = "Choose how this identity should be used here."; pub(crate) const PRIMARY_HELP: &str = "Primary\n For a main host, server, desktop, home gateway, or always-on endpoint."; pub(crate) const SECONDARY_HELP: &str = @@ -84,7 +83,7 @@ mod tests { assert_eq!(IdentityKind::Secondary.to_string(), "secondary"); assert_eq!( IdentityKind::SELECT_PROMPT, - "Choose how this device should be used for this identity." + "Choose how this identity should be used here." ); assert!(IdentityKind::PRIMARY_HELP.contains("main host")); assert!(IdentityKind::SECONDARY_HELP.contains("additional device")); diff --git a/genmeta-identity/src/cli/flow/local.rs b/genmeta-identity/src/cli/flow/local.rs index 0a3dc51..e7a3de9 100644 --- a/genmeta-identity/src/cli/flow/local.rs +++ b/genmeta-identity/src/cli/flow/local.rs @@ -91,7 +91,6 @@ pub(crate) struct LocalInventory { pub(crate) enum InteractiveInventoryChoice { Saved(LocalIdentitySummary), Organization { target: IdentityTarget }, - EnterAnotherIdentity, } pub(crate) fn classify_status( @@ -211,10 +210,19 @@ pub(crate) async fn load_inventory( dhttp_home: &DhttpHome, default_name: Option>, ) -> Result { - let names = dhttp_home + let names = match dhttp_home .identity_profile_names() .try_collect::>() - .await?; + .await + { + Ok(names) => names, + Err(dhttp::home::identity::ssl::ListIdentityProfilesError::ReadDir { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + return Ok(LocalInventory { groups: Vec::new() }); + } + Err(error) => return Err(Error::from(error)), + }; let mut summaries = Vec::with_capacity(names.len()); for name in names { summaries.push(load_summary(dhttp_home, name.borrow(), default_name.clone()).await?); @@ -222,6 +230,20 @@ pub(crate) async fn load_inventory( Ok(build_inventory(summaries)) } +pub(crate) async fn try_load_summary( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + default_name: Option>, +) -> Result, Error> { + match load_summary(dhttp_home, name, default_name).await { + Ok(summary) => Ok(Some(summary)), + Err(Error::ResolveIdentityProfile { + source: dhttp::home::identity::ssl::ResolveIdentityProfileError::NotFound { .. }, + }) => Ok(None), + Err(error) => Err(error), + } +} + pub(crate) async fn load_summary( dhttp_home: &DhttpHome, name: DhttpName<'_>, @@ -244,33 +266,6 @@ pub(crate) async fn load_summary( }) } -pub(crate) fn build_apply_inventory_choices( - inventory: &LocalInventory, -) -> Vec { - let mut choices = Vec::new(); - for group in &inventory.groups { - match &group.root { - LocalInventoryRoot::Saved(summary) => { - choices.push(InteractiveInventoryChoice::Saved(summary.clone())); - } - LocalInventoryRoot::Organization { target } => { - choices.push(InteractiveInventoryChoice::Organization { - target: target.clone(), - }); - } - } - choices.extend( - group - .children - .iter() - .cloned() - .map(InteractiveInventoryChoice::Saved), - ); - } - choices.push(InteractiveInventoryChoice::EnterAnotherIdentity); - choices -} - pub(crate) fn build_renew_inventory_choices( inventory: &LocalInventory, ) -> Vec { @@ -457,18 +452,34 @@ fn private_key_state_from_error( #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::{home::DhttpHome, name::DhttpName}; use super::{ InteractiveInventoryChoice, LocalIdentityAssessment, LocalIdentityMaterialState, LocalIdentityStatus, LocalIdentitySummary, LocalInventoryRoot, - build_apply_inventory_choices, build_default_inventory_choices, build_inventory, - build_renew_inventory_choices, classify_status, + build_default_inventory_choices, build_inventory, build_renew_inventory_choices, + classify_status, }; use crate::cli::flow::target::IdentityTarget; const NOW: i64 = 1_794_298_000; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-local-{test_name}-{}-{nonce}", + std::process::id() + )) + } + fn ready_summary(name: &str, is_default: bool, chain: &str) -> LocalIdentitySummary { LocalIdentitySummary { target: IdentityTarget::parse(name).unwrap(), @@ -595,45 +606,6 @@ mod tests { ); } - #[test] - fn builds_apply_inventory_choices_with_organization_root_and_enter_another_identity() { - let mut tv = ready_summary("tv.alice.smith", false, "secondary:3"); - tv.status = LocalIdentityStatus::Incomplete { - detail: "private key missing".to_string(), - }; - tv.certificate_chain = None; - - let inventory = build_inventory(vec![ - ready_summary("tablet.reimu.scarlet", false, "secondary:1"), - tv.clone(), - ready_summary("phone.alice.smith", false, "secondary:2"), - ]); - - assert_eq!( - build_apply_inventory_choices(&inventory), - vec![ - InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("alice.smith").unwrap(), - }, - InteractiveInventoryChoice::Saved(ready_summary( - "phone.alice.smith", - false, - "secondary:2", - )), - InteractiveInventoryChoice::Saved(tv), - InteractiveInventoryChoice::Organization { - target: IdentityTarget::parse("reimu.scarlet").unwrap(), - }, - InteractiveInventoryChoice::Saved(ready_summary( - "tablet.reimu.scarlet", - false, - "secondary:1", - )), - InteractiveInventoryChoice::EnterAnotherIdentity, - ] - ); - } - #[test] fn build_renew_inventory_choices_keeps_missing_parent_roots() { let child = ready_summary("shanghai.alice.ma", false, "secondary:1"); @@ -679,4 +651,27 @@ mod tests { ] ); } + + #[tokio::test] + async fn load_inventory_returns_empty_when_home_directory_is_missing() { + let home_path = unique_test_home_path("missing-home-inventory"); + let dhttp_home = DhttpHome::new(home_path); + + let inventory = super::load_inventory(&dhttp_home, None).await.unwrap(); + + assert!(inventory.groups.is_empty()); + } + + #[tokio::test] + async fn try_load_summary_returns_none_when_profile_is_missing() { + let home_path = unique_test_home_path("missing-home-summary"); + let dhttp_home = DhttpHome::new(home_path); + let name = DhttpName::try_from("alice.smith").unwrap(); + + let summary = super::try_load_summary(&dhttp_home, name.borrow(), None) + .await + .unwrap(); + + assert!(summary.is_none()); + } } diff --git a/genmeta-identity/src/cli/flow/output.rs b/genmeta-identity/src/cli/flow/output.rs index afee240..3306a83 100644 --- a/genmeta-identity/src/cli/flow/output.rs +++ b/genmeta-identity/src/cli/flow/output.rs @@ -1,5 +1,4 @@ use crossterm::style::Stylize; -use time::OffsetDateTime; use super::{ local::{ @@ -64,16 +63,29 @@ pub(crate) enum DefaultIdentityBlock { Changed { old: String, new: String }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SavedIdentityAction { + Created, + Applied, + Renewed, +} + +impl SavedIdentityAction { + fn verb(self) -> &'static str { + match self { + Self::Created => "Created", + Self::Applied => "Applied", + Self::Renewed => "Renewed", + } + } +} + pub(crate) fn summary_line_style(summary: &LocalIdentitySummary) -> LineStyle { - if matches!( - summary.status, - LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. } - ) { - LineStyle::Dim - } else if summary.is_default { - LineStyle::Bold - } else { - LineStyle::Plain + match status_line_style(&summary.status) { + LineStyle::Dim => LineStyle::Dim, + LineStyle::Plain if summary.is_default => LineStyle::Bold, + LineStyle::Plain => LineStyle::Plain, + LineStyle::Bold => LineStyle::Bold, } } @@ -89,6 +101,50 @@ fn render_line(text: String, style: LineStyle, ansi: bool) -> String { } } +pub(crate) fn compact_identity_label(summary: &LocalIdentitySummary) -> String { + compact_identity_label_parts( + summary.target.short_name(), + &summary.status, + summary.is_default, + ) +} + +pub(crate) fn compact_identity_label_parts( + name: &str, + status: &LocalIdentityStatus, + is_default: bool, +) -> String { + let mut label = format!("{name} [{}]", status.label()); + if is_default { + label.push_str(" (default identity)"); + } + label +} + +pub(crate) fn status_line_style(status: &LocalIdentityStatus) -> LineStyle { + match status { + LocalIdentityStatus::Invalid { .. } | LocalIdentityStatus::Incomplete { .. } => { + LineStyle::Dim + } + LocalIdentityStatus::Ready { .. } | LocalIdentityStatus::Expired { .. } => LineStyle::Plain, + } +} + +pub(crate) fn format_current_default_suffix( + name: &str, + status: &LocalIdentityStatus, + ansi: bool, +) -> String { + render_line( + format!( + "(current: {})", + compact_identity_label_parts(name, status, false) + ), + LineStyle::Dim, + ansi, + ) +} + pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: bool) -> String { match choice { InteractiveInventoryChoice::Saved(summary) => { @@ -97,36 +153,27 @@ pub(crate) fn render_choice_label(choice: &InteractiveInventoryChoice, ansi: boo } else { "" }; - let mut label = format!( - "{prefix}{} [{}]", - summary.target.short_name(), - summary.status.label() - ); - if summary.is_default { - label.push_str(" (default identity)"); - } - render_line(label, summary_line_style(summary), ansi) + render_line( + format!("{prefix}{}", compact_identity_label(summary)), + summary_line_style(summary), + ansi, + ) } InteractiveInventoryChoice::Organization { target } => render_line( - format!("{} (not saved locally)", target.short_name()), + format!("{} (not saved here)", target.short_name()), LineStyle::Dim, ansi, ), - InteractiveInventoryChoice::EnterAnotherIdentity => "Enter another identity".to_string(), } } -pub(crate) fn format_default_identity_block(block: &DefaultIdentityBlock) -> String { +pub(crate) fn format_default_identity_sentence(block: &DefaultIdentityBlock) -> String { match block { - DefaultIdentityBlock::None => "Default identity: (none)".to_string(), - DefaultIdentityBlock::NewlySet { name } => { - format!("Default identity: {name} (newly set)") - } - DefaultIdentityBlock::Unchanged { name } => { - format!("Default identity: {name} (unchanged)") - } + DefaultIdentityBlock::None => "No default identity is set here".to_string(), + DefaultIdentityBlock::NewlySet { name } => format!("Default identity set to {name}"), + DefaultIdentityBlock::Unchanged { name } => format!("Default identity remains {name}"), DefaultIdentityBlock::Changed { old, new } => { - format!("Default identity: {new} (changed from {old})") + format!("Default identity changed from {old} to {new}") } } } @@ -139,18 +186,33 @@ pub(crate) fn format_safekeeping_reminder(ansi: bool) -> String { ) } +pub(crate) fn format_saved_identity_result( + action: SavedIdentityAction, + summary: &LocalIdentitySummary, + ansi: bool, +) -> String { + let mut lines = Vec::new(); + lines.push(render_line( + format!( + "{} identity {}", + action.verb(), + compact_identity_label(summary) + ), + summary_line_style(summary), + ansi, + )); + lines.extend(detail_lines(summary)); + lines.join("\n") +} + pub(crate) fn format_info(summary: &LocalIdentitySummary, ansi: bool) -> String { let mut lines = Vec::new(); lines.push(render_line( - format!("Name: {}", summary_name(summary)), + compact_identity_label(summary), summary_line_style(summary), ansi, )); - if let Some(certificate_chain) = summary.certificate_chain.as_ref() { - lines.push(format!("Certificate chain: {certificate_chain}")); - } - lines.push(format!("Status: {}", format_status(summary.status.clone()))); - lines.push(format!("Saved at: {}", summary.saved_at.display())); + lines.extend(detail_lines(summary)); lines.join("\n") } @@ -158,6 +220,23 @@ pub(crate) fn format_default_summary(summary: &LocalIdentitySummary, ansi: bool) format_info(summary, ansi) } +fn detail_lines(summary: &LocalIdentitySummary) -> Vec { + let mut lines = Vec::new(); + match (&summary.status, summary.certificate_chain.as_deref()) { + (LocalIdentityStatus::Ready { .. }, Some(chain)) + | (LocalIdentityStatus::Expired { .. }, Some(chain)) => { + lines.push(format!(" uses certificate chain {chain}")); + } + (LocalIdentityStatus::Incomplete { detail }, _) + | (LocalIdentityStatus::Invalid { detail }, _) => { + lines.push(format!(" {detail}")); + } + _ => {} + } + lines.push(format!(" saved at {}", summary.saved_at.display())); + lines +} + fn render_root(root: &LocalInventoryRoot, width: usize, ansi: bool) -> String { match root { LocalInventoryRoot::Saved(summary) => render_line( @@ -194,7 +273,7 @@ fn root_label(root: &LocalInventoryRoot) -> String { match root { LocalInventoryRoot::Saved(summary) => summary_label(summary), LocalInventoryRoot::Organization { target } => { - format!("{} (not saved locally)", target.short_name()) + format!("{} (not saved here)", target.short_name()) } } } @@ -211,53 +290,15 @@ fn summary_label(summary: &LocalIdentitySummary) -> String { label } -fn summary_name(summary: &LocalIdentitySummary) -> String { - let mut name = summary.target.short_name().to_string(); - if matches!(summary.target.level(), IdentityLevel::SubIdentity) - && let Some(parent) = summary.target.parent() - { - name.push_str(&format!(" (sub-identity of {})", parent.as_partial())); - } - if summary.is_default { - name.push_str(" (default identity)"); - } - name -} - -fn format_status(status: LocalIdentityStatus) -> String { - match status { - LocalIdentityStatus::Ready { expires_at } => { - format!("ready (expires after {})", format_timestamp(expires_at)) - } - LocalIdentityStatus::Expired { expired_at } => { - format!("expired (expired after {})", format_timestamp(expired_at)) - } - LocalIdentityStatus::Incomplete { detail } => format!("incomplete ({detail})"), - LocalIdentityStatus::Invalid { detail } => format!("invalid ({detail})"), - } -} - -fn format_timestamp(timestamp: i64) -> String { - let datetime = OffsetDateTime::from_unix_timestamp(timestamp) - .expect("BUG: timestamps should be representable"); - format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", - datetime.year(), - u8::from(datetime.month()), - datetime.day(), - datetime.hour(), - datetime.minute(), - datetime.second() - ) -} - #[cfg(test)] mod tests { use std::path::PathBuf; use super::{ - DefaultIdentityBlock, LineStyle, format_default_identity_block, format_default_summary, - format_info, render_choice_label, render_inventory, summary_line_style, + DefaultIdentityBlock, LineStyle, SavedIdentityAction, compact_identity_label, + format_current_default_suffix, format_default_identity_sentence, format_default_summary, + format_info, format_saved_identity_result, render_choice_label, render_inventory, + summary_line_style, }; use crate::cli::flow::{ local::{ @@ -294,16 +335,31 @@ mod tests { Some("secondary:2"), ); - let expected = "\ -Name: phone.alice.smith (sub-identity of alice.smith) (default identity)\n\ -Certificate chain: secondary:2\n\ -Status: ready (expires after 2026-11-10 08:12:44 UTC)\n\ -Saved at: /tmp/phone.alice.smith"; + let expected = "phone.alice.smith [ready] (default identity)\n uses certificate chain secondary:2\n saved at /tmp/phone.alice.smith"; assert_eq!(format_info(&profile, false), expected); assert_eq!(format_default_summary(&profile, false), expected); } + #[test] + fn formats_created_identity_result() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + let expected = "Created identity alice.smith [ready]\n uses certificate chain primary:0\n saved at /tmp/alice.smith"; + + assert_eq!( + format_saved_identity_result(SavedIdentityAction::Created, &profile, false), + expected, + ); + } + #[test] fn ready_default_line_prefers_bold() { let profile = summary( @@ -318,6 +374,20 @@ Saved at: /tmp/phone.alice.smith"; assert_eq!(summary_line_style(&profile), LineStyle::Bold); } + #[test] + fn compact_label_uses_square_bracket_status_without_chain_id() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Ready { + expires_at: EXPIRES_AT, + }, + Some("primary:0"), + ); + + assert_eq!(compact_identity_label(&profile), "alice.smith [ready]"); + } + #[test] fn invalid_default_line_prefers_dim_over_bold() { let profile = summary( @@ -333,10 +403,59 @@ Saved at: /tmp/phone.alice.smith"; } #[test] - fn formats_none_default_block() { + fn formats_invalid_identity_without_field_labels() { + let profile = summary( + "alice.smith", + false, + LocalIdentityStatus::Invalid { + detail: "certificate chain metadata is invalid".to_string(), + }, + None, + ); + + let expected = "alice.smith [invalid]\n certificate chain metadata is invalid\n saved at /tmp/alice.smith"; + + assert_eq!(format_info(&profile, false), expected); + } + + #[test] + fn current_default_suffix_uses_compact_label_text() { + assert_eq!( + format_current_default_suffix( + "meng.lin", + &LocalIdentityStatus::Invalid { + detail: "certificate is unreadable".to_string(), + }, + false, + ), + "(current: meng.lin [invalid])" + ); + } + + #[test] + fn formats_default_identity_sentences() { + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::NewlySet { + name: "alice.smith".to_string(), + }), + "Default identity set to alice.smith" + ); + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::Changed { + old: "meng.lin".to_string(), + new: "alice.smith".to_string(), + }), + "Default identity changed from meng.lin to alice.smith" + ); + assert_eq!( + format_default_identity_sentence(&DefaultIdentityBlock::Unchanged { + name: "alice.smith".to_string(), + }), + "Default identity remains alice.smith" + ); assert_eq!( - format_default_identity_block(&DefaultIdentityBlock::None), - "Default identity: (none)" + format_default_identity_sentence(&DefaultIdentityBlock::None), + "No default identity is set here" ); } @@ -381,7 +500,7 @@ Saved at: /tmp/phone.alice.smith"; alice.smith (default identity) ready primary:0\n\ ├─ phone.alice.smith ready secondary:2\n\ └─ tv.alice.smith incomplete (private key missing)\n\ -reimu.scarlet (not saved locally)\n\ +reimu.scarlet (not saved here)\n\ └─ tablet.reimu.scarlet expired secondary:1"; assert_eq!(render_inventory(&inventory, false), expected); @@ -412,7 +531,7 @@ reimu.scarlet (not saved locally)\n\ assert_eq!( labels, vec![ - "alice.ma (not saved locally)".to_string(), + "alice.ma (not saved here)".to_string(), " shanghai.alice.ma [ready]".to_string(), ] ); @@ -449,16 +568,14 @@ reimu.scarlet (not saved locally)\n\ )), false, ), - render_choice_label(&InteractiveInventoryChoice::EnterAnotherIdentity, false), ]; assert_eq!( labels, vec![ "alice.smith [ready] (default identity)".to_string(), - "reimu.scarlet (not saved locally)".to_string(), + "reimu.scarlet (not saved here)".to_string(), " tablet.reimu.scarlet [ready]".to_string(), - "Enter another identity".to_string(), ] ); } diff --git a/genmeta-identity/src/cli/flow/renew.rs b/genmeta-identity/src/cli/flow/renew.rs index 3e6ce59..1045165 100644 --- a/genmeta-identity/src/cli/flow/renew.rs +++ b/genmeta-identity/src/cli/flow/renew.rs @@ -163,10 +163,24 @@ fn apply_verification_recovery( fn renew_not_saved_root_message(short_name: &str) -> String { format!( - "The identity {short_name} is not saved on this device.\n\nRenew updates a local identity already saved on this device.\nThis identity has not been applied locally yet.\n\nApply {short_name} to this device first, then return to renew." + "The identity {short_name} is not saved here.\n\nRenew updates an identity already saved here.\nThis identity has not been applied here yet.\n\nApply {short_name} here first, then return to renew." ) } +async fn ensure_saved_renew_target( + dhttp_home: &DhttpHome, + name: dhttp::name::DhttpName<'_>, +) -> Result<(), Error> { + if local::try_load_summary(dhttp_home, name.borrow(), None) + .await? + .is_some() + { + return Ok(()); + } + + whatever!("{}", renew_not_saved_root_message(name.as_partial())); +} + fn build_renew_approval_options(target: &str) -> Vec { approval::build_approval_options(approval::ApprovalMenuSpec { email_label: "Verify with email".to_string(), @@ -260,9 +274,7 @@ async fn resolve_target( .await?; let choices = local::build_renew_inventory_choices(&inventory); if choices.is_empty() { - whatever!( - "No local identities found. Renew requires a saved local identity profile." - ); + whatever!("No identities found here. Renew requires an identity saved here."); } let labels: Vec = choices .iter() @@ -271,7 +283,7 @@ async fn resolve_target( }) .collect(); let selected = crate::cli::prompt::prompt_select_string( - "Select a local identity to renew on this device:", + "Select an identity to renew here:", labels.clone(), ) .await @@ -283,9 +295,8 @@ async fn resolve_target( .whatever_context::<_, Error>("selected identity choice is unavailable")?; match choice { InteractiveInventoryChoice::Saved(summary) => Ok(summary.target.into_dhttp_name()), - InteractiveInventoryChoice::Organization { .. } - | InteractiveInventoryChoice::EnterAnotherIdentity => { - whatever!("renew requires a saved local identity profile") + InteractiveInventoryChoice::Organization { .. } => { + whatever!("renew requires an identity already saved here") } } } @@ -375,9 +386,7 @@ async fn run_interactive( .await?; let choices = local::build_renew_inventory_choices(&inventory); if choices.is_empty() { - whatever!( - "No local identities found. Renew requires a saved local identity profile." - ); + whatever!("No identities found here. Renew requires an identity saved here."); } let labels: Vec = choices .iter() @@ -386,7 +395,7 @@ async fn run_interactive( }) .collect(); let selected = crate::cli::prompt::prompt_select_string( - "Select a local identity to renew on this device:", + "Select an identity to renew here:", labels.clone(), ) .await @@ -406,9 +415,6 @@ async fn run_interactive( )); state.revisit_target_selection(); } - InteractiveInventoryChoice::EnterAnotherIdentity => { - whatever!("renew requires a saved local identity profile") - } } continue; } @@ -417,6 +423,7 @@ async fn run_interactive( .target .clone() .whatever_context::<_, Error>("interactive renew target is unavailable")?; + ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; if state.approval_plan.is_none() { if let Some(auth) = command.auth { @@ -627,7 +634,13 @@ async fn run_interactive( ) .instrument(info_span!("save_identity")) .await?; - return crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain.borrow()).await; + return crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain.borrow(), + crate::cli::flow::output::SavedIdentityAction::Renewed, + None, + ) + .await; } } @@ -660,6 +673,7 @@ pub(crate) async fn run( return run_interactive(command, dhttp_home, cert_server).await; } let domain = resolve_target(command, dhttp_home).await?; + ensure_saved_renew_target(dhttp_home, domain.borrow()).await?; let approval_plan = resolve_approval_plan(domain.as_partial(), command.auth, is_interactive).await?; let identity_profile = dhttp_home.resolve_identity_profile(domain.borrow()).await?; @@ -729,11 +743,24 @@ pub(crate) async fn run( detail.cert_pem.as_bytes(), ) .await?; - crate::cli::flow::epilogue::run_local_epilogue(dhttp_home, domain.borrow()).await + crate::cli::flow::epilogue::run_local_epilogue( + dhttp_home, + domain.borrow(), + crate::cli::flow::output::SavedIdentityAction::Renewed, + None, + ) + .await } #[cfg(test)] mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::home::DhttpHome; + use super::{ InteractiveRenewState, RenewApprovalMenuAction, RenewApprovalPlan, RenewEmailAction, RenewVerifyCodeAction, approval_plan_from_selection, build_renew_approval_options, @@ -743,6 +770,22 @@ mod tests { }; use crate::{auth::AuthMethod, cli::Renew}; + fn unique_test_home_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-renew-{test_name}-{}-{nonce}", + std::process::id() + )) + } + + fn dummy_cert_server() -> crate::cert_server::CertServer { + _ = rustls::crypto::ring::default_provider().install_default(); + crate::cert_server::CertServer::new("https://license.genmeta.net").unwrap() + } + #[test] fn stay_recovery_keeps_renew_verify_state() { let mut state = InteractiveRenewState::from_command( @@ -835,12 +878,37 @@ mod tests { fn renew_not_saved_root_message_mentions_apply_and_return() { assert_eq!( renew_not_saved_root_message("alice.ma"), - "The identity alice.ma is not saved on this device. + "The identity alice.ma is not saved here. + +Renew updates an identity already saved here. +This identity has not been applied here yet. -Renew updates a local identity already saved on this device. -This identity has not been applied locally yet. +Apply alice.ma here first, then return to renew." + ); + } + + #[tokio::test] + async fn renew_reports_saved_local_requirement_when_named_identity_is_missing() { + let home_path = unique_test_home_path("renew-unsaved"); + let dhttp_home = DhttpHome::new(home_path); + let command = Renew { + name: Some("alice.smith".to_string()), + use_default: false, + device_name: None, + email: None, + send_code: false, + verify_code: None, + auth: None, + }; + + let error = super::run(&command, &dhttp_home, &dummy_cert_server()) + .await + .unwrap_err(); + let rendered = error.to_string(); -Apply alice.ma to this device first, then return to renew." + assert!( + rendered.contains("Apply alice.smith here first"), + "{rendered}" ); } diff --git a/genmeta-identity/src/cli/flow/welcome.rs b/genmeta-identity/src/cli/flow/welcome.rs new file mode 100644 index 0000000..46f9750 --- /dev/null +++ b/genmeta-identity/src/cli/flow/welcome.rs @@ -0,0 +1,470 @@ +use std::path::{Path, PathBuf}; + +use dhttp::{ + home::{DhttpHome, HomeScope}, + name::DhttpName, +}; +use snafu::{IntoError, ResultExt, Snafu}; +use tokio::{fs, io::AsyncWriteExt}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WelcomeServiceCreated { + pub(crate) server_conf_path: PathBuf, + pub(crate) welcome_page_path: PathBuf, + pub(crate) url: String, +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum WelcomeServiceError { + #[cfg(unix)] + #[snafu(display("failed to determine whether welcome service onboarding is allowed"))] + EligibilityLookup { source: nix::errno::Errno }, + + #[snafu(display("failed to create identity profile directory at {}", path.display()))] + CreateProfileDir { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to create welcome page directory at {}", path.display()))] + CreateWelcomePageDir { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to inspect welcome service file {}", path.display()))] + Metadata { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to create welcome service file {}", path.display()))] + CreateFile { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to write welcome service file {}", path.display()))] + WriteFile { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("failed to roll back incomplete welcome service file {}", path.display()))] + RollbackDelete { + path: PathBuf, + source: std::io::Error, + }, +} + +const SERVER_CONF_TEMPLATE: &str = "server { + listen all 0; + + location / { + root templates/welcome; + index index.html; + } +} +"; + +const WELCOME_PAGE_PATH: &str = "templates/welcome/index.html"; + +pub(crate) async fn maybe_create_welcome_service( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + home_scope: HomeScope, +) -> Result, WelcomeServiceError> { + maybe_create_welcome_service_with_probe(dhttp_home, name, home_scope, user_in_pishoo_group) + .await +} + +pub(crate) async fn maybe_create_welcome_service_with_probe( + dhttp_home: &DhttpHome, + name: DhttpName<'_>, + home_scope: HomeScope, + user_in_pishoo_group: F, +) -> Result, WelcomeServiceError> +where + F: Fn() -> Result, +{ + if !welcome_onboarding_allowed(home_scope, &user_in_pishoo_group)? { + return Ok(None); + } + + let profile = dhttp_home.identity_profile(name.borrow()); + let server_conf_path = profile.server_conf_path(); + let welcome_page_path = profile.join(WELCOME_PAGE_PATH); + + if path_exists(&server_conf_path).await? || path_exists(&welcome_page_path).await? { + return Ok(None); + } + + fs::create_dir_all(profile.path()).await.context( + welcome_service_error::CreateProfileDirSnafu { + path: profile.path().to_path_buf(), + }, + )?; + + let welcome_page_dir = welcome_page_path + .parent() + .expect("welcome page path should have a parent directory"); + fs::create_dir_all(welcome_page_dir).await.context( + welcome_service_error::CreateWelcomePageDirSnafu { + path: welcome_page_dir.to_path_buf(), + }, + )?; + + write_new_file(&server_conf_path, SERVER_CONF_TEMPLATE.as_bytes()).await?; + + let welcome_page = render_welcome_page(); + + if let Err(error) = write_new_file(&welcome_page_path, welcome_page.as_bytes()).await { + if let Err(source) = fs::remove_file(&server_conf_path).await { + return Err(welcome_service_error::RollbackDeleteSnafu { + path: server_conf_path.clone(), + } + .into_error(source)); + } + return Err(error); + } + + Ok(Some(WelcomeServiceCreated { + server_conf_path, + welcome_page_path, + url: format!("https://{}/", name.as_partial()), + })) +} + +pub(crate) fn format_welcome_service_created(created: &WelcomeServiceCreated) -> String { + format!( + "Welcome service created\n Created server.conf at {}\n Created welcome page at {}\n Open {} after pishoo starts or reloads", + created.server_conf_path.display(), + created.welcome_page_path.display(), + created.url, + ) +} + +fn render_welcome_page() -> &'static str { + "\n\ +\n\ + \n\ + \n\ + \n\ + Hello from DHTTP\n\ + \n\ + \n\ + \n\ +
\n\ +

Hello from DHTTP.

\n\ +

This identity is ready to serve.

\n\ +\n\ +

Next steps

\n\ +
    \n\ +
  • Replace this page with your own site.
  • \n\ +
  • Add routes in server.conf to serve files or proxy services.
  • \n\ +
  • Reload pishoo after changing your service configuration.
  • \n\ +
\n\ +\n\ +

Generated by genmeta identity.

\n\ +
\n\ + \n\ +\n" +} + +fn welcome_onboarding_allowed( + home_scope: HomeScope, + user_in_pishoo_group: &F, +) -> Result +where + F: Fn() -> Result, +{ + #[cfg(unix)] + { + match home_scope { + HomeScope::Global => Ok(true), + HomeScope::User => user_in_pishoo_group(), + } + } + + #[cfg(not(unix))] + { + let _ = home_scope; + let _ = user_in_pishoo_group; + Ok(false) + } +} + +#[cfg(all(unix, not(target_vendor = "apple")))] +fn user_in_pishoo_group() -> Result { + use nix::unistd::{Group, getegid, getgroups}; + + let Some(group) = + Group::from_name("pishoo").context(welcome_service_error::EligibilityLookupSnafu)? + else { + return Ok(false); + }; + + if getegid() == group.gid { + return Ok(true); + } + + let groups = getgroups().context(welcome_service_error::EligibilityLookupSnafu)?; + Ok(groups.into_iter().any(|gid| gid == group.gid)) +} + +#[cfg(all(unix, target_vendor = "apple"))] +fn user_in_pishoo_group() -> Result { + Ok(false) +} + +#[cfg(not(unix))] +fn user_in_pishoo_group() -> Result { + Ok(false) +} + +async fn path_exists(path: &Path) -> Result { + match fs::try_exists(path).await { + Ok(exists) => Ok(exists), + Err(source) => Err(welcome_service_error::MetadataSnafu { + path: path.to_path_buf(), + } + .into_error(source)), + } +} + +async fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), WelcomeServiceError> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut file = options + .open(path) + .await + .context(welcome_service_error::CreateFileSnafu { + path: path.to_path_buf(), + })?; + file.write_all(contents) + .await + .context(welcome_service_error::WriteFileSnafu { + path: path.to_path_buf(), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use dhttp::{ + home::{DhttpHome, HomeScope}, + name::DhttpName, + }; + + use super::{format_welcome_service_created, maybe_create_welcome_service_with_probe}; + + fn unique_test_home_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "genmeta-identity-welcome-{label}-{}-{nonce}", + std::process::id() + )) + } + + #[tokio::test] + async fn user_scope_requires_pishoo_group_for_welcome_onboarding() { + let home = DhttpHome::new(unique_test_home_path("user-scope-no-group")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + + let created = + maybe_create_welcome_service_with_probe(&home, name.borrow(), HomeScope::User, || { + Ok(false) + }) + .await + .unwrap(); + + assert!(created.is_none()); + assert!( + !home + .identity_profile(name.borrow()) + .server_conf_path() + .exists() + ); + } + + #[tokio::test] + async fn global_scope_bypasses_group_gate() { + let home = DhttpHome::new(unique_test_home_path("global-scope")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + + let created = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(false), + ) + .await + .unwrap(); + + let created = created.expect("global scope should create welcome files"); + let profile = home.identity_profile(name.borrow()); + assert!(created.server_conf_path.exists()); + assert!(created.welcome_page_path.exists()); + assert_eq!( + created.welcome_page_path, + profile.join("templates/welcome/index.html") + ); + assert!(!profile.join("index.html").exists()); + + let server_conf = tokio::fs::read_to_string(&created.server_conf_path) + .await + .unwrap(); + assert!( + server_conf.contains("root templates/welcome;"), + "{server_conf}" + ); + + let welcome_page = tokio::fs::read_to_string(&created.welcome_page_path) + .await + .unwrap(); + assert!( + welcome_page.contains("

Hello from DHTTP.

"), + "{welcome_page}" + ); + assert!( + welcome_page.contains("This identity is ready to serve."), + "{welcome_page}" + ); + assert!( + welcome_page.contains("Add routes in server.conf"), + "{welcome_page}" + ); + assert!( + !welcome_page.contains("templates/welcome"), + "{welcome_page}" + ); + } + + #[tokio::test] + async fn skips_pair_creation_when_server_conf_already_exists() { + let home = DhttpHome::new(unique_test_home_path("server-conf-exists")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + let profile = home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + tokio::fs::write(profile.server_conf_path(), "server { listen all 0; }") + .await + .unwrap(); + + let created = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(true), + ) + .await + .unwrap(); + + assert!(created.is_none()); + assert!(!profile.join("templates/welcome/index.html").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn rolls_back_server_conf_when_index_html_creation_fails() { + use std::os::unix::fs::symlink; + + let home = DhttpHome::new(unique_test_home_path("rollback")); + let name = DhttpName::try_from("alice.smith".to_owned()).unwrap(); + let profile = home.identity_profile(name.borrow()); + tokio::fs::create_dir_all(profile.path()).await.unwrap(); + tokio::fs::create_dir_all(profile.join("templates/welcome")) + .await + .unwrap(); + symlink( + profile.join("missing-index-html-target"), + profile.join("templates/welcome/index.html"), + ) + .unwrap(); + + let error = maybe_create_welcome_service_with_probe( + &home, + name.borrow(), + HomeScope::Global, + || Ok(true), + ) + .await + .expect_err("index.html directory should make file creation fail"); + + let rendered = error.to_string(); + assert!(rendered.contains("welcome service"), "{rendered}"); + assert!(!profile.server_conf_path().exists()); + } + + #[test] + fn renders_welcome_service_created_block() { + let created = super::WelcomeServiceCreated { + server_conf_path: PathBuf::from("/tmp/alice/server.conf"), + welcome_page_path: PathBuf::from("/tmp/alice/templates/welcome/index.html"), + url: "https://alice.smith/".to_string(), + }; + + let expected = "Welcome service created\n Created server.conf at /tmp/alice/server.conf\n Created welcome page at /tmp/alice/templates/welcome/index.html\n Open https://alice.smith/ after pishoo starts or reloads"; + + assert_eq!(format_welcome_service_created(&created), expected); + } +} diff --git a/genmeta-identity/src/lib.rs b/genmeta-identity/src/lib.rs index ffab0f8..d2055b2 100644 --- a/genmeta-identity/src/lib.rs +++ b/genmeta-identity/src/lib.rs @@ -5,7 +5,7 @@ mod bootstrap; #[cfg(feature = "cli")] pub mod cli; #[cfg(feature = "cli")] -pub use cli::{Error, Options, run}; +pub use cli::{Cli, Error, Options, run}; #[cfg(feature = "cli")] pub mod auth; diff --git a/genmeta-identity/src/main.rs b/genmeta-identity/src/main.rs index 2388f07..cba9418 100644 --- a/genmeta-identity/src/main.rs +++ b/genmeta-identity/src/main.rs @@ -1,7 +1,7 @@ #![recursion_limit = "256"] use clap::{Arg, ArgAction, Command as ClapCommand, CommandFactory, FromArgMatches}; -use genmeta_identity::{Error, Options, run}; +use genmeta_identity::{Cli, Error, run}; fn enable_help(mut cmd: ClapCommand) -> ClapCommand { let names: Vec = cmd @@ -33,7 +33,7 @@ fn enable_help(mut cmd: ClapCommand) -> ClapCommand { #[tokio::main] #[snafu::report] async fn main() -> Result<(), Error> { - let mut cmd = Options::command(); + let mut cmd = Cli::command(); let names: Vec = cmd .get_subcommands() @@ -45,7 +45,7 @@ async fn main() -> Result<(), Error> { } let matches = cmd.get_matches(); - let options = Options::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); + let options = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); run(options).await.inspect_err(|error| { tracing::debug!(?error, "Exit with error"); diff --git a/genmeta-nat/Cargo.toml b/genmeta-nat/Cargo.toml index 2b4ae4f..11f8ee2 100644 --- a/genmeta-nat/Cargo.toml +++ b/genmeta-nat/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nat" description = "Diagnose network and environment issues" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nat/src/lib.rs b/genmeta-nat/src/lib.rs index 6bd64c6..10dcbcd 100644 --- a/genmeta-nat/src/lib.rs +++ b/genmeta-nat/src/lib.rs @@ -1,6 +1,5 @@ use std::{ collections::BTreeMap, - fmt, io::{IsTerminal, Write as _}, net::SocketAddr, str::FromStr, @@ -20,7 +19,6 @@ use dhttp::{ DetectNatTypeError, DetectOuterAddrError, StunClientsComponent, StunProbeError, }, }, - resolver::{Resolve, ResolveFuture, handy::SystemResolver}, }, endpoint::Endpoint, home::{self, DhttpHome, identity::IdentityProfile}, @@ -35,34 +33,6 @@ use tracing_subscriber::prelude::*; const STUN_AGENT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); const STUN_AGENT_DISCOVERY_INTERVAL: Duration = Duration::from_millis(100); -#[derive(Debug, Clone)] -struct FirstEndpointResolver { - inner: Arc, -} - -impl FirstEndpointResolver { - fn system() -> Arc { - Arc::new(Self { - inner: Arc::new(SystemResolver), - }) - } -} - -impl fmt::Display for FirstEndpointResolver { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "first endpoint of {}", self.inner) - } -} - -impl Resolve for FirstEndpointResolver { - fn lookup<'l>(&'l self, name: &'l str) -> ResolveFuture<'l> { - Box::pin(async move { - let records = self.inner.lookup(name).await?; - Ok(records.take(1).boxed()) - }) - } -} - #[derive(Parser, Debug, Clone)] #[command(name = "nat-detect", version, about)] pub struct Options { @@ -70,6 +40,10 @@ pub struct Options { #[arg(short, long)] pub id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + pub global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] pub anonymous: bool, @@ -84,11 +58,21 @@ pub struct Options { pub verbose: bool, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, snafu::Snafu)] #[snafu(module)] pub enum Error { - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -274,16 +258,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -329,11 +313,9 @@ async fn diagnose_nat(options: &mut Options) -> Result<(), Error> { let bind_patterns = Arc::new(options.binds.clone()); let network = DhttpNetwork::builder() - // NAT filtering tests are stateful: probing multiple STUN agents in - // parallel can open holes and misclassify restricted NATs. A single - // bootstrap STUN endpoint is enough because the STUN changed-address - // attribute supplies alternate servers for classification. - .stun_resolver(FirstEndpointResolver::system()) + .bind(bind_patterns.clone()) + .dns(DnsScheme::H3) + .dns(DnsScheme::System) .build() .await .context(error::BuildDhttpNetworkSnafu)?; @@ -655,6 +637,13 @@ mod tests { use super::*; + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["nat-detect", "--global"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } + #[test] fn write_nat_report_prints_failures_without_hiding_successes() { let failed_bind_uri = BindUri::from("iface://v4.fail0:0"); @@ -833,4 +822,18 @@ mod tests { ); assert!(!wrote_summary); } + + #[test] + fn diagnose_nat_uses_dhttp_dns_plan_without_endpoint_truncation() { + let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")); + let first_endpoint_resolver = ["First", "Endpoint", "Resolver"].concat(); + let take_one = [".take", "(1)"].concat(); + let single_resolver = ["single_stun", "_endpoint_resolver("].concat(); + + assert!(!source.contains(&first_endpoint_resolver)); + assert!(!source.contains(&take_one)); + assert!(!source.contains(&single_resolver)); + assert!(source.contains(".dns(DnsScheme::H3)")); + assert!(source.contains(".dns(DnsScheme::System)")); + } } diff --git a/genmeta-nslookup/Cargo.toml b/genmeta-nslookup/Cargo.toml index ab597ec..2aab49c 100644 --- a/genmeta-nslookup/Cargo.toml +++ b/genmeta-nslookup/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-nslookup" description = "resolve domain names" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-nslookup/src/lib.rs b/genmeta-nslookup/src/lib.rs index 89f95f1..1d6fcc8 100644 --- a/genmeta-nslookup/src/lib.rs +++ b/genmeta-nslookup/src/lib.rs @@ -31,6 +31,10 @@ pub struct Options { #[arg(short, long)] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -45,11 +49,21 @@ pub struct Options { binds: Vec, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(module)] pub enum Error { - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { name: Name<'static>, @@ -100,16 +114,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -198,3 +212,18 @@ pub async fn run(options: Options) -> Result<(), Error> { Ok(()) } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn options_accept_global_flag() { + let options = + Options::try_parse_from(["nslookup", "--global", "alice.smith", "mdns"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} diff --git a/genmeta-proxy/Cargo.toml b/genmeta-proxy/Cargo.toml index 6ca3d94..d0714f0 100644 --- a/genmeta-proxy/Cargo.toml +++ b/genmeta-proxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-proxy" description = "forward proxy routing .dhttp.net requests over DHTTP/3" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-proxy/src/lib.rs b/genmeta-proxy/src/lib.rs index a086be2..8d9ca2a 100644 --- a/genmeta-proxy/src/lib.rs +++ b/genmeta-proxy/src/lib.rs @@ -26,6 +26,10 @@ pub struct Options { #[arg(short, long, value_name = "client_identity")] pub id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + pub global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] pub anonymous: bool, @@ -51,6 +55,16 @@ pub struct Options { pub log: Option, } +impl Options { + fn home_scope(&self) -> home::HomeScope { + if self.global { + home::HomeScope::Global + } else { + home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum Error { @@ -59,8 +73,8 @@ pub enum Error { source: dhttp::message::IntoUriError, }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { @@ -282,16 +296,16 @@ async fn load_identity_profile(options: &Options) -> Result home, Err(source) if options.id.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(LoadDhttpHomeSnafu.into_error(source)), }; if let Some(name) = &options.id { @@ -449,3 +463,17 @@ pub mod forward; pub mod h3_forward; pub mod route; pub mod tunnel; + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn options_accept_global_flag() { + let options = Options::try_parse_from(["genmeta-proxy", "--global"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} diff --git a/genmeta-ssh/Cargo.toml b/genmeta-ssh/Cargo.toml index d494891..c2fdfb5 100644 --- a/genmeta-ssh/Cargo.toml +++ b/genmeta-ssh/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta-ssh" description = "DShell client" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta-ssh/src/config.rs b/genmeta-ssh/src/config.rs index 3426992..6d2605f 100644 --- a/genmeta-ssh/src/config.rs +++ b/genmeta-ssh/src/config.rs @@ -36,8 +36,8 @@ pub enum Error { MissingAuthority {}, #[snafu(display("failed to read ssh configuration"))] ReadConfig { source: ssh_config::ReadConfigError }, - #[snafu(display("failed to locate dhttp config"))] - LocateDhttpHome { source: home::LocateDhttpHomeError }, + #[snafu(display("failed to load dhttp home"))] + LoadDhttpHome { source: home::LoadDhttpHomeError }, #[snafu(display("failed to load explicit identity `{name}`"))] LoadExplicitIdentity { name: Name<'static>, @@ -85,16 +85,16 @@ async fn load_identity_profile( .map(|name| ("command line options", name)) .or_else(|| ssh_config_id_name.map(|name| ("ssh config", name))); - let home = match DhttpHome::load_from_environment() { + let home = match DhttpHome::load(options.home_scope()) { Ok(home) => home, Err(source) if explicit.is_none() => { tracing::warn!( error = %snafu::Report::from_error(&source), - "failed to locate dhttp config, using anonymous endpoint" + "failed to load dhttp home, using anonymous endpoint" ); return Ok(None); } - Err(source) => return Err(config_error::LocateDhttpHomeSnafu.into_error(source)), + Err(source) => return Err(config_error::LoadDhttpHomeSnafu.into_error(source)), }; if let Some((source_name, name)) = explicit { diff --git a/genmeta-ssh/src/lib.rs b/genmeta-ssh/src/lib.rs index 888154e..e011c70 100644 --- a/genmeta-ssh/src/lib.rs +++ b/genmeta-ssh/src/lib.rs @@ -71,6 +71,10 @@ pub struct Options { #[arg(short = 'i', long, value_name = "client_identity")] id: Option>, + /// Use the global dhttp home instead of the default user home + #[arg(long)] + global: bool, + /// Skip identity loading and use anonymous mode #[arg(long, conflicts_with = "id")] anonymous: bool, @@ -128,6 +132,16 @@ pub struct Options { commands: Vec, } +impl Options { + pub(crate) fn home_scope(&self) -> dhttp::home::HomeScope { + if self.global { + dhttp::home::HomeScope::Global + } else { + dhttp::home::HomeScope::User + } + } +} + #[derive(Debug, Snafu)] pub enum Error { #[snafu(transparent)] @@ -633,3 +647,18 @@ fn sigwinch_stream() -> impl futures::Stream + Unpin + Send { fn sigwinch_stream() -> impl futures::Stream + Unpin + Send { futures::stream::empty() } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn options_accept_global_flag() { + let options = + Options::try_parse_from(["genmeta-ssh", "--global", "alice@example"]).unwrap(); + + assert_eq!(options.home_scope(), dhttp::home::HomeScope::Global); + } +} diff --git a/genmeta/Cargo.toml b/genmeta/Cargo.toml index f77659c..305958c 100644 --- a/genmeta/Cargo.toml +++ b/genmeta/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "genmeta" description = "Genmeta Binary Utilities" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/genmeta/src/main.rs b/genmeta/src/main.rs index d4f8408..70d7163 100644 --- a/genmeta/src/main.rs +++ b/genmeta/src/main.rs @@ -11,10 +11,7 @@ enum Options { #[command(subcommand)] options: genmeta_doctor::Options, }, - Identity { - #[command(subcommand)] - options: genmeta_identity::Options, - }, + Identity(genmeta_identity::Cli), Nslookup(genmeta_nslookup::Options), Proxy(genmeta_proxy::Options), Ssh(genmeta_ssh::Options), @@ -119,7 +116,7 @@ async fn run(options: Options) -> Result<(), Error> { Options::Curl(options) => genmeta_curl::run(options).await?, Options::Discover(options) => genmeta_discover::run(options).await?, Options::Doctor { options } => genmeta_doctor::run(options).await?, - Options::Identity { options } => genmeta_identity::run(options).await?, + Options::Identity(options) => genmeta_identity::run(options).await?, Options::Nslookup(options) => genmeta_nslookup::run(options).await?, Options::Proxy(options) => genmeta_proxy::run(options).await?, Options::Ssh(options) => genmeta_ssh::run(options).await?, @@ -130,7 +127,9 @@ async fn run(options: Options) -> Result<(), Error> { #[cfg(test)] mod tests { - use super::install_process_crypto_provider; + use clap::Parser; + + use super::{Options, install_process_crypto_provider}; #[test] fn installs_rustls_crypto_provider() { @@ -138,4 +137,10 @@ mod tests { assert!(rustls::crypto::CryptoProvider::get_default().is_some()); } + + #[test] + fn launcher_accepts_identity_global_flag() { + let parsed = Options::try_parse_from(["genmeta", "identity", "--global", "list"]); + assert!(parsed.is_ok(), "{parsed:?}"); + } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index fd78721..921cb13 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -9,15 +9,6 @@ homepage.workspace = true publish = false [dependencies] -aws-credential-types = "1" -# Keep default `default-https-client` disabled. It pulls aws-lc, and aws-lc-sys -# fails to assemble on i686-pc-windows-msvc under clang-cl during release -# cross-builds. Enable only the rustls/ring-backed features used by S3 publish. -aws-sdk-s3 = { version = "1", default-features = false, features = [ - "rt-tokio", - "rustls", - "sigv4a", -] } bollard = "0.21" cargo_metadata = "0.23" clap = { version = "4", features = ["derive", "env"] } @@ -39,3 +30,17 @@ rpm-version = "0.5.0" tokio = { version = "1", features = ["full"] } walkdir = "2" zip = { version = "8", default-features = false, features = ["deflate"] } + +[target.'cfg(xtask_s3_publish)'.dependencies] +aws-credential-types = "1" +# Keep default `default-https-client` disabled. It pulls aws-lc, and aws-lc-sys +# fails to assemble on i686-pc-windows-msvc under clang-cl during release +# cross-builds. Enable only the rustls/ring-backed features used by S3 publish. +aws-sdk-s3 = { version = "1", default-features = false, features = [ + "rt-tokio", + "rustls", + "sigv4a", +] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(xtask_s3_publish)'] } diff --git a/xtask/deb/rules b/xtask/deb/rules index d84201a..c294b42 100755 --- a/xtask/deb/rules +++ b/xtask/deb/rules @@ -6,11 +6,13 @@ # cargo-zigbuild (e.g. x86_64-unknown-linux-gnu.2.28). # # This file lives in xtask/deb/ and is copied into target/.../deb/src/debian/ -# before dpkg-buildpackage runs. All paths use /workspace (the bind mount root). +# before dpkg-buildpackage runs. xtask points SOURCE_ROOT at the primary source +# bind mount inside the build container. export DH_VERBOSE = 1 BUILD_PROFILE ?= release CARGO_PROFILE_ARGS ?= --release +SOURCE_ROOT ?= /workspace %: dh $@ @@ -19,7 +21,7 @@ CARGO_PROFILE_ARGS ?= --release # Cortex-A53 843419 mitigation linker argument. override_dh_auto_build: ifdef TRIPLE - cd /workspace && \ + cd $(SOURCE_ROOT) && \ export RUSTFLAGS="$${RUSTFLAGS:-} -L /usr/lib/$(DEB_HOST_MULTIARCH)" && \ if [ "$(TRIPLE)" = "aarch64-unknown-linux-gnu" ]; then \ { \ @@ -51,8 +53,8 @@ endif override_dh_auto_install: ifdef TRIPLE mkdir -p debian/gmutils/usr/bin - cp /workspace/target/$(TRIPLE)/$(BUILD_PROFILE)/genmeta debian/gmutils/usr/bin/ - cp /workspace/genmeta-ssh.sh debian/gmutils/usr/bin/ + cp $(SOURCE_ROOT)/target/$(TRIPLE)/$(BUILD_PROFILE)/genmeta debian/gmutils/usr/bin/ + cp $(SOURCE_ROOT)/genmeta-ssh.sh debian/gmutils/usr/bin/ chmod 755 debian/gmutils/usr/bin/* endif diff --git a/xtask/release.toml b/xtask/release.toml new file mode 100644 index 0000000..e1c9fc0 --- /dev/null +++ b/xtask/release.toml @@ -0,0 +1,62 @@ +[cargo] +manifest = "genmeta/Cargo.toml" + +[package] +name = "gmutils" + +[homebrew.template] +path = "xtask/templates/gmutils.rb.in" + +[build.env.DHTTP_ROOT_CA] +env = "DHTTP_ROOT_CA" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" + +[build.env.DHTTP_H3_DNS_SERVER] +env = "DHTTP_H3_DNS_SERVER" + +[build.env.DHTTP_HTTP_DNS_SERVER] +env = "DHTTP_HTTP_DNS_SERVER" + +[build.env.DHTTP_MDNS_SERVICE] +env = "DHTTP_MDNS_SERVICE" + +[build.env.DHTTP_CERT_SERVER_URL] +env = "DHTTP_CERT_SERVER_URL" + +[build.env.DHTTP_GLOBAL_HOME] +env = "DHTTP_GLOBAL_HOME" + +[homebrew.target.aarch64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/opt/homebrew/etc/dhttp" + +[homebrew.target.x86_64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/usr/local/etc/dhttp" + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" + +[destination.brew] +prefix = "brew/gmutils" +public_base_url = "https://download.dhttp.net/brew/gmutils" +tap.repository = "genmeta/homebrew-genmeta" +tap.base_branch = "main" +tap.token.env = "HOMEBREW_TAP_GITHUB_TOKEN" + +[destination.deb] +prefix = "ppa/genmeta" +suite = "genmeta" +signing.key.env = "XTASK_RELEASE_APT_SIGNING_KEY" +signing.passphrase.env = "XTASK_RELEASE_APT_SIGNING_PASSPHRASE" +fingerprint.env = "XTASK_RELEASE_APT_SIGNING_FINGERPRINT" + +[destination.rpm] +prefix = "rpm/gmutils" + +[destination.scoop] +prefix = "scoop/gmutils" +public_base_url = "https://download.dhttp.net/scoop/gmutils" diff --git a/xtask/src/brew.rs b/xtask/src/brew.rs index 2bf4c5d..f532394 100644 --- a/xtask/src/brew.rs +++ b/xtask/src/brew.rs @@ -1,12 +1,19 @@ #![allow(dead_code)] -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use flate2::{Compression, write::GzEncoder}; use snafu::{ResultExt, Whatever}; use tracing::{Instrument, info, info_span}; -use crate::{BrewTarget, package_meta, run_cmd, run_cmd_quiet, sha256_file, target_dir}; +use crate::{ + BrewTarget, package_meta, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + run_cmd, run_cmd_quiet, sha256_file, target_dir, +}; const CARGO_NAME: &str = "genmeta"; @@ -39,7 +46,10 @@ fn create_tar_gz(staging: &Path, output: &Path) -> Result<(), Whatever> { Ok(()) } -pub async fn run(targets: &[BrewTarget]) -> Result, Whatever> { +pub async fn run( + contract: &ReleaseContract, + targets: &[BrewTarget], +) -> Result, Whatever> { info!(target_count = targets.len(), "starting brew dist build"); let meta = package_meta(CARGO_NAME)?; let target_dir = target_dir()?; @@ -51,10 +61,12 @@ pub async fn run(targets: &[BrewTarget]) -> Result, Whatever> { let target_dir = target_dir.clone(); let workspace = workspace.clone(); let triple = target.triple(); + let build_env = resolve_build_env_from_process(contract, PackageKind::Brew, Some(triple)) + .whatever_context("failed to resolve build environment for brew target")?; info!(triple, "queued brew target build"); let span = info_span!("brew", triple); tasks.spawn( - async move { build_one(triple, &version, &target_dir, &workspace).await } + async move { build_one(triple, &version, &target_dir, &workspace, build_env).await } .instrument(span), ); } @@ -75,20 +87,25 @@ async fn build_one( version: &str, target_dir: &Path, workspace: &Path, + build_env: BTreeMap, ) -> Result { info!(triple, "checking cargo availability"); check_cargo().await?; // Build info!(triple, "starting cargo build for brew target"); - run_cmd(tokio::process::Command::new("cargo").args([ - "build", - "--release", - "--target", - triple, - "--bin", - CARGO_NAME, - ])) + run_cmd( + tokio::process::Command::new("cargo") + .envs(&build_env) + .args([ + "build", + "--release", + "--target", + triple, + "--bin", + CARGO_NAME, + ]), + ) .await .whatever_context(format!("cargo build failed for {triple}"))?; info!(triple, "cargo build finished for brew target"); diff --git a/xtask/src/container.rs b/xtask/src/container.rs index 10dc0e4..9121228 100644 --- a/xtask/src/container.rs +++ b/xtask/src/container.rs @@ -22,6 +22,7 @@ pub(crate) const RUSTUP_HOME: &str = "/opt/rustup"; pub(crate) const ZIG_GLIBC_VERSION: &str = "2.28"; pub(crate) const DHTTP_BOOTSTRAP_ROOT_CA_TARGET: &str = "/dhttp-bootstrap/root.crt"; +pub(crate) const SOURCES_ROOT: &str = "/sources"; const DHTTP_ROOT_CA: &str = "DHTTP_ROOT_CA"; const DHTTP_STUN_SERVER: &str = "DHTTP_STUN_SERVER"; @@ -29,22 +30,25 @@ const DHTTP_H3_DNS_SERVER: &str = "DHTTP_H3_DNS_SERVER"; const DHTTP_HTTP_DNS_SERVER: &str = "DHTTP_HTTP_DNS_SERVER"; const DHTTP_MDNS_SERVICE: &str = "DHTTP_MDNS_SERVICE"; const DHTTP_CERT_SERVER_URL: &str = "DHTTP_CERT_SERVER_URL"; +const DHTTP_GLOBAL_HOME: &str = "DHTTP_GLOBAL_HOME"; -const DHTTP_BOOTSTRAP_VARS: [&str; 6] = [ +const DHTTP_BOOTSTRAP_VARS: [&str; 7] = [ DHTTP_ROOT_CA, DHTTP_STUN_SERVER, DHTTP_H3_DNS_SERVER, DHTTP_HTTP_DNS_SERVER, DHTTP_MDNS_SERVICE, DHTTP_CERT_SERVER_URL, + DHTTP_GLOBAL_HOME, ]; -const DHTTP_BOOTSTRAP_SCALAR_VARS: [&str; 5] = [ +const DHTTP_BOOTSTRAP_SCALAR_VARS: [&str; 6] = [ DHTTP_STUN_SERVER, DHTTP_H3_DNS_SERVER, DHTTP_HTTP_DNS_SERVER, DHTTP_MDNS_SERVICE, DHTTP_CERT_SERVER_URL, + DHTTP_GLOBAL_HOME, ]; #[derive(Debug)] @@ -266,15 +270,59 @@ pub(crate) fn cargo_cache_mounts() -> Vec { mounts } -/// Resolved sibling bind-mount: canonical host path + basename used as the -/// container target path (`/{basename}`). +/// Resolved source checkout bind-mount. The CLI still calls non-primary +/// checkouts `--sibling`, but the container layout is source-centric: every +/// checkout, including the primary repo, lives under `/sources/`. #[derive(Clone)] -pub(crate) struct Sibling { +pub(crate) struct SourceCheckout { pub(crate) host: std::path::PathBuf, - pub(crate) basename: String, + pub(crate) name: String, + pub(crate) container: String, } -pub(crate) fn resolve_siblings(paths: &[std::path::PathBuf]) -> Result, Whatever> { +#[derive(Clone)] +pub(crate) struct ContainerSourceLayout { + pub(crate) primary: SourceCheckout, + pub(crate) overrides: Vec, +} + +impl ContainerSourceLayout { + pub(crate) fn all(&self) -> impl Iterator { + std::iter::once(&self.primary).chain(self.overrides.iter()) + } +} + +pub(crate) fn source_layout( + primary_name: &str, + paths: &[std::path::PathBuf], +) -> Result { + let host = std::env::current_dir().whatever_context("failed to get current directory")?; + let primary = SourceCheckout { + host, + name: primary_name.to_string(), + container: format!("{SOURCES_ROOT}/{primary_name}"), + }; + Ok(ContainerSourceLayout { + primary, + overrides: resolve_source_overrides(paths)?, + }) +} + +pub(crate) fn source_mounts(layout: &ContainerSourceLayout) -> Vec { + layout + .all() + .map(|source| Mount { + target: Some(source.container.clone()), + source: Some(source.host.to_string_lossy().into_owned()), + typ: Some(MountType::BIND), + ..Default::default() + }) + .collect() +} + +pub(crate) fn resolve_source_overrides( + paths: &[std::path::PathBuf], +) -> Result, Whatever> { let mut out = Vec::with_capacity(paths.len()); for raw in paths { let host = raw @@ -283,7 +331,7 @@ pub(crate) fn resolve_siblings(paths: &[std::path::PathBuf]) -> Result Result Option { +pub(crate) fn cargo_config_from_siblings(siblings: &[SourceCheckout]) -> Option { let mut config = String::new(); let mut registry_patches = Vec::new(); if has_sibling(siblings, "dhttp") { let packages = [ - ("dhttp", "/dhttp/dhttp"), - ("dhttp-access", "/dhttp/access"), - ("dhttp-home", "/dhttp/home"), - ("dhttp-identity", "/dhttp/identity"), + ("dhttp", "/sources/dhttp/dhttp"), + ("dhttp-access", "/sources/dhttp/access"), + ("dhttp-home", "/sources/dhttp/home"), + ("dhttp-identity", "/sources/dhttp/identity"), ]; registry_patches.extend(packages); push_patch_section( @@ -318,7 +371,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "ddns") { - let packages = [("dyns", "/ddns")]; + let packages = [("dyns", "/sources/ddns")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -328,13 +381,13 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "h3x") { - let packages = [("h3x", "/h3x")]; + let packages = [("h3x", "/sources/h3x")]; registry_patches.extend(packages); push_patch_section(&mut config, "https://github.com/genmeta/h3x.git", &packages); } if has_sibling(siblings, "dssh") { - let packages = [("dshell", "/dssh")]; + let packages = [("dshell", "/sources/dssh")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -344,7 +397,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "dquic") { - let packages = [("dquic", "/dquic/dquic")]; + let packages = [("dquic", "/sources/dquic/dquic")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -354,7 +407,7 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } if has_sibling(siblings, "rankey") { - let packages = [("rankey", "/rankey")]; + let packages = [("rankey", "/sources/rankey")]; registry_patches.extend(packages); push_patch_section( &mut config, @@ -373,8 +426,8 @@ pub(crate) fn cargo_config_from_siblings(siblings: &[Sibling]) -> Option } } -fn has_sibling(siblings: &[Sibling], basename: &str) -> bool { - siblings.iter().any(|sibling| sibling.basename == basename) +fn has_sibling(siblings: &[SourceCheckout], name: &str) -> bool { + siblings.iter().any(|sibling| sibling.name == name) } fn push_registry_patch_section(config: &mut String, packages: &[(&str, &str)]) { @@ -437,6 +490,10 @@ mod tests { "DHTTP_CERT_SERVER_URL".to_string(), "https://license.test".to_string(), ); + values.insert( + "DHTTP_GLOBAL_HOME".to_string(), + "/opt/homebrew/etc/dhttp".to_string(), + ); let bootstrap = dhttp_bootstrap_from_values(values).expect("build bootstrap"); @@ -490,6 +547,11 @@ mod tests { .exports .contains("export DHTTP_CERT_SERVER_URL='https://license.test'\n") ); + assert!( + bootstrap + .exports + .contains("export DHTTP_GLOBAL_HOME='/opt/homebrew/etc/dhttp'\n") + ); } #[test] @@ -531,53 +593,81 @@ mod tests { #[test] fn cargo_config_from_siblings_patches_genmeta_git_and_registry_sources() { let siblings = vec![ - Sibling { + SourceCheckout { host: "/host/reimu/dhttp".into(), - basename: "dhttp".to_string(), + name: "dhttp".to_string(), + container: "/sources/dhttp".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/ddns".into(), - basename: "ddns".to_string(), + name: "ddns".to_string(), + container: "/sources/ddns".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/dquic".into(), - basename: "dquic".to_string(), + name: "dquic".to_string(), + container: "/sources/dquic".to_string(), }, - Sibling { + SourceCheckout { host: "/host/reimu/rankey".into(), - basename: "rankey".to_string(), + name: "rankey".to_string(), + container: "/sources/rankey".to_string(), }, ]; let config = cargo_config_from_siblings(&siblings).expect("config should be rendered"); assert!(config.contains("[patch.crates-io]")); - assert!(config.contains("dhttp = { path = \"/dhttp/dhttp\" }")); - assert!(config.contains("dhttp-identity = { path = \"/dhttp/identity\" }")); - assert!(config.contains("dyns = { path = \"/ddns\" }")); - assert!(config.contains("dquic = { path = \"/dquic/dquic\" }")); - assert!(config.contains("rankey = { path = \"/rankey\" }")); + assert!(config.contains("dhttp = { path = \"/sources/dhttp/dhttp\" }")); + assert!(config.contains("dhttp-identity = { path = \"/sources/dhttp/identity\" }")); + assert!(config.contains("dyns = { path = \"/sources/ddns\" }")); + assert!(config.contains("dquic = { path = \"/sources/dquic/dquic\" }")); + assert!(config.contains("rankey = { path = \"/sources/rankey\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/dhttp.git\"]")); - assert!(config.contains("dhttp = { path = \"/dhttp/dhttp\" }")); - assert!(config.contains("dhttp-identity = { path = \"/dhttp/identity\" }")); + assert!(config.contains("dhttp = { path = \"/sources/dhttp/dhttp\" }")); + assert!(config.contains("dhttp-identity = { path = \"/sources/dhttp/identity\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/ddns.git\"]")); - assert!(config.contains("dyns = { path = \"/ddns\" }")); + assert!(config.contains("dyns = { path = \"/sources/ddns\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/dquic.git\"]")); - assert!(config.contains("dquic = { path = \"/dquic/dquic\" }")); + assert!(config.contains("dquic = { path = \"/sources/dquic/dquic\" }")); assert!(config.contains("[patch.\"https://github.com/genmeta/rankey.git\"]")); - assert!(config.contains("rankey = { path = \"/rankey\" }")); + assert!(config.contains("rankey = { path = \"/sources/rankey\" }")); assert!(!config.contains("ddns =")); } #[test] fn cargo_config_from_siblings_returns_none_without_supported_siblings() { - let siblings = vec![Sibling { + let siblings = vec![SourceCheckout { host: "/host/reimu/other".into(), - basename: "other".to_string(), + name: "other".to_string(), + container: "/sources/other".to_string(), }]; assert!(cargo_config_from_siblings(&siblings).is_none()); } + #[test] + fn source_layout_mounts_primary_and_overrides_under_sources() { + let layout = ContainerSourceLayout { + primary: SourceCheckout { + host: "/host/reimu/gmutils".into(), + name: "gmutils".to_string(), + container: "/sources/gmutils".to_string(), + }, + overrides: vec![SourceCheckout { + host: "/host/reimu/dhttp".into(), + name: "dhttp".to_string(), + container: "/sources/dhttp".to_string(), + }], + }; + + let paths = layout + .all() + .map(|source| source.container.as_str()) + .collect::>(); + + assert_eq!(paths, ["/sources/gmutils", "/sources/dhttp"]); + } + #[test] fn deb_rules_do_not_forward_legacy_root_ca_env() { let rules = include_str!("../deb/rules"); diff --git a/xtask/src/deb.rs b/xtask/src/deb.rs index ccc90ba..cee9986 100644 --- a/xtask/src/deb.rs +++ b/xtask/src/deb.rs @@ -1,8 +1,10 @@ #![allow(dead_code)] +use std::collections::BTreeMap; + use bollard::{ Docker, - models::{ContainerConfig, ContainerCreateBody, HostConfig, Mount, MountType}, + models::{ContainerConfig, ContainerCreateBody, HostConfig}, query_parameters::{ CommitContainerOptionsBuilder, CreateContainerOptionsBuilder, CreateImageOptionsBuilder, }, @@ -14,12 +16,14 @@ use tracing::{Instrument, info, info_span}; use crate::{ BuildProfile, DebTarget, container::{ - CARGO_HOME, RUSTUP_HOME, Sibling, ZIG_GLIBC_VERSION, cargo_cache_mounts, - cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, + CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, + cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_values, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, - resolve_siblings, start_container, + source_layout, source_mounts, start_container, }, - package_version, target_dir, + package_version, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + target_dir, }; const CARGO_NAME: &str = "genmeta"; @@ -214,6 +218,7 @@ chmod -R a+rX {CARGO_HOME} {RUSTUP_HOME} } pub async fn run( + contract: &ReleaseContract, targets: &[DebTarget], profile: BuildProfile, siblings: &[std::path::PathBuf], @@ -229,10 +234,12 @@ pub async fn run( // Resolve sibling paths up front so every target build sees the same set // and path errors surface before we spin up containers. - let siblings = resolve_siblings(siblings)?; + let layout = source_layout("gmutils", siblings)?; let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; + let build_env = resolve_build_env_from_process(contract, PackageKind::Deb, None) + .whatever_context("failed to resolve build environment for deb packaging")?; let mut tasks = tokio::task::JoinSet::new(); @@ -240,7 +247,8 @@ pub async fn run( let docker = docker.clone(); let version = version.clone(); let target_dir = target_dir.clone(); - let siblings = siblings.clone(); + let layout = layout.clone(); + let build_env = build_env.clone(); let triple = target.triple(); info!( triple, @@ -250,8 +258,16 @@ pub async fn run( let span = info_span!("deb", triple); tasks.spawn( async move { - build_one_with_retry(&docker, triple, &version, &target_dir, profile, &siblings) - .await + build_one_with_retry( + &docker, + triple, + &version, + &target_dir, + profile, + &layout, + build_env, + ) + .await } .instrument(span), ); @@ -275,10 +291,15 @@ async fn build_one_with_retry( version: &str, target_dir: &std::path::Path, profile: BuildProfile, - siblings: &[Sibling], + layout: &ContainerSourceLayout, + build_env: BTreeMap, ) -> Result { for attempt in 1..=BUILD_ATTEMPTS { - match build_one(docker, triple, version, target_dir, profile, siblings).await { + match build_one( + docker, triple, version, target_dir, profile, layout, &build_env, + ) + .await + { Ok(artifact) => return Ok(artifact), Err(error) if attempt < BUILD_ATTEMPTS => { let report = Report::from_error(&error); @@ -302,7 +323,8 @@ async fn build_one( version: &str, target_dir: &std::path::Path, profile: BuildProfile, - siblings: &[Sibling], + layout: &ContainerSourceLayout, + build_env: &BTreeMap, ) -> Result { let arch = deb_arch(triple)?; let gnu = gnu_arch(triple)?; @@ -316,31 +338,12 @@ async fn build_one( .await .whatever_context(format!("failed to create {}", out_dir.display()))?; - let workspace_dir = - std::env::current_dir().whatever_context("failed to get current directory")?; - - let mut mounts = vec![Mount { - target: Some("/workspace".into()), - source: Some(workspace_dir.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }]; - // User-requested sibling crates, bind-mounted at /{basename} so that - // `path = "../{basename}"` references in Cargo.toml resolve inside the - // container. - for sibling in siblings { - mounts.push(Mount { - target: Some(format!("/{}", sibling.basename)), - source: Some(sibling.host.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }); - } + let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); - let bootstrap = dhttp_bootstrap_from_env()?; + let bootstrap = dhttp_bootstrap_from_values(build_env.clone())?; mounts.extend(bootstrap.mounts); - let cargo_config = cargo_config_from_siblings(siblings); + let cargo_config = cargo_config_from_siblings(&layout.overrides); let container_name = format!("{CARGO_NAME}-xtask-deb-{triple}"); info!(triple, container = %container_name, "creating build container"); @@ -376,6 +379,7 @@ async fn build_one( arch, gnu, profile, + &layout.primary.container, &bootstrap.exports, cargo_config.as_deref(), ) @@ -399,6 +403,7 @@ async fn build_one_inner( arch: &str, gnu: &str, profile: BuildProfile, + primary_source: &str, dhttp_bootstrap_exports: &str, cargo_config: Option<&str>, ) -> Result<(), Whatever> { @@ -435,10 +440,11 @@ export ZIG_TARGET={triple}.{ZIG_GLIBC_VERSION} export BUILD_PROFILE={profile_dir} export CARGO_PROFILE_ARGS="{cargo_profile_args}" export DEB_HOST_MULTIARCH={gnu} +export SOURCE_ROOT={primary_source} {dhttp_bootstrap_exports} -SRC=/workspace/target/{triple}/{profile_dir}/deb/src +SRC={primary_source}/target/{triple}/{profile_dir}/deb/src mkdir -p "$SRC/debian" -cp -r /workspace/{DEBIAN_PKG_DIR}/. "$SRC/debian/" +cp -r {primary_source}/{DEBIAN_PKG_DIR}/. "$SRC/debian/" printf '{PACKAGE_NAME} ({version}-1) unstable; urgency=low\n\n * release {version}\n\n -- Genmeta Tech Limited %s\n' \ "$(date -R)" > "$SRC/debian/changelog" cd "$SRC" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d525d51..ace6f95 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,9 +3,12 @@ mod container; mod deb; mod grouped; mod package; +#[cfg(xtask_s3_publish)] mod publish; +mod release_contract; mod rpm; mod scoop; +mod template; mod version_cmp; use std::{io::IsTerminal, path::PathBuf, process::Stdio}; @@ -43,6 +46,7 @@ enum Command { targets: Vec, }, /// Publish package manifests to a backend + #[cfg(xtask_s3_publish)] Publish { #[command(subcommand)] command: publish::PublishCommand, @@ -265,7 +269,9 @@ pub async fn run_cmd(cmd: &mut tokio::process::Command) -> Result<(), Whatever> mod tests { use clap::{CommandFactory, Parser, error::ErrorKind}; - use super::{BuildProfile, Cli, Command, publish}; + #[cfg(xtask_s3_publish)] + use super::publish; + use super::{BuildProfile, Cli, Command}; const RELEASE_WORKFLOW: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -297,7 +303,10 @@ mod tests { let names = subcommand_names(&command); assert!(names.contains(&"package")); + #[cfg(xtask_s3_publish)] assert!(names.contains(&"publish")); + #[cfg(not(xtask_s3_publish))] + assert!(!names.contains(&"publish")); assert!(!names.contains(&"dist")); assert!(!names.contains(&"stage")); assert!(!names.contains(&"verify")); @@ -318,65 +327,41 @@ mod tests { ]) .expect("package command should parse"); - match cli.command { + let (overwrite_manifest, targets) = match cli.command { Command::Package { overwrite_manifest, targets, - } => { - assert!(overwrite_manifest); - assert_eq!( - targets, - [ - "deb", - "--target", - "x86_64-unknown-linux-gnu", - "rpm", - "--target", - "aarch64-unknown-linux-gnu", - ] - .map(std::ffi::OsString::from) - ); - } + } => (overwrite_manifest, targets), + #[cfg(xtask_s3_publish)] _ => panic!("expected package command"), - } + }; + assert!(overwrite_manifest); + assert_eq!( + targets, + [ + "deb", + "--target", + "x86_64-unknown-linux-gnu", + "rpm", + "--target", + "aarch64-unknown-linux-gnu", + ] + .map(std::ffi::OsString::from) + ); } #[test] + #[cfg(xtask_s3_publish)] fn publish_s3_accepts_grouped_targets() { - let cli = Cli::try_parse_from([ - "xtask", - "publish", - "s3", - "--dry-run", - "--endpoint-url", - "https://example.invalid", - "--bucket", - "download", - "--access-key-id", - "access", - "--secret-access-key", - "secret", - "deb", - "--prefix", - "apt/gmutils", - "--suite", - "stable", - "--fingerprint", - "0123456789ABCDEF0123456789ABCDEF01234567", - "brew", - "--prefix", - "brew/gmutils", - "--public-base-url", - "https://download.example/brew/gmutils", - ]) - .expect("publish command should parse"); + let cli = Cli::try_parse_from(["xtask", "publish", "s3", "--dry-run", "deb", "brew"]) + .expect("publish command should parse"); match cli.command { Command::Publish { command } => match command { publish::PublishCommand::S3 { options, targets } => { assert!(options.dry_run); - assert_eq!(options.bucket, "download"); assert_eq!(targets[0], std::ffi::OsString::from("deb")); + assert_eq!(targets[1], std::ffi::OsString::from("brew")); } }, _ => panic!("expected publish command"), @@ -396,9 +381,27 @@ mod tests { fn release_workflow_publish_commands_are_tag_mode_safe() { assert!(!RELEASE_WORKFLOW.contains("publish_args=()")); assert!(!RELEASE_WORKFLOW.contains("\"${publish_args[@]}\"")); + assert!(RELEASE_WORKFLOW.contains("DHTTP_ROOT_CA_PEM: ${{ secrets.DHTTP_ROOT_CA_PEM }}")); + assert!( + RELEASE_WORKFLOW + .contains("DHTTP_ROOT_CA: ${{ github.workspace }}/.release/dhttp-root-ca.pem") + ); + assert!(RELEASE_WORKFLOW.contains("Materialize DHTTP root CA")); + assert!( + RELEASE_WORKFLOW.contains("missing required release configuration: DHTTP_ROOT_CA_PEM") + ); + assert!(!RELEASE_WORKFLOW.contains("keychain/root.crt")); + assert!(!RELEASE_WORKFLOW.contains("--endpoint-url")); + assert!(!RELEASE_WORKFLOW.contains("--bucket")); + assert!(!RELEASE_WORKFLOW.contains("--prefix")); + assert!(!RELEASE_WORKFLOW.contains("--public-base-url")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" deb")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" rpm")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" scoop")); + assert!(RELEASE_WORKFLOW.contains("\"${publish_cmd[@]}\" brew")); assert_eq!( RELEASE_WORKFLOW - .matches("publish_cmd=(cargo xtask publish s3)") + .matches(r#"publish_cmd=(env RUSTFLAGS="${RUSTFLAGS:-} --cfg xtask_s3_publish" cargo run --package xtask -- publish s3)"#) .count(), 4 ); @@ -437,6 +440,17 @@ mod tests { ); } + #[test] + fn release_workflow_homebrew_tap_updates_root_formula() { + assert!(RELEASE_WORKFLOW.contains("id: homebrew_destination")); + assert!(!RELEASE_WORKFLOW.contains("download.genmeta.net")); + assert!(RELEASE_WORKFLOW.contains("tomllib.loads(Path(\"xtask/release.toml\")")); + assert!(RELEASE_WORKFLOW.contains("formula_dest=\"$tap_dir/$FORMULA_NAME\"")); + assert!(RELEASE_WORKFLOW.contains("git status --porcelain -- \"$FORMULA_NAME\"")); + assert!(RELEASE_WORKFLOW.contains("git add \"$FORMULA_NAME\"")); + assert!(!RELEASE_WORKFLOW.contains("Formula/$FORMULA_NAME")); + } + #[test] fn public_package_manifests_declare_apache_2_license() { let workspace_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -526,6 +540,7 @@ async fn main() -> Result<(), Whatever> { }) .await? } + #[cfg(xtask_s3_publish)] Command::Publish { command } => publish::run(command).await?, } Ok(()) diff --git a/xtask/src/package.rs b/xtask/src/package.rs index 4b9276d..cfd9e41 100644 --- a/xtask/src/package.rs +++ b/xtask/src/package.rs @@ -10,7 +10,7 @@ use std::ffi::OsString; use clap::{CommandFactory, Parser, Subcommand, error::ErrorKind}; #[allow(unused_imports)] pub use manifest::{ArtifactKind, PackageArtifact, PackageManifest}; -use snafu::Whatever; +use snafu::{ResultExt, Whatever}; use crate::{BrewTarget, DebTarget, RpmTarget, ScoopTarget, grouped}; @@ -77,6 +77,8 @@ pub fn parse_package_sections(tokens: &[OsString]) -> Result, } pub async fn run(options: PackageOptions) -> Result<(), Whatever> { + let contract = crate::release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; let formats = parse_package_sections(&options.targets).unwrap_or_else(|error| error.exit()); for format in formats { match format { @@ -86,6 +88,7 @@ pub async fn run(options: PackageOptions) -> Result<(), Whatever> { siblings, } => { deb::run( + &contract, &targets, crate::BuildProfile::from_debug(debug), &siblings, @@ -94,13 +97,13 @@ pub async fn run(options: PackageOptions) -> Result<(), Whatever> { .await? } PackageFormat::Rpm { targets, siblings } => { - rpm::run(&targets, &siblings, options.overwrite_manifest).await? + rpm::run(&contract, &targets, &siblings, options.overwrite_manifest).await? } PackageFormat::Brew { targets } => { - brew::run(&targets, options.overwrite_manifest).await? + brew::run(&contract, &targets, options.overwrite_manifest).await? } PackageFormat::Scoop { targets } => { - scoop::run(&targets, options.overwrite_manifest).await? + scoop::run(&contract, &targets, options.overwrite_manifest).await? } } } diff --git a/xtask/src/package/brew.rs b/xtask/src/package/brew.rs index 9c960b2..5adb5a3 100644 --- a/xtask/src/package/brew.rs +++ b/xtask/src/package/brew.rs @@ -76,8 +76,12 @@ fn target_relative_path(path: &Path) -> Result { .ok_or(BrewPackageError::ArtifactPathUtf8) } -pub async fn run(targets: &[BrewTarget], overwrite_manifest: bool) -> Result<(), Whatever> { - let archives = crate::brew::run(targets).await?; +pub async fn run( + contract: &crate::release_contract::ReleaseContract, + targets: &[BrewTarget], + overwrite_manifest: bool, +) -> Result<(), Whatever> { + let archives = crate::brew::run(contract, targets).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("brew").join("manifest.toml"); diff --git a/xtask/src/package/deb.rs b/xtask/src/package/deb.rs index 50eba93..1d3a41f 100644 --- a/xtask/src/package/deb.rs +++ b/xtask/src/package/deb.rs @@ -104,12 +104,13 @@ async fn read_deb_metadata(path: &Path) -> Result Result<(), Whatever> { - let deb_artifacts = crate::deb::run(targets, profile, siblings).await?; + let deb_artifacts = crate::deb::run(contract, targets, profile, siblings).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("deb").join("manifest.toml"); diff --git a/xtask/src/package/rpm.rs b/xtask/src/package/rpm.rs index c895c19..8cb30a9 100644 --- a/xtask/src/package/rpm.rs +++ b/xtask/src/package/rpm.rs @@ -100,11 +100,12 @@ async fn read_rpm_metadata(path: &Path) -> Result Result<(), Whatever> { - let rpm_artifacts = crate::rpm::run(targets, siblings).await?; + let rpm_artifacts = crate::rpm::run(contract, targets, siblings).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir.join("common").join("rpm").join("manifest.toml"); diff --git a/xtask/src/package/scoop.rs b/xtask/src/package/scoop.rs index 6a164d1..a74af46 100644 --- a/xtask/src/package/scoop.rs +++ b/xtask/src/package/scoop.rs @@ -76,8 +76,12 @@ fn target_relative_path(path: &Path) -> Result { .ok_or(ScoopPackageError::ArtifactPathUtf8) } -pub async fn run(targets: &[ScoopTarget], overwrite_manifest: bool) -> Result<(), Whatever> { - let archives = crate::scoop::run(targets).await?; +pub async fn run( + contract: &crate::release_contract::ReleaseContract, + targets: &[ScoopTarget], + overwrite_manifest: bool, +) -> Result<(), Whatever> { + let archives = crate::scoop::run(contract, targets).await?; let meta = crate::package_meta("genmeta")?; let target_dir = crate::target_dir()?; let manifest_path = target_dir diff --git a/xtask/src/publish/s3.rs b/xtask/src/publish/s3.rs index e6c387c..49e2790 100644 --- a/xtask/src/publish/s3.rs +++ b/xtask/src/publish/s3.rs @@ -21,6 +21,7 @@ use tracing::info; use crate::{ grouped, package::manifest::{ArtifactKind, PackageManifest, validate_manifest}, + release_contract::{self, EnvRef, ReleaseContract}, }; pub mod brew; @@ -32,24 +33,17 @@ pub mod scoop; #[derive(Debug, Clone, Args)] pub struct S3Options { - /// S3 endpoint URL + /// Print the publish plan without uploading #[arg(long)] + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedS3Options { pub endpoint_url: String, - /// S3 bucket name - #[arg(long)] pub bucket: String, - /// AWS access key id - #[arg(long, env = "XTASK_RELEASE_S3_ACCESS_KEY_ID", hide_env_values = true)] pub access_key_id: String, - /// AWS secret access key - #[arg( - long, - env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY", - hide_env_values = true - )] pub secret_access_key: String, - /// Print the publish plan without uploading - #[arg(long)] pub dry_run: bool, } @@ -94,47 +88,59 @@ struct S3TargetCli { #[derive(Debug, Subcommand)] enum S3TargetFormat { - Brew { - #[arg(long)] - prefix: String, - #[arg(long = "public-base-url")] - public_base_url: String, - }, - Scoop { - #[arg(long)] - prefix: String, - #[arg(long = "public-base-url")] - public_base_url: String, - }, - Deb { - #[arg(long)] - prefix: String, - #[arg(long)] - suite: String, - #[arg(long)] - fingerprint: String, - }, - Rpm { - #[arg(long)] - prefix: String, - }, + Brew, + Scoop, + Deb, + Rpm, } pub async fn run(options: S3Options, targets: Vec) -> Result<(), Whatever> { - let targets = parse_s3_targets(&targets).unwrap_or_else(|error| error.exit()); - let client = client(&options).await?; + let contract = release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; + let resolved_options = resolve_s3_options(&options, &contract)?; + let targets = parse_s3_targets(&targets, &contract).unwrap_or_else(|error| error.exit()); + let client = client(&resolved_options).await?; for target in targets { match target { - S3Target::Brew(target) => brew::run(&options, &client, target).await?, - S3Target::Scoop(target) => scoop::run(&options, &client, target).await?, - S3Target::Deb(target) => deb::run(&options, &client, target).await?, - S3Target::Rpm(target) => rpm::run(&options, &client, target).await?, + S3Target::Brew(target) => brew::run(&resolved_options, &client, target).await?, + S3Target::Scoop(target) => scoop::run(&resolved_options, &client, target).await?, + S3Target::Deb(target) => deb::run(&resolved_options, &client, target).await?, + S3Target::Rpm(target) => rpm::run(&resolved_options, &client, target).await?, } } Ok(()) } -fn parse_s3_targets(tokens: &[OsString]) -> Result, clap::Error> { +fn resolve_s3_options( + options: &S3Options, + contract: &ReleaseContract, +) -> Result { + Ok(ResolvedS3Options { + endpoint_url: read_env_ref(&contract.destination.s3.endpoint)?, + bucket: contract.destination.s3.bucket.clone(), + access_key_id: read_env_ref(&contract.destination.s3.access_key_id)?, + secret_access_key: read_env_ref(&contract.destination.s3.secret_access_key)?, + dry_run: options.dry_run, + }) +} + +pub(crate) fn read_env_ref(ref_name: &EnvRef) -> Result { + let value = std::env::var(&ref_name.env).whatever_context(format!( + "missing required release environment variable {}", + ref_name.env + ))?; + snafu::ensure_whatever!( + !value.is_empty(), + "release environment variable {} must not be empty", + ref_name.env + ); + Ok(value) +} + +fn parse_s3_targets( + tokens: &[OsString], + contract: &ReleaseContract, +) -> Result, clap::Error> { let sections = match grouped::parse_grouped_targets(tokens, &["deb", "rpm", "brew", "scoop"]) { Ok(sections) => sections, Err(error) => return Err(target_error(ErrorKind::ValueValidation, error)), @@ -147,48 +153,87 @@ fn parse_s3_targets(tokens: &[OsString]) -> Result, clap::Error> { } sections .into_iter() - .map(|section| parse_s3_target(§ion.name, section.args)) + .map(|section| parse_s3_target(§ion.name, section.args, contract)) .collect() } -fn parse_s3_target(section_name: &str, args: Vec) -> Result { +fn parse_s3_target( + section_name: &str, + args: Vec, + contract: &ReleaseContract, +) -> Result { let mut argv = vec!["xtask publish s3".into(), section_name.to_owned().into()]; argv.extend(args); S3TargetCli::try_parse_from(argv) - .and_then(|cli| target_format_to_target(section_name, cli.target)) + .and_then(|cli| target_format_to_target(section_name, cli.target, contract)) } fn target_format_to_target( section_name: &str, target: S3TargetFormat, + contract: &ReleaseContract, ) -> Result { match target { - S3TargetFormat::Brew { - prefix, - public_base_url, - } => Ok(S3Target::Brew(BrewPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - public_base_url: parse_public_base_url(section_name, &public_base_url)?, - })), - S3TargetFormat::Scoop { - prefix, - public_base_url, - } => Ok(S3Target::Scoop(ScoopPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - public_base_url: parse_public_base_url(section_name, &public_base_url)?, - })), - S3TargetFormat::Deb { - prefix, - suite, - fingerprint, - } => Ok(S3Target::Deb(DebPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - suite, - fingerprint, - })), - S3TargetFormat::Rpm { prefix } => Ok(S3Target::Rpm(RpmPublishTarget { - prefix: parse_prefix(section_name, &prefix)?, - })), + S3TargetFormat::Brew => { + let brew = contract.destination.brew.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.brew is required", + ) + })?; + Ok(S3Target::Brew(BrewPublishTarget { + prefix: parse_prefix(section_name, &brew.prefix)?, + public_base_url: parse_public_base_url(section_name, &brew.public_base_url)?, + })) + } + S3TargetFormat::Scoop => { + let scoop = contract.destination.scoop.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.scoop is required", + ) + })?; + Ok(S3Target::Scoop(ScoopPublishTarget { + prefix: parse_prefix(section_name, &scoop.prefix)?, + public_base_url: parse_public_base_url(section_name, &scoop.public_base_url)?, + })) + } + S3TargetFormat::Deb => { + let deb = contract.destination.deb.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.deb is required", + ) + })?; + let fingerprint_ref = deb.fingerprint.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.deb.fingerprint.env is required", + ) + })?; + let fingerprint = match read_env_ref(fingerprint_ref) { + Ok(fingerprint) => fingerprint, + Err(error) => { + return Err(target_error(ErrorKind::ValueValidation, error.to_string())); + } + }; + Ok(S3Target::Deb(DebPublishTarget { + prefix: parse_prefix(section_name, &deb.prefix)?, + suite: deb.suite.clone(), + fingerprint, + })) + } + S3TargetFormat::Rpm => { + let rpm = contract.destination.rpm.as_ref().ok_or_else(|| { + target_error( + ErrorKind::MissingRequiredArgument, + "destination.rpm is required", + ) + })?; + Ok(S3Target::Rpm(RpmPublishTarget { + prefix: parse_prefix(section_name, &rpm.prefix)?, + })) + } } } @@ -500,7 +545,7 @@ async fn sha256_stream(mut body: ByteStream, key: &str) -> Result Result { +async fn client(options: &ResolvedS3Options) -> Result { let credentials = Credentials::new( options.access_key_id.trim().to_string(), options.secret_access_key.trim().to_string(), @@ -524,22 +569,24 @@ async fn client(options: &S3Options) -> Result { #[cfg(test)] mod tests { use aws_sdk_s3::config::RequestChecksumCalculation; - use clap::error::ErrorKind; - use super::{S3Options, client, parse_s3_targets}; + use super::{ResolvedS3Options, S3Target, client, parse_s3_targets}; + use crate::release_contract::load_release_contract; #[test] - fn brew_requires_public_base_url() { - let targets = ["brew", "--prefix", "brew/gmutils"].map(std::ffi::OsString::from); - let error = parse_s3_targets(&targets).expect_err("missing public url should fail"); - - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - assert!(error.to_string().contains("--public-base-url")); + fn parses_destination_contract_targets_without_per_target_options() { + let contract = load_release_contract().expect("contract should load"); + let targets = ["rpm", "brew", "scoop"].map(std::ffi::OsString::from); + let parsed = parse_s3_targets(&targets, &contract).expect("targets should parse"); + + assert!(matches!(parsed[0], S3Target::Rpm(_))); + assert!(matches!(parsed[1], S3Target::Brew(_))); + assert!(matches!(parsed[2], S3Target::Scoop(_))); } #[tokio::test] async fn s3_client_calculates_request_checksums_only_when_required() { - let options = S3Options { + let options = ResolvedS3Options { endpoint_url: "https://example.invalid".to_string(), bucket: "download".to_string(), access_key_id: "access".to_string(), @@ -556,23 +603,9 @@ mod tests { } #[test] - fn release_workflow_uses_public_r2_download_domain() { + fn release_workflow_uses_xtask_destination_contract() { let workflow = include_str!("../../../.github/workflows/release.yml"); - assert!(workflow.contains("BREW_PUBLIC_BASE_URL: https://download.dhttp.net/brew/gmutils")); - assert!( - workflow.contains("SCOOP_PUBLIC_BASE_URL: https://download.dhttp.net/scoop/gmutils") - ); assert!(!workflow.contains("download.genmeta.net")); } - - #[test] - fn release_workflow_publishes_deb_to_unified_apt_repo() { - let workflow = include_str!("../../../.github/workflows/release.yml"); - - assert!(workflow.contains("APT_PREFIX: ppa/genmeta")); - assert!(workflow.contains("APT_SUITE: genmeta")); - assert!(!workflow.contains("APT_PREFIX: apt/gmutils")); - assert!(!workflow.contains("APT_SUITE: stable")); - } } diff --git a/xtask/src/publish/s3/brew.rs b/xtask/src/publish/s3/brew.rs index bfd9b99..1270078 100644 --- a/xtask/src/publish/s3/brew.rs +++ b/xtask/src/publish/s3/brew.rs @@ -1,27 +1,20 @@ +use std::{collections::BTreeMap, path::Path}; + use aws_sdk_s3::Client; -use snafu::{ResultExt, Snafu, Whatever}; +use snafu::{OptionExt, ResultExt, Snafu, Whatever}; use tracing::{info, warn}; use super::{ - BrewPublishTarget, S3Options, + BrewPublishTarget, ResolvedS3Options, key::{PublicBaseUrl, PublicBaseUrlError}, plan::PlannedUpload, }; -use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; +use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::{self, ResolvedPackageMetadata}, +}; -const PACKAGE_NAME: &str = "gmutils"; const FORMULA_NAME: &str = "gmutils.rb"; -const DESCRIPTION: &str = "Genmeta Binary Utilities"; -const HOMEPAGE: &str = "https://www.dhttp.net"; -const LICENSE: &str = "Apache-2.0"; -const INSTALL_CONTENT: &str = r##" def install - bin.install "genmeta" - bin.install "genmeta-ssh.sh" - end - - test do - system "#{bin}/genmeta", "-V" - end"##; #[derive(Debug, Snafu)] #[snafu(module)] @@ -34,11 +27,17 @@ pub enum RenderBrewError { UnsupportedTarget { target: String }, #[snafu(display("invalid public base url"))] PublicBaseUrl { source: PublicBaseUrlError }, + #[snafu(display("failed to render brew template"))] + Template { + source: crate::template::RenderTemplateError, + }, } pub fn render_formula( manifest: &PackageManifest, public_base_url: &str, + metadata: &ResolvedPackageMetadata, + template: &str, ) -> Result { snafu::ensure!( manifest.kind == ArtifactKind::Brew, @@ -46,36 +45,34 @@ pub fn render_formula( ); let base = PublicBaseUrl::parse(public_base_url).context(render_brew_error::PublicBaseUrlSnafu)?; - let class_name = formula_class_name(PACKAGE_NAME); - let mut lines = vec![ - format!("class {class_name} < Formula"), - format!(" desc \"{}\"", escape_formula_string(DESCRIPTION)), - format!(" version \"{}\"", escape_formula_string(&manifest.version)), - format!(" homepage \"{}\"", escape_formula_string(HOMEPAGE)), - format!(" license \"{}\"", escape_formula_string(LICENSE)), - String::new(), - ]; - - for artifact in &manifest.artifacts { - let archive_name = archive_name(artifact)?; - let block = brew_on_block(&artifact.target)?; - lines.extend([ - format!(" {block} do"), - format!(" url \"{}\"", base.join(archive_name)), - format!(" sha256 \"{}\"", artifact.sha256), - " end".to_string(), - String::new(), - ]); - } - - lines.push(INSTALL_CONTENT.trim_end().to_string()); - lines.push("end".to_string()); - lines.push(String::new()); - Ok(lines.join("\n")) + let variables = BTreeMap::from([ + ( + "homebrew.class".to_string(), + formula_class_name(&metadata.name), + ), + ("homebrew.urls".to_string(), formula_urls(manifest, &base)?), + ( + "package.description".to_string(), + crate::template::ruby_string(&metadata.description), + ), + ( + "package.homepage".to_string(), + crate::template::ruby_string(&metadata.homepage), + ), + ( + "package.license".to_string(), + crate::template::ruby_string(&metadata.license), + ), + ( + "package.version".to_string(), + crate::template::ruby_string(&metadata.version), + ), + ]); + crate::template::render_template(template, &variables).context(render_brew_error::TemplateSnafu) } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: BrewPublishTarget, ) -> Result<(), Whatever> { @@ -88,8 +85,14 @@ pub async fn run( &target.prefix, ) .await?; - let formula = render_formula(&manifest, target.public_base_url.as_str()) - .whatever_context("failed to render brew formula")?; + let (metadata, template) = metadata_and_template().await?; + let formula = render_formula( + &manifest, + target.public_base_url.as_str(), + &metadata, + &template, + ) + .whatever_context("failed to render brew formula")?; let formula_path = loaded .target_dir .join("common") @@ -135,6 +138,28 @@ pub async fn run( Ok(()) } +async fn metadata_and_template() -> Result<(ResolvedPackageMetadata, String), Whatever> { + let contract = release_contract::load_release_contract() + .whatever_context("failed to load release contract")?; + let metadata = release_contract::resolve_package_metadata(&contract) + .whatever_context("failed to resolve package metadata")?; + let homebrew = contract + .homebrew + .as_ref() + .whatever_context("release contract is missing homebrew template")?; + let template_path = repo_root().join(&homebrew.template.path); + let template = tokio::fs::read_to_string(&template_path) + .await + .whatever_context(format!("failed to read {}", template_path.display()))?; + Ok((metadata, template)) +} + +fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest directory should have a parent") +} + async fn plan_payload_uploads( client: &Client, bucket: &str, @@ -184,6 +209,23 @@ async fn plan_payload_uploads( Ok((uploads, manifest)) } +fn formula_urls( + manifest: &PackageManifest, + base: &PublicBaseUrl, +) -> Result { + let mut blocks = Vec::new(); + for artifact in &manifest.artifacts { + let archive_name = archive_name(artifact)?; + let block = brew_on_block(&artifact.target)?; + blocks.push(format!( + " {block} do\n url \"{}\"\n sha256 \"{}\"\n end", + base.join(archive_name), + artifact.sha256, + )); + } + Ok(blocks.join("\n\n")) +} + fn archive_name(artifact: &PackageArtifact) -> Result<&str, RenderBrewError> { artifact .archive_name @@ -204,21 +246,30 @@ fn brew_on_block(target: &str) -> Result<&'static str, RenderBrewError> { } fn formula_class_name(name: &str) -> String { - let mut chars = name.chars(); - match chars.next() { - Some(first) => first.to_uppercase().to_string() + chars.as_str(), - None => String::new(), + let mut output = String::new(); + let mut uppercase_next = true; + for c in name.chars() { + if matches!(c, '-' | '_' | '.') { + uppercase_next = true; + continue; + } + if uppercase_next { + output.extend(c.to_uppercase()); + uppercase_next = false; + } else { + output.push(c); + } } -} - -fn escape_formula_string(value: &str) -> String { - value.replace('"', "\\\"") + output } #[cfg(test)] mod tests { use super::render_formula; - use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; + use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::ResolvedPackageMetadata, + }; #[test] fn formula_uses_public_base_url() { @@ -244,12 +295,28 @@ mod tests { profile: Some("release".to_string()), }], }; + let metadata = ResolvedPackageMetadata { + name: "gmutils".to_string(), + version: "0.5.2".to_string(), + description: "Genmeta Binary Utilities".to_string(), + homepage: "https://www.dhttp.net".to_string(), + license: "Apache-2.0".to_string(), + repository: None, + authors: Vec::new(), + }; + let template = include_str!("../../../templates/gmutils.rb.in"); - let formula = render_formula(&manifest, "https://download.example/brew/gmutils") - .expect("formula should render"); + let formula = render_formula( + &manifest, + "https://download.example/brew/gmutils", + &metadata, + template, + ) + .expect("formula should render"); assert!(formula.contains("license \"Apache-2.0\"")); assert!(formula.contains("url \"https://download.example/brew/gmutils/gmutils-0.5.2-aarch64-apple-darwin.tar.gz\"")); assert!(formula.contains("sha256 \"arm-sha\"")); + assert!(formula.contains(r##"system "#{bin}/genmeta", "version""##)); } } diff --git a/xtask/src/publish/s3/deb.rs b/xtask/src/publish/s3/deb.rs index 7f1c28b..4619d22 100644 --- a/xtask/src/publish/s3/deb.rs +++ b/xtask/src/publish/s3/deb.rs @@ -20,7 +20,7 @@ use tracing::info; use walkdir::WalkDir; use super::{ - DebPublishTarget, S3Options, + DebPublishTarget, ResolvedS3Options, plan::{PlannedUpload, UploadCondition}, }; use crate::{ @@ -116,7 +116,7 @@ pub fn deb_payload_key(prefix: &str, package: &str, filename: &str) -> String { } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: DebPublishTarget, ) -> Result<(), Whatever> { diff --git a/xtask/src/publish/s3/rpm.rs b/xtask/src/publish/s3/rpm.rs index b9aa47b..e21e995 100644 --- a/xtask/src/publish/s3/rpm.rs +++ b/xtask/src/publish/s3/rpm.rs @@ -19,7 +19,7 @@ use tempfile::TempDir; use tracing::info; use walkdir::WalkDir; -use super::{RpmPublishTarget, S3Options, plan::PlannedUpload}; +use super::{ResolvedS3Options, RpmPublishTarget, plan::PlannedUpload}; use crate::{ container::{ check_docker, exec_in_container, force_remove_container, remove_container_if_exists, @@ -74,7 +74,7 @@ pub fn rpm_payload_key(prefix: &str, package: &str, version: &str, filename: &st } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: RpmPublishTarget, ) -> Result<(), Whatever> { diff --git a/xtask/src/publish/s3/scoop.rs b/xtask/src/publish/s3/scoop.rs index 0f8ae7e..6de0809 100644 --- a/xtask/src/publish/s3/scoop.rs +++ b/xtask/src/publish/s3/scoop.rs @@ -4,17 +4,17 @@ use snafu::{ResultExt, Snafu, Whatever}; use tracing::{info, warn}; use super::{ - S3Options, ScoopPublishTarget, + ResolvedS3Options, ScoopPublishTarget, key::{PublicBaseUrl, PublicBaseUrlError}, plan::PlannedUpload, }; -use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; +use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::{self, ResolvedPackageMetadata}, +}; const MANIFEST_NAME: &str = "gmutils.json"; const CARGO_NAME: &str = "genmeta"; -const DESCRIPTION: &str = "Genmeta Binary Utilities"; -const HOMEPAGE: &str = "https://www.dhttp.net"; -const LICENSE: &str = "Apache-2.0"; #[derive(Debug, Serialize)] struct ScoopManifest { @@ -52,6 +52,7 @@ pub enum RenderScoopError { pub fn render_scoop_json( manifest: &PackageManifest, public_base_url: &str, + metadata: &ResolvedPackageMetadata, ) -> Result { snafu::ensure!( manifest.kind == ArtifactKind::Scoop, @@ -82,9 +83,9 @@ pub fn render_scoop_json( let scoop_manifest = ScoopManifest { version: manifest.version.clone(), - description: DESCRIPTION.to_string(), - license: LICENSE.to_string(), - homepage: HOMEPAGE.to_string(), + description: metadata.description.clone(), + license: metadata.license.clone(), + homepage: metadata.homepage.clone(), architecture, bin: vec![format!("{CARGO_NAME}.exe"), "genmeta-ssh.bat".to_string()], checkver: CheckVer { @@ -99,7 +100,7 @@ pub fn render_scoop_json( } pub async fn run( - options: &S3Options, + options: &ResolvedS3Options, client: &Client, target: ScoopPublishTarget, ) -> Result<(), Whatever> { @@ -112,7 +113,12 @@ pub async fn run( &target.prefix, ) .await?; - let json = render_scoop_json(&manifest, target.public_base_url.as_str()) + let metadata = release_contract::resolve_package_metadata( + &release_contract::load_release_contract() + .whatever_context("failed to load release contract")?, + ) + .whatever_context("failed to resolve package metadata")?; + let json = render_scoop_json(&manifest, target.public_base_url.as_str(), &metadata) .whatever_context("failed to render scoop json")?; let manifest_path = loaded .target_dir @@ -230,7 +236,10 @@ fn scoop_arch_key(target: &str) -> Result<&'static str, RenderScoopError> { #[cfg(test)] mod tests { use super::render_scoop_json; - use crate::package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}; + use crate::{ + package::manifest::{ArtifactKind, PackageArtifact, PackageManifest}, + release_contract::ResolvedPackageMetadata, + }; #[test] fn scoop_json_uses_public_base_url() { @@ -257,13 +266,27 @@ mod tests { profile: Some("release".to_string()), }], }; + let metadata = ResolvedPackageMetadata { + name: "gmutils".to_string(), + version: "0.5.2".to_string(), + description: "Genmeta Binary Utilities".to_string(), + homepage: "https://www.dhttp.net".to_string(), + license: "Apache-2.0".to_string(), + repository: None, + authors: Vec::new(), + }; - let json = render_scoop_json(&manifest, "https://download.example/scoop/gmutils") - .expect("json should render"); + let json = render_scoop_json( + &manifest, + "https://download.example/scoop/gmutils", + &metadata, + ) + .expect("json should render"); let value: serde_json::Value = serde_json::from_str(&json).expect("json should parse"); assert_eq!(value["version"], "0.5.2"); assert_eq!(value["license"], "Apache-2.0"); + assert_eq!(value["homepage"], "https://www.dhttp.net"); assert_eq!( value["architecture"]["64bit"]["url"], "https://download.example/scoop/gmutils/gmutils-0.5.2-x86_64-pc-windows-msvc.zip" diff --git a/xtask/src/release_contract.rs b/xtask/src/release_contract.rs new file mode 100644 index 0000000..3b19c76 --- /dev/null +++ b/xtask/src/release_contract.rs @@ -0,0 +1,581 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use cargo_metadata::MetadataCommand; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; + +const RELEASE_CONTRACT_PATH: &str = "xtask/release.toml"; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ReleaseContract { + pub cargo: CargoSource, + pub package: Option, + pub homebrew: Option, + pub scoop: Option, + pub build: BuildContract, + pub destination: DestinationContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CargoSource { + pub manifest: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageOverride { + pub name: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct HomebrewContract { + pub template: TemplateContract, + #[serde(default)] + pub target: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +pub struct HomebrewTargetContract { + #[serde(default)] + pub env: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ScoopContract { + pub template: TemplateContract, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TemplateContract { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +pub struct BuildContract { + #[serde(default)] + pub env: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BuildEnvBinding { + pub env: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DestinationContract { + pub s3: S3Destination, + pub brew: Option, + pub deb: Option, + pub rpm: Option, + pub scoop: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct EnvRef { + pub env: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct S3Destination { + pub bucket: String, + pub endpoint: EnvRef, + pub access_key_id: EnvRef, + pub secret_access_key: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BrewDestination { + pub prefix: String, + pub public_base_url: String, + pub tap: TapDestination, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TapDestination { + pub repository: String, + pub base_branch: String, + pub token: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DebDestination { + pub prefix: String, + pub suite: String, + pub signing: DebSigning, + pub fingerprint: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DebSigning { + pub key: EnvRef, + pub passphrase: EnvRef, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct RpmDestination { + pub prefix: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ScoopDestination { + pub prefix: String, + pub public_base_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPackageMetadata { + pub name: String, + pub version: String, + pub description: String, + pub homepage: String, + pub license: String, + pub repository: Option, + pub authors: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageKind { + Deb, + Rpm, + Brew, + Scoop, +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ReleaseContractError { + #[snafu(display("failed to read release contract"))] + Read { + source: std::io::Error, + path: PathBuf, + }, + #[snafu(display("failed to parse release contract"))] + Parse { + source: toml::de::Error, + path: PathBuf, + }, + #[snafu(display("failed to read cargo metadata"))] + CargoMetadata { + source: cargo_metadata::Error, + manifest: PathBuf, + }, + #[snafu(display("cargo metadata did not return a root package"))] + MissingRootPackage { manifest: PathBuf }, + #[snafu(display("cargo package is missing description"))] + MissingDescription { manifest: PathBuf }, + #[snafu(display("cargo package is missing homepage"))] + MissingHomepage { manifest: PathBuf }, + #[snafu(display("cargo package is missing license"))] + MissingLicense { manifest: PathBuf }, + #[snafu(display("build env binding {name} must set exactly one of env or value"))] + InvalidBuildEnvBinding { name: String }, + #[snafu(display("missing required build environment variable {name}"))] + MissingBuildEnv { name: String }, + #[snafu(display("build environment variable {name} must not be empty"))] + EmptyBuildEnv { name: String }, +} + +pub fn load_release_contract() -> Result { + read_release_contract(&default_release_contract_path()) +} + +fn default_release_contract_path() -> PathBuf { + let cwd_path = PathBuf::from(RELEASE_CONTRACT_PATH); + if cwd_path.exists() { + return cwd_path; + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("release.toml") +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest directory should have a parent") + .to_path_buf() +} + +pub fn read_release_contract(path: &Path) -> Result { + let input = std::fs::read_to_string(path).context(release_contract_error::ReadSnafu { + path: path.to_path_buf(), + })?; + parse_release_contract_at(path, &input) +} + +fn parse_release_contract_at( + path: &Path, + input: &str, +) -> Result { + let contract = toml::from_str(input).context(release_contract_error::ParseSnafu { + path: path.to_path_buf(), + })?; + validate_release_contract(&contract)?; + Ok(contract) +} + +fn validate_release_contract(contract: &ReleaseContract) -> Result<(), ReleaseContractError> { + validate_build_env_bindings(&contract.build.env)?; + if let Some(homebrew) = &contract.homebrew { + for target in homebrew.target.values() { + validate_build_env_bindings(&target.env)?; + } + } + Ok(()) +} + +pub fn resolve_package_metadata( + contract: &ReleaseContract, +) -> Result { + let manifest = if contract.cargo.manifest.is_absolute() { + contract.cargo.manifest.clone() + } else { + repo_root().join(&contract.cargo.manifest) + }; + let metadata = MetadataCommand::new() + .manifest_path(&manifest) + .no_deps() + .exec() + .context(release_contract_error::CargoMetadataSnafu { + manifest: manifest.clone(), + })?; + let package = + metadata + .root_package() + .ok_or_else(|| ReleaseContractError::MissingRootPackage { + manifest: manifest.clone(), + })?; + let name = contract + .package + .as_ref() + .and_then(|package| package.name.clone()) + .unwrap_or_else(|| package.name.to_string()); + Ok(ResolvedPackageMetadata { + name, + version: package.version.to_string(), + description: package.description.clone().ok_or_else(|| { + ReleaseContractError::MissingDescription { + manifest: manifest.clone(), + } + })?, + homepage: package.homepage.clone().ok_or_else(|| { + ReleaseContractError::MissingHomepage { + manifest: manifest.clone(), + } + })?, + license: package + .license + .clone() + .ok_or_else(|| ReleaseContractError::MissingLicense { + manifest: manifest.clone(), + })?, + repository: package.repository.clone(), + authors: package.authors.clone(), + }) +} + +pub fn resolve_build_env_from_process( + contract: &ReleaseContract, + package_kind: PackageKind, + target: Option<&str>, +) -> Result, ReleaseContractError> { + let values = std::env::vars().collect::>(); + resolve_build_env_values(contract, package_kind, target, &values) +} + +pub fn resolve_build_env_values( + contract: &ReleaseContract, + package_kind: PackageKind, + target: Option<&str>, + values: &BTreeMap, +) -> Result, ReleaseContractError> { + let mut bindings = contract.build.env.clone(); + if package_kind == PackageKind::Brew + && let (Some(homebrew), Some(target)) = (&contract.homebrew, target) + && let Some(target_contract) = homebrew.target.get(target) + { + bindings.extend(target_contract.env.clone()); + } + + let mut resolved = BTreeMap::new(); + for (name, binding) in bindings { + if let Some(value) = resolve_build_env_binding_value(&name, &binding, values)? { + resolved.insert(name, value); + } + } + Ok(resolved) +} + +fn validate_build_env_bindings( + bindings: &BTreeMap, +) -> Result<(), ReleaseContractError> { + for (name, binding) in bindings { + validate_build_env_binding(name, binding)?; + } + Ok(()) +} + +fn validate_build_env_binding( + name: &str, + binding: &BuildEnvBinding, +) -> Result<(), ReleaseContractError> { + match (&binding.env, &binding.value) { + (Some(_), None) | (None, Some(_)) => Ok(()), + _ => Err(ReleaseContractError::InvalidBuildEnvBinding { + name: name.to_owned(), + }), + } +} + +fn resolve_build_env_binding_value( + name: &str, + binding: &BuildEnvBinding, + values: &BTreeMap, +) -> Result, ReleaseContractError> { + validate_build_env_binding(name, binding)?; + + if let Some(env_name) = &binding.env { + return resolve_env_ref(name, env_name, values); + } + + let value = binding + .value + .as_ref() + .expect("validated build env binding must have a value"); + if value.is_empty() { + return Err(ReleaseContractError::EmptyBuildEnv { + name: name.to_owned(), + }); + } + Ok(Some(value.clone())) +} + +fn resolve_env_ref( + logical_name: &str, + env_name: &str, + values: &BTreeMap, +) -> Result, ReleaseContractError> { + let Some(value) = values.get(env_name) else { + if build_env_is_optional(logical_name) { + return Ok(None); + } + return Err(ReleaseContractError::MissingBuildEnv { + name: env_name.to_owned(), + }); + }; + + if value.is_empty() { + return Err(ReleaseContractError::EmptyBuildEnv { + name: env_name.to_owned(), + }); + } + + Ok(Some(value.clone())) +} + +fn build_env_is_optional(name: &str) -> bool { + matches!(name, "DHTTP_GLOBAL_HOME") +} + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, path::Path}; + + use super::{ + PackageKind, ReleaseContractError, parse_release_contract_at, resolve_build_env_values, + }; + + const CONTRACT: &str = r#" +[cargo] +manifest = "genmeta/Cargo.toml" + +[package] +name = "gmutils" + +[homebrew.template] +path = "xtask/templates/gmutils.rb.in" + +[build.env.DHTTP_ROOT_CA] +env = "DHTTP_ROOT_CA" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" + +[build.env.DHTTP_H3_DNS_SERVER] +env = "DHTTP_H3_DNS_SERVER" + +[build.env.DHTTP_HTTP_DNS_SERVER] +env = "DHTTP_HTTP_DNS_SERVER" + +[build.env.DHTTP_MDNS_SERVICE] +env = "DHTTP_MDNS_SERVICE" + +[build.env.DHTTP_CERT_SERVER_URL] +env = "DHTTP_CERT_SERVER_URL" + +[build.env.DHTTP_GLOBAL_HOME] +env = "DHTTP_GLOBAL_HOME" + +[homebrew.target.aarch64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/opt/homebrew/etc/dhttp" + +[homebrew.target.x86_64-apple-darwin.env.DHTTP_GLOBAL_HOME] +value = "/usr/local/etc/dhttp" + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" + +[destination.brew] +prefix = "brew/gmutils" +public_base_url = "https://download.dhttp.net/brew/gmutils" +tap.repository = "genmeta/homebrew-genmeta" +tap.base_branch = "main" +tap.token.env = "HOMEBREW_TAP_GITHUB_TOKEN" + +[destination.deb] +prefix = "ppa/genmeta" +suite = "genmeta" +signing.key.env = "XTASK_RELEASE_APT_SIGNING_KEY" +signing.passphrase.env = "XTASK_RELEASE_APT_SIGNING_PASSPHRASE" + +[destination.rpm] +prefix = "rpm/gmutils" + +[destination.scoop] +prefix = "scoop/gmutils" +public_base_url = "https://download.dhttp.net/scoop/gmutils" +"#; + + #[test] + fn parses_dotted_snake_case_contract() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + + assert_eq!(contract.cargo.manifest, Path::new("genmeta/Cargo.toml")); + assert_eq!(contract.package.unwrap().name.as_deref(), Some("gmutils")); + assert_eq!( + contract.destination.s3.endpoint.env, + "XTASK_RELEASE_S3_ENDPOINT_URL" + ); + assert_eq!( + contract.destination.brew.unwrap().tap.token.env, + "HOMEBREW_TAP_GITHUB_TOKEN" + ); + assert_eq!( + contract.destination.deb.unwrap().signing.key.env, + "XTASK_RELEASE_APT_SIGNING_KEY" + ); + } + + #[test] + fn rejects_invalid_contract_toml() { + let error = parse_release_contract_at(Path::new("xtask/release.toml"), "[cargo") + .expect_err("invalid toml should fail"); + + assert!(matches!(error, ReleaseContractError::Parse { .. })); + } + + #[test] + fn resolves_homebrew_target_override() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + let values = BTreeMap::from([ + ("DHTTP_ROOT_CA".to_string(), "/tmp/root.crt".to_string()), + ( + "DHTTP_STUN_SERVER".to_string(), + "nat.genmeta.net:20004".to_string(), + ), + ( + "DHTTP_H3_DNS_SERVER".to_string(), + "https://dns.genmeta.net:4433".to_string(), + ), + ( + "DHTTP_HTTP_DNS_SERVER".to_string(), + "https://dns.genmeta.net".to_string(), + ), + ("DHTTP_MDNS_SERVICE".to_string(), "_dhttp.local".to_string()), + ( + "DHTTP_CERT_SERVER_URL".to_string(), + "https://license.genmeta.net".to_string(), + ), + ( + "DHTTP_GLOBAL_HOME".to_string(), + "/runtime/should-be-overridden".to_string(), + ), + ]); + + let resolved = resolve_build_env_values( + &contract, + PackageKind::Brew, + Some("aarch64-apple-darwin"), + &values, + ) + .expect("build env should resolve"); + + assert_eq!( + resolved.get("DHTTP_GLOBAL_HOME").map(String::as_str), + Some("/opt/homebrew/etc/dhttp") + ); + } + + #[test] + fn skips_missing_optional_global_home_env() { + let contract = parse_release_contract_at(Path::new("xtask/release.toml"), CONTRACT) + .expect("contract should parse"); + let values = BTreeMap::from([ + ("DHTTP_ROOT_CA".to_string(), "/tmp/root.crt".to_string()), + ( + "DHTTP_STUN_SERVER".to_string(), + "nat.genmeta.net:20004".to_string(), + ), + ( + "DHTTP_H3_DNS_SERVER".to_string(), + "https://dns.genmeta.net:4433".to_string(), + ), + ( + "DHTTP_HTTP_DNS_SERVER".to_string(), + "https://dns.genmeta.net".to_string(), + ), + ("DHTTP_MDNS_SERVICE".to_string(), "_dhttp.local".to_string()), + ( + "DHTTP_CERT_SERVER_URL".to_string(), + "https://license.genmeta.net".to_string(), + ), + ]); + + let resolved = resolve_build_env_values(&contract, PackageKind::Deb, None, &values) + .expect("optional global home may be absent"); + + assert!(!resolved.contains_key("DHTTP_GLOBAL_HOME")); + } + + #[test] + fn rejects_binding_with_env_and_value() { + let error = parse_release_contract_at( + Path::new("xtask/release.toml"), + r#" +[cargo] +manifest = "genmeta/Cargo.toml" + +[build.env.DHTTP_STUN_SERVER] +env = "DHTTP_STUN_SERVER" +value = "nat.genmeta.net:20004" + +[destination.s3] +bucket = "download" +endpoint.env = "XTASK_RELEASE_S3_ENDPOINT_URL" +access_key_id.env = "XTASK_RELEASE_S3_ACCESS_KEY_ID" +secret_access_key.env = "XTASK_RELEASE_S3_SECRET_ACCESS_KEY" +"#, + ) + .expect_err("conflicting binding must fail"); + + assert!(error.to_string().contains("DHTTP_STUN_SERVER")); + } +} diff --git a/xtask/src/rpm.rs b/xtask/src/rpm.rs index d5a77db..448f417 100644 --- a/xtask/src/rpm.rs +++ b/xtask/src/rpm.rs @@ -5,18 +5,21 @@ //! Flow per target triple: //! 1. Ensure an image `xtask-{triple}:{IMAGE_TAG_PREFIX}` exists //! (Fedora + rpm-build + rustup nightly + Zig + cargo-zigbuild). -//! 2. Spin up a container with the workspace bind-mounted at `/workspace`. +//! 2. Spin up a container with the primary source bind-mounted at `/sources/gmutils`. //! 3. Run `cargo zigbuild --target {triple}.{glibc}` as the host uid:gid. //! 4. Generate a minimal `.spec` file in Rust (no template files), lay out a //! private `_topdir`, and run `rpmbuild -bb --target={rpm_arch}`. //! 5. Move the produced `.rpm` next to the `.deb` outputs under //! `target/{triple}/release/rpm/`. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use bollard::{ Docker, - models::{ContainerConfig, ContainerCreateBody, HostConfig, Mount, MountType}, + models::{ContainerConfig, ContainerCreateBody, HostConfig}, query_parameters::{ CommitContainerOptionsBuilder, CreateContainerOptionsBuilder, CreateImageOptionsBuilder, }, @@ -28,12 +31,14 @@ use tracing::{Instrument, info, info_span}; use crate::{ RpmTarget, container::{ - CARGO_HOME, RUSTUP_HOME, Sibling, ZIG_GLIBC_VERSION, cargo_cache_mounts, - cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_env, exec_in_container, + CARGO_HOME, ContainerSourceLayout, RUSTUP_HOME, ZIG_GLIBC_VERSION, cargo_cache_mounts, + cargo_config_from_siblings, check_docker, dhttp_bootstrap_from_values, exec_in_container, force_remove_container, host_uid_gid, install_cargo_config, remove_container_if_exists, - resolve_siblings, start_container, + source_layout, source_mounts, start_container, }, - package_version, target_dir, + package_version, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + target_dir, }; const CARGO_NAME: &str = "genmeta"; @@ -129,6 +134,7 @@ fn aarch64_zigbuild_workaround_script(triple: &str) -> String { } pub async fn run( + contract: &ReleaseContract, targets: &[RpmTarget], siblings: &[std::path::PathBuf], ) -> Result, Whatever> { @@ -137,21 +143,26 @@ pub async fn run( .whatever_context("failed to connect to Docker/Podman")?; check_docker(&docker).await?; - let siblings = resolve_siblings(siblings)?; + let layout = source_layout("gmutils", siblings)?; let version = package_version(CARGO_NAME)?; let target_dir = target_dir()?; + let build_env = resolve_build_env_from_process(contract, PackageKind::Rpm, None) + .whatever_context("failed to resolve build environment for rpm packaging")?; let mut tasks = tokio::task::JoinSet::new(); for &target in targets { let docker = docker.clone(); let version = version.clone(); let target_dir = target_dir.clone(); - let siblings = siblings.clone(); + let layout = layout.clone(); + let build_env = build_env.clone(); let triple = target.triple(); info!(triple, "queued rpm target build"); let span = info_span!("rpm", triple); tasks.spawn( - async move { build_one(&docker, triple, &version, &target_dir, &siblings).await } + async move { + build_one(&docker, triple, &version, &target_dir, &layout, &build_env).await + } .instrument(span), ); } @@ -280,7 +291,8 @@ async fn build_one( triple: &str, version: &str, target_dir: &Path, - siblings: &[Sibling], + layout: &ContainerSourceLayout, + build_env: &BTreeMap, ) -> Result { let arch = rpm_arch(triple)?; info!(triple, arch, "ensuring build image"); @@ -291,28 +303,12 @@ async fn build_one( .await .whatever_context(format!("failed to create {}", out_dir.display()))?; - let workspace_dir = - std::env::current_dir().whatever_context("failed to get current directory")?; - - let mut mounts = vec![Mount { - target: Some("/workspace".into()), - source: Some(workspace_dir.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }]; - for sibling in siblings { - mounts.push(Mount { - target: Some(format!("/{}", sibling.basename)), - source: Some(sibling.host.to_string_lossy().into_owned()), - typ: Some(MountType::BIND), - ..Default::default() - }); - } + let mut mounts = source_mounts(layout); mounts.extend(cargo_cache_mounts()); - let bootstrap = dhttp_bootstrap_from_env()?; + let bootstrap = dhttp_bootstrap_from_values(build_env.clone())?; mounts.extend(bootstrap.mounts); - let cargo_config = cargo_config_from_siblings(siblings); + let cargo_config = cargo_config_from_siblings(&layout.overrides); let container_name = format!("{CARGO_NAME}-xtask-rpm-{triple}"); remove_container_if_exists(docker, &container_name).await; @@ -343,6 +339,7 @@ async fn build_one( triple, version, arch, + &layout.primary.container, &bootstrap.exports, cargo_config.as_deref(), ) @@ -395,12 +392,14 @@ async fn find_rpm_artifact(out_dir: &Path) -> Result, ) -> Result<(), Whatever> { @@ -426,10 +425,10 @@ export CARGO_HOME={CARGO_HOME} {dhttp_bootstrap_exports} export RUSTFLAGS="${{RUSTFLAGS:-}}" {aarch64_zigbuild_workaround} -cd /workspace +cd {primary_source} cargo zigbuild --release --target {triple}.{ZIG_GLIBC_VERSION} --bin genmeta -TOPDIR=/workspace/target/{triple}/release/rpm +TOPDIR={primary_source}/target/{triple}/release/rpm rm -rf "$TOPDIR"/{{SPECS,BUILD,BUILDROOT,SOURCES,SRPMS,RPMS}} mkdir -p "$TOPDIR"/{{SPECS,BUILD,BUILDROOT,SOURCES,SRPMS,RPMS}} @@ -437,8 +436,8 @@ SPEC="$TOPDIR/SPECS/{PACKAGE_NAME}.spec" printf '%s' {spec_escaped} > "$SPEC" # stage prebuilt binary + script as SOURCES so rpmbuild's %install can pick them up -cp /workspace/target/{triple}/release/genmeta "$TOPDIR/SOURCES/genmeta" -cp /workspace/genmeta-ssh.sh "$TOPDIR/SOURCES/genmeta-ssh.sh" +cp {primary_source}/target/{triple}/release/genmeta "$TOPDIR/SOURCES/genmeta" +cp {primary_source}/genmeta-ssh.sh "$TOPDIR/SOURCES/genmeta-ssh.sh" rpmbuild -bb \ --target={arch} \ diff --git a/xtask/src/scoop.rs b/xtask/src/scoop.rs index 9ef1730..54ff481 100644 --- a/xtask/src/scoop.rs +++ b/xtask/src/scoop.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use std::{ + collections::BTreeMap, io::Write, path::{Path, PathBuf}, }; @@ -8,7 +9,11 @@ use std::{ use snafu::{OptionExt, ResultExt, Whatever}; use tracing::{Instrument, info, info_span}; -use crate::{ScoopTarget, package_meta, run_cmd, run_cmd_quiet, target_dir}; +use crate::{ + ScoopTarget, package_meta, + release_contract::{PackageKind, ReleaseContract, resolve_build_env_from_process}, + run_cmd, run_cmd_quiet, target_dir, +}; const CARGO_NAME: &str = "genmeta"; @@ -57,7 +62,10 @@ fn create_zip(staging: &Path, output: &Path) -> Result<(), Whatever> { Ok(()) } -pub async fn run(targets: &[ScoopTarget]) -> Result, Whatever> { +pub async fn run( + contract: &ReleaseContract, + targets: &[ScoopTarget], +) -> Result, Whatever> { info!(target_count = targets.len(), "starting scoop dist build"); let meta = package_meta(CARGO_NAME)?; let target_dir = target_dir()?; @@ -75,7 +83,9 @@ pub async fn run(targets: &[ScoopTarget]) -> Result, Whatever> for &target in targets { let triple = target.triple(); let span = info_span!("scoop", triple); - let archive = build_one(target, &meta.version, &target_dir, &workspace) + let build_env = resolve_build_env_from_process(contract, PackageKind::Scoop, Some(triple)) + .whatever_context("failed to resolve build environment for scoop target")?; + let archive = build_one(target, &meta.version, &target_dir, &workspace, build_env) .instrument(span) .await?; archives.push(archive); @@ -91,6 +101,7 @@ async fn build_one( version: &str, target_dir: &Path, workspace: &Path, + build_env: BTreeMap, ) -> Result { let triple = target.triple(); info!(triple, "starting cargo-xwin build for scoop target"); @@ -100,6 +111,7 @@ async fn build_one( // Pinning XWIN_ARCH=x86,x86_64 ensures both architectures are present on first splat. run_cmd( tokio::process::Command::new("cargo-xwin") + .envs(&build_env) .env("XWIN_ARCH", "x86,x86_64") .args([ "build", diff --git a/xtask/src/template.rs b/xtask/src/template.rs new file mode 100644 index 0000000..ca569ed --- /dev/null +++ b/xtask/src/template.rs @@ -0,0 +1,83 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + +use std::collections::BTreeMap; + +use snafu::{Snafu, ensure}; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum RenderTemplateError { + #[snafu(display("template variable {name} is not defined"))] + MissingVariable { name: String }, + #[snafu(display("template contains unresolved placeholders"))] + UnresolvedPlaceholder, +} + +pub fn render_template( + template: &str, + variables: &BTreeMap, +) -> Result { + let mut output = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find("{{") { + output.push_str(&rest[..start]); + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find("}}") else { + return Err(RenderTemplateError::UnresolvedPlaceholder); + }; + let name = after_start[..end].trim().to_string(); + let value = variables + .get(&name) + .ok_or(RenderTemplateError::MissingVariable { name })?; + output.push_str(value); + rest = &after_start[end + 2..]; + } + output.push_str(rest); + ensure!( + !output.contains("{{") && !output.contains("}}"), + render_template_error::UnresolvedPlaceholderSnafu + ); + Ok(output) +} + +pub fn ruby_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{render_template, ruby_string}; + + #[test] + fn renders_named_variables() { + let variables = BTreeMap::from([ + ("package.name".to_string(), "gmutils".to_string()), + ("package.version".to_string(), "0.6.1".to_string()), + ]); + + let rendered = render_template("{{ package.name }} {{package.version}}", &variables) + .expect("template should render"); + + assert_eq!(rendered, "gmutils 0.6.1"); + } + + #[test] + fn rejects_missing_variables() { + let variables = BTreeMap::new(); + + let error = render_template("{{package.name}}", &variables) + .expect_err("missing variable should fail"); + + assert_eq!( + error.to_string(), + "template variable package.name is not defined" + ); + } + + #[test] + fn escapes_ruby_strings() { + assert_eq!(ruby_string("a\\\"b"), "a\\\\\\\"b"); + } +} diff --git a/xtask/src/version_cmp.rs b/xtask/src/version_cmp.rs index 5adba3d..eb94684 100644 --- a/xtask/src/version_cmp.rs +++ b/xtask/src/version_cmp.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(xtask_s3_publish), allow(dead_code))] + use std::{cmp::Ordering, str::FromStr}; use snafu::{ResultExt, Snafu}; diff --git a/xtask/templates/gmutils.rb.in b/xtask/templates/gmutils.rb.in new file mode 100644 index 0000000..b209001 --- /dev/null +++ b/xtask/templates/gmutils.rb.in @@ -0,0 +1,17 @@ +class {{homebrew.class}} < Formula + desc "{{package.description}}" + version "{{package.version}}" + homepage "{{package.homepage}}" + license "{{package.license}}" + +{{homebrew.urls}} + + def install + bin.install "genmeta" + bin.install "genmeta-ssh.sh" + end + + test do + system "#{bin}/genmeta", "version" + end +end