From 586bf6d09dc6ae024e74b6fdb88023fd52909cbc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 26 Oct 2026 07:16:48 -0700 Subject: [PATCH] inbuxa-cli: the registry from the command line, and plans List, get, create, update and delete any registry type the server's schema describes, naming objects by id or by key. snapshot writes the settings as a plan with references by key; apply makes a server match one, in reference order, changing only what the plan names. Settings are reloaded after a change where the server didn't do it itself. CI and releases follow inbuxa-migrate: Gitea waits on GitHub's build, and v* tags publish Linux amd64 and arm64 archives with SHA256SUMS. --- .gitea/workflows/announce.yml | 20 + .gitea/workflows/ci.yml | 99 +++ .github/workflows/ci.yml | 273 +++++++ CHANGELOG.md | 13 + Cargo.lock | 1318 ++++++++++++++++++++++++++++++++ Cargo.toml | 26 + README.md | 172 +++++ rust-toolchain.toml | 2 + rustfmt.toml | 1 + scripts/build-release.sh | 45 ++ src/client.rs | 448 +++++++++++ src/error.rs | 49 ++ src/main.rs | 620 +++++++++++++++ src/plan.rs | 1346 +++++++++++++++++++++++++++++++++ src/reload.rs | 265 +++++++ src/schema.rs | 494 ++++++++++++ src/secret.rs | 59 ++ src/values.rs | 78 ++ 18 files changed, 5328 insertions(+) create mode 100644 .gitea/workflows/announce.yml create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100755 scripts/build-release.sh create mode 100644 src/client.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/plan.rs create mode 100644 src/reload.rs create mode 100644 src/schema.rs create mode 100644 src/secret.rs create mode 100644 src/values.rs diff --git a/.gitea/workflows/announce.yml b/.gitea/workflows/announce.yml new file mode 100644 index 0000000..abcfde7 --- /dev/null +++ b/.gitea/workflows/announce.yml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +# +# Announce each published release on the community forum, in this project's +# Announcements category (coffey-labs/actions discourse-release; the repo -> +# category map is its release-map.json). Safe to re-run: one topic per tag. +name: announce + +on: + release: + types: [published] + +jobs: + announce: + runs-on: light + steps: + - uses: coffey-labs/actions/discourse-release@6f0888c791db2ad0c40adb53e49b7b35aea3def6 + with: + api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }} + discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..96c4cb8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +# +# CI on the self-hosted Gitea. Gitea reads .gitea/workflows and ignores +# .github/ once this directory exists; .github/workflows is GitHub's side of +# the switch below. +# +# Every job runs in an image pinned by digest (tag in the trailing comment), +# and the only actions used are coffey-labs/actions ones pinned by SHA. The +# instance resolves short `uses:` against itself, never GitHub, so nothing +# unreviewed can be pulled in. +# +# BUILD ON GITHUB. The org variable BUILD_ON decides which forge builds. +# Set to 'github', the test job below skips and .github/workflows/ci.yml does +# the work on GitHub's runners -- GitHub holds a push mirror of this +# repository, updated on every commit -- and reports back as the commit status +# "github/ci (branch)" or "github/ci (tag)". The `github` job waits for that +# status and passes or fails with it, so a run here still says whether the +# commit is good. Unset (or anything but 'github'), the tests run here. +# +# Releases are built only on GitHub: each architecture is compiled on a +# native runner there, and these runners are amd64 only. With BUILD_ON unset +# a v* tag is tested here but not released. +name: ci + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The unit tests. They need no server; the checks against a live one are + # by hand (README, "Testing"). + test: + if: ${{ vars.BUILD_ON != 'github' }} + runs-on: light + container: + image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + - run: cargo test --locked + + # Stands in for test and release while GitHub builds: waits for the status + # GitHub's ci.yml posts on this commit and passes or fails with it. A pull + # request is checked at its head commit, which is what GitHub built when + # the branch reached the mirror. Two and a half hours covers a slow queue; + # a timeout here with BUILD_ON=github usually means GitHub never got the + # push -- check the mirror's last error in the repository settings. + github: + if: ${{ vars.BUILD_ON == 'github' }} + # Its own runner label with plenty of slots: this job only polls, but holds a slot + # for as long as the GitHub build takes, and must not starve the build runners. + runs-on: wait + timeout-minutes: 150 + container: + image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm + steps: + - shell: bash + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + run: | + set -euo pipefail + apt-get update -qq && apt-get install -y -qq --no-install-recommends jq >/dev/null + ctx="github/ci (branch)"; [ "$IS_TAG" = true ] && ctx="github/ci (tag)" + echo "waiting for '$ctx' on $SHA" + while :; do + s="$(curl -fsS -H "Authorization: token $TOKEN" \ + "$CI_SERVER_INTERNAL/api/v1/repos/$REPO/commits/$SHA/statuses?limit=50" \ + | jq -c --arg c "$ctx" '[.[] | select(.context == $c)] | sort_by(.id) | last // empty')" || s="" + state="$(printf '%s' "$s" | jq -r '.status // .state // empty')" + case "$state" in + success) echo "GitHub: success"; exit 0 ;; + failure|error) echo "GitHub: $state -- $(jq -r '.target_url' <<<"$s")"; exit 1 ;; + esac + sleep 20 + done + + # GitHub makes the Release and publishes it only once its files are + # attached; this waits for GitHub's success and announces then. The action + # makes one topic per tag, so announce.yml firing for the same Release is a + # no-op. + announce: + needs: [github] + if: ${{ startsWith(github.ref, 'refs/tags/v') && needs.github.result == 'success' }} + runs-on: light + steps: + - uses: coffey-labs/actions/discourse-release@6f0888c791db2ad0c40adb53e49b7b35aea3def6 + with: + api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }} + discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + tag: ${{ github.ref_name }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e53edb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +# +# CI on GitHub Actions, for the GitHub copy of this repository. +# +# The repository lives on the self-hosted Gitea; GitHub holds a push mirror +# that Gitea updates on every commit. Nothing is merged here -- pull requests +# happen on Gitea, and their branches reach GitHub as ordinary pushes, which is +# why this workflow runs on `push` and not on `pull_request`. +# +# Which forge does the building is one switch, the variable BUILD_ON, set on +# both forges at the organization level: +# +# BUILD_ON=github every job here runs; .gitea/workflows/ci.yml skips its +# test job and waits for the status this workflow reports +# back instead. +# unset / other every job here skips; Gitea runs the tests. Releases need +# GitHub: they are built on native amd64 and arm64 runners. +# +# The result goes back to Gitea as one commit status, "github/ci (branch)" or +# "github/ci (tag)", which is what Gitea's `github` job waits on. +# +# v* tags build a release: the tag must be on main and name the version in +# Cargo.toml; each architecture is built on its own native runner by +# scripts/build-release.sh, and the archives plus SHA256SUMS are attached to +# the Gitea Release -- Gitea is where the install guide points. The Release is +# created as a draft, filled, then published, so it is never visible with +# assets missing; if an upload fails, a Release this run created is deleted. +# Gitea announces the release once this run has reported success. +# +# The binaries link glibc dynamically, so they are built on Ubuntu 22.04: the +# oldest glibc GitHub offers, and so the widest range of servers they run on. +# +# Configuration, all at the organization level on GitHub: variables BUILD_ON, +# GITEA_URL; secret GITEA_TOKEN (write:repository on Gitea). +# +# Every `uses:` is pinned to a full commit SHA, with the release it was in the +# trailing comment. Nothing schedules here: schedules belong to Gitea. +name: ci + +on: + push: + branches: ['**'] + tags: ['**'] + # For a run GitHub queued and then orphaned. Run it from a tag to redo that + # tag's release; attaching skips any file already on the Release. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +permissions: + contents: read + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + CONTEXT: github/ci (${{ github.ref_type }}) + CARGO_TERM_COLOR: never + +jobs: + # Tells Gitea a result is on its way, so a status that is still missing + # reads as "running" rather than "never started". + pending: + if: vars.BUILD_ON == 'github' + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -d "$(jq -n --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}')" + + # The unit tests. They need no server; the checks against a live one are + # by hand (README, "Testing"). + test: + if: vars.BUILD_ON == 'github' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-test-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: cargo-test-${{ runner.os }}-${{ runner.arch }}- + # rustup is on the runner; rust-toolchain.toml picks the toolchain. + - run: rustup show active-toolchain || rustup toolchain install + - run: cargo test --locked + + # Guards shared by both architectures, checked once. + version: + if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + runs-on: ubuntu-latest + env: + TAG: ${{ github.ref_name }} + steps: + # Full history: the ancestry check cannot be answered from a shallow + # clone. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # A release can never describe code that was not reviewed onto main, + # and its tag must be the version the binary reports. + - run: | + git fetch --quiet origin main + git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + want="v$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')" + [ "$TAG" = "$want" ] || { echo "::error::$TAG does not match Cargo.toml ($want)"; exit 1; } + + build: + if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + needs: [test, version] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-22.04-arm + env: + TAG: ${{ github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - run: rustup show active-toolchain || rustup toolchain install + - run: | + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist + sha256sum dist/*.tar.gz + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: archive-${{ matrix.arch }} + path: dist/*.tar.gz + retention-days: 7 + overwrite: true + if-no-files-found: error + + release: + if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + needs: [build] + runs-on: ubuntu-latest + env: + TAG: ${{ github.ref_name }} + steps: + # The release notes are this version's section of CHANGELOG.md, so the + # release, and the announcement made from it, say what changed. Checked + # out first: a checkout cleans the workspace, and would take dist/ with + # it if it ran after the download. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: CHANGELOG.md + sparse-checkout-cone-mode: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + pattern: archive-* + merge-multiple: true + - run: | + (cd dist && sha256sum ./*.tar.gz | sed 's| \./| |' > SHA256SUMS) + cat dist/SHA256SUMS + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth=(-H "Authorization: token $GITEA_TOKEN") + # Everything the release carries has to be here before a release is made. + for f in inbuxa-cli-linux-amd64.tar.gz inbuxa-cli-linux-arm64.tar.gz SHA256SUMS; do + [ -s "dist/$f" ] || { echo "::error::dist/$f is missing; not creating a release"; exit 1; } + done + files="Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS." + notes="$(awk -v v="${TAG#v}" 'index($0, "## [" v "]") == 1 {f = 1; next} + f && (/^## / || /^---$/) {exit} + f' CHANGELOG.md | sed -e '/./,$!d')" + if [ -n "$notes" ]; then notes="$notes"$'\n\n'"$files"; else notes="$files"; fi + # Reuse the Release if the tag already has one (a re-run), else make a + # draft of our own. + created=0 + id="$(curl -fsS "${auth[@]}" "$API/releases/tags/$TAG" 2>/dev/null | jq -r '.id // empty' || true)" + if [ -z "$id" ]; then + id="$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg t "$TAG" --arg b "$notes" '{tag_name:$t, name:$t, draft:true, body:$b}')" \ + "$API/releases" | jq -r '.id // empty')" + [ -n "$id" ] || { echo "::error::could not create the release"; exit 1; } + created=1 + fi + have="$(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r '.[].name')" + for f in dist/*; do + n="$(basename "$f")" + if grep -qxF "$n" <<<"$have"; then echo "already attached: $n"; continue; fi + echo "uploading $n" + curl -fsS -o /dev/null "${auth[@]}" --form "attachment=@$f" "$API/releases/$id/assets?name=$n" || { + [ "$created" = 1 ] && curl -sS -o /dev/null "${auth[@]}" -X DELETE "$API/releases/$id" + exit 1 + } + done + if [ "$created" = 1 ]; then + curl -fsS -o /dev/null "${auth[@]}" -H 'Content-Type: application/json' -X PATCH \ + -d '{"draft":false}' "$API/releases/$id" + fi + + # ---------------------------------------------------- github release ------ + # Copies this tag's Gitea release -- notes and files -- to a GitHub release, + # so the replica's Releases page, and anyone watching it, keeps up. Gitea's + # release is the real one; this is left out of the report to Gitea, so a + # failure here cannot fail a release. PR and issue numbers in the notes are + # rewritten to Gitea links: on GitHub a bare #16 is some other PR. + github-release: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + needs: [release] + runs-on: ubuntu-latest + permissions: + contents: write + env: + GITEA_URL: ${{ vars.GITEA_URL }} + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + steps: + - run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "GitHub already has a release for $TAG"; exit 0 + fi + # The Gitea release exists by now if this run made it; allow a few + # minutes either way. + code=0 + for _ in $(seq 1 15); do + code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + [ "$code" = 200 ] && break + sleep 20 + done + if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi + if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi + export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)" + jq -r '.body // ""' rel.json | perl -pe 's{(? notes.md + printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \ + "$(jq -r .html_url rel.json)" "$BASE" >> notes.md + files=() + mkdir -p files + while IFS=$'\t' read -r name url; do + curl -fsSL -o "files/$name" "$url"; files+=("files/$name") + done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json) + title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG" + if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \ + --notes-file notes.md "$kind" "${files[@]}" + echo "created the GitHub release for $TAG with ${#files[@]} file(s)" + + report: + if: always() && vars.BUILD_ON == 'github' + needs: [pending, test, version, build, release] + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }} + run: | + # A cancelled run was superseded by a newer run for the same commit (the + # mirror can push one commit twice); that run reports. Posting "failure" + # here would fail the Gitea check while the real build is still going. + if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -d "$(jq -n --arg s "$STATE" --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}')" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85fe76f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [2026.10.26] + +The first release. + +- Lists, reads, creates, updates and deletes any registry type the server + describes, naming objects by id or by key. +- `snapshot` writes the server's settings as a plan; `apply` makes a server + match one, with references between objects written by key so a plan moves + between servers. +- Settings are applied on the running server after a change, with a note when + a store needs a restart. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b1aaa8c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1318 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inbuxa-cli" +version = "2026.10.26" +dependencies = [ + "base64 0.22.1", + "clap", + "rustls", + "serde_json", + "thiserror", + "ureq", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4295137 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "inbuxa-cli" +description = "Command-line administration for the inbuxa mail server" +version = "2026.10.26" +authors = ["Coffey Labs LLC "] +license = "AGPL-3.0-only" +repository = "https://git.coffeylabs.org/inbuxa/inbuxa-cli" +homepage = "https://git.coffeylabs.org/inbuxa/inbuxa-cli" +edition = "2024" +readme = "README.md" + +[[bin]] +name = "inbuxa-cli" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6.1", features = ["derive", "env"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +thiserror = "2.0.18" +ureq = { version = "3.3.0", default-features = false, features = ["json", "gzip", "rustls-no-provider", "platform-verifier"] } +rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] } +base64 = "0.22" + +[profile.release] +lto = "thin" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..86190cb --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# inbuxa-cli + +> [!NOTE] +> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-cli](https://git.coffeylabs.org/inbuxa/inbuxa-cli); the copy on GitHub is a read-only mirror. +> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-cli/issues](https://git.coffeylabs.org/inbuxa/inbuxa-cli/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**. + +Administer an **inbuxa** server from the command line, and keep its settings +in a file. + + inbuxa-cli list Domain what's there + inbuxa-cli create Domain name=example.com add to it + inbuxa-cli snapshot -o server.json the settings, as a plan + inbuxa-cli apply server.json --dry-run what a plan would change + +Everything the console manages lives in the server's registry: domains, +accounts, listeners, spam rules, tracers, about a hundred types in all. +inbuxa-cli reads the server's own description of those types, so it knows +every one the server knows, including types added after this program was +built. + +## Installing + +Download the archive for your machine from the +[releases](https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases), check it +against `SHA256SUMS`, and put `inbuxa-cli` on your `PATH`: + + curl -LO https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases/latest/download/inbuxa-cli-linux-amd64.tar.gz + curl -LO https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases/latest/download/SHA256SUMS + sha256sum --check --ignore-missing SHA256SUMS + tar xzf inbuxa-cli-linux-amd64.tar.gz + sudo install inbuxa-cli /usr/local/bin/ + +Or build it: `cargo build --release` leaves it in `target/release/`. + +## Signing in + +Give the server's address with `--url` or `INBUXA_URL`, and an API key in +`INBUXA_API_KEY`. Without one, inbuxa-cli asks for it. Make the key in the +console under **Account › Credentials › API Keys**, signed in as an +administrator. + + export INBUXA_URL=https://mail.example.com + export INBUXA_API_KEY=API_… + inbuxa-cli types + +The server takes passwords on its management API only in bootstrap and +recovery mode, or when it runs with `INBUXA_HTTP_BASIC_AUTH=all`. There, +`--user admin@example.com` signs in with the password in `INBUXA_PASSWORD`, +or asks for it. Secrets never go on the command line, where they would end up +in the shell's history. + +`--insecure` skips the TLS certificate check, for a test server with a +self-signed certificate. + +## Commands + +A type is named as the console's settings pages name it, with or without the +`x:` prefix: `Domain`, `x:Domain` and `domain` are the same. An object is +named by its id or by its key: a domain by its name, an account by its +address. + +| Command | Does | +| --- | --- | +| `types` | lists the registry types | +| `describe TYPE` | shows a type's properties: which are set on create only, which the server sets, which are secret, which refer to other types | +| `list TYPE [--filter JSON] [--json]` | lists the objects of a type | +| `get TYPE [ID…]` | prints objects as JSON | +| `create TYPE name=value… [--file F]` | creates an object and prints its id | +| `update TYPE [ID] name=value… [--file F]` | changes properties; a singleton needs no id | +| `delete TYPE ID…` | deletes objects | +| `reload [settings\|tls\|lookups\|blocked-ips]` | applies saved settings on the running server | +| `snapshot [TYPE…] [-o FILE]` | writes the settings as a plan | +| `apply FILE [--dry-run] [--prune] [--yes] [--write-secrets]` | makes the server match a plan | + +A value that reads as JSON is JSON (`true`, `42`, `["a","b"]`, +`{"@type":"Stdout"}`); anything else is a string, so `name=example.com` needs +no quotes. A name with slashes, such as `limits/maxSize=10`, changes one +property inside an object. To refer to another object, write its type and +key: `domainId=@x:Domain/example.com`. + + inbuxa-cli create Account @type=User name=alice domainId=@x:Domain/example.com + inbuxa-cli update Account alice@example.com description="Alice Liddell" + inbuxa-cli update Http usePermissiveCors=false + inbuxa-cli delete Account alice@example.com + +Most settings take effect when the server reloads its configuration. A +current server does that after every write that needs it, and inbuxa-cli +reports when a reload failed. An older one doesn't, and inbuxa-cli sends the +reload itself. Stores (data, blob, search and the rest) take effect only when +the server restarts, and inbuxa-cli says so. + +## Plans + +A plan is the registry written down as JSON: a type and its objects, or a +singleton and its properties. + +```json +{ + "x:Domain": [ + { "name": "example.com", "isEnabled": true } + ], + "x:Account": [ + { "@type": "Group", "@key": "staff@example.com", "name": "staff", + "domainId": "@x:Domain/example.com" }, + { "@type": "User", "@key": "alice@example.com", "name": "alice", + "domainId": "@x:Domain/example.com", + "memberGroupIds": ["@x:Account/staff@example.com"] } + ], + "x:Http": { "usePermissiveCors": false } +} +``` + +`snapshot` writes one from a server, and `apply` makes a server match one. Keep +the server's settings in version control, review a change as a diff, and +apply the same plan to a second server to set it up the same way. + +- **Matching.** Objects are matched by key: the property that names them, such + as a domain's `name`. Where the server sets the key itself, as with an + account's address, the plan carries it as `@key`. Lookup keys repeat from + one list to the next, so their key is `namespace/key`. A type whose objects + have no name, such as tracers, is matched by `@id`, which ties those + entries to the server they came from. +- **References.** Ids differ from one server to the next, so a property that + refers to another object holds `@/`. `apply` creates what's + referred to before what refers to it, including objects of the same type + (a group before its members). +- **Only what's named.** `apply` changes only the properties a plan names and + leaves the rest as they are. Inside a nested object it compares property by + property. A list or set is replaced whole. +- **Secrets.** The server never shows a secret, so a snapshot has none: no + passwords, no API keys, no DNS provider credentials. Add them to a plan by + hand. `apply` writes them when it creates an object, but not to one that + exists, since it can't compare them, unless you pass `--write-secrets`. +- **Set once.** A property that can only be set on create (a domain's name) is + reported, not changed. Delete the object and apply again. +- **Deleting.** `--prune` deletes the objects of each type in the plan that + the plan leaves out. It asks first, or takes `--yes`. Try `--dry-run` before + either. + +A snapshot leaves out what isn't settings: reports, logs, queued messages, +tasks and other records, people's own credentials, and DKIM keys, which the +server makes for each domain. Name a type to include it anyway: +`inbuxa-cli snapshot DkimSignature`. + +`apply` prints a line per change (`+` created, `~` updated, `-` deleted, `!` +failed) and a count of each at the end. + +## Exit status + +| Status | Means | +| --- | --- | +| 0 | done | +| 2 | the command line or the plan asks for something that can't work | +| 3 | the server couldn't be reached | +| 4 | the server refused the request: bad credentials, a missing permission | +| 5 | some changes failed; the rest were made | + +## Testing + + cargo test + +The unit tests need no server. `apply` is tested against a registry held in +memory. Before a release, the commands are also run by hand against a +throwaway server in a container: a snapshot applied back to the server it +came from changes nothing, and one applied after its objects are deleted +recreates them with every reference intact. + +## License + +AGPL-3.0-only. The text is in [LICENSES](LICENSES). + +Copyright (C) 2026, Coffey Labs LLC diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..284d219 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 110 diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..0642bf3 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Coffey Labs LLC +# SPDX-License-Identifier: AGPL-3.0-only +# +# Build one release archive for the machine this runs on. +# +# Usage: scripts/build-release.sh VERSION OUTDIR +# scripts/build-release.sh v2026.9.30 dist +# +# CI runs exactly this on one native runner per architecture (amd64 and +# arm64) and joins the results with SHA256SUMS, so a release can be checked +# before tagging on any Linux machine with cargo. The archive name carries no +# version, so .../releases/latest/download/ always means the newest. +set -euo pipefail + +VERSION="${1:?usage: $0 VERSION OUTDIR}" +OUT="${2:?usage: $0 VERSION OUTDIR}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +# The tag names the version the binary reports, so the two cannot disagree. +want="v$(cd "$ROOT" && cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')" +[ "$VERSION" = "$want" ] || { echo "tag $VERSION does not match Cargo.toml ($want)" >&2; exit 1; } + +name="inbuxa-cli-linux-$arch" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +echo "==> building $name ($VERSION)" +(cd "$ROOT" && cargo build --release --locked) +mkdir -p "$STAGE/$name" +cp "$ROOT/target/release/inbuxa-cli" "$ROOT/README.md" "$STAGE/$name/" +cp -r "$ROOT/LICENSES" "$STAGE/$name/" +# Fixed owner, order and time, so the archive's layout and metadata do not +# change from one build of a tag to the next. +tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH:-0}" \ + -C "$STAGE/$name" -czf "$OUT/$name.tar.gz" inbuxa-cli LICENSES README.md +echo "==> $OUT/$name.tar.gz" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..871a448 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The server's management API: JMAP with the registry capability, and the +//! schema that describes every registry type. +//! +//! Registry objects (`x:` types) live on the signed-in account's primary +//! account. Outside DAV the server takes a token, never a password (contract +//! C-23), so an API key is the normal way in; a password works in bootstrap +//! and recovery mode, or when the server runs with INBUXA_HTTP_BASIC_AUTH=all. + +use std::time::Duration; + +use base64::Engine; +use serde_json::{Map, Value, json}; +use ureq::config::RedirectAuthHeaders; +use ureq::tls::{RootCerts, TlsConfig}; + +use crate::error::{Error, Result}; + +pub const REGISTRY: &str = "urn:inbuxa:jmap:registry"; +const CORE: &str = "urn:ietf:params:jmap:core"; + +/// The schema is about a megabyte; leave room for it to grow. +const MAX_BODY: u64 = 64 * 1024 * 1024; + +#[derive(Clone)] +pub enum Auth { + /// An API key (`API_…`) or an OAuth access token. + Bearer(String), + /// A user name and password. + Basic(String, String), +} + +impl Auth { + fn header(&self) -> String { + match self { + Auth::Bearer(token) => format!("Bearer {token}"), + Auth::Basic(user, pass) => format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")) + ), + } + } +} + +/// One method call's answer: its name, its arguments and its call id. +pub type Response = (String, Value, String); + +pub struct Client { + agent: ureq::Agent, + base: String, + auth: Auth, + api_url: String, + account_id: String, + pub max_objects_in_get: usize, + pub max_objects_in_set: usize, + pub max_calls_in_request: usize, +} + +impl Client { + /// Fetches the JMAP session from `base` and checks the server offers the + /// registry. + pub fn connect(base: &str, auth: Auth, insecure: bool) -> Result { + let base = base.trim_end_matches('/').to_owned(); + if !base.starts_with("http://") && !base.starts_with("https://") { + return Err(Error::Usage(format!( + "{base} isn't a URL: give the server's address with http:// or https://" + ))); + } + let tls = TlsConfig::builder() + .unversioned_rustls_crypto_provider(std::sync::Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .root_certs(RootCerts::PlatformVerifier) + .disable_verification(insecure) + .build(); + let agent: ureq::Agent = ureq::Agent::config_builder() + .tls_config(tls) + .http_status_as_error(false) + // /.well-known/jmap redirects to the session on the same server, + // and the redirect has to keep the credentials. + .redirect_auth_headers(RedirectAuthHeaders::SameHost) + .timeout_connect(Some(Duration::from_secs(30))) + .timeout_recv_response(Some(Duration::from_secs(300))) + .user_agent(concat!("inbuxa-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .into(); + + let mut client = Client { + agent, + base, + auth, + api_url: "/jmap".into(), + account_id: String::new(), + max_objects_in_get: 500, + max_objects_in_set: 500, + max_calls_in_request: 16, + }; + let session = client.get_json("/.well-known/jmap")?; + client.read_session(&session)?; + Ok(client) + } + + fn read_session(&mut self, session: &Value) -> Result<()> { + // The registry is an account capability: the account that holds it is + // the one an administrator's session names as its primary for it. + let primary = session.get("primaryAccounts").and_then(Value::as_object); + self.account_id = primary + .and_then(|p| p.get(REGISTRY)) + .and_then(Value::as_str) + .or_else(|| { + session.get("accounts").and_then(Value::as_object).and_then(|accounts| { + accounts + .iter() + .find(|(_, a)| { + a.get("accountCapabilities") + .and_then(Value::as_object) + .is_some_and(|c| c.contains_key(REGISTRY)) + }) + .map(|(id, _)| id.as_str()) + }) + }) + .ok_or_else(|| { + Error::Server(format!( + "the server doesn't offer {REGISTRY}: either it isn't inbuxa, or this account can't administer it" + )) + })? + .to_owned(); + if let Some(core) = session.get("capabilities").and_then(|c| c.get(CORE)) { + let limit = |key: &str, default: usize| { + core.get(key) + .and_then(Value::as_u64) + .map_or(default, |n| n as usize) + .max(1) + }; + self.max_objects_in_get = limit("maxObjectsInGet", 500); + self.max_objects_in_set = limit("maxObjectsInSet", 500); + self.max_calls_in_request = limit("maxCallsInRequest", 16); + } + // Only the path: an absolute apiUrl names the server's public + // address, which isn't necessarily the one we were given. + if let Some(api) = session.get("apiUrl").and_then(Value::as_str) { + self.api_url = match api.split_once("://") { + Some((_, rest)) => rest.find('/').map_or("/jmap".into(), |i| rest[i..].into()), + None => api.into(), + }; + } + Ok(()) + } + + /// The registry's schema: every type, its fields, forms and lists. + pub fn schema(&self) -> Result { + self.get_json("/api/schema") + } + + fn get_json(&self, path: &str) -> Result { + let url = format!("{}{path}", self.base); + let mut response = self + .agent + .get(&url) + .header("Authorization", self.auth.header()) + .header("Accept", "application/json") + .call() + .map_err(|e| Error::Transport(format!("{url}: {e}")))?; + let status = response.status().as_u16(); + let body = response + .body_mut() + .with_config() + .limit(MAX_BODY) + .read_to_string() + .map_err(|e| Error::Transport(format!("{url}: {e}")))?; + if status != 200 { + return Err(http_error(status, &body)); + } + serde_json::from_str(&body) + .map_err(|e| Error::Transport(format!("{url} didn't answer with JSON: {e}"))) + } + + /// Sends method calls in one request and returns their answers, in order. + /// Every call gets the primary account unless it names one. + pub fn request(&self, calls: Vec<(String, Value)>) -> Result> { + let method_calls: Vec = calls + .into_iter() + .enumerate() + .map(|(i, (name, mut args))| { + if let Some(obj) = args.as_object_mut() { + obj.entry("accountId") + .or_insert_with(|| Value::String(self.account_id.clone())); + } + json!([name, args, i.to_string()]) + }) + .collect(); + let body = json!({ "using": [CORE, REGISTRY], "methodCalls": method_calls }); + let url = format!("{}{}", self.base, self.api_url); + let mut response = self + .agent + .post(&url) + .header("Authorization", self.auth.header()) + .send_json(&body) + .map_err(|e| Error::Transport(format!("{url}: {e}")))?; + let status = response.status().as_u16(); + let text = response + .body_mut() + .with_config() + .limit(MAX_BODY) + .read_to_string() + .map_err(|e| Error::Transport(format!("{url}: {e}")))?; + if status != 200 { + return Err(http_error(status, &text)); + } + let value: Value = serde_json::from_str(&text) + .map_err(|e| Error::Transport(format!("{url} didn't answer with JSON: {e}")))?; + let responses = value + .get("methodResponses") + .and_then(Value::as_array) + .ok_or_else(|| Error::Transport(format!("{url} answered without methodResponses")))?; + responses + .iter() + .map(|r| match r.as_array().map(Vec::as_slice) { + Some([Value::String(name), args, Value::String(id)]) => { + Ok((name.clone(), args.clone(), id.clone())) + } + _ => Err(Error::Transport( + "a method response isn't [name, args, id]".into(), + )), + }) + .collect() + } + + /// One method call, its answer, and an error if the server answered + /// `error` instead. + pub fn call(&self, name: &str, args: Value) -> Result { + let mut responses = self.request(vec![(name.to_owned(), args)])?; + let (answer, args, _) = responses + .pop() + .ok_or_else(|| Error::Transport(format!("no answer to {name}")))?; + if answer == "error" { + return Err(Error::Server(format!("{name}: {}", method_error(&args)))); + } + Ok(args) + } + + /// Every id of a type, a page at a time. Singletons have the one id + /// `singleton`. + pub fn all_ids(&self, object: &str, filter: Option<&Value>) -> Result> { + let mut ids = Vec::new(); + loop { + let mut args = json!({ "position": ids.len(), "limit": self.max_objects_in_get }); + if let Some(filter) = filter { + args["filter"] = filter.clone(); + } + let answer = self.call(&format!("{object}/query"), args)?; + let page: Vec = answer + .get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default(); + let done = page.len() < self.max_objects_in_get; + ids.extend(page); + if done { + return Ok(ids); + } + } + } + + /// The objects with these ids, in chunks the server accepts. Ids it + /// doesn't know are left out. + pub fn get(&self, object: &str, ids: &[String]) -> Result>> { + self.get_properties(object, ids, None) + } + + /// As [`Client::get`], with only the named properties (and the id). + pub fn get_properties( + &self, + object: &str, + ids: &[String], + properties: Option<&[&str]>, + ) -> Result>> { + let mut list = Vec::with_capacity(ids.len()); + for chunk in ids.chunks(self.max_objects_in_get) { + let mut args = json!({ "ids": chunk }); + if let Some(properties) = properties { + args["properties"] = json!(properties); + } + let answer = self.call(&format!("{object}/get"), args)?; + if let Some(found) = answer.get("list").and_then(Value::as_array) { + list.extend(found.iter().filter_map(|v| v.as_object().cloned())); + } + } + Ok(list) + } + + /// Every object of a type. + pub fn get_all(&self, object: &str) -> Result>> { + let ids = self.all_ids(object, None)?; + self.get(object, &ids) + } +} + +/// The server's account of a failed request: a JMAP problem document when it +/// sent one, otherwise the status. +fn http_error(status: u16, body: &str) -> Error { + let detail = serde_json::from_str::(body).ok().and_then(|v| { + v.get("detail") + .or_else(|| v.get("title")) + .and_then(Value::as_str) + .map(str::to_owned) + }); + let hint = match status { + 401 => { + " (an API key goes in INBUXA_API_KEY; a password only works in bootstrap or recovery mode, or with INBUXA_HTTP_BASIC_AUTH=all on the server)" + } + 403 => " (the account lacks the permission for this)", + _ => "", + }; + Error::Server(match detail { + Some(detail) => format!("HTTP {status}: {detail}{hint}"), + None => format!("HTTP {status}{hint}"), + }) +} + +/// A method-level error (`["error", {type, description}, id]`) in words. +pub fn method_error(args: &Value) -> String { + let kind = args.get("type").and_then(Value::as_str).unwrap_or("error"); + match args.get("description").and_then(Value::as_str) { + Some(d) if !d.is_empty() => format!("{kind}: {d}"), + _ => kind.to_owned(), + } +} + +/// A SetError (`notCreated` / `notUpdated` / `notDestroyed` entry) in words. +pub fn set_error(err: &Value) -> String { + let mut text = method_error(err); + if let Some(props) = err.get("properties").and_then(Value::as_array) { + let names: Vec<&str> = props.iter().filter_map(Value::as_str).collect(); + if !names.is_empty() { + text.push_str(&format!(" [{}]", names.join(", "))); + } + } + if let Some(linked) = err.get("linkedObjects").and_then(Value::as_array) { + let names: Vec = linked + .iter() + .filter_map(|l| { + Some(format!( + "x:{} {}", + l.get("object")?.as_str()?, + l.get("id")?.as_str()? + )) + }) + .collect(); + if !names.is_empty() { + text.push_str(&format!(" (still used by {})", names.join(", "))); + } + } + if let Some(details) = err.get("validationErrors").or_else(|| err.get("errors")) + && !details.is_null() + { + text.push_str(&format!(" {details}")); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session(api: &str) -> Value { + json!({ + "capabilities": { CORE: { "maxObjectsInGet": 7, "maxObjectsInSet": 9 } }, + "primaryAccounts": { CORE: "q", REGISTRY: "p" }, + "accounts": { "p": { "accountCapabilities": { REGISTRY: {} } }, "q": {} }, + "apiUrl": api, + }) + } + + fn bare() -> Client { + Client { + agent: ureq::Agent::new_with_defaults(), + base: "http://h".into(), + auth: Auth::Bearer("t".into()), + api_url: String::new(), + account_id: String::new(), + max_objects_in_get: 0, + max_objects_in_set: 0, + max_calls_in_request: 0, + } + } + + #[test] + fn reads_limits_account_and_api_path() { + let mut c = bare(); + c.read_session(&session("https://mail.example.com/jmap/")) + .unwrap(); + assert_eq!(c.account_id, "p"); + assert_eq!(c.api_url, "/jmap/"); + assert_eq!(c.max_objects_in_get, 7); + assert_eq!(c.max_objects_in_set, 9); + } + + #[test] + fn a_relative_api_url_is_kept() { + let mut c = bare(); + c.read_session(&session("/jmap")).unwrap(); + assert_eq!(c.api_url, "/jmap"); + } + + #[test] + fn the_registry_account_is_found_without_a_primary() { + let mut c = bare(); + let mut s = session("/jmap"); + s["primaryAccounts"].as_object_mut().unwrap().remove(REGISTRY); + c.read_session(&s).unwrap(); + assert_eq!(c.account_id, "p"); + } + + #[test] + fn a_server_without_the_registry_is_refused() { + let mut c = bare(); + let mut s = session("/jmap"); + s["primaryAccounts"].as_object_mut().unwrap().remove(REGISTRY); + s["accounts"]["p"]["accountCapabilities"] + .as_object_mut() + .unwrap() + .remove(REGISTRY); + assert!(matches!(c.read_session(&s), Err(Error::Server(_)))); + } + + #[test] + fn basic_auth_is_base64_of_user_and_password() { + assert_eq!(Auth::Basic("a".into(), "b".into()).header(), "Basic YTpi"); + } + + #[test] + fn set_errors_name_the_properties() { + let e = json!({"type": "invalidProperties", "description": "bad", "properties": ["name"]}); + assert_eq!(set_error(&e), "invalidProperties: bad [name]"); + } + + #[test] + fn set_errors_name_what_still_links_to_an_object() { + let e = json!({"type": "objectIsLinked", "linkedObjects": [{"object": "DkimSignature", "id": "k1"}]}); + assert_eq!(set_error(&e), "objectIsLinked (still used by x:DkimSignature k1)"); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..df6514e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What can go wrong, and the exit status each kind ends the program with. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + /// The command line or a plan file asks for something that can't work. + #[error("{0}")] + Usage(String), + + /// The server couldn't be reached, or answered something that isn't HTTP + /// we understand. + #[error("{0}")] + Transport(String), + + /// The server refused the request as a whole: bad credentials, a missing + /// permission, an unknown capability. + #[error("{0}")] + Server(String), + + /// The request went through, but some of what it asked for didn't. + #[error("{0}")] + Partial(String), + + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Json(#[from] serde_json::Error), +} + +impl Error { + pub fn exit_code(&self) -> i32 { + match self { + Error::Usage(_) => 2, + Error::Transport(_) | Error::Io(_) => 3, + Error::Server(_) | Error::Json(_) => 4, + Error::Partial(_) => 5, + } + } +} + +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..aca5b08 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,620 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa-cli: administer an inbuxa server from the command line, and keep +//! its settings in a file. + +mod client; +mod error; +mod plan; +mod reload; +mod schema; +mod secret; +mod values; + +use std::io::{IsTerminal, Write}; + +/// println! that hands a closed pipe (`| head`) back as an error instead of +/// panicking. +macro_rules! out { + ($($arg:tt)*) => { writeln!(std::io::stdout(), $($arg)*)? }; +} + +use clap::{Parser, Subcommand}; +use serde_json::{Map, Value, json}; + +use client::{Auth, Client, set_error}; +use error::{Error, Result}; +use plan::Registry; +use reload::Tracker; +use schema::{Kind, Schema, Update}; + +#[derive(Parser)] +#[command( + name = "inbuxa-cli", + version, + about = "Administer an inbuxa server from the command line" +)] +#[command( + after_help = "Sign in with an API key in INBUXA_API_KEY (asked for when unset), \ +or with --user and INBUXA_PASSWORD where the server takes passwords." +)] +struct Cli { + /// The server's address, such as https://mail.example.com + #[arg(long, short = 'u', env = "INBUXA_URL", global = true)] + url: Option, + + /// Sign in with this account's password (from INBUXA_PASSWORD, or asked + /// for) instead of an API key + #[arg(long, env = "INBUXA_USER", global = true)] + user: Option, + + /// Don't check the server's TLS certificate + #[arg(long, global = true)] + insecure: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// List the registry types + Types, + + /// Show a type's properties, and which of them refer to other types + Describe { r#type: String }, + + /// List the objects of a type + List { + r#type: String, + /// A JMAP filter, as JSON + #[arg(long)] + filter: Option, + /// Print the objects as JSON instead of a table + #[arg(long)] + json: bool, + }, + + /// Print objects as JSON, by id or key; all of them if none is named + Get { r#type: String, ids: Vec }, + + /// Create an object from name=value pairs or a JSON file + Create { + r#type: String, + /// name=value; a value that reads as JSON is JSON, anything else a + /// string. @x:Domain/example.com stands for that domain's id. + pairs: Vec, + /// Read the object from this JSON file, or - for standard input + #[arg(long, short)] + file: Option, + }, + + /// Change an object's properties (a singleton needs no id) + Update { + r#type: String, + /// The object's id or key, then name=value pairs + args: Vec, + /// Read the changes from this JSON file, or - for standard input + #[arg(long, short)] + file: Option, + }, + + /// Delete objects by id or key + Delete { + r#type: String, + #[arg(required = true)] + ids: Vec, + }, + + /// Apply saved settings on the running server + Reload { + /// settings, tls, lookups or blocked-ips; settings if none + what: Vec, + }, + + /// Write the registry's settings as a plan + Snapshot { + /// The types to include; every settings type if none + types: Vec, + /// Write the plan to this file instead of standard output + #[arg(long, short)] + output: Option, + }, + + /// Make the server match a plan + Apply { + /// The plan, or - for standard input + file: String, + /// Show what would change, and change nothing + #[arg(long, short = 'n')] + dry_run: bool, + /// Also delete objects of the plan's types that the plan leaves out + #[arg(long)] + prune: bool, + /// Don't ask before pruning + #[arg(long, short)] + yes: bool, + /// Write secrets to objects that exist (they're only set on create + /// otherwise, since the server never shows them to compare) + #[arg(long)] + write_secrets: bool, + }, +} + +fn main() { + let cli = Cli::parse(); + if let Err(e) = run(cli) { + if let Error::Io(io) = &e + && io.kind() == std::io::ErrorKind::BrokenPipe + { + return; + } + eprintln!("inbuxa-cli: {e}"); + std::process::exit(e.exit_code()); + } +} + +fn connect(cli: &Cli) -> Result<(Client, Schema)> { + let url = cli + .url + .as_deref() + .ok_or_else(|| Error::Usage("which server? Give --url or set INBUXA_URL".into()))?; + let auth = match &cli.user { + Some(user) => Auth::Basic( + user.clone(), + secret::from_env_or_prompt("INBUXA_PASSWORD", "password")?, + ), + None => Auth::Bearer(secret::from_env_or_prompt("INBUXA_API_KEY", "API key")?), + }; + let client = Client::connect(url, auth, cli.insecure)?; + let schema = Schema::new(client.schema()?)?; + Ok((client, schema)) +} + +fn run(cli: Cli) -> Result<()> { + let (client, schema) = connect(&cli)?; + match cli.command { + Command::Types => types(&schema), + Command::Describe { r#type } => describe(&schema, &schema.resolve(&r#type)?), + Command::List { r#type, filter, json } => { + let filter = filter + .map(|f| { + serde_json::from_str::(&f).map_err(|e| Error::Usage(format!("--filter: {e}"))) + }) + .transpose()?; + list(&client, &schema, &schema.resolve(&r#type)?, filter.as_ref(), json) + } + Command::Get { r#type, ids } => get(&client, &schema, &schema.resolve(&r#type)?, &ids), + Command::Create { r#type, pairs, file } => { + let object = schema.resolve(&r#type)?; + create(&client, &schema, &object, properties(&pairs, file.as_deref())?) + } + Command::Update { r#type, args, file } => { + let object = schema.resolve(&r#type)?; + let (id, pairs) = match schema.kind(&object) { + Kind::Singleton => ("singleton".to_owned(), &args[..]), + Kind::Object => match args.split_first() { + Some((target, rest)) if !target.contains('=') => { + (plan::find_id(&client, &schema, &object, target)?, rest) + } + _ => return Err(Error::Usage(format!("which {object}? Give its id or key first"))), + }, + }; + update( + &client, + &schema, + &object, + &id, + properties(pairs, file.as_deref())?, + ) + } + Command::Delete { r#type, ids } => delete(&client, &schema, &schema.resolve(&r#type)?, &ids), + Command::Reload { what } => { + let mut actions = Vec::new(); + for word in if what.is_empty() { + vec!["settings".to_owned()] + } else { + what + } { + actions.push(reload::Action::from_word(&word).ok_or_else(|| { + Error::Usage(format!( + "can't reload {word}: settings, tls, lookups or blocked-ips" + )) + })?); + } + actions.sort(); + actions.dedup(); + reload::send(&client, &actions)?; + for a in actions { + out!("{}: done", a.name()); + } + Ok(()) + } + Command::Snapshot { types, output } => snapshot(&client, &schema, &types, output.as_deref()), + Command::Apply { + file, + dry_run, + prune, + yes, + write_secrets, + } => apply( + &client, + &schema, + &file, + plan::Options { + dry_run, + prune, + write_secrets, + }, + yes, + ), + } +} + +/// The object a command line describes: a file, pairs, or pairs over a file. +fn properties(pairs: &[String], file: Option<&str>) -> Result> { + let mut object = match file { + Some(path) => values::read_object(path)?, + None => Map::new(), + }; + object.extend(values::parse_pairs(pairs)?); + if object.is_empty() { + return Err(Error::Usage("nothing given: name=value pairs, or --file".into())); + } + Ok(object) +} + +fn print_json(value: &Value) -> Result<()> { + let mut out = std::io::stdout().lock(); + serde_json::to_writer_pretty(&mut out, value)?; + writeln!(out)?; + Ok(()) +} + +fn types(schema: &Schema) -> Result<()> { + let names = schema.registry_types(); + let width = names.iter().map(|n| n.len()).max().unwrap_or(0); + for name in names { + let kind = match schema.kind(name) { + Kind::Singleton => "singleton", + Kind::Object => "", + }; + let description = schema.description(name).lines().next().unwrap_or(""); + out!("{name:width$} {kind:9} {description}"); + } + Ok(()) +} + +fn describe(schema: &Schema, object: &str) -> Result<()> { + out!( + "{object}{}", + if schema.kind(object) == Kind::Singleton { + " (singleton)" + } else { + "" + } + ); + let description = schema.description(object); + if !description.is_empty() { + out!("{description}"); + } + if let Some(key) = schema.key_property(object) { + out!("key: {key}"); + } + let variants = schema.variants(object); + let mut sections: Vec<(String, Option>)> = Vec::new(); + if variants.is_empty() { + sections.push((String::new(), None)); + } else { + for (name, label, _) in variants { + let mut sample = Map::new(); + sample.insert("@type".into(), Value::String(name.clone())); + sections.push((format!("@type {name} ({label})"), Some(sample))); + } + } + for (heading, sample) in sections { + out!(); + if !heading.is_empty() { + out!("{heading}"); + } + let Some(props) = schema.properties(object, sample.as_ref()) else { + out!(" (no properties)"); + continue; + }; + let refs = schema.references(object, sample.as_ref()); + let width = props.keys().map(String::len).max().unwrap_or(0); + for (name, spec) in props { + let t = spec.get("type"); + let mut kind = t + .and_then(|t| t.get("type")) + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(); + if let Some(class) = t + .and_then(|t| t.get("class")) + .and_then(|c| c.get("type")) + .and_then(Value::as_str) + { + kind = format!("{kind} of {class}"); + } + if let Some(r) = refs.iter().find(|r| &r.property == name) { + kind = format!("{kind} -> {}", r.target); + } + let mut notes = Vec::new(); + match Schema::update_rule(spec) { + Update::ServerSet => notes.push("server-set"), + Update::Immutable => notes.push("set on create"), + Update::Mutable => {} + } + if schema.holds_secret(spec) { + notes.push("secret"); + } + let notes = if notes.is_empty() { + String::new() + } else { + format!(" ({})", notes.join(", ")) + }; + out!(" {name:width$} {kind}{notes}"); + } + } + Ok(()) +} + +/// A cell of the list table. +fn cell(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Array(a)) => format!("[{}]", a.len()), + Some(Value::Object(o)) => match o.get("@type") { + Some(Value::String(t)) => t.clone(), + _ => format!("{{{}}}", o.len()), + }, + Some(other) => other.to_string(), + } +} + +fn list(client: &Client, schema: &Schema, object: &str, filter: Option<&Value>, as_json: bool) -> Result<()> { + let items = if schema.kind(object) == Kind::Singleton { + client.get(object, &["singleton".to_owned()])? + } else { + let ids = client.all_ids(object, filter)?; + client.get(object, &ids)? + }; + if as_json { + return print_json(&Value::Array(items.into_iter().map(Value::Object).collect())); + } + let mut columns = vec!["id".to_owned()]; + for c in schema.columns(object) { + if !columns.contains(&c) { + columns.push(c); + } + } + if columns.len() == 1 { + columns.push("@type".into()); + } + let rows: Vec> = items + .iter() + .map(|i| columns.iter().map(|c| cell(i.get(c))).collect()) + .collect(); + let widths: Vec = columns + .iter() + .enumerate() + .map(|(n, c)| { + rows.iter() + .map(|r| r[n].chars().count()) + .chain([c.len()]) + .max() + .unwrap_or(0) + .min(48) + }) + .collect(); + let line = |cells: &[String]| -> Result<()> { + let parts: Vec = cells + .iter() + .zip(&widths) + .map(|(c, w)| { + let c: String = c.chars().take(*w).collect(); + format!("{c:w$}") + }) + .collect(); + out!("{}", parts.join(" ").trim_end()); + Ok(()) + }; + line(&columns)?; + for row in &rows { + line(row)?; + } + eprintln!("{} {object}", rows.len()); + Ok(()) +} + +fn get(client: &Client, schema: &Schema, object: &str, names: &[String]) -> Result<()> { + let items = match schema.kind(object) { + Kind::Singleton => client.get(object, &["singleton".to_owned()])?, + Kind::Object if names.is_empty() => client.get_all(object)?, + Kind::Object => { + let ids = names + .iter() + .map(|n| plan::find_id(client, schema, object, n)) + .collect::>>()?; + client.get(object, &ids)? + } + }; + match (schema.kind(object), names.len(), items.len()) { + (Kind::Singleton, _, _) | (Kind::Object, 1, 1) => { + print_json(&Value::Object(items.into_iter().next().unwrap_or_default())) + } + _ => print_json(&Value::Array(items.into_iter().map(Value::Object).collect())), + } +} + +/// Applies what a write needs applying, and says what's left to do. +fn finish(client: &Client, mut tracker: Tracker) -> Result<()> { + for action in tracker.apply(client)? { + eprintln!("sent {}", action.name()); + } + for t in &tracker.restart { + eprintln!("note: {t} takes effect when the server restarts"); + } + if tracker.failures.is_empty() { + Ok(()) + } else { + Err(Error::Partial(tracker.failures.join("\n"))) + } +} + +fn create(client: &Client, schema: &Schema, object: &str, mut item: Map) -> Result<()> { + if schema.kind(object) == Kind::Singleton { + return Err(Error::Usage(format!("{object} is a singleton: use update"))); + } + plan::resolve_refs(client, schema, object, &mut item)?; + let answer = Registry::set(client, object, json!({ "create": { "new": item } }))?; + if let Some(err) = answer.get("notCreated").and_then(|n| n.get("new")) { + return Err(Error::Server(format!("{object} not created: {}", set_error(err)))); + } + let id = answer + .get("created") + .and_then(|c| c.get("new")) + .and_then(|c| c.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| Error::Server("the server didn't say what it created".into()))?; + out!("{id}"); + let mut tracker = Tracker::default(); + tracker.record(object, &answer); + finish(client, tracker) +} + +fn update( + client: &Client, + schema: &Schema, + object: &str, + id: &str, + mut patch: Map, +) -> Result<()> { + plan::resolve_refs(client, schema, object, &mut patch)?; + let answer = Registry::set(client, object, json!({ "update": { id: patch } }))?; + if let Some(err) = answer.get("notUpdated").and_then(|n| n.get(id)) { + return Err(Error::Server(format!( + "{object} {id} not updated: {}", + set_error(err) + ))); + } + eprintln!("updated {object} {id}"); + let mut tracker = Tracker::default(); + tracker.record(object, &answer); + finish(client, tracker) +} + +fn delete(client: &Client, schema: &Schema, object: &str, names: &[String]) -> Result<()> { + if schema.kind(object) == Kind::Singleton { + return Err(Error::Usage(format!( + "{object} is a singleton and can't be deleted" + ))); + } + let ids = names + .iter() + .map(|n| plan::find_id(client, schema, object, n)) + .collect::>>()?; + let answer = Registry::set(client, object, json!({ "destroy": ids }))?; + let mut problems = Vec::new(); + for (id, name) in ids.iter().zip(names) { + match answer.get("notDestroyed").and_then(|n| n.get(id)) { + Some(err) => problems.push(format!("{object} {name} not deleted: {}", set_error(err))), + None => eprintln!("deleted {object} {name}"), + } + } + let mut tracker = Tracker::default(); + if problems.len() < ids.len() { + tracker.record(object, &answer); + } + finish(client, tracker)?; + if problems.is_empty() { + Ok(()) + } else { + Err(Error::Partial(problems.join("\n"))) + } +} + +fn snapshot(client: &Client, schema: &Schema, names: &[String], output: Option<&str>) -> Result<()> { + let types: Vec = if names.is_empty() { + schema + .registry_types() + .into_iter() + .filter(|t| !plan::NOT_SETTINGS.contains(t)) + .map(str::to_owned) + .collect() + } else { + names.iter().map(|n| schema.resolve(n)).collect::>()? + }; + let (plan, warnings) = plan::snapshot(client, schema, &types)?; + for w in warnings { + eprintln!("note: {w}"); + } + let mut text = serde_json::to_string_pretty(&plan)?; + text.push('\n'); + match output { + Some(path) => std::fs::write(path, text).map_err(|e| Error::Usage(format!("{path}: {e}")))?, + None => std::io::stdout().lock().write_all(text.as_bytes())?, + } + Ok(()) +} + +fn apply(client: &Client, schema: &Schema, file: &str, options: plan::Options, yes: bool) -> Result<()> { + let text = if file == "-" { + std::io::read_to_string(std::io::stdin())? + } else { + std::fs::read_to_string(file).map_err(|e| Error::Usage(format!("{file}: {e}")))? + }; + let value: Value = serde_json::from_str(&text).map_err(|e| Error::Usage(format!("{file}: {e}")))?; + let plan = plan::read_plan(schema, value)?; + + if options.prune && !options.dry_run && !yes { + if !std::io::stdin().is_terminal() || file == "-" { + return Err(Error::Usage( + "--prune deletes objects: add --yes, or try --dry-run first".into(), + )); + } + let types: Vec<&str> = plan.iter().map(|(t, _)| t.as_str()).collect(); + eprint!( + "--prune deletes every {} the plan leaves out. Go ahead? [y/N] ", + types.join(", ") + ); + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !matches!(answer.trim(), "y" | "Y" | "yes") { + return Err(Error::Usage("nothing changed".into())); + } + } + + let dry_run = options.dry_run; + let mut applier = plan::Applier::new(client, schema, options); + applier.run(&plan)?; + let o = &applier.outcome; + let verb = if dry_run { "would be " } else { "" }; + eprintln!( + "{} {verb}created, {} {verb}updated, {} {verb}deleted, {} unchanged{}", + o.created, + o.updated, + o.destroyed, + o.unchanged, + if o.failed > 0 { + format!(", {} failed", o.failed) + } else { + String::new() + } + ); + let failed = o.failed; + let tracker = std::mem::take(&mut applier.tracker); + if !dry_run { + finish(client, tracker)?; + } + if failed > 0 { + return Err(Error::Partial(format!("{failed} change(s) failed"))); + } + Ok(()) +} diff --git a/src/plan.rs b/src/plan.rs new file mode 100644 index 0000000..283c6f8 --- /dev/null +++ b/src/plan.rs @@ -0,0 +1,1346 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Plans: the registry written down as JSON, read back out of a server +//! (`snapshot`) and made true on one (`apply`). +//! +//! A plan is one JSON object keyed by registry type. A singleton's value is +//! an object of its properties; a collection's is an array of objects: +//! +//! ```json +//! { +//! "x:Domain": [ { "name": "example.com", "isEnabled": true } ], +//! "x:Account": [ { "@type": "User", "name": "alice", +//! "domainId": "@x:Domain/example.com" } ], +//! "x:Http": { "usePermissiveCors": false } +//! } +//! ``` +//! +//! An object is matched to one on the server by its key: the property that +//! names it (a domain's `name`), or `@key` where that property is the +//! server's to set (an account's address). A type with no such property, like +//! tracers, is matched by `@id`, which ties the plan to one server. +//! +//! Ids differ from server to server, so a property that refers to another +//! object holds `@/` instead of an id, and `apply` looks it up. +//! Only the properties a plan names are managed; the rest are left as they +//! are. Secrets are never read back, so a snapshot has none. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; + +use serde_json::{Map, Value, json}; + +use crate::client::{Client, set_error}; +use crate::error::{Error, Result}; +use crate::reload::Tracker; +use crate::schema::{Kind, Reference, Schema, Update}; + +/// What `apply` needs from a server, so it can be tested without one. +pub trait Registry { + fn get_all(&self, object: &str) -> Result>>; + fn get_singleton(&self, object: &str) -> Result>>; + fn set(&self, object: &str, args: Value) -> Result>; + fn max_objects_in_set(&self) -> usize; +} + +impl Registry for Client { + fn get_all(&self, object: &str) -> Result>> { + Client::get_all(self, object) + } + + fn get_singleton(&self, object: &str) -> Result>> { + Ok(self.get(object, &["singleton".to_owned()])?.into_iter().next()) + } + + fn set(&self, object: &str, args: Value) -> Result> { + match self.call(&format!("{object}/set"), args)? { + Value::Object(map) => Ok(map), + _ => Err(Error::Transport(format!("{object}/set answered something odd"))), + } + } + + fn max_objects_in_set(&self) -> usize { + self.max_objects_in_set + } +} + +/// Types a snapshot leaves out unless asked for by name: records, telemetry +/// and operations rather than settings, per-person credentials, and DKIM keys, +/// which the server makes for each domain. +pub const NOT_SETTINGS: &[&str] = &[ + "x:AccountPassword", + "x:Action", + "x:ApiKey", + "x:AppPassword", + "x:ArchivedItem", + "x:ArfExternalReport", + "x:Bootstrap", + "x:ClusterNode", + "x:DkimSignature", + "x:DmarcExternalReport", + "x:DmarcInternalReport", + "x:Log", + "x:MaskedEmail", + "x:Metric", + "x:PublicKey", + "x:QueuedMessage", + "x:SpamTrainingSample", + "x:Task", + "x:TlsExternalReport", + "x:TlsInternalReport", + "x:Trace", +]; + +/// A reference as a plan writes it: `@x:Domain/example.com`. +pub fn format_ref(object: &str, key: &str) -> String { + format!("@{object}/{key}") +} + +/// The type and key of a reference, if `value` is one. +pub fn parse_ref(value: &str) -> Option<(&str, &str)> { + let rest = value.strip_prefix('@')?; + if !rest.starts_with("x:") { + return None; + } + let (object, key) = rest.split_once('/')?; + (!key.is_empty()).then_some((object, key)) +} + +/// A key as a string, whatever JSON type the property is. +fn key_text(value: &Value) -> Option { + match value { + Value::String(s) if !s.is_empty() => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// The key of an object on the server, or None if it has none. Where keys +/// repeat across namespaces, the namespace comes first: +/// `trusted-domains/example.com`. +fn server_key(schema: &Schema, object: &str, item: &Map) -> Option { + let key = item.get(&schema.key_property(object)?).and_then(key_text)?; + match schema + .key_qualifier(object) + .and_then(|q| item.get(&q).and_then(key_text)) + { + Some(scope) => Some(format!("{scope}/{key}")), + None => Some(key), + } +} + +/// The key of an object in a plan: `@key`, else its key property. +fn plan_key(schema: &Schema, object: &str, item: &Map) -> Option { + item.get("@key") + .and_then(key_text) + .or_else(|| server_key(schema, object, item)) +} + +/// Ids and keys of the objects of each type, looked up as they're needed. +/// An id of None is an object a dry run would have created. +struct Index<'a, R: Registry> { + registry: &'a R, + schema: &'a Schema, + types: HashMap, +} + +#[derive(Default)] +struct TypeIndex { + by_key: HashMap>, + by_id: HashMap, +} + +impl<'a, R: Registry> Index<'a, R> { + fn new(registry: &'a R, schema: &'a Schema) -> Self { + Index { + registry, + schema, + types: HashMap::new(), + } + } + + fn load(&mut self, object: &str) -> Result<&mut TypeIndex> { + if !self.types.contains_key(object) { + let mut index = TypeIndex::default(); + if self.schema.kind(object) == Kind::Object { + for item in self.registry.get_all(object)? { + if let (Some(id), Some(key)) = ( + item.get("id").and_then(Value::as_str), + server_key(self.schema, object, &item), + ) { + index.by_key.insert(key.clone(), Some(id.to_owned())); + index.by_id.insert(id.to_owned(), key); + } + } + } + self.types.insert(object.to_owned(), index); + } + Ok(self.types.get_mut(object).expect("just inserted")) + } + + /// Seeds a type's index from objects already fetched. + fn seed(&mut self, object: &str, items: &[Map]) { + let mut index = TypeIndex::default(); + for item in items { + if let (Some(id), Some(key)) = ( + item.get("id").and_then(Value::as_str), + server_key(self.schema, object, item), + ) { + index.by_key.insert(key.clone(), Some(id.to_owned())); + index.by_id.insert(id.to_owned(), key); + } + } + self.types.insert(object.to_owned(), index); + } + + fn learn(&mut self, object: &str, key: &str, id: Option<&str>) { + let index = self.types.entry(object.to_owned()).or_default(); + index.by_key.insert(key.to_owned(), id.map(str::to_owned)); + if let Some(id) = id { + index.by_id.insert(id.to_owned(), key.to_owned()); + } + } + + fn key_of(&mut self, object: &str, id: &str) -> Result> { + Ok(self.load(object)?.by_id.get(id).cloned()) + } + + /// `Some(Some(id))` for an object that exists, `Some(None)` for one a dry + /// run would have created, None for one that doesn't exist. + fn id_of(&mut self, object: &str, key: &str) -> Result>> { + Ok(self.load(object)?.by_key.get(key).cloned()) + } +} + +/// The ids in a reference property, whether written as one id, an array, or +/// a set (`{"id": true}`). +fn ids_in(value: &Value) -> Vec<&str> { + match value { + Value::String(s) => vec![s.as_str()], + Value::Array(a) => a.iter().filter_map(Value::as_str).collect(), + Value::Object(o) => o.keys().map(String::as_str).collect(), + _ => Vec::new(), + } +} + +/// `value` with each id replaced by `f(id)`, in the same shape. +fn map_ids(value: &Value, mut f: impl FnMut(&str) -> String) -> Value { + match value { + Value::String(s) => Value::String(f(s)), + Value::Array(a) => Value::Array( + a.iter() + .map(|v| v.as_str().map_or(v.clone(), |s| Value::String(f(s)))) + .collect(), + ), + Value::Object(o) => Value::Object(o.iter().map(|(k, v)| (f(k), v.clone())).collect()), + other => other.clone(), + } +} + +/// Writes a server object's references as `@type/key`, where the target has +/// a key. Returns the targets it couldn't name. +fn refs_to_keys( + index: &mut Index, + refs: &[Reference], + item: &mut Map, +) -> Result> { + let mut unnamed = Vec::new(); + for r in refs { + let Some(value) = item.get(&r.property) else { + continue; + }; + if index.schema.key_property(&r.target).is_none() { + continue; + } + let mut keys = HashMap::new(); + for id in ids_in(value) { + match index.key_of(&r.target, id)? { + Some(key) => { + keys.insert(id.to_owned(), format_ref(&r.target, &key)); + } + None => unnamed.push(format!("{} {id}", r.target)), + } + } + let mapped = map_ids(value, |id| keys.get(id).cloned().unwrap_or_else(|| id.to_owned())); + item.insert(r.property.clone(), mapped); + } + Ok(unnamed) +} + +/// Placeholder for an id a dry run doesn't have yet. +fn placeholder(object: &str, key: &str) -> String { + format!("(new {object} {key})") +} + +/// Replaces `@type/key` references in a plan object with ids. Returns the +/// references that name nothing, yet. +fn keys_to_refs( + index: &mut Index, + item: &mut Map, +) -> Result> { + let mut missing = Vec::new(); + let names: Vec = item.keys().cloned().collect(); + for name in names { + if name.starts_with('@') { + continue; + } + let value = item[&name].clone(); + let mut resolved: HashMap = HashMap::new(); + for text in ids_in(&value) { + if let Some((object, key)) = parse_ref(text) { + match index.id_of(object, key)? { + Some(Some(id)) => { + resolved.insert(text.to_owned(), id); + } + Some(None) => { + resolved.insert(text.to_owned(), placeholder(object, key)); + } + None => missing.push((object.to_owned(), key.to_owned())), + } + } + } + if !resolved.is_empty() { + item.insert( + name, + map_ids(&value, |t| { + resolved.get(t).cloned().unwrap_or_else(|| t.to_owned()) + }), + ); + } + } + Ok(missing) +} + +/// Replaces `@type/key` references in `item` with ids, for a create or update +/// given on the command line. +pub fn resolve_refs( + registry: &R, + schema: &Schema, + object: &str, + item: &mut Map, +) -> Result<()> { + let mut index = Index::new(registry, schema); + let missing = keys_to_refs(&mut index, item)?; + sets_as_maps(schema, object, item); + match missing.first() { + Some((t, k)) => Err(Error::Usage(format!("{} doesn't exist", format_ref(t, k)))), + None => Ok(()), + } +} + +/// The id of the object `text` names: an id, its key, or `@type/key`. +pub fn find_id(registry: &R, schema: &Schema, object: &str, text: &str) -> Result { + let key = match parse_ref(text) { + Some((t, k)) if t == object => k, + Some((t, _)) => return Err(Error::Usage(format!("{text} is a {t}, not a {object}"))), + None => text, + }; + let items = registry.get_all(object)?; + if items + .iter() + .any(|i| i.get("id").and_then(Value::as_str) == Some(text)) + { + return Ok(text.to_owned()); + } + items + .iter() + .find(|i| server_key(schema, object, i).as_deref() == Some(key)) + .and_then(|i| i.get("id").and_then(Value::as_str)) + .map(str::to_owned) + .ok_or_else(|| Error::Usage(format!("no {object} {text}"))) +} + +/// One server object as a plan writes it: no id, nothing the server sets, no +/// secrets, and references by key. +fn to_plan_object( + index: &mut Index, + object: &str, + mut item: Map, + warnings: &mut Vec, +) -> Result> { + let schema = index.schema; + let key_property = schema.key_property(object); + let refs = schema.references(object, Some(&item)); + let unnamed = refs_to_keys(index, &refs, &mut item)?; + for u in unnamed { + warnings.push(format!( + "{object}: refers to {u}, which has no key, so its id stays" + )); + } + + let props = schema + .properties(object, Some(&item)) + .cloned() + .unwrap_or_default(); + let mut out = Map::new(); + if let Some(t) = item.get("@type") { + out.insert("@type".into(), t.clone()); + } + let mut keyed = false; + for (name, value) in &item { + if name == "id" || name == "@type" || value.is_null() { + continue; + } + if let Some(spec) = props.get(name) { + if schema.holds_secret(spec) { + continue; + } + if Schema::update_rule(spec) == Update::ServerSet { + if key_property.as_deref() == Some(name.as_str()) + && let Some(k) = key_text(value) + { + out.insert("@key".into(), Value::String(k)); + keyed = true; + } + continue; + } + } + if key_property.as_deref() == Some(name.as_str()) && key_text(value).is_some() { + keyed = true; + } + out.insert(name.clone(), value.clone()); + } + if !keyed + && schema.kind(object) == Kind::Object + && let Some(id) = item.get("id") + { + out.insert("@id".into(), id.clone()); + } + Ok(out) +} + +/// The registry as a plan, for the types asked for. +pub fn snapshot( + registry: &R, + schema: &Schema, + types: &[String], +) -> Result<(Value, Vec)> { + let mut index = Index::new(registry, schema); + let mut warnings = Vec::new(); + let mut plan = Map::new(); + for object in types { + match schema.kind(object) { + Kind::Singleton => { + let Some(item) = registry.get_singleton(object)? else { + warnings.push(format!("{object}: the server returned nothing")); + continue; + }; + let out = to_plan_object(&mut index, object, item, &mut warnings)?; + plan.insert(object.clone(), Value::Object(out)); + } + Kind::Object => { + let items = registry.get_all(object)?; + index.seed(object, &items); + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(to_plan_object(&mut index, object, item, &mut warnings)?); + } + out.sort_by_key(|o| { + plan_key(schema, object, o) + .or_else(|| o.get("@id").and_then(key_text)) + .unwrap_or_default() + }); + plan.insert( + object.clone(), + Value::Array(out.into_iter().map(Value::Object).collect()), + ); + } + } + } + Ok((Value::Object(plan), warnings)) +} + +/// A plan file's types, resolved against the schema, each with its value. +pub fn read_plan(schema: &Schema, plan: Value) -> Result> { + let Value::Object(map) = plan else { + return Err(Error::Usage("a plan is a JSON object keyed by type".into())); + }; + let mut out: Vec<(String, Value)> = Vec::new(); + for (name, value) in map { + let object = schema.resolve(&name)?; + if out.iter().any(|(o, _)| *o == object) { + return Err(Error::Usage(format!("the plan names {object} twice"))); + } + match (schema.kind(&object), &value) { + (Kind::Singleton, Value::Object(_)) | (Kind::Object, Value::Array(_)) => {} + (Kind::Singleton, _) => { + return Err(Error::Usage(format!( + "{object} is a singleton: give it an object" + ))); + } + (Kind::Object, _) => { + return Err(Error::Usage(format!( + "{object} is a collection: give it an array" + ))); + } + } + if let Value::Array(items) = &value + && let Some(bad) = items.iter().position(|i| !i.is_object()) + { + return Err(Error::Usage(format!("{object}[{bad}] isn't an object"))); + } + out.push((object, value)); + } + Ok(out) +} + +/// The plan's types, each after the types it refers to. A cycle keeps the +/// plan's own order for what's left. +fn ordered(schema: &Schema, plan: &[(String, Value)]) -> Vec { + let names: Vec<&str> = plan.iter().map(|(n, _)| n.as_str()).collect(); + let mut done: Vec = Vec::new(); + let mut left: Vec = (0..plan.len()).collect(); + while !left.is_empty() { + let ready = left.iter().position(|&i| { + schema + .referenced_types(names[i]) + .iter() + .all(|t| t == names[i] || !names.contains(&t.as_str()) || done.iter().any(|&d| names[d] == t)) + }); + let pick = left.remove(ready.unwrap_or(0)); + done.push(pick); + } + done +} + +#[derive(Default, Debug)] +pub struct Options { + pub dry_run: bool, + pub prune: bool, + pub write_secrets: bool, +} + +#[derive(Default, Debug)] +pub struct Outcome { + pub created: usize, + pub updated: usize, + pub unchanged: usize, + pub destroyed: usize, + pub failed: usize, +} + +/// JSON Pointer escaping for a patch path segment (RFC 6901). +fn escape(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} + +fn numbers_equal(a: &Value, b: &Value) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => x == y, + _ => a == b, + } +} + +/// Whether two values hold the same set of ids or members, ignoring order and +/// whether the set is written as an array or as `{"x": true}`. +fn same_set(a: &Value, b: &Value) -> bool { + let set = |v: &Value| -> Option> { + match v { + Value::Array(items) => Some(items.iter().map(|i| i.to_string()).collect()), + Value::Object(o) => Some(o.keys().map(|k| Value::String(k.clone()).to_string()).collect()), + Value::Null => Some(HashSet::new()), + _ => None, + } + }; + match (set(a), set(b)) { + (Some(x), Some(y)) => x == y, + _ => a == b, + } +} + +/// Patch entries that make `current` agree with `desired` under `path`. A +/// nested object is compared property by property, so what the plan leaves +/// out keeps its value; anything else is replaced whole. +fn diff_value(path: &str, desired: &Value, current: &Value, patch: &mut Map) { + match (desired, current) { + (Value::Object(d), Value::Object(c)) if d.get("@type") == c.get("@type") => { + for (k, v) in d { + if k == "@type" { + continue; + } + diff_value( + &format!("{path}/{}", escape(k)), + v, + c.get(k).unwrap_or(&Value::Null), + patch, + ); + } + } + _ if numbers_equal(desired, current) => {} + _ => { + patch.insert(path.to_owned(), desired.clone()); + } + } +} + +/// The update that makes `current` what `desired` says, or why there can't be +/// one. +fn diff( + schema: &Schema, + object: &str, + desired: &Map, + current: &Map, + options: &Options, +) -> std::result::Result, String> { + if let (Some(d), Some(c)) = (desired.get("@type"), current.get("@type")) + && d != c + { + return Err(format!( + "is a {c} on the server and a {d} in the plan: delete it and apply again" + )); + } + let props = schema + .properties(object, Some(current)) + .cloned() + .unwrap_or_default(); + let mut patch = Map::new(); + for (name, value) in desired { + if name.starts_with('@') { + continue; + } + let spec = props.get(name); + if let Some(spec) = spec { + if Schema::update_rule(spec) == Update::ServerSet { + continue; + } + if schema.holds_secret(spec) && !options.write_secrets { + continue; + } + } + let now = current.get(name).unwrap_or(&Value::Null); + let kind = spec + .and_then(|s| s.get("type")) + .and_then(|t| t.get("type")) + .and_then(Value::as_str); + let mut entries = Map::new(); + match kind { + Some("set" | "map" | "list" | "objectList") => { + if !same_set(value, now) && value != now { + entries.insert(escape(name), value.clone()); + } + } + _ => diff_value(&escape(name), value, now, &mut entries), + } + if entries.is_empty() { + continue; + } + if spec.is_some_and(|s| Schema::update_rule(s) == Update::Immutable) { + return Err(format!( + "{name} can't be changed once set: delete it and apply again" + )); + } + patch.extend(entries); + } + Ok(patch) +} + +/// A set written as an array in a plan, as JMAP writes sets: `{"a": true}`. +fn sets_as_maps(schema: &Schema, object: &str, item: &mut Map) { + let Some(props) = schema.properties(object, Some(item)).cloned() else { + return; + }; + for (name, spec) in props { + let is_set = spec + .get("type") + .and_then(|t| t.get("type")) + .and_then(Value::as_str) + == Some("set"); + if let Some(Value::Array(members)) = item.get(&name) + && is_set + { + let map: Map = members + .iter() + .filter_map(|m| Some((key_text(m)?, Value::Bool(true)))) + .collect(); + item.insert(name, Value::Object(map)); + } + } +} + +/// The object as it is created: plan markers dropped. +fn creatable(item: &Map) -> Map { + item.iter() + .filter(|(k, _)| !k.starts_with('@') || *k == "@type") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// A collection the plan named: its type, the ids the plan matched, and what +/// the server had. +type Matched = (String, HashSet, Vec>); + +pub struct Applier<'a, R: Registry> { + index: Index<'a, R>, + registry: &'a R, + schema: &'a Schema, + options: Options, + pub outcome: Outcome, + pub tracker: Tracker, + /// Ids each collection's plan matched, for pruning the rest. + matched: Vec, +} + +impl<'a, R: Registry> Applier<'a, R> { + pub fn new(registry: &'a R, schema: &'a Schema, options: Options) -> Self { + Applier { + index: Index::new(registry, schema), + registry, + schema, + options, + outcome: Outcome::default(), + tracker: Tracker::default(), + matched: Vec::new(), + } + } + + // What apply does goes to standard output a line at a time; a reader + // that has gone away doesn't stop the run. + fn say(&self, line: String) { + let _ = writeln!(std::io::stdout(), "{line}"); + } + + fn fail(&mut self, line: String) { + self.outcome.failed += 1; + let _ = writeln!(std::io::stdout(), "! {line}"); + } + + /// Sends one /set, unless this is a dry run, and notes what it changed. + fn write(&mut self, object: &str, args: Value) -> Result> { + let answer = self.registry.set(object, args)?; + let changed = ["created", "updated"].iter().any(|k| { + answer + .get(*k) + .and_then(Value::as_object) + .is_some_and(|m| !m.is_empty()) + }) || answer + .get("destroyed") + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty()); + if changed { + self.tracker.record(object, &answer); + } + Ok(answer) + } + + pub fn run(&mut self, plan: &[(String, Value)]) -> Result<()> { + for i in ordered(self.schema, plan) { + let (object, value) = &plan[i]; + match value { + Value::Object(item) => self.singleton(object, item)?, + Value::Array(items) => { + let items: Vec> = + items.iter().filter_map(|v| v.as_object().cloned()).collect(); + self.collection(object, items)?; + } + _ => {} + } + } + if self.options.prune { + self.prune()?; + } + Ok(()) + } + + fn singleton(&mut self, object: &str, item: &Map) -> Result<()> { + let mut desired = item.clone(); + let missing = keys_to_refs(&mut self.index, &mut desired)?; + sets_as_maps(self.schema, object, &mut desired); + if let Some((t, k)) = missing.first() { + self.fail(format!( + "{object}: refers to {}, which doesn't exist", + format_ref(t, k) + )); + return Ok(()); + } + let Some(current) = self.registry.get_singleton(object)? else { + self.fail(format!("{object}: the server returned nothing")); + return Ok(()); + }; + match diff(self.schema, object, &desired, ¤t, &self.options) { + Err(why) => self.fail(format!("{object} {why}")), + Ok(patch) if patch.is_empty() => self.outcome.unchanged += 1, + Ok(patch) => { + let names: Vec<&str> = patch.keys().map(String::as_str).collect(); + self.say(format!("~ {object}: {}", names.join(", "))); + if !self.options.dry_run { + let answer = self.write(object, json!({ "update": { "singleton": patch } }))?; + if let Some(err) = answer.get("notUpdated").and_then(|n| n.get("singleton")) { + self.fail(format!("{object}: {}", set_error(err))); + return Ok(()); + } + } + self.outcome.updated += 1; + } + } + Ok(()) + } + + fn collection(&mut self, object: &str, items: Vec>) -> Result<()> { + let existing = self.registry.get_all(object)?; + self.index.seed(object, &existing); + let by_id: HashMap> = existing + .iter() + .filter_map(|e| Some((e.get("id")?.as_str()?.to_owned(), e))) + .collect(); + let by_key: HashMap = existing + .iter() + .filter_map(|e| { + Some(( + server_key(self.schema, object, e)?, + e.get("id")?.as_str()?.to_owned(), + )) + }) + .collect(); + let keys_in_plan: HashSet = items + .iter() + .filter_map(|i| plan_key(self.schema, object, i)) + .collect(); + + let mut matched: HashSet = HashSet::new(); + let mut pending: Vec> = items; + loop { + let mut creates: Vec<(String, Map)> = Vec::new(); + let mut updates: Vec<(String, String, Map)> = Vec::new(); + let mut deferred = Vec::new(); + let mut blocked = Vec::new(); + + for item in pending { + let label = plan_key(self.schema, object, &item) + .or_else(|| item.get("@id").and_then(key_text).map(|id| format!("id {id}"))) + .unwrap_or_else(|| "(no key)".into()); + let mut desired = item.clone(); + let missing = keys_to_refs(&mut self.index, &mut desired)?; + sets_as_maps(self.schema, object, &mut desired); + if let Some((t, k)) = missing.first() { + // Another object of this type, still to be created, is + // fine: it goes in an earlier round. + if t == object && keys_in_plan.contains(k) { + deferred.push(item); + } else { + blocked.push(format!( + "{object} {label}: refers to {}, which doesn't exist", + format_ref(t, k) + )); + } + continue; + } + + let found = plan_key(self.schema, object, &item) + .and_then(|k| by_key.get(&k).cloned()) + .or_else(|| { + item.get("@id") + .and_then(Value::as_str) + .filter(|id| by_id.contains_key(*id)) + .map(str::to_owned) + }); + match found { + Some(id) => { + matched.insert(id.clone()); + match diff(self.schema, object, &desired, by_id[&id], &self.options) { + Err(why) => self.fail(format!("{object} {label} {why}")), + Ok(patch) if patch.is_empty() => self.outcome.unchanged += 1, + Ok(patch) => updates.push((id, label, patch)), + } + } + None => creates.push((label, desired)), + } + } + for line in blocked { + self.fail(line); + } + + let progress = !creates.is_empty() || !updates.is_empty(); + self.send_updates(object, updates)?; + self.send_creates(object, creates)?; + + if deferred.is_empty() { + break; + } + if !progress { + for item in deferred { + let label = plan_key(self.schema, object, &item).unwrap_or_default(); + self.fail(format!( + "{object} {label}: refers to objects of its own type that can't be created first" + )); + } + break; + } + pending = deferred; + } + self.matched.push((object.to_owned(), matched, existing)); + Ok(()) + } + + fn send_updates( + &mut self, + object: &str, + updates: Vec<(String, String, Map)>, + ) -> Result<()> { + for chunk in updates.chunks(self.registry.max_objects_in_set()) { + for (_, label, patch) in chunk { + let names: Vec<&str> = patch.keys().map(String::as_str).collect(); + self.say(format!("~ {object} {label}: {}", names.join(", "))); + } + if self.options.dry_run { + self.outcome.updated += chunk.len(); + continue; + } + let update: Map = chunk + .iter() + .map(|(id, _, p)| (id.clone(), Value::Object(p.clone()))) + .collect(); + let answer = self.write(object, json!({ "update": update }))?; + for (id, label, _) in chunk { + match answer.get("notUpdated").and_then(|n| n.get(id)) { + Some(err) => self.fail(format!("{object} {label}: {}", set_error(err))), + None => self.outcome.updated += 1, + } + } + } + Ok(()) + } + + fn send_creates(&mut self, object: &str, creates: Vec<(String, Map)>) -> Result<()> { + for chunk in creates.chunks(self.registry.max_objects_in_set()) { + for (label, _) in chunk { + self.say(format!("+ {object} {label}")); + } + if self.options.dry_run { + for (label, item) in chunk { + if let Some(key) = plan_key(self.schema, object, item).or_else(|| Some(label.clone())) { + self.index.learn(object, &key, None); + } + } + self.outcome.created += chunk.len(); + continue; + } + let create: Map = chunk + .iter() + .enumerate() + .map(|(i, (_, item))| (format!("c{i}"), Value::Object(creatable(item)))) + .collect(); + let answer = self.write(object, json!({ "create": create }))?; + for (i, (label, item)) in chunk.iter().enumerate() { + let cid = format!("c{i}"); + if let Some(err) = answer.get("notCreated").and_then(|n| n.get(&cid)) { + self.fail(format!("{object} {label}: {}", set_error(err))); + continue; + } + let id = answer + .get("created") + .and_then(|c| c.get(&cid)) + .and_then(|c| c.get("id")) + .and_then(Value::as_str); + match id { + Some(id) => { + self.outcome.created += 1; + // The key may be one the server sets, such as an + // account's address; the plan's @key says what it is. + if let Some(key) = plan_key(self.schema, object, item).or_else(|| { + answer + .get("created") + .and_then(|c| c.get(&cid)) + .and_then(Value::as_object) + .and_then(|c| server_key(self.schema, object, c)) + }) { + self.index.learn(object, &key, Some(id)); + } + } + None => self.fail(format!("{object} {label}: the server didn't say it was created")), + } + } + } + Ok(()) + } + + /// Destroys what the plan's collections didn't name, last types first. + fn prune(&mut self) -> Result<()> { + let matched = std::mem::take(&mut self.matched); + for (object, keep, existing) in matched.iter().rev() { + let doomed: Vec<(String, String)> = existing + .iter() + .filter_map(|e| { + let id = e.get("id")?.as_str()?; + (!keep.contains(id)).then(|| { + let label = server_key(self.schema, object, e).unwrap_or_else(|| format!("id {id}")); + (id.to_owned(), label) + }) + }) + .collect(); + for chunk in doomed.chunks(self.registry.max_objects_in_set()) { + for (_, label) in chunk { + self.say(format!("- {object} {label}")); + } + if self.options.dry_run { + self.outcome.destroyed += chunk.len(); + continue; + } + let ids: Vec<&str> = chunk.iter().map(|(id, _)| id.as_str()).collect(); + let answer = self.write(object, json!({ "destroy": ids }))?; + for (id, label) in chunk { + match answer.get("notDestroyed").and_then(|n| n.get(id)) { + Some(err) => self.fail(format!("{object} {label}: {}", set_error(err))), + None => self.outcome.destroyed += 1, + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::tests::sample; + use std::cell::RefCell; + + /// A registry in memory that behaves like the server for what apply uses. + struct Fake { + objects: RefCell>>>, + singletons: RefCell>>, + next: RefCell, + calls: RefCell>, + } + + impl Fake { + fn new() -> Self { + Fake { + objects: RefCell::default(), + singletons: RefCell::default(), + next: RefCell::new(0), + calls: RefCell::default(), + } + } + + fn insert(&self, object: &str, item: Value) -> String { + let mut n = self.next.borrow_mut(); + *n += 1; + let id = format!("i{n}"); + let mut map = item.as_object().unwrap().clone(); + map.insert("id".into(), json!(id)); + if object == "x:Account" { + // The server sets the address from name and domain. + let domain = map["domainId"].as_str().unwrap().to_owned(); + let domains = self.objects.borrow(); + let dname = domains["x:Domain"] + .iter() + .find(|d| d["id"] == json!(domain)) + .map(|d| d["name"].as_str().unwrap().to_owned()) + .unwrap(); + map.insert( + "emailAddress".into(), + json!(format!("{}@{dname}", map["name"].as_str().unwrap())), + ); + } + self.objects + .borrow_mut() + .entry(object.into()) + .or_default() + .push(map); + id + } + } + + impl Registry for Fake { + fn get_all(&self, object: &str) -> Result>> { + Ok(self.objects.borrow().get(object).cloned().unwrap_or_default()) + } + + fn get_singleton(&self, object: &str) -> Result>> { + Ok(Some( + self.singletons.borrow().get(object).cloned().unwrap_or_default(), + )) + } + + fn set(&self, object: &str, args: Value) -> Result> { + self.calls.borrow_mut().push((object.into(), args.clone())); + let mut answer = Map::new(); + if let Some(create) = args.get("create").and_then(Value::as_object) { + let mut created = Map::new(); + for (cid, item) in create { + let id = self.insert(object, item.clone()); + created.insert(cid.clone(), json!({ "id": id })); + } + answer.insert("created".into(), Value::Object(created)); + } + if let Some(update) = args.get("update").and_then(Value::as_object) { + let mut updated = Map::new(); + for (id, patch) in update { + let mut singletons = self.singletons.borrow_mut(); + let mut objects = self.objects.borrow_mut(); + let target = if id == "singleton" { + singletons.entry(object.into()).or_default() + } else { + objects + .get_mut(object) + .and_then(|l| l.iter_mut().find(|o| o["id"] == json!(id))) + .unwrap() + }; + for (path, value) in patch.as_object().unwrap() { + let mut parts = path.split('/'); + let first = parts.next().unwrap(); + match parts.next() { + None => { + target.insert(first.into(), value.clone()); + } + Some(second) => { + target.entry(first).or_insert(json!({}))[second] = value.clone(); + } + } + } + updated.insert(id.clone(), Value::Null); + } + answer.insert("updated".into(), Value::Object(updated)); + } + if let Some(destroy) = args.get("destroy").and_then(Value::as_array) { + let mut objects = self.objects.borrow_mut(); + if let Some(list) = objects.get_mut(object) { + list.retain(|o| !destroy.contains(&o["id"])); + } + answer.insert("destroyed".into(), Value::Array(destroy.clone())); + } + answer.insert("x:settingsReload".into(), json!({ "applied": true })); + Ok(answer) + } + + fn max_objects_in_set(&self) -> usize { + 2 + } + } + + fn plan_of(schema: &Schema, v: Value) -> Vec<(String, Value)> { + read_plan(schema, v).unwrap() + } + + fn run(fake: &Fake, schema: &Schema, plan: Value, options: Options) -> Outcome { + let plan = plan_of(schema, plan); + let mut a = Applier::new(fake, schema, options); + a.run(&plan).unwrap(); + a.outcome + } + + fn sample_plan() -> Value { + json!({ + "Account": [ + {"@type": "User", "@key": "alice@example.com", "name": "alice", "domainId": "@x:Domain/example.com"} + ], + "Domain": [{"name": "example.com", "isEnabled": true}, {"name": "example.net"}], + "Http": {"usePermissiveCors": true} + }) + } + + #[test] + fn references_parse_only_with_a_registry_type_and_a_key() { + assert_eq!( + parse_ref("@x:Domain/example.com"), + Some(("x:Domain", "example.com")) + ); + assert_eq!( + parse_ref("@x:WebHook/https://h/x"), + Some(("x:WebHook", "https://h/x")) + ); + assert_eq!(parse_ref("@x:Domain/"), None); + assert_eq!(parse_ref("x:Domain/a"), None); + assert_eq!(parse_ref("@alice"), None); + } + + #[test] + fn apply_creates_in_reference_order_then_does_nothing_the_second_time() { + let schema = sample(); + let fake = Fake::new(); + let first = run(&fake, &schema, sample_plan(), Options::default()); + assert_eq!((first.created, first.updated, first.failed), (3, 1, 0)); + let calls = fake.calls.borrow(); + assert_eq!( + calls[0].0, "x:Domain", + "domains before the accounts that need them" + ); + let account = &fake.objects.borrow()["x:Account"][0]; + let domain_id = fake.objects.borrow()["x:Domain"][0]["id"].clone(); + assert_eq!(account["domainId"], domain_id); + drop(calls); + + let second = run(&fake, &schema, sample_plan(), Options::default()); + assert_eq!( + (second.created, second.updated, second.unchanged, second.failed), + (0, 0, 4, 0) + ); + } + + #[test] + fn an_object_can_refer_to_one_of_its_type_created_in_the_same_run() { + let schema = sample(); + let fake = Fake::new(); + let plan = json!({ + "Domain": [{"name": "example.com"}], + "Account": [ + {"@type": "User", "@key": "carol@example.com", "name": "carol", + "domainId": "@x:Domain/example.com", "memberGroupIds": ["@x:Account/staff@example.com"]}, + {"@type": "Group", "@key": "staff@example.com", "name": "staff", + "domainId": "@x:Domain/example.com"} + ] + }); + let out = run(&fake, &schema, plan.clone(), Options::default()); + assert_eq!((out.created, out.failed), (3, 0)); + let objects = fake.objects.borrow(); + let staff = objects["x:Account"] + .iter() + .find(|a| a["name"] == "staff") + .unwrap()["id"] + .clone(); + let carol = objects["x:Account"] + .iter() + .find(|a| a["name"] == "carol") + .unwrap(); + assert_eq!(carol["memberGroupIds"], json!({ staff.as_str().unwrap(): true })); + drop(objects); + let again = run(&fake, &schema, plan, Options::default()); + assert_eq!((again.created, again.updated, again.unchanged), (0, 0, 3)); + } + + #[test] + fn a_dry_run_writes_nothing_and_still_resolves_new_references() { + let schema = sample(); + let fake = Fake::new(); + let out = run( + &fake, + &schema, + sample_plan(), + Options { + dry_run: true, + ..Default::default() + }, + ); + assert_eq!((out.created, out.failed), (3, 0)); + assert!(fake.calls.borrow().is_empty()); + } + + #[test] + fn only_changed_properties_are_sent_and_immutable_ones_refuse() { + let schema = sample(); + let fake = Fake::new(); + fake.insert("x:Domain", json!({"name": "example.com", "isEnabled": false})); + let out = run( + &fake, + &schema, + json!({"Domain": [{"name": "example.com", "isEnabled": true}]}), + Options::default(), + ); + assert_eq!(out.updated, 1); + let calls = fake.calls.borrow(); + assert_eq!(calls[0].1["update"]["i1"], json!({"isEnabled": true})); + } + + #[test] + fn a_missing_reference_fails_that_object_only() { + let schema = sample(); + let fake = Fake::new(); + let out = run( + &fake, + &schema, + json!({ + "Domain": [{"name": "example.com"}], + "Account": [{"@type": "User", "@key": "bob@nowhere.test", "name": "bob", "domainId": "@x:Domain/nowhere.test"}] + }), + Options::default(), + ); + assert_eq!((out.created, out.failed), (1, 1)); + } + + #[test] + fn prune_removes_what_the_plan_leaves_out() { + let schema = sample(); + let fake = Fake::new(); + fake.insert("x:Domain", json!({"name": "keep.com"})); + fake.insert("x:Domain", json!({"name": "old.com"})); + let out = run( + &fake, + &schema, + json!({"Domain": [{"name": "keep.com"}]}), + Options { + prune: true, + ..Default::default() + }, + ); + assert_eq!((out.destroyed, out.unchanged), (1, 1)); + assert_eq!(fake.objects.borrow()["x:Domain"].len(), 1); + } + + #[test] + fn snapshot_writes_references_by_key_and_drops_what_the_server_sets() { + let schema = sample(); + let fake = Fake::new(); + let d = fake.insert( + "x:Domain", + json!({"name": "example.com", "createdAt": "2026-01-01T00:00:00Z"}), + ); + fake.insert( + "x:Account", + json!({"@type": "User", "name": "alice", "domainId": d, "secret": "s"}), + ); + let types = vec!["x:Domain".to_owned(), "x:Account".to_owned()]; + let (plan, warnings) = snapshot(&fake, &schema, &types).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(plan["x:Domain"], json!([{"name": "example.com"}])); + assert_eq!( + plan["x:Account"], + json!([{"@type": "User", "name": "alice", "domainId": "@x:Domain/example.com", "@key": "alice@example.com"}]) + ); + + // Applied back to the same server, a snapshot changes nothing. + let out = run(&fake, &schema, plan, Options::default()); + assert_eq!((out.created, out.updated, out.failed), (0, 0, 0)); + } + + #[test] + fn keys_that_repeat_across_namespaces_stay_apart() { + let schema = sample(); + let fake = Fake::new(); + fake.insert("x:LookupKey", json!({"namespace": "trusted", "key": "li.ru"})); + fake.insert("x:LookupKey", json!({"namespace": "redirectors", "key": "li.ru"})); + let plan = json!({"LookupKey": [ + {"namespace": "trusted", "key": "li.ru"}, + {"namespace": "redirectors", "key": "li.ru"} + ]}); + let out = run(&fake, &schema, plan, Options::default()); + assert_eq!((out.created, out.updated, out.unchanged), (0, 0, 2)); + assert_eq!( + find_id(&fake, &schema, "x:LookupKey", "redirectors/li.ru").unwrap(), + "i2" + ); + } + + #[test] + fn nested_objects_are_patched_property_by_property() { + let mut patch = Map::new(); + diff_value( + "limits", + &json!({"a": 1, "b": 2}), + &json!({"a": 1, "b": 3, "c": 4}), + &mut patch, + ); + assert_eq!(patch, json!({"limits/b": 2}).as_object().unwrap().clone()); + let mut patch = Map::new(); + diff_value( + "t", + &json!({"@type": "B", "x": 1}), + &json!({"@type": "A", "x": 1}), + &mut patch, + ); + assert_eq!( + patch, + json!({"t": {"@type": "B", "x": 1}}).as_object().unwrap().clone() + ); + } + + #[test] + fn sets_compare_without_order_or_shape() { + assert!(same_set(&json!(["a", "b"]), &json!({"b": true, "a": true}))); + assert!(!same_set(&json!(["a"]), &json!({"a": true, "b": true}))); + } +} diff --git a/src/reload.rs b/src/reload.rs new file mode 100644 index 0000000..d79ced9 --- /dev/null +++ b/src/reload.rs @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Getting saved settings applied on the running server. +//! +//! A registry write is stored at once, but most settings take effect only +//! when the server rebuilds its configuration from the registry, which the +//! x:Action reloads do. A current server reloads after every write that needs +//! it and says how that went in the set response's `x:settingsReload`; an +//! older one doesn't, and needs the reload sent. This follows the console's +//! table of which types need which reload. + +use serde_json::{Map, Value, json}; + +use crate::client::{Client, set_error}; +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Action { + // In the order they run when more than one is due. + LookupStores, + TlsCertificates, + BlockedIps, + Settings, +} + +impl Action { + pub fn name(self) -> &'static str { + match self { + Action::LookupStores => "ReloadLookupStores", + Action::TlsCertificates => "ReloadTlsCertificates", + Action::BlockedIps => "ReloadBlockedIps", + Action::Settings => "ReloadSettings", + } + } + + pub fn from_word(word: &str) -> Option { + match word { + "settings" => Some(Action::Settings), + "tls" | "certificates" => Some(Action::TlsCertificates), + "lookups" => Some(Action::LookupStores), + "blocked-ips" => Some(Action::BlockedIps), + _ => None, + } + } +} + +/// Types the server keeps current without a reload, or that take effect only +/// on a restart (the stores), or that aren't settings at all. +const NOTHING_TO_APPLY: &[&str] = &[ + "Directory", + "Authentication", + "Account", + "AccountPassword", + "AccountSettings", + "Alert", + "ApiKey", + "AppPassword", + "DnsServer", + "Domain", + "DkimSignature", + "Enterprise", + "MailingList", + "MaskedEmail", + "OAuthClient", + "PublicKey", + "Role", + "SpamLlm", + "Tenant", + "Action", + "ArchivedItem", + "ArfExternalReport", + "Bootstrap", + "ClusterNode", + "DmarcExternalReport", + "DmarcInternalReport", + "Log", + "Metric", + "QueuedMessage", + "SpamTrainingSample", + "Task", + "TlsExternalReport", + "TlsInternalReport", + "Trace", + "BlobStore", + "Coordinator", + "DataStore", + "InMemoryStore", + "MetricsStore", + "SearchStore", + "TracingStore", + "Application", +]; + +/// Types a store needs a restart for, which a reload never applies. +pub const RESTART_TYPES: &[&str] = &[ + "x:BlobStore", + "x:Coordinator", + "x:DataStore", + "x:InMemoryStore", + "x:MetricsStore", + "x:SearchStore", + "x:TracingStore", +]; + +/// The reload that applies a write to `object`, or None when none is needed. +pub fn action_for(object: &str) -> Option { + let name = object.strip_prefix("x:")?; + if NOTHING_TO_APPLY.contains(&name) { + return None; + } + Some(match name { + "Certificate" => Action::TlsCertificates, + "StoreLookup" | "HttpLookup" | "MemoryLookupKey" | "MemoryLookupKeyValue" => Action::LookupStores, + "BlockedIp" => Action::BlockedIps, + _ => Action::Settings, + }) +} + +/// What a set response says about applying its writes, when it says anything. +pub fn server_reload(set_response: &Map) -> Option<(bool, Option)> { + let reload = set_response.get("x:settingsReload")?; + let applied = reload.get("applied")?.as_bool()?; + let description = reload + .get("description") + .and_then(Value::as_str) + .filter(|d| !d.is_empty()) + .map(str::to_owned); + Some((applied, description)) +} + +/// Tracks the writes of one run and applies what the server didn't. +#[derive(Default)] +pub struct Tracker { + /// Reloads still to send, for writes the server didn't say it applied. + pending: Vec, + /// Reloads the server tried and couldn't apply, with its reason. + pub failures: Vec, + /// Store types that were written, which need a restart. + pub restart: Vec, +} + +impl Tracker { + /// Notes a set response for `object` that changed something. + pub fn record(&mut self, object: &str, response: &Map) { + if RESTART_TYPES.contains(&object) && !self.restart.iter().any(|t| t == object) { + self.restart.push(object.to_owned()); + } + match server_reload(response) { + // x:AllowedIp's answer covers the blocked-IP reload only; allowed + // addresses are rebuilt by a full one. + Some((true, _)) if object != "x:AllowedIp" => {} + Some((false, description)) => self.failures.push(format!( + "{object} was saved but not applied: {}", + description.unwrap_or_else(|| "the server gave no reason".into()) + )), + _ => { + if let Some(action) = action_for(object) + && !self.pending.contains(&action) + { + self.pending.push(action); + } + } + } + } + + /// Sends the reloads still due. Returns what was sent. + pub fn apply(&mut self, client: &Client) -> Result> { + let mut actions = std::mem::take(&mut self.pending); + actions.sort(); + if actions.is_empty() { + return Ok(actions); + } + if let Err(e) = send(client, &actions) { + self.failures.push(e.to_string()); + } + Ok(actions) + } +} + +/// Runs reload actions on the server, in order. +pub fn send(client: &Client, actions: &[Action]) -> Result<()> { + let mut create = Map::new(); + for (i, action) in actions.iter().enumerate() { + create.insert(format!("r{i}"), json!({ "@type": action.name() })); + } + let answer = client.call("x:Action/set", json!({ "create": create }))?; + let not_created = answer.get("notCreated").and_then(Value::as_object); + let mut problems = Vec::new(); + for (i, action) in actions.iter().enumerate() { + let key = format!("r{i}"); + let created = answer + .get("created") + .and_then(Value::as_object) + .is_some_and(|c| c.contains_key(&key)); + if created { + continue; + } + let why = not_created + .and_then(|n| n.get(&key)) + .map(set_error) + .unwrap_or_else(|| "the server didn't confirm it".into()); + problems.push(format!("{} failed: {why}", action.name())); + } + if problems.is_empty() { + Ok(()) + } else { + Err(Error::Partial(problems.join("; "))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_type_gets_the_reload_that_applies_it() { + assert_eq!(action_for("x:Http"), Some(Action::Settings)); + assert_eq!(action_for("x:Certificate"), Some(Action::TlsCertificates)); + assert_eq!(action_for("x:HttpLookup"), Some(Action::LookupStores)); + assert_eq!(action_for("x:BlockedIp"), Some(Action::BlockedIps)); + assert_eq!(action_for("x:Domain"), None); + assert_eq!(action_for("x:BlobStore"), None); + assert_eq!(action_for("Mailbox"), None); + } + + #[test] + fn a_server_that_applied_needs_nothing_sent() { + let mut t = Tracker::default(); + let applied = json!({"x:settingsReload": {"applied": true}}); + t.record("x:Http", applied.as_object().unwrap()); + assert!(t.pending.is_empty() && t.failures.is_empty()); + } + + #[test] + fn a_server_that_says_nothing_gets_the_reload_sent() { + let mut t = Tracker::default(); + t.record("x:Http", &Map::new()); + t.record("x:MtaRoute", &Map::new()); + t.record("x:Certificate", &Map::new()); + assert_eq!(t.pending, vec![Action::Settings, Action::TlsCertificates]); + } + + #[test] + fn a_failed_reload_is_reported_with_its_reason() { + let mut t = Tracker::default(); + let failed = json!({"x:settingsReload": {"applied": false, "description": "bad route"}}); + t.record("x:MtaRoute", failed.as_object().unwrap()); + assert_eq!( + t.failures, + vec!["x:MtaRoute was saved but not applied: bad route"] + ); + } + + #[test] + fn stores_need_a_restart() { + let mut t = Tracker::default(); + t.record("x:BlobStore", &Map::new()); + assert_eq!(t.restart, vec!["x:BlobStore"]); + assert!(t.pending.is_empty()); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..a01cf62 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,494 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The server's description of its own registry, as `/api/schema` serves it: +//! which types exist, whether each is a collection or a singleton, the +//! properties of each (or of each variant, for a type with several), and how +//! the console lists them. + +use serde_json::{Map, Value}; + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Any number of objects, each with its own id. + Object, + /// Exactly one, with the id `singleton`. + Singleton, +} + +/// A property that holds the id of another registry object, or a set of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reference { + pub property: String, + pub target: String, + pub many: bool, +} + +/// How a property may be written. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Update { + Mutable, + /// Set when the object is created, never changed after. + Immutable, + /// The server's to set; never written. + ServerSet, +} + +pub struct Schema { + root: Value, +} + +impl Schema { + pub fn new(root: Value) -> Result { + for key in ["objects", "schemas", "fields"] { + if !root.get(key).is_some_and(Value::is_object) { + return Err(Error::Server(format!("the server's schema has no {key}"))); + } + } + Ok(Schema { root }) + } + + fn section(&self, key: &str) -> &Map { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + self.root + .get(key) + .and_then(Value::as_object) + .unwrap_or_else(|| EMPTY.get_or_init(Map::new)) + } + + /// The registry types, collections and singletons, in name order. + pub fn registry_types(&self) -> Vec<&str> { + let mut names: Vec<&str> = self + .section("objects") + .iter() + .filter(|(name, obj)| { + name.starts_with("x:") + && matches!( + obj.get("type").and_then(Value::as_str), + Some("object" | "singleton") + ) + }) + .map(|(name, _)| name.as_str()) + .collect(); + names.sort_unstable(); + names + } + + /// The registry type a name on the command line means: `Domain`, + /// `x:Domain` and `domain` are all `x:Domain`; a console view such as + /// `x:Account/User` is the type it shows. + pub fn resolve(&self, name: &str) -> Result { + let objects = self.section("objects"); + let wanted = if name.starts_with("x:") { + name.to_owned() + } else { + format!("x:{name}") + }; + let found = objects + .get_key_value(&wanted) + .map(|(k, _)| k.clone()) + .or_else(|| objects.keys().find(|k| k.eq_ignore_ascii_case(&wanted)).cloned()) + .ok_or_else(|| Error::Usage(format!("no registry type {name}: `inbuxa-cli types` lists them")))?; + match objects[&found].get("type").and_then(Value::as_str) { + Some("view") => objects[&found] + .get("objectName") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| Error::Usage(format!("{name} is a view of no type"))), + Some("object" | "singleton") => Ok(found), + _ => Err(Error::Usage(format!("{name} isn't a registry type"))), + } + } + + pub fn kind(&self, name: &str) -> Kind { + match self + .section("objects") + .get(name) + .and_then(|o| o.get("type")) + .and_then(Value::as_str) + { + Some("singleton") => Kind::Singleton, + _ => Kind::Object, + } + } + + pub fn description(&self, name: &str) -> &str { + self.section("objects") + .get(name) + .and_then(|o| o.get("description")) + .and_then(Value::as_str) + .unwrap_or("") + } + + /// For a type with variants (`@type`), each one's name, label and the + /// schema that holds its properties. + pub fn variants(&self, name: &str) -> Vec<(String, String, Option)> { + let Some(schema) = self.section("schemas").get(name) else { + return Vec::new(); + }; + if schema.get("type").and_then(Value::as_str) != Some("multiple") { + return Vec::new(); + } + schema + .get("variants") + .and_then(Value::as_array) + .map(|vs| { + vs.iter() + .map(|v| { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned); + ( + s("name").unwrap_or_default(), + s("label").unwrap_or_default(), + s("schemaName"), + ) + }) + .collect() + }) + .unwrap_or_default() + } + + /// The schema name that holds the properties of `object`, which for a + /// type with variants depends on its `@type`. + fn schema_name(&self, name: &str, object: Option<&Map>) -> Option { + let schema = self.section("schemas").get(name)?; + match schema.get("type").and_then(Value::as_str) { + Some("single") => schema + .get("schemaName") + .and_then(Value::as_str) + .map(str::to_owned), + Some("multiple") => { + let variant = object?.get("@type")?.as_str()?; + self.variants(name) + .into_iter() + .find(|(n, _, _)| n == variant) + .and_then(|(_, _, s)| s) + } + _ => None, + } + } + + /// The properties `object` can have, by name, with each one's spec. + pub fn properties(&self, name: &str, object: Option<&Map>) -> Option<&Map> { + let schema_name = self.schema_name(name, object)?; + self.properties_of_schema(&schema_name) + } + + pub fn properties_of_schema(&self, schema_name: &str) -> Option<&Map> { + self.section("fields") + .get(schema_name)? + .get("properties")? + .as_object() + } + + pub fn update_rule(spec: &Value) -> Update { + match spec.get("update").and_then(Value::as_str) { + Some("serverSet") => Update::ServerSet, + Some("immutable") => Update::Immutable, + _ => Update::Mutable, + } + } + + /// Whether a property holds a secret anywhere inside it: a password in an + /// account's credentials, a key in a DNS provider's settings. The server + /// masks these, so they can't be read back or compared. + pub fn holds_secret(&self, spec: &Value) -> bool { + self.type_holds_secret(spec.get("type").unwrap_or(&Value::Null), 0) + } + + fn type_holds_secret(&self, t: &Value, depth: usize) -> bool { + if matches!( + t.get("format").and_then(Value::as_str), + Some("secret" | "secretText") + ) { + return true; + } + if depth > 8 { + return false; + } + if let Some(class) = t.get("class") { + return self.type_holds_secret(class, depth + 1); + } + let Some(name) = t.get("objectName").and_then(Value::as_str) else { + return false; + }; + // A nested object's own type, never a registry reference. + if t.get("type").and_then(Value::as_str) == Some("objectId") { + return false; + } + let mut schemas: Vec = self.schema_name(name, None).into_iter().collect(); + schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s)); + schemas + .iter() + .filter_map(|s| self.properties_of_schema(s)) + .any(|props| { + props + .values() + .any(|p| self.type_holds_secret(p.get("type").unwrap_or(&Value::Null), depth + 1)) + }) + } + + /// The properties of `object` that refer to other registry objects. + pub fn references(&self, name: &str, object: Option<&Map>) -> Vec { + let Some(props) = self.properties(name, object) else { + return Vec::new(); + }; + let mut refs: Vec = props + .iter() + .filter_map(|(prop, spec)| { + let t = spec.get("type")?; + let (id_type, many) = match t.get("type").and_then(Value::as_str)? { + "objectId" => (t, false), + "set" | "list" => (t.get("class")?, true), + _ => return None, + }; + if id_type.get("type").and_then(Value::as_str) != Some("objectId") { + return None; + } + let target = id_type.get("objectName")?.as_str()?; + // A view (x:Account/Group) refers to the type it shows. + let target = target.split('/').next()?; + target.starts_with("x:").then(|| Reference { + property: prop.clone(), + target: target.to_owned(), + many, + }) + }) + .collect(); + refs.sort_by(|a, b| a.property.cmp(&b.property)); + refs + } + + /// Every registry type any variant of `name` refers to. + pub fn referenced_types(&self, name: &str) -> Vec { + let mut schemas: Vec = Vec::new(); + if let Some(s) = self.schema_name(name, None) { + schemas.push(s); + } + schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s)); + let mut targets: Vec = Vec::new(); + for schema in schemas { + let Some(props) = self.properties_of_schema(&schema) else { + continue; + }; + for spec in props.values() { + let Some(t) = spec.get("type") else { continue }; + let id_type = match t.get("type").and_then(Value::as_str) { + Some("objectId") => Some(t), + Some("set" | "list") => t.get("class"), + _ => None, + }; + if let Some(target) = id_type + .filter(|i| i.get("type").and_then(Value::as_str) == Some("objectId")) + .and_then(|i| i.get("objectName")) + .and_then(Value::as_str) + .and_then(|t| t.split('/').next()) + .filter(|t| t.starts_with("x:")) + && !targets.iter().any(|t| t == target) + { + targets.push(target.to_owned()); + } + } + } + targets + } + + /// The list the console shows for `name`: its own, or the first of its + /// views'. + fn list(&self, name: &str) -> Option<&Map> { + let lists = self.section("lists"); + lists.get(name).and_then(Value::as_object).or_else(|| { + let prefix = format!("{name}/"); + let mut views: Vec<(&String, &Value)> = + lists.iter().filter(|(k, _)| k.starts_with(&prefix)).collect(); + views.sort_by(|a, b| a.0.cmp(b.0)); + views.first().and_then(|(_, v)| v.as_object()) + }) + } + + /// The property that tells one object of a type from another, the way a + /// person would: a domain's name, an account's address. None for a type + /// whose objects have nothing of the kind, such as tracers. + pub fn key_property(&self, name: &str) -> Option { + let list = self.list(name)?; + if let Some(label) = list.get("labelProperty").and_then(Value::as_str) { + return Some(label.to_owned()); + } + list.get("columns") + .and_then(Value::as_array)? + .first()? + .get("name")? + .as_str() + .filter(|n| !n.starts_with('@')) + .map(str::to_owned) + } + + /// A property that scopes the key, for a type whose keys repeat from one + /// namespace to the next (lookup keys: `li.ru` is both a trusted domain + /// and a URL redirector). + pub fn key_qualifier(&self, name: &str) -> Option { + let key = self.key_property(name)?; + let mut schemas: Vec = self.schema_name(name, None).into_iter().collect(); + schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s)); + schemas + .iter() + .filter_map(|s| self.properties_of_schema(s)) + .any(|props| props.contains_key("namespace")) + .then(|| "namespace".to_owned()) + .filter(|q| *q != key) + } + + /// The columns the console shows for a type, by property name. + pub fn columns(&self, name: &str) -> Vec { + self.list(name) + .and_then(|l| l.get("columns")) + .and_then(Value::as_array) + .map(|cols| { + cols.iter() + .filter_map(|c| c.get("name").and_then(Value::as_str).map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use serde_json::json; + + pub fn sample() -> Schema { + Schema::new(json!({ + "objects": { + "x:Domain": {"type": "object", "description": "A domain."}, + "x:Account": {"type": "object"}, + "x:Account/User": {"type": "view", "objectName": "x:Account"}, + "x:Http": {"type": "singleton"}, + "x:Tracer": {"type": "object"}, + "x:LookupKey": {"type": "object"}, + "Mailbox": {"type": "object"} + }, + "schemas": { + "x:Domain": {"type": "single", "schemaName": "x:Domain"}, + "x:Http": {"type": "single", "schemaName": "x:Http"}, + "x:Account": {"type": "multiple", "variants": [ + {"name": "User", "label": "User account", "schemaName": "x:UserAccount"}, + {"name": "Group", "label": "Group account", "schemaName": "x:GroupAccount"} + ]}, + "x:LookupKey": {"type": "single", "schemaName": "x:LookupKey"}, + "x:Credential": {"type": "multiple", "variants": [ + {"name": "Password", "label": "Password", "schemaName": "x:PasswordCredential"} + ]}, + "x:Tracer": {"type": "multiple", "variants": [ + {"name": "Stdout", "label": "Console", "schemaName": "x:TracerStdout"} + ]} + }, + "fields": { + "x:Domain": {"properties": { + "name": {"type": {"type": "string"}, "update": "immutable"}, + "isEnabled": {"type": {"type": "boolean"}, "update": "mutable"}, + "createdAt": {"type": {"type": "utcDateTime"}, "update": "serverSet"} + }}, + "x:Http": {"properties": {"usePermissiveCors": {"type": {"type": "boolean"}}}}, + "x:UserAccount": {"properties": { + "name": {"type": {"type": "string"}, "update": "mutable"}, + "domainId": {"type": {"type": "objectId", "objectName": "x:Domain"}}, + "memberGroupIds": {"type": {"type": "set", "class": {"type": "objectId", "objectName": "x:Account/Group"}}}, + "secret": {"type": {"type": "string", "format": "secret"}}, + "credentials": {"type": {"type": "objectList", "objectName": "x:Credential"}}, + "emailAddress": {"type": {"type": "string"}, "update": "serverSet"} + }}, + "x:GroupAccount": {"properties": {"name": {"type": {"type": "string"}}}}, + "x:TracerStdout": {"properties": {"level": {"type": {"type": "string"}}}}, + "x:PasswordCredential": {"properties": { + "secret": {"type": {"type": "string", "format": "secret"}}, + "expiresAt": {"type": {"type": "utcDateTime"}} + }}, + "x:LookupKey": {"properties": { + "namespace": {"type": {"type": "string"}}, + "key": {"type": {"type": "string"}} + }} + }, + "lists": { + "x:Domain": {"labelProperty": "name", "columns": [{"name": "name"}, {"name": "isEnabled"}]}, + "x:Account/User": {"columns": [{"name": "emailAddress"}, {"name": "description"}]}, + "x:Tracer": {"columns": [{"name": "@type"}, {"name": "level"}]}, + "x:LookupKey": {"labelProperty": "key", "columns": [{"name": "namespace"}, {"name": "key"}]} + } + })) + .unwrap() + } + + #[test] + fn names_resolve_with_or_without_the_prefix_and_through_views() { + let s = sample(); + assert_eq!(s.resolve("Domain").unwrap(), "x:Domain"); + assert_eq!(s.resolve("x:domain").unwrap(), "x:Domain"); + assert_eq!(s.resolve("Account/User").unwrap(), "x:Account"); + assert!(s.resolve("Nope").is_err()); + } + + #[test] + fn registry_types_leave_out_views_and_mail_types() { + assert_eq!( + sample().registry_types(), + vec!["x:Account", "x:Domain", "x:Http", "x:LookupKey", "x:Tracer"] + ); + } + + #[test] + fn keys_come_from_the_label_or_the_first_column() { + let s = sample(); + assert_eq!(s.key_property("x:Domain").as_deref(), Some("name")); + assert_eq!(s.key_property("x:Account").as_deref(), Some("emailAddress")); + assert_eq!(s.key_property("x:Tracer"), None); + assert_eq!(s.key_property("x:Http"), None); + } + + #[test] + fn secrets_are_found_inside_nested_objects() { + let s = sample(); + let mut user = Map::new(); + user.insert("@type".into(), json!("User")); + let props = s.properties("x:Account", Some(&user)).unwrap(); + assert!(s.holds_secret(&props["credentials"])); + assert!(s.holds_secret(&props["secret"])); + assert!(!s.holds_secret(&props["domainId"])); + assert!(!s.holds_secret(&props["name"])); + } + + #[test] + fn a_namespace_scopes_the_key() { + let s = sample(); + assert_eq!(s.key_qualifier("x:LookupKey").as_deref(), Some("namespace")); + assert_eq!(s.key_qualifier("x:Domain"), None); + } + + #[test] + fn references_follow_the_variant() { + let s = sample(); + let mut user = Map::new(); + user.insert("@type".into(), json!("User")); + let refs = s.references("x:Account", Some(&user)); + assert_eq!( + refs, + vec![ + Reference { + property: "domainId".into(), + target: "x:Domain".into(), + many: false + }, + Reference { + property: "memberGroupIds".into(), + target: "x:Account".into(), + many: true + }, + ] + ); + assert_eq!(s.referenced_types("x:Account"), vec!["x:Domain", "x:Account"]); + } +} diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..204fa07 --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Secrets come from the environment or a prompt, never the command line, +//! where they would end up in the shell's history and the process list. + +use std::io::{BufRead, IsTerminal, Write}; +use std::process::{Command, Stdio}; + +use crate::error::{Error, Result}; + +/// The value of `var`, or what the person types at a prompt that doesn't +/// echo. Without a terminal to prompt on, says which variable to set. +pub fn from_env_or_prompt(var: &str, label: &str) -> Result { + if let Ok(value) = std::env::var(var) + && !value.is_empty() + { + return Ok(value); + } + if !std::io::stdin().is_terminal() { + return Err(Error::Usage(format!( + "no {label}: set {var}, or run this in a terminal to be asked for it" + ))); + } + prompt(label) +} + +fn prompt(label: &str) -> Result { + let mut stderr = std::io::stderr(); + write!(stderr, "{label}: ")?; + stderr.flush()?; + + let echo_was_off = set_echo(false); + let mut line = String::new(); + let read = std::io::stdin().lock().read_line(&mut line); + if echo_was_off { + set_echo(true); + } + writeln!(stderr)?; + read?; + + let value = line.trim_end_matches(['\r', '\n']); + if value.is_empty() { + return Err(Error::Usage(format!("no {label} given"))); + } + Ok(value.to_owned()) +} + +/// Turns terminal echo on or off with stty, and says whether it worked. +fn set_echo(on: bool) -> bool { + Command::new("stty") + .arg(if on { "echo" } else { "-echo" }) + .stdin(Stdio::inherit()) + .status() + .is_ok_and(|s| s.success()) +} diff --git a/src/values.rs b/src/values.rs new file mode 100644 index 0000000..0a1dbbb --- /dev/null +++ b/src/values.rs @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Properties given on the command line as `name=value`. + +use serde_json::{Map, Value}; + +use crate::error::{Error, Result}; + +/// `name=value` pairs as a JSON object. A value that reads as JSON (`true`, +/// `42`, `null`, `["a"]`, `{"@type": "Stdout"}`) is taken as JSON; anything +/// else is a string, so `name=example.com` needs no quoting. To pass a string +/// that looks like JSON, quote it: `description='"true"'`. +/// +/// A name with slashes (`limits/maxSize=10`) is a JMAP patch path, which an +/// update applies to that one nested property. +pub fn parse_pairs(pairs: &[String]) -> Result> { + let mut out = Map::new(); + for pair in pairs { + let (name, raw) = pair + .split_once('=') + .ok_or_else(|| Error::Usage(format!("{pair:?} isn't name=value")))?; + if name.is_empty() { + return Err(Error::Usage(format!("{pair:?} has no property name"))); + } + let value = serde_json::from_str::(raw).unwrap_or_else(|_| Value::String(raw.to_owned())); + out.insert(name.to_owned(), value); + } + Ok(out) +} + +/// A JSON object from a file, or from standard input for `-`. +pub fn read_object(path: &str) -> Result> { + let text = if path == "-" { + std::io::read_to_string(std::io::stdin())? + } else { + std::fs::read_to_string(path).map_err(|e| Error::Usage(format!("{path}: {e}")))? + }; + match serde_json::from_str::(&text) { + Ok(Value::Object(map)) => Ok(map), + Ok(_) => Err(Error::Usage(format!("{path} isn't a JSON object"))), + Err(e) => Err(Error::Usage(format!("{path}: {e}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn json_where_it_parses_and_strings_elsewhere() { + let m = parse_pairs(&[ + "name=example.com".into(), + "isEnabled=true".into(), + "max=10".into(), + "tags=[\"a\"]".into(), + "description=\"true\"".into(), + "empty=".into(), + ]) + .unwrap(); + assert_eq!(m["name"], json!("example.com")); + assert_eq!(m["isEnabled"], json!(true)); + assert_eq!(m["max"], json!(10)); + assert_eq!(m["tags"], json!(["a"])); + assert_eq!(m["description"], json!("true")); + assert_eq!(m["empty"], json!("")); + } + + #[test] + fn a_pair_needs_a_name_and_an_equals_sign() { + assert!(parse_pairs(&["name".into()]).is_err()); + assert!(parse_pairs(&["=x".into()]).is_err()); + } +} -- 2.54.0