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()); + } +}