From 66085936a2537b3ec3ab6eb6b7d5186a3256f551 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Sat, 2 Nov 2024 01:44:50 +0100 Subject: [PATCH] Initial commit Signed-off-by: Marc 'risson' Schmitt --- .cargo/config.toml | 2 + .clippy.toml | 3 + .envrc | 19 + .github/workflows/ci-python.yml | 214 ++++++ .github/workflows/ci-rust.yml | 120 ++++ .github/workflows/release-create.yml | 39 + .github/workflows/release-pr.yml | 40 ++ .gitignore | 7 + .rustfmt.toml | 16 + Cargo.lock | 668 ++++++++++++++++++ Cargo.toml | 41 ++ LICENSE | 22 + README.md | 102 +++ flake.lock | 210 ++++++ flake.nix | 62 ++ justfile | 145 ++++ kadmin-sys/Cargo.toml | 37 + kadmin-sys/build.rs | 43 ++ kadmin-sys/src/lib.rs | 45 ++ kadmin-sys/src/wrapper.h | 2 + kadmin/Cargo.toml | 33 + kadmin/src/context.rs | 134 ++++ kadmin/src/db_args.rs | 96 +++ kadmin/src/error.rs | 132 ++++ kadmin/src/kadmin.rs | 396 +++++++++++ kadmin/src/lib.rs | 27 + kadmin/src/params.rs | 209 ++++++ kadmin/src/principal.rs | 26 + kadmin/src/strconv.rs | 14 + kadmin/src/sync.rs | 172 +++++ kadmin/tests/k5test.rs | 114 +++ kadmin/tests/kadmin_builder.rs | 117 +++ kadmin/tests/principals.rs | 77 ++ kadmin/tests/valgrind.supp | 10 + poetry.lock | 397 +++++++++++ pyproject.toml | 81 +++ python-kadmin-rs/Cargo.toml | 23 + python-kadmin-rs/python/kadmin/__init__.py | 8 + python-kadmin-rs/python/kadmin/__init__.pyi | 52 ++ .../python/kadmin/exceptions/__init__.py | 19 + .../python/kadmin/exceptions/__init__.pyi | 7 + python-kadmin-rs/python/kadmin/py.typed | 0 .../python/kadmin_local/__init__.py | 8 + .../python/kadmin_local/__init__.pyi | 28 + .../kadmin_local/exceptions/__init__.py | 19 + .../kadmin_local/exceptions/__init__.pyi | 7 + python-kadmin-rs/python/kadmin_local/py.typed | 0 python-kadmin-rs/src/lib.rs | 294 ++++++++ python-kadmin-rs/tests/__init__.py | 0 python-kadmin-rs/tests/test_init.py | 36 + python-kadmin-rs/tests/test_principal.py | 26 + python-kadmin-rs/tests/utils.py | 31 + rust-toolchain.toml | 3 + 53 files changed, 4433 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .clippy.toml create mode 100644 .envrc create mode 100644 .github/workflows/ci-python.yml create mode 100644 .github/workflows/ci-rust.yml create mode 100644 .github/workflows/release-create.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile create mode 100644 kadmin-sys/Cargo.toml create mode 100644 kadmin-sys/build.rs create mode 100644 kadmin-sys/src/lib.rs create mode 100644 kadmin-sys/src/wrapper.h create mode 100644 kadmin/Cargo.toml create mode 100644 kadmin/src/context.rs create mode 100644 kadmin/src/db_args.rs create mode 100644 kadmin/src/error.rs create mode 100644 kadmin/src/kadmin.rs create mode 100644 kadmin/src/lib.rs create mode 100644 kadmin/src/params.rs create mode 100644 kadmin/src/principal.rs create mode 100644 kadmin/src/strconv.rs create mode 100644 kadmin/src/sync.rs create mode 100644 kadmin/tests/k5test.rs create mode 100644 kadmin/tests/kadmin_builder.rs create mode 100644 kadmin/tests/principals.rs create mode 100644 kadmin/tests/valgrind.supp create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 python-kadmin-rs/Cargo.toml create mode 100644 python-kadmin-rs/python/kadmin/__init__.py create mode 100644 python-kadmin-rs/python/kadmin/__init__.pyi create mode 100644 python-kadmin-rs/python/kadmin/exceptions/__init__.py create mode 100644 python-kadmin-rs/python/kadmin/exceptions/__init__.pyi create mode 100644 python-kadmin-rs/python/kadmin/py.typed create mode 100644 python-kadmin-rs/python/kadmin_local/__init__.py create mode 100644 python-kadmin-rs/python/kadmin_local/__init__.pyi create mode 100644 python-kadmin-rs/python/kadmin_local/exceptions/__init__.py create mode 100644 python-kadmin-rs/python/kadmin_local/exceptions/__init__.pyi create mode 100644 python-kadmin-rs/python/kadmin_local/py.typed create mode 100644 python-kadmin-rs/src/lib.rs create mode 100644 python-kadmin-rs/tests/__init__.py create mode 100644 python-kadmin-rs/tests/test_init.py create mode 100644 python-kadmin-rs/tests/test_principal.py create mode 100644 python-kadmin-rs/tests/utils.py create mode 100644 rust-toolchain.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b2c9bde --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[doc.extern-map.registries] +crates-io = "https://docs.rs/" diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..ce833e6 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,3 @@ +avoid-breaking-exported-api = true +disallowed-types = ["std::collections::HashMap", "std::collections::HashSet"] +msrv = "1.77" diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..7f7290a --- /dev/null +++ b/.envrc @@ -0,0 +1,19 @@ +layout_poetry() { + if [[ ! -f pyproject.toml ]]; then + log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.' + exit 2 + fi + + local VENV=$(poetry run poetry env info --path) + if [[ -z $VENV || ! -d $VENV/bin ]]; then + log_error 'No poetry virtual environment found. Use `poetry install` to create one first.' + exit 2 + fi + + export VIRTUAL_ENV=$VENV + export POETRY_ACTIVE=1 + PATH_add "$VENV/bin" +} + +use flake +layout_poetry diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..08d1908 --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,214 @@ +--- +name: Python CI + +"on": + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - main + +env: + DEBIAN_FRONTEND: noninteractive + +jobs: + lint: + strategy: + fail-fast: false + matrix: + job: + - black + - mypy + - ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pipx install poetry || true + - uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + cache: "poetry" + - run: poetry install --only=dev + - uses: taiki-e/install-action@v2 + with: + tool: just + - run: poetry run just ci-lint-${{ matrix.job }} + build: + strategy: + fail-fast: false + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: pipx install poetry || true + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "poetry" + - run: poetry install --only=dev + - uses: taiki-e/install-action@v2 + with: + tool: just + - run: poetry run just ci-build-python + - uses: actions/upload-artifact@v4 + with: + name: python-${{ matrix.python-version }}-build + path: dist + test: + needs: build + strategy: + fail-fast: false + matrix: + job: + - mit + # Several issues in k5test preventing us from running kadmind with it currently + # - h5l + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + runs-on: ubuntu-latest + env: + KRB5_TRACE: /dev/stderr + steps: + - uses: actions/checkout@v4 + - shell: bash + run: | + pipx install poetry || true + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "poetry" + - run: poetry install --only=test + - uses: taiki-e/install-action@v2 + with: + tool: just + - uses: actions/download-artifact@v4 + with: + name: python-${{ matrix.python-version }}-build + path: dist + - run: | + PATH="/usr/lib/heimdal-servers:$PATH̀ˆ" poetry run just ci-test-python-${{ matrix.job }} + check: + if: always() + needs: + - lint + - build + - test + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + build-sdist: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pipx install poetry || true + - uses: actions/setup-python@v5 + with: + python-version-file: pyproject.toml + cache: "poetry" + - run: poetry install --only=dev + - uses: taiki-e/install-action@v2 + with: + tool: just + - run: poetry run just ci-build-python-sdist + - uses: actions/upload-artifact@v4 + with: + name: python-cibw-sdist + path: dist/*.tar.gz + build-wheels-matrix: + # needs: check + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: pipx install poetry || true + - uses: actions/setup-python@v5 + with: + python-version-file: pyproject.toml + cache: "poetry" + - run: poetry install --only=dev + - id: set-matrix + name: compute matrix + run: | + MATRIX="$( + { + poetry run cibuildwheel --print-build-identifiers --platform linux --archs x86_64,aarch64,ppc64le,s390x \ + | sed 's/.*/{"cibw-only": "&", "os": "ubuntu-latest"}/' \ + && poetry run cibuildwheel --print-build-identifiers --platform macos --archs x86_64 \ + | sed 's/.*/{"cibw-only": "&", "os": "macos-13" }/' \ + && poetry run cibuildwheel --print-build-identifiers --platform macos --archs arm64 \ + | sed 's/.*/{"cibw-only": "&", "os": "macos-14" }/' + } | jq --slurp --compact-output '{"include": .}' + )" + echo matrix="$MATRIX" >> "$GITHUB_OUTPUT" + build-wheels: + needs: build-wheels-matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.build-wheels-matrix.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: docker/setup-qemu-action@v3 + if: runner.os == 'Linux' + with: + platforms: all + - uses: pypa/cibuildwheel@v2.21.3 + env: + CIBW_BUILD: "${{ matrix.cibw-only }}" + CIBW_BEFORE_ALL_LINUX: "curl -sSf https://sh.rustup.rs | sh -s -- -y && yum install -y krb5-devel clang-devel" + CIBW_BEFORE_ALL_MACOS: "curl -sSf https://sh.rustup.rs | sh -s -- -y && brew install llvm krb5" + CIBW_ENVIRONMENT_LINUX: "PATH=$HOME/.cargo/bin:$PATH" + CIBW_ENVIRONMENT_MACOS: "PKG_CONFIG_PATH=/opt/homebrew/opt/krb5/lib/pkgconfig:/usr/local/opt/krb5/lib/pkgconfig MACOSX_DEPLOYMENT_TARGET=14.0" + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_PYPY_X86_64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_PPC64LE_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_S390X_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: manylinux_2_28 + - uses: actions/upload-artifact@v4 + with: + name: python-cibw-wheels-${{ matrix.os }} + path: ./wheelhouse/*.whl + release: + needs: + - check + - build-sdist + - build-wheels + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/python-kadmin-rs/version/') + steps: + - uses: actions/download-artifact@v4 + with: + pattern: python-cibw-* + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml new file mode 100644 index 0000000..b64cd20 --- /dev/null +++ b/.github/workflows/ci-rust.yml @@ -0,0 +1,120 @@ +--- +name: Rust CI + +"on": + push: + branches: + - main + pull_request: + branches: + - main + +env: + DEBIAN_FRONTEND: noninteractive + +jobs: + lint: + strategy: + fail-fast: false + matrix: + job: + - clippy + include: + - job: rustfmt + toolchain: nightly + components: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - if: ${{ matrix.toolchain }} + name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ matrix.toolchain }} + components: ${{ matrix.components }} + - if: ${{ ! matrix.toolchain }} + name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@v2 + with: + tool: just + - name: Lint + run: just ci-lint-${{ matrix.job }} + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@v2 + with: + tool: just + - name: Build + run: just ci-build-rust + test: + strategy: + fail-fast: false + matrix: + job: + - rust + - sanity + runs-on: ubuntu-latest + env: + KRB5_TRACE: /dev/stderr + steps: + - uses: actions/checkout@v4 + - name: Install poetry & deps + shell: bash + run: | + pipx install poetry || true + - name: Setup python and restore poetry + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + cache: "poetry" + - name: Install Python dependencies + run: poetry install --only=test + - name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@v2 + with: + tool: just + - name: Test + run: poetry run just ci-test-${{ matrix.job }} + check: + if: always() + needs: + - lint + - build + - test + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + release: + needs: + - check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - crate: kadmin-sys + extra_args: "--no-verify" + - crate: kadmin + extra_args: "" + if: github.event_name == 'push' && startsWith(github.ref, format('refs/tags/{0}/version/', matrix.crate)) + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@v2 + with: + tool: just + - run: just ci-build-deps + - run: cargo publish --package ${{ matrix.crate }} ${{ matrix.extra_args }} diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml new file mode 100644 index 0000000..9afda21 --- /dev/null +++ b/.github/workflows/release-create.yml @@ -0,0 +1,39 @@ +--- +name: Create release + +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + release-info: + if: github.event.pull_request.merged + outputs: + is-release: ${{ steps.meta.outputs.is-release }} + package: ${{ steps.meta.outputs.crates-name }} + version: ${{ steps.meta.outputs.version-actual }} + runs-on: ubuntu-latest + steps: + - id: meta + uses: cargo-bins/release-meta@v1 + with: + event-data: ${{ toJSON(github.event) }} + + release-create: + needs: + - info + if: needs.info.outputs.is-release == 'true' + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: softprops/action-gh-release@v2 + with: + name: "${{ needs.info.outputs.package }} ${{ needs.info.outputs.version }}" + tag_name: "${{ needs.info.outputs.package }}/version/${{ needs.info.outputs.version }}" + target_commitish: "${{ github.sha }}" + draft: true + make_latest: true diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..ef8a8c7 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,40 @@ +--- +name: Create release PR + +on: + workflow_dispatch: + inputs: + package: + description: Package to release + required: true + type: choice + options: + - kadmin-sys + - kadmin + - python-kadmin-rs + version: + description: Version to release + required: true + type: string + +jobs: + release-pr: + permissions: + id-token: write + pull-requests: write + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chainguard-dev/actions/setup-gitsign@main + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@v2 + with: + tool: just,cargo-release,cargo-workspaces + - run: just ci-build-deps + - uses: cargo-bins/release-pr@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ inputs.version }} + crate-name: ${{ inputs.package }} + pr-label: release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..705b823 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build +dist +sdist +target +*.whl +*.egg-info +__pycache__ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..0e15701 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,16 @@ +comment_width = 120 +format_code_in_doc_comments = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Lower" +imports_granularity = "Crate" +max_width = 120 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +reorder_impl_items = true +use_field_init_shorthand = true +use_try_shorthand = true +style_edition = "2024" +where_single_line = true +wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..40a5f43 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,668 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0890061c4d3223e7267f3bad2ec40b997d64faac1c2815a4a9d95018e2b9e9c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "kadmin" +version = "0.0.0" +dependencies = [ + "anyhow", + "kadmin-sys", + "pyo3", + "serial_test", + "thiserror", +] + +[[package]] +name = "kadmin-sys" +version = "0.0.0" +dependencies = [ + "bindgen", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d922163ba1f79c04bc49073ba7b32fd5a8d3b76a87c955921234b8e77333c51" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc38c5feeb496c8321091edf3d63e9a6829eab4b863b4a6a65f26f3e9cc6b179" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94845622d88ae274d2729fcefc850e63d7a3ddff5e3ce11bd88486db9f1d357d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e655aad15e09b94ffdb3ce3d217acf652e26bbc37697ef012f5e5e348c716e5e" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1e3f09eecd94618f60a455a23def79f79eba4dc561a97324bf9ac8c6df30ce" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "python-kadmin-rs" +version = "0.0.0" +dependencies = [ + "kadmin", + "pyo3", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "scc" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d25269dd3a12467afe2e510f69fb0b46b698e5afb296b59f2145259deaf8e8" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c1eeaf4b6a87c7479688c6d52b9f1153cedd3c489300564f932b065c6eab95" + +[[package]] +name = "serde" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.214" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serial_test" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" +dependencies = [ + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "2.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[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 = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5692a88 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[workspace] +members = ["kadmin-sys", "kadmin", "python-kadmin-rs"] +resolver = "2" + +[workspace.package] +authors = [ + "Marc 'risson' Schmitt ", + "authentik community ", +] +edition = "2021" +rust-version = "1.77" +readme = "README.md" +homepage = "https://github.com/authentik-community/kadmin-rs" +repository = "https://github.com/authentik-community/kadmin-rs.git" +license = "MIT" + +[workspace.lints.rust] +semicolon_in_expressions_from_macros = "warn" +unreachable_pub = "warn" +unused_import_braces = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +branches_sharing_code = "warn" +cloned_instead_of_copied = "warn" +dbg_macro = "warn" +disallowed_types = "warn" +empty_line_after_outer_attr = "warn" +exhaustive_enums = "warn" +exhaustive_structs = "warn" +inefficient_to_string = "warn" +macro_use_imports = "warn" +map_flatten = "warn" +missing_enforced_import_renames = "warn" +mut_mut = "warn" +nonstandard_macro_braces = "warn" +semicolon_if_nothing_returned = "warn" +str_to_string = "warn" +todo = "warn" +unreadable_literal = "warn" +unseparated_literal_suffix = "warn" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a49ca04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Marc 'risson' Schmitt +Copyright (c) 2024 authentik community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d96c2f4 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Rust and Python bindings for the Kerberos administration interface (kadm5) + +This repository contains both a work-in-progress safe, idiomatic Rust bindings for libkadm5, the library to administrate a Kerberos realm that supports the Kerberos administration interface (mainly Heimdal and MIT Kerberos 5), and the underlying "unsafe" bindings generated by bindgen in kadmin-sys. + +It also contains a Python API to those bindings. + +### Kerberos implementations compatibility + +These libraries will only compile against MIT krb5. However, they will allow you to communicate with an MIT krb5 KDC as well as a Heimdal KDC. In fact, these libraries are tested against both! + +## kadmin-sys + +![Crates.io Version](https://img.shields.io/crates/v/kadmin-sys) +![docs.rs](https://img.shields.io/docsrs/kadmin-sys) + +These are the raw bindings to libkadm5. This crate offers two features, `client` and `server`. You must choose one of them depending on how your application is going to interact with the KDC. By default, none are enabled and the crate will not compile. + +- `client`: links against `kadm5clnt`. Use this is you plan to remotely access the KDC, using kadmind's GSS-API RPC interface, like the CLI tool `kadmin` does. +- `server`: links against `kadm5srv`. Use this is you plan to directly edit the KDB from the machine where the KDC is running, like the CLI tool `kadmin.local` does. + +## kadmin + +![Crates.io Version](https://img.shields.io/crates/v/kadmin) +![docs.rs](https://img.shields.io/docsrs/kadmin) + +This is a safe, idiomatic Rust interface to libkadm5. This crate offers two features, `client` and `local`. They are similar to how kadmin-sys behaves. You should only enable one of them. + +With the `client` feature: + +```rust +use kadmin::KAdmin; + +let princ = "user/admin@EXAMPLE.ORG"; +let password = "vErYsEcUrE"; + +let kadmin = KAdmin::builder().with_password(&princ, &password).unwrap(); + +dbg!("{}", kadmin.list_principals("*").unwrap()); +``` + +With the `local` feature: + +```rust +use kadmin::KAdmin; + +let princ = "user/admin@EXAMPLE.ORG"; +let password = "vErYsEcUrE"; + +let kadmin = KAdmin::builder().local().unwrap(); + +dbg!("{}", kadmin.list_principals("*").unwrap()); +``` + +#### About thread safety + +As far as I can tell, libkadm5 APIs are **not** thread safe. As such, the types provided by this crate are neither `Send` nor `Sync`. You _must not_ use those with threads. You can either create a `KAdmin` instance per thread, or use the `kadmin::sync::KAdmin` interface that spawns a thread and sends the various commands to it. The API is not exactly the same as the non-thread-safe one, but should be close enough that switching between one or the other is easy enough. Read more about this in the documentation of the crate. + +## python-kadmin-rs + +![PyPI - Version](https://img.shields.io/pypi/v/python-kadmin-rs) +![Read the Docs](https://img.shields.io/readthedocs/python-kadmin-rs) + +These are Python bindings to the above Rust library, using the `kadmin::sync` interface to ensure thread safety. It provides two Python modules: `kadmin` for remote operations, and `kadmin_local` for local operations. + +With `kadmin`: + +```python +import kadmin + +princ = "user/admin@EXAMPLE.ORG" +password = "vErYsEcUrE" +kadm = kadmin.KAdmin.with_password(princ, password) +print(kadm.list_principals("*")) +``` + +With `kadmin_local`: + +```python +import kadmin + +kadm = kadmin.KAdmin.with_local() +print(kadm.list_principals("*")) +``` + +## License + +Licensed under the [MIT License](./LICENSE). + +## Contributing + +Just open a PR. + +### Releasing + +1. Go to [Actions > Create release PR](https://github.com/authentik-community/kadmin-rs/actions/workflows/release-pr.yml) +2. Click "Run workflow" and select what you need to release and input the new version. +3. Wait for the PR to be opened and the CI to pass +4. Merge the PR. +5. Go to [Releases](https://github.com/authentik-community/kadmin-rs/releases) +6. Edit the created release. +7. Click "Generate release notes" +8. Publish diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a670756 --- /dev/null +++ b/flake.lock @@ -0,0 +1,210 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "futils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1729742964, + "narHash": "sha256-B4mzTcQ0FZHdpeWcpDYPERtyjJd/NIuaQ9+BV1h+MpA=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "e04df33f62cdcf93d73e9a04142464753a16db67", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1730272153, + "narHash": "sha256-B5WRZYsRlJgwVHIV6DvidFN7VX7Fg9uuwkRW9Ha8z+w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2d2a9ddbe3f2c00747398f3dc9b05f7f2ebb0f53", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1728538411, + "narHash": "sha256-f0SBJz1eZ2yOuKUr5CA9BHULGXVSn6miBuUWdTyhUhU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "poetry2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nix-github-actions": "nix-github-actions", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems_3", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1730284601, + "narHash": "sha256-eHYcKVLIRRv3J1vjmxurS6HVdGphB53qxUeAkylYrZY=", + "owner": "nix-community", + "repo": "poetry2nix", + "rev": "43a898b4d76f7f3f70df77a2cc2d40096bc9d75e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "poetry2nix", + "type": "github" + } + }, + "root": { + "inputs": { + "futils": "futils", + "nixpkgs": "nixpkgs", + "poetry2nix": "poetry2nix", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1730687492, + "narHash": "sha256-xQVadjquBA/tFxDt5A55LJ1D1AvkVWsnrKC2o+pr8F4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "41814763a2c597755b0755dbe3e721367a5e420f", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "id": "systems", + "type": "indirect" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1730120726, + "narHash": "sha256-LqHYIxMrl/1p3/kvm2ir925tZ8DkI0KA10djk8wecSk=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "9ef337e492a5555d8e17a51c911ff1f02635be15", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c7516fd --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + poetry2nix = { + url = "github:nix-community/poetry2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + futils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + rust-overlay, + poetry2nix, + futils, + } @ inputs: let + inherit (nixpkgs) lib; + inherit (futils.lib) eachDefaultSystem defaultSystems; + + nixpkgsFor = lib.genAttrs defaultSystems (system: + import nixpkgs { + inherit system; + overlays = [ + rust-overlay.overlays.default + poetry2nix.overlays.default + ]; + }); + in + eachDefaultSystem (system: let + pkgs = nixpkgsFor.${system}; + in { + devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + (lib.hiPrio rust-bin.nightly.latest.rustfmt) + (rust-bin.fromRustupToolchainFile ./rust-toolchain.toml) + + poetry + python3Full + + clang + glibc + krb5.dev + krb5.out + libclang + openssl + pkg-config + + cargo-msrv + cargo-release + git + just + valgrind + ]; + + RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + RUST_BACKTRACE = 1; + LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; + }; + }); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..386e501 --- /dev/null +++ b/justfile @@ -0,0 +1,145 @@ +# List available commands +default: + just --list + +# Auto format code +lint-fix: + cargo fmt + black python-kadmin-rs + ruff check --fix python-kadmin-rs +[private] +ci-lint-rustfmt: + cargo fmt --check +[private] +ci-lint-black: + black --check python-kadmin-rs +[private] +ci-lint-ruff: + ruff check python-kadmin-rs + +# Lint code +lint-rust: + cd kadmin-sys && cargo clippy --features client + cd kadmin-sys && cargo clippy --features server + cd kadmin && cargo clippy + cd kadmin && cargo clippy --no-default-features --features local + cd python-kadmin-rs && cargo clippy + cd python-kadmin-rs && cargo clippy --no-default-features --features local +[private] +ci-lint-clippy: ci-build-deps + RUSTFLAGS="-Dwarnings" just lint-rust + +# Mypy types checking +lint-mypy: install-python + stubtest kadmin kadmin_local +[private] +ci-lint-mypy: ci-build-deps lint-mypy + +alias l := lint +# Lint and auto format +lint: lint-fix lint-rust + +alias la := lint-all +# Common lint plus mypy types checking +lint-all: lint lint-mypy + +alias b := build-rust +# Build all rust crates +build-rust: + cd kadmin-sys && cargo build --features client + cd kadmin-sys && cargo build --features server + cd kadmin && cargo build + cd kadmin && cargo build --no-default-features --features local + cd python-kadmin-rs && cargo build + cd python-kadmin-rs && cargo build --no-default-features --features local +[private] +ci-build-deps: + sudo apt-get update + sudo apt-get install -y --no-install-recommends libkrb5-dev krb5-multidev +[private] +ci-build-rust: ci-build-deps + RUSTFLAGS="-Dwarnings" just build-rust + +# Build python wheel +build-python: + python -m build +[private] +ci-build-python: ci-build-deps build-python +[private] +ci-build-python-sdist: + python -m build --sdist + +# Build rust crates and python wheel +build: build-rust build-python + +# Test kadmin-sys crate +test-kadmin-sys: + cd kadmin-sys && cargo test --features client + cd kadmin-sys && cargo test --features server + +# Test kadmin crate +test-kadmin: + cd kadmin && cargo test + cd kadmin && cargo test --no-default-features --features local + +test-python-kadmin-rs: + cd python-kadmin-rs && cargo test + cd python-kadmin-rs && cargo test --no-default-features --features local + +alias t := test-rust +# Test all rust crates +test-rust: test-kadmin-sys test-kadmin test-python-kadmin-rs +[private] +ci-test-deps: + sudo apt-get install -y --no-install-recommends valgrind +[private] +ci-test-deps-mit: ci-build-deps ci-test-deps + sudo apt-get install -y --no-install-recommends krb5-kdc krb5-user krb5-admin-server +[private] +ci-test-rust: ci-test-deps-mit + RUSTFLAGS="-Dwarnings" just test-rust + +alias ts := test-sanity +# Test kadmin with valgrind for memory leaks +test-sanity: + cd kadmin && \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="valgrind --error-exitcode=1 --suppressions=tests/valgrind.supp -s --leak-check=full" \ + cargo test + cd kadmin && \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="valgrind --error-exitcode=1 --suppressions=tests/valgrind.supp -s --leak-check=full" \ + cargo test --no-default-features --features local +[private] +ci-test-sanity: ci-test-deps-mit + just test-sanity + +_test-python: + python -m unittest python-kadmin-rs/tests/test_*.py +# Test python bindings +test-python: install-python _test-python +[private] +ci-test-deps-h5l: ci-test-deps + sudo apt-get install -y --no-install-recommends libkrb5-3 libkadm5clnt-mit12 libkadm5srv-mit12 heimdal-dev heimdal-servers heimdal-kdc +[private] +ci-test-python-mit: ci-test-deps-mit _install-python _test-python +ci-test-python-h5l: ci-test-deps-h5l _install-python _test-python + +# Test rust crates and python bindings +test-all: test-rust test-sanity test-python +alias ta := test-all + +_install-python: + pip install --force-reinstall dist/python_kadmin_rs-*.whl +# Build and install wheel +install-python: clean-python build-python _install-python + +# Cleanup rust build directory +clean-rust: + rm -rf target + +# Cleanup python wheel builds +clean-python: + pip uninstall -y python-kadmin-rs + rm -rf dist wheelhouse + +# Cleanup all +clean: clean-rust clean-python diff --git a/kadmin-sys/Cargo.toml b/kadmin-sys/Cargo.toml new file mode 100644 index 0000000..1cdea03 --- /dev/null +++ b/kadmin-sys/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "kadmin-sys" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "FFI bindings for the Kerberos administration interface (kadm5)" +readme.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords = ["kerberos", "krb5", "kadm5", "kadmin"] +categories = ["external-ffi-bindings", "authentication"] + +links = "kadm5" + +[package.metadata.system-deps] +krb5 = "*" +kadm5clnt = { name = "kadm-client", version = "*", feature = "client", fallback-names = [ + "kadm5clnt", + "kadm5clnt_mit", +] } +kadm5srv = { name = "kadm-server", version = "*", feature = "server", fallback-names = [ + "kadm5srv", + "kadm5srv_mit", +] } + +[features] +default = [] +client = [] +server = [] + +[dependencies] + +[build-dependencies] +bindgen = "0.70" +system-deps = "7.0" diff --git a/kadmin-sys/build.rs b/kadmin-sys/build.rs new file mode 100644 index 0000000..c010112 --- /dev/null +++ b/kadmin-sys/build.rs @@ -0,0 +1,43 @@ +use std::{env, path::PathBuf}; + +fn main() { + let deps = system_deps::Config::new().probe().unwrap(); + + let mut builder = bindgen::builder() + .header("src/wrapper.h") + .allowlist_type("(_|)kadm5.*") + .allowlist_function("kadm5.*") + .allowlist_var("KADM5_.*") + .allowlist_var("KRB5_NT_SRV_HST") + .allowlist_var("KRB5_OK") + .allowlist_function("krb5_init_context") + .allowlist_function("krb5_free_context") + .allowlist_function("krb5_get_error_message") + .allowlist_function("krb5_free_error_message") + .allowlist_function("krb5_parse_name") + .allowlist_function("krb5_sname_to_principal") + .allowlist_function("krb5_free_principal") + .allowlist_function("krb5_unparse_name") + .allowlist_function("krb5_free_unparsed_name") + .allowlist_function("krb5_cc_get_principal") + .allowlist_function("krb5_cc_default") + .allowlist_function("krb5_cc_resolve") + .allowlist_function("krb5_cc_close") + .allowlist_function("krb5_get_default_realm") + .allowlist_function("krb5_free_default_realm") + .clang_arg("-fparse-all-comments") + .derive_default(true) + .generate_cstr(true) + .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())); + + for include_path in deps.all_include_paths() { + builder = builder.clang_arg(format!("-I{}", include_path.display())); + } + + let bindings = builder.generate().expect("Unable to generate bindings"); + + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("Couldn't write bindings!"); +} diff --git a/kadmin-sys/src/lib.rs b/kadmin-sys/src/lib.rs new file mode 100644 index 0000000..5e02aa7 --- /dev/null +++ b/kadmin-sys/src/lib.rs @@ -0,0 +1,45 @@ +//! # Raw bindings to libkadm5 +//! +//! This crate providers raw bindings to libkadm5. +//! +//! These bindings are generated by [bindgen](https://docs.rs/bindgen) by including `kadm5/admin.h`. The types provided +//! are filtered to only import required symbols for kadm5. In the future, this crate may also allow for more symbols +//! that may be useful, such as error types. +//! +//! This crate links against libkrb5 plus the required kadm5 library depending on the feature +//! selected (see below). +//! +//! By default, those include headers and libraries are found using pkg-config. You can override this behavior with the +//! following environment variables (which must be paths to directories containing the required libraries and header +//! files): +//! +//! - `SYSTEM_DEPS_KRB5_SEARCH_NATIVE` +//! - `SYSTEM_DEPS_KRB5_INCLUDE` +//! - `SYSTEM_DEPS_KADM5CLNT_SEARCH_NATIVE` +//! - `SYSTEM_DEPS_KADM5CLNT_INCLUDE` +//! - `SYSTEM_DEPS_KADM5SRV_SEARCH_NATIVE` +//! - `SYSTEM_DEPS_KADM5SRV_INCLUDE` +//! +//! You can read more about this in the [system-deps documentation](https://docs.rs/system-deps). +//! +//! # Features +//! +//! This crate offers two features, client and server. You must choose one of them depending on how your application is +//! going to interact with the KDC. By default, none are enabled and the crate will not compile. +//! +//! - `client`: links against `kadm5clnt`. Use this is you plan to remotely access the KDC, using kadmind's GSS-API RPC +//! interface, like the CLI tool `kadmin` does. +//! - `server`: links against `kadm5srv`. Use this is you plan to directly edit the KDB from the machine where the KDC +//! is running, like the CLI tool `kadmin.local` does. + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] + +#[cfg(all(feature = "client", feature = "server", not(docsrs)))] +compile_error!("Feature \"client\" and feature \"server\" cannot be enabled at the same time."); + +#[cfg(all(not(feature = "client"), not(feature = "server"), not(docsrs)))] +compile_error!("Exactly one of feature \"client\" or feature \"server\" must be selected."); + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/kadmin-sys/src/wrapper.h b/kadmin-sys/src/wrapper.h new file mode 100644 index 0000000..079c01d --- /dev/null +++ b/kadmin-sys/src/wrapper.h @@ -0,0 +1,2 @@ +#include +const krb5_error_code KRB5_OK = 0; diff --git a/kadmin/Cargo.toml b/kadmin/Cargo.toml new file mode 100644 index 0000000..fe67cda --- /dev/null +++ b/kadmin/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "kadmin" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Rust bindings for the Kerberos administration interface (kadm5)" +readme.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords = ["kerberos", "krb5", "kadm5", "kadmin"] +categories = ["api-bindings", "authentication"] + +[features] +default = ["client"] +client = ["kadmin-sys/client"] +local = ["kadmin-sys/server"] + +[dependencies] +kadmin-sys = { path = "../kadmin-sys", version = "0.0.0" } +thiserror = "1" + +[dev-dependencies] +anyhow = "1" +pyo3 = { version = "0.22", features = ["auto-initialize"] } +serial_test = { version = "3.1", default-features = false, features = [ + "log", + "logging", +] } + +[lints] +workspace = true diff --git a/kadmin/src/context.rs b/kadmin/src/context.rs new file mode 100644 index 0000000..648ec9c --- /dev/null +++ b/kadmin/src/context.rs @@ -0,0 +1,134 @@ +use std::{ + ffi::{CStr, CString}, + mem::MaybeUninit, + os::raw::c_char, + ptr::null_mut, + sync::Mutex, +}; + +use kadmin_sys::*; + +use crate::{ + error::{Result, krb5_error_code_escape_hatch}, + strconv::c_string_to_string, +}; + +static CONTEXT_INIT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug)] +pub struct KAdminContext { + pub(crate) context: krb5_context, + pub(crate) default_realm: Option, +} + +impl KAdminContext { + pub fn new() -> Result { + Self::builder().build() + } + + pub fn builder() -> KAdminContextBuilder { + KAdminContextBuilder::default() + } + + fn fill_default_realm(&mut self) { + self.default_realm = { + let mut raw_default_realm: *mut c_char = null_mut(); + let code = unsafe { krb5_get_default_realm(self.context, &mut raw_default_realm) }; + match code { + KRB5_OK => { + let default_realm = unsafe { CStr::from_ptr(raw_default_realm) }.to_owned(); + unsafe { + krb5_free_default_realm(self.context, raw_default_realm); + } + Some(default_realm) + } + _ => None, + } + }; + } + + pub(crate) fn error_code_to_message(&self, code: krb5_error_code) -> String { + let message: *const c_char = unsafe { krb5_get_error_message(self.context, code) }; + + match c_string_to_string(message) { + Ok(string) => { + unsafe { krb5_free_error_message(self.context, message) }; + string + } + Err(error) => error.to_string(), + } + } +} + +#[derive(Debug, Default)] +pub struct KAdminContextBuilder { + context: Option, +} + +impl KAdminContextBuilder { + pub unsafe fn context(mut self, context: krb5_context) -> Self { + self.context = Some(context); + self + } + + pub fn build(self) -> Result { + if let Some(ctx) = self.context { + let mut context = KAdminContext { + context: ctx, + default_realm: None, + }; + context.fill_default_realm(); + return Ok(context); + } + + let _guard = CONTEXT_INIT_LOCK + .lock() + .expect("Failed to lock context initialization."); + + let mut context_ptr: MaybeUninit = MaybeUninit::zeroed(); + + let code = unsafe { kadm5_init_krb5_context(context_ptr.as_mut_ptr()) }; + let mut context = KAdminContext { + context: unsafe { context_ptr.assume_init() }, + default_realm: None, + }; + krb5_error_code_escape_hatch(&context, code)?; + context.fill_default_realm(); + Ok(context) + } +} + +impl Drop for KAdminContext { + fn drop(&mut self) { + let _guard = CONTEXT_INIT_LOCK + .lock() + .expect("Failed to lock context for de-initialization."); + + unsafe { krb5_free_context(self.context) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new() { + let context = KAdminContext::new(); + assert!(context.is_ok()); + } + + #[test] + fn error_code_to_message() { + let context = KAdminContext::new().unwrap(); + let message = context.error_code_to_message(-1765328384); + assert_eq!(message, "No error".to_string()); + } + + #[test] + fn error_code_to_message_wrong_code() { + let context = KAdminContext::new().unwrap(); + let message = context.error_code_to_message(-1); + assert_eq!(message, "Unknown code ____ 255".to_string()); + } +} diff --git a/kadmin/src/db_args.rs b/kadmin/src/db_args.rs new file mode 100644 index 0000000..e111122 --- /dev/null +++ b/kadmin/src/db_args.rs @@ -0,0 +1,96 @@ +use std::{ffi::CString, os::raw::c_char, ptr::null_mut}; + +use crate::error::Result; + +#[derive(Debug)] +pub struct KAdminDbArgs { + pub(crate) db_args: *mut *mut c_char, + + // Additional fields to store transient strings so the pointer stored in db_args + // doesn't become invalid while this struct lives. + _origin_args: Vec, + _ptr_vec: Vec<*mut c_char>, +} + +impl KAdminDbArgs { + pub fn builder() -> KAdminDbArgsBuilder { + KAdminDbArgsBuilder::default() + } +} + +impl Default for KAdminDbArgs { + fn default() -> Self { + Self::builder().build().unwrap() + } +} + +#[derive(Clone, Debug, Default)] +pub struct KAdminDbArgsBuilder(Vec<(String, Option)>); + +impl KAdminDbArgsBuilder { + pub fn arg(mut self, name: &str, value: Option<&str>) -> Self { + self.0.push((name.to_owned(), value.map(|s| s.to_owned()))); + self + } + + pub fn build(&self) -> Result { + let formatted_args = self.0.clone().into_iter().map(|(name, value)| { + if let Some(value) = value { + format!("{name}={value}") + } else { + name + } + }); + let mut _origin_args = vec![]; + let mut _ptr_vec = vec![]; + for arg in formatted_args { + let c_arg = CString::new(arg)?; + _ptr_vec.push(c_arg.as_ptr().cast_mut()); + _origin_args.push(c_arg); + } + // Null terminated + _ptr_vec.push(null_mut()); + + let db_args = _ptr_vec.as_mut_ptr(); + + Ok(KAdminDbArgs { + db_args, + _origin_args, + _ptr_vec, + }) + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CStr; + + use super::*; + + #[test] + fn build_empty() { + let db_args = KAdminDbArgs::builder().build().unwrap(); + + unsafe { + assert_eq!(*db_args.db_args, null_mut()); + } + } + + #[test] + fn build_no_value() { + let db_args = KAdminDbArgs::builder().arg("lockiter", None).build().unwrap(); + assert_eq!( + unsafe { CStr::from_ptr(*db_args.db_args).to_owned() }, + CString::new("lockiter").unwrap() + ); + } + + #[test] + fn build_with_value() { + let db_args = KAdminDbArgs::builder().arg("host", Some("ldap.test")).build().unwrap(); + assert_eq!( + unsafe { CStr::from_ptr(*db_args.db_args).to_owned() }, + CString::new("host=ldap.test").unwrap() + ); + } +} diff --git a/kadmin/src/error.rs b/kadmin/src/error.rs new file mode 100644 index 0000000..f9af117 --- /dev/null +++ b/kadmin/src/error.rs @@ -0,0 +1,132 @@ +use kadmin_sys::*; + +use crate::context::KAdminContext; + +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum Error { + #[error("Kerberos error: {message} (code: {code})")] + Kerberos { code: krb5_error_code, message: String }, + + #[error("KAdmin error: {message} (code: {code})")] + KAdmin { code: kadm5_ret_t, message: String }, + + #[error("NULL pointer dereference error")] + NullPointerDereference, + + #[error(transparent)] + CStringConversion(#[from] std::ffi::IntoStringError), + #[error(transparent)] + CStringImportFromVec(#[from] std::ffi::FromVecWithNulError), + #[error(transparent)] + StringConversion(#[from] std::ffi::NulError), + #[error("Failed to send operation to executor")] + ThreadSendError, + #[error("Failed to receive result from executor")] + ThreadRecvError(#[from] std::sync::mpsc::RecvError), +} + +impl From> for Error { + fn from(_error: std::sync::mpsc::SendError) -> Self { + Self::ThreadSendError + } +} + +pub type Result = std::result::Result; + +pub(crate) fn krb5_error_code_escape_hatch(context: &KAdminContext, code: krb5_error_code) -> Result<()> { + if code == 0 { + Ok(()) + } else { + Err(Error::Kerberos { + code, + message: context.error_code_to_message(code), + }) + } +} + +pub(crate) fn kadm5_ret_t_escape_hatch(code: kadm5_ret_t) -> Result<()> { + if code == KADM5_OK as kadm5_ret_t { + return Ok(()); + } + let message = match code as u32 { + KADM5_FAILURE => "Operation failed for unspecified reason", + KADM5_AUTH_GET => "Operation requires ``get'' privilege", + KADM5_AUTH_ADD => "Operation requires ``add'' privilege", + KADM5_AUTH_MODIFY => "Operation requires ``modify'' privilege", + KADM5_AUTH_DELETE => "Operation requires ``delete'' privilege", + KADM5_AUTH_INSUFFICIENT => "Insufficient authorization for operation", + KADM5_BAD_DB => "Database inconsistency detected", + KADM5_DUP => "Principal or policy already exists", + KADM5_RPC_ERROR => "Communication failure with server", + KADM5_NO_SRV => "No administration server found for realm", + KADM5_BAD_HIST_KEY => "Password history principal key version mismatch", + KADM5_NOT_INIT => "Connection to server not initialized", + KADM5_UNK_PRINC => "Principal does not exist", + KADM5_UNK_POLICY => "Policy does not exist", + KADM5_BAD_MASK => "Invalid field mask for operation", + KADM5_BAD_CLASS => "Invalid number of character classes", + KADM5_BAD_LENGTH => "Invalid password length", + KADM5_BAD_POLICY => "Illegal policy name", + KADM5_BAD_PRINCIPAL => "Illegal principal name", + KADM5_BAD_AUX_ATTR => "Invalid auxillary attributes", + KADM5_BAD_HISTORY => "Invalid password history count", + KADM5_BAD_MIN_PASS_LIFE => "Password minimum life is greater then password maximum life", + KADM5_PASS_Q_TOOSHORT => "Password is too short", + KADM5_PASS_Q_CLASS => "Password does not contain enough character classes", + KADM5_PASS_Q_DICT => "Password is in the password dictionary", + KADM5_PASS_REUSE => "Cannot reuse password", + KADM5_PASS_TOOSOON => "Current password's minimum life has not expired", + KADM5_POLICY_REF => "Policy is in use", + KADM5_INIT => "Connection to server already initialized", + KADM5_BAD_PASSWORD => "Incorrect password", + KADM5_PROTECT_PRINCIPAL => "Cannot change protected principal", + KADM5_BAD_SERVER_HANDLE => "Programmer error! Bad Admin server handle", + KADM5_BAD_STRUCT_VERSION => "Programmer error! Bad API structure version", + KADM5_OLD_STRUCT_VERSION => { + "API structure version specified by application is no longer supported (to fix, recompile application \ + against current Admin API header files and libraries)" + } + KADM5_NEW_STRUCT_VERSION => { + "API structure version specified by application is unknown to libraries (to fix, obtain current Admin API \ + header files and libraries and recompile application)" + } + KADM5_BAD_API_VERSION => "Programmer error! Bad API version", + KADM5_OLD_LIB_API_VERSION => { + "API version specified by application is no longer supported by libraries (to fix, update application to \ + adhere to current API version and recompile)" + } + KADM5_OLD_SERVER_API_VERSION => { + "API version specified by application is no longer supported by server (to fix, update application to \ + adhere to current API version and recompile)" + } + KADM5_NEW_LIB_API_VERSION => { + "API version specified by application is unknown to libraries (to fix, obtain current Admin API header \ + files and libraries and recompile application)" + } + KADM5_NEW_SERVER_API_VERSION => { + "API version specified by application is unknown to server (to fix, obtain and install newest Admin Server)" + } + KADM5_SECURE_PRINC_MISSING => "Database error! Required principal missing", + KADM5_NO_RENAME_SALT => "The salt type of the specified principal does not support renaming", + KADM5_BAD_CLIENT_PARAMS => "Illegal configuration parameter for remote KADM5 client", + KADM5_BAD_SERVER_PARAMS => "Illegal configuration parameter for local KADM5 client.", + KADM5_AUTH_LIST => "Operation requires ``list'' privilege", + KADM5_AUTH_CHANGEPW => "Operation requires ``change-password'' privilege", + KADM5_GSS_ERROR => "GSS-API (or Kerberos) error", + KADM5_BAD_TL_TYPE => "Programmer error! Illegal tagged data list element type", + KADM5_MISSING_CONF_PARAMS => "Required parameters in kdc.conf missing", + KADM5_BAD_SERVER_NAME => "Bad krb5 admin server hostname", + KADM5_AUTH_SETKEY => "Operation requires ``set-key'' privilege", + KADM5_SETKEY_DUP_ENCTYPES => "Multiple values for single or folded enctype", + KADM5_SETV4KEY_INVAL_ENCTYPE => "Invalid enctype for setv4key", + KADM5_SETKEY3_ETYPE_MISMATCH => "Mismatched enctypes for setkey3", + KADM5_MISSING_KRB5_CONF_PARAMS => "Missing parameters in krb5.conf required for kadmin client", + KADM5_XDR_FAILURE => "XDR encoding error", + KADM5_CANT_RESOLVE => "", + KADM5_PASS_Q_GENERIC => "Database synchronization failed", + _ => "Unknown error", + } + .to_owned(); + Err(Error::KAdmin { code, message }) +} diff --git a/kadmin/src/kadmin.rs b/kadmin/src/kadmin.rs new file mode 100644 index 0000000..a41b080 --- /dev/null +++ b/kadmin/src/kadmin.rs @@ -0,0 +1,396 @@ +#[cfg(feature = "client")] +use std::{ffi::CStr, mem::MaybeUninit}; +use std::{ + ffi::CString, + os::raw::{c_char, c_void}, + ptr::null_mut, + sync::Mutex, +}; + +use kadmin_sys::*; + +use crate::{ + context::KAdminContext, + db_args::KAdminDbArgs, + error::{Result, kadm5_ret_t_escape_hatch, krb5_error_code_escape_hatch}, + params::KAdminParams, + principal::Principal, + strconv::c_string_to_string, +}; + +static KADMIN_INIT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug)] +pub struct KAdmin { + context: KAdminContext, + pub(crate) server_handle: *mut c_void, +} + +impl KAdmin { + pub fn builder() -> KAdminBuilder { + KAdminBuilder::default() + } + + // ank, addprinc, add_principal + pub fn add_principal() { + unimplemented!(); + } + + // delprinc, delete_principal + pub fn delete_principal() { + unimplemented!(); + } + + // modify_principal, modprinc + pub fn modify_principal() { + unimplemented!(); + } + + // rename_principal, renprinc + pub fn rename_principal() { + unimplemented!(); + } + + // get_principal, getprinc + #[allow(unreachable_code)] + #[allow(unused_variables)] + pub fn get_principal(&self, name: &str) -> Result>> { + unimplemented!(); + let mut temp_princ = null_mut(); + let name = CString::new(name)?; + let code = unsafe { krb5_parse_name(self.context.context, name.as_ptr().cast_mut(), &mut temp_princ) }; + krb5_error_code_escape_hatch(&self.context, code)?; + let mut principal_entry = Principal::new(self); + let code = unsafe { + kadm5_get_principal( + self.server_handle, + temp_princ, + &mut principal_entry.inner, + (KADM5_PRINCIPAL_NORMAL_MASK | KADM5_KEY_DATA) as i64, + ) + }; + unsafe { + krb5_free_principal(self.context.context, temp_princ); + } + if code == KADM5_UNK_PRINC as i64 { + return Ok(None); + } + kadm5_ret_t_escape_hatch(code)?; + Ok(Some(principal_entry)) + } + + // list_principals, listprincs, get_principals, getprincs + pub fn list_principals(&self, query: &str) -> Result> { + let query = CString::new(query)?; + let mut count = 0; + let mut princs: *mut *mut c_char = null_mut(); + let code = + unsafe { kadm5_get_principals(self.server_handle, query.as_ptr().cast_mut(), &mut princs, &mut count) }; + kadm5_ret_t_escape_hatch(code)?; + let mut result = Vec::with_capacity(count as usize); + for raw in unsafe { std::slice::from_raw_parts(princs, count as usize) }.iter() { + result.push(c_string_to_string(*raw)?); + } + unsafe { + kadm5_free_name_list(self.server_handle, princs, count); + } + Ok(result) + } + + // add_policy, addpol + pub fn add_policy() { + unimplemented!(); + } + + // modify_policy, modpol + pub fn modify_policy() { + unimplemented!(); + } + + // delete_policy, delpol + pub fn delete_policy() { + unimplemented!(); + } + + // get_policy, getpol + pub fn get_policy() { + unimplemented!(); + } + + // list_policies, listpols, get_policies, getpols + pub fn list_policies(&self, query: &str) -> Result> { + let query = CString::new(query)?; + let mut count = 0; + let mut policies: *mut *mut c_char = null_mut(); + let code = + unsafe { kadm5_get_policies(self.server_handle, query.as_ptr().cast_mut(), &mut policies, &mut count) }; + kadm5_ret_t_escape_hatch(code)?; + let mut result = Vec::with_capacity(count as usize); + for raw in unsafe { std::slice::from_raw_parts(policies, count as usize) }.iter() { + result.push(c_string_to_string(*raw)?); + } + unsafe { + kadm5_free_name_list(self.server_handle, policies, count); + } + Ok(result) + } + + // get_privs, getprivs + pub fn get_privs() { + unimplemented!(); + } +} + +impl Drop for KAdmin { + fn drop(&mut self) { + let _guard = KADMIN_INIT_LOCK + .lock() + .expect("Failed to lock kadmin for de-initialization."); + unsafe { + kadm5_flush(self.server_handle); + kadm5_destroy(self.server_handle); + } + } +} + +#[derive(Debug, Default)] +pub struct KAdminBuilder { + context: Option>, + params: Option, + db_args: Option, +} + +impl KAdminBuilder { + pub fn context(mut self, context: KAdminContext) -> Self { + self.context = Some(Ok(context)); + self + } + + pub fn params(mut self, params: KAdminParams) -> Self { + self.params = Some(params); + self + } + + pub fn db_args(mut self, db_args: KAdminDbArgs) -> Self { + self.db_args = Some(db_args); + self + } + + fn get_kadmin(self) -> Result<(KAdmin, KAdminParams, KAdminDbArgs)> { + let params = self.params.unwrap_or_default(); + let db_args = self.db_args.unwrap_or_default(); + let context = self.context.unwrap_or(KAdminContext::new())?; + let kadmin = KAdmin { + context, + server_handle: null_mut(), + }; + Ok((kadmin, params, db_args)) + } + + #[cfg(feature = "client")] + pub fn with_password(self, client_name: &str, password: &str) -> Result { + let _guard = KADMIN_INIT_LOCK.lock().expect("Failed to lock context initialization."); + + let (mut kadmin, params, db_args) = self.get_kadmin()?; + + let client_name = CString::new(client_name)?; + let password = CString::new(password)?; + let service_name = KADM5_ADMIN_SERVICE.to_owned(); + + let mut params = params; + + let code = unsafe { + kadm5_init_with_password( + kadmin.context.context, + client_name.as_ptr().cast_mut(), + password.as_ptr().cast_mut(), + service_name.as_ptr().cast_mut(), + &mut params.params, + KADM5_STRUCT_VERSION, + KADM5_API_VERSION_2, + db_args.db_args, + &mut kadmin.server_handle, + ) + }; + + kadm5_ret_t_escape_hatch(code)?; + + Ok(kadmin) + } + + #[cfg(feature = "client")] + pub fn with_keytab(self, client_name: Option<&str>, keytab: Option<&str>) -> Result { + let _guard = KADMIN_INIT_LOCK.lock().expect("Failed to lock context initialization."); + + let (mut kadmin, params, db_args) = self.get_kadmin()?; + + let client_name = if let Some(client_name) = client_name { + CString::new(client_name)? + } else { + let mut princ_ptr: MaybeUninit = MaybeUninit::zeroed(); + let code = unsafe { + krb5_sname_to_principal( + kadmin.context.context, + null_mut(), + CString::new("host")?.as_ptr().cast_mut(), + KRB5_NT_SRV_HST as i32, + princ_ptr.as_mut_ptr(), + ) + }; + krb5_error_code_escape_hatch(&kadmin.context, code)?; + let princ = unsafe { princ_ptr.assume_init() }; + let mut raw_client_name: *mut c_char = null_mut(); + let code = unsafe { krb5_unparse_name(kadmin.context.context, princ, &mut raw_client_name) }; + krb5_error_code_escape_hatch(&kadmin.context, code)?; + unsafe { + krb5_free_principal(kadmin.context.context, princ); + } + let client_name = unsafe { CStr::from_ptr(raw_client_name) }.to_owned(); + unsafe { + krb5_free_unparsed_name(kadmin.context.context, raw_client_name); + } + client_name + }; + let keytab = if let Some(keytab) = keytab { + CString::new(keytab)? + } else { + CString::new("/etc/krb5.keytab")? + }; + let service_name = KADM5_ADMIN_SERVICE.to_owned(); + + let mut params = params; + + let code = unsafe { + kadm5_init_with_skey( + kadmin.context.context, + client_name.as_ptr().cast_mut(), + keytab.as_ptr().cast_mut(), + service_name.as_ptr().cast_mut(), + &mut params.params, + KADM5_STRUCT_VERSION, + KADM5_API_VERSION_2, + db_args.db_args, + &mut kadmin.server_handle, + ) + }; + + kadm5_ret_t_escape_hatch(code)?; + + Ok(kadmin) + } + + #[cfg(feature = "client")] + pub fn with_ccache(self, client_name: Option<&str>, ccache_name: Option<&str>) -> Result { + let _guard = KADMIN_INIT_LOCK.lock().expect("Failed to lock context initialization."); + + let (mut kadmin, params, db_args) = self.get_kadmin()?; + + let ccache = { + let mut ccache: MaybeUninit = MaybeUninit::zeroed(); + let code = if let Some(ccache_name) = ccache_name { + let ccache_name = CString::new(ccache_name)?; + unsafe { + krb5_cc_resolve( + kadmin.context.context, + ccache_name.as_ptr().cast_mut(), + ccache.as_mut_ptr(), + ) + } + } else { + unsafe { krb5_cc_default(kadmin.context.context, ccache.as_mut_ptr()) } + }; + krb5_error_code_escape_hatch(&kadmin.context, code)?; + unsafe { ccache.assume_init() } + }; + + let client_name = if let Some(client_name) = client_name { + CString::new(client_name)? + } else { + let mut princ_ptr: MaybeUninit = MaybeUninit::zeroed(); + let code = unsafe { krb5_cc_get_principal(kadmin.context.context, ccache, princ_ptr.as_mut_ptr()) }; + krb5_error_code_escape_hatch(&kadmin.context, code)?; + let princ = unsafe { princ_ptr.assume_init() }; + let mut raw_client_name: *mut c_char = null_mut(); + let code = unsafe { krb5_unparse_name(kadmin.context.context, princ, &mut raw_client_name) }; + krb5_error_code_escape_hatch(&kadmin.context, code)?; + unsafe { + krb5_free_principal(kadmin.context.context, princ); + } + let client_name = unsafe { CStr::from_ptr(raw_client_name) }.to_owned(); + unsafe { + krb5_free_unparsed_name(kadmin.context.context, raw_client_name); + } + client_name + }; + let service_name = KADM5_ADMIN_SERVICE.to_owned(); + + let mut params = params; + + let code = unsafe { + kadm5_init_with_creds( + kadmin.context.context, + client_name.as_ptr().cast_mut(), + ccache, + service_name.as_ptr().cast_mut(), + &mut params.params, + KADM5_STRUCT_VERSION, + KADM5_API_VERSION_2, + db_args.db_args, + &mut kadmin.server_handle, + ) + }; + + unsafe { + krb5_cc_close(kadmin.context.context, ccache); + } + + kadm5_ret_t_escape_hatch(code)?; + + Ok(kadmin) + } + + #[cfg(feature = "client")] + pub fn with_anonymous(self, _client_name: &str) -> Result { + let _guard = KADMIN_INIT_LOCK.lock().expect("Failed to lock context initialization."); + + let (mut _kadmin, _params, _db_args) = self.get_kadmin()?; + + unimplemented!(); + } + + #[cfg(any(feature = "local", docsrs))] + pub fn with_local(self) -> Result { + let _guard = KADMIN_INIT_LOCK.lock().expect("Failed to lock context initialization."); + + let (mut kadmin, params, db_args) = self.get_kadmin()?; + + let client_name = if let Some(default_realm) = &kadmin.context.default_realm { + let mut concat = CString::new("root/admin@")?.into_bytes(); + concat.extend_from_slice(default_realm.to_bytes_with_nul()); + CString::from_vec_with_nul(concat)? + } else { + CString::new("root/admin")? + }; + let service_name = KADM5_ADMIN_SERVICE.to_owned(); + + let mut params = params; + + let code = unsafe { + kadm5_init_with_creds( + kadmin.context.context, + client_name.as_ptr().cast_mut(), + null_mut(), + service_name.as_ptr().cast_mut(), + &mut params.params, + KADM5_STRUCT_VERSION, + KADM5_API_VERSION_2, + db_args.db_args, + &mut kadmin.server_handle, + ) + }; + + kadm5_ret_t_escape_hatch(code)?; + + Ok(kadmin) + } +} diff --git a/kadmin/src/lib.rs b/kadmin/src/lib.rs new file mode 100644 index 0000000..1d49ba9 --- /dev/null +++ b/kadmin/src/lib.rs @@ -0,0 +1,27 @@ +#[cfg(all(feature = "client", feature = "local"))] +compile_error!("Feature \"client\" and feature \"local\" cannot be enabled at the same time."); + +#[cfg(all(not(feature = "client"), not(feature = "local")))] +compile_error!("Exactly one of feature \"client\" or feature \"local\" must be selected."); + +pub mod context; +pub use context::KAdminContext; + +pub mod db_args; +pub use db_args::KAdminDbArgs; + +pub mod error; +pub use error::Error; + +pub mod kadmin; +pub use kadmin::KAdmin; + +pub mod params; +pub use params::KAdminParams; + +pub mod principal; +pub use principal::Principal; + +mod strconv; + +pub mod sync; diff --git a/kadmin/src/params.rs b/kadmin/src/params.rs new file mode 100644 index 0000000..34b9b85 --- /dev/null +++ b/kadmin/src/params.rs @@ -0,0 +1,209 @@ +use std::{ffi::CString, ptr::null_mut}; + +use kadmin_sys::*; + +use crate::error::Result; + +#[derive(Debug)] +pub struct KAdminParams { + pub(crate) params: kadm5_config_params, + + // Additional fields to store transient strings so the pointer stored in kadm5_config_params + // doesn't become invalid while this struct lives. + _strings: Vec>, +} + +impl KAdminParams { + pub fn builder() -> KAdminParamsBuilder { + KAdminParamsBuilder::default() + } +} + +impl Default for KAdminParams { + fn default() -> Self { + Self::builder().build().unwrap() + } +} + +#[derive(Clone, Debug, Default)] +pub struct KAdminParamsBuilder { + mask: i64, + + realm: Option, + kadmind_port: i32, + kpasswd_port: i32, + admin_server: Option, + dbname: Option, + acl_file: Option, + dict_file: Option, + stash_file: Option, +} + +impl KAdminParamsBuilder { + pub fn realm(mut self, realm: &str) -> Self { + self.realm = Some(realm.to_owned()); + self.mask |= KADM5_CONFIG_REALM as i64; + self + } + + pub fn kadmind_port(mut self, port: i32) -> Self { + self.kadmind_port = port; + self.mask |= KADM5_CONFIG_KADMIND_PORT as i64; + self + } + + pub fn kpasswd_port(mut self, port: i32) -> Self { + self.kpasswd_port = port; + self.mask |= KADM5_CONFIG_KPASSWD_PORT as i64; + self + } + + pub fn admin_server(mut self, admin_server: &str) -> Self { + self.admin_server = Some(admin_server.to_owned()); + self.mask |= KADM5_CONFIG_ADMIN_SERVER as i64; + self + } + + pub fn dbname(mut self, dbname: &str) -> Self { + self.dbname = Some(dbname.to_owned()); + self.mask |= KADM5_CONFIG_DBNAME as i64; + self + } + + pub fn acl_file(mut self, acl_file: &str) -> Self { + self.acl_file = Some(acl_file.to_owned()); + self.mask |= KADM5_CONFIG_ACL_FILE as i64; + self + } + + pub fn dict_file(mut self, dict_file: &str) -> Self { + self.dict_file = Some(dict_file.to_owned()); + self.mask |= KADM5_CONFIG_DICT_FILE as i64; + self + } + + pub fn stash_file(mut self, stash_file: &str) -> Self { + self.stash_file = Some(stash_file.to_owned()); + self.mask |= KADM5_CONFIG_STASH_FILE as i64; + self + } + + pub fn build(self) -> Result { + let realm = self.realm.map(CString::new).transpose()?; + let admin_server = self.admin_server.map(CString::new).transpose()?; + let dbname = self.dbname.map(CString::new).transpose()?; + let acl_file = self.acl_file.map(CString::new).transpose()?; + let dict_file = self.dict_file.map(CString::new).transpose()?; + let stash_file = self.stash_file.map(CString::new).transpose()?; + + let params = kadm5_config_params { + mask: self.mask, + realm: if let Some(realm) = &realm { + realm.as_ptr().cast_mut() + } else { + null_mut() + }, + kadmind_port: self.kadmind_port, + kpasswd_port: self.kpasswd_port, + + admin_server: if let Some(admin_server) = &admin_server { + admin_server.as_ptr().cast_mut() + } else { + null_mut() + }, + + dbname: if let Some(dbname) = &dbname { + dbname.as_ptr().cast_mut() + } else { + null_mut() + }, + acl_file: if let Some(acl_file) = &acl_file { + acl_file.as_ptr().cast_mut() + } else { + null_mut() + }, + dict_file: if let Some(dict_file) = &dict_file { + dict_file.as_ptr().cast_mut() + } else { + null_mut() + }, + mkey_from_kbd: 0, + stash_file: if let Some(stash_file) = &stash_file { + stash_file.as_ptr().cast_mut() + } else { + null_mut() + }, + mkey_name: null_mut(), + enctype: 0, + max_life: 0, + max_rlife: 0, + expiration: 0, + flags: 0, + keysalts: null_mut(), + num_keysalts: 0, + kvno: 0, + iprop_enabled: 0, + iprop_ulogsize: 0, + iprop_poll_time: 0, + iprop_logfile: null_mut(), + iprop_port: 0, + iprop_resync_timeout: 0, + kadmind_listen: null_mut(), + kpasswd_listen: null_mut(), + iprop_listen: null_mut(), + }; + + Ok(KAdminParams { + params, + _strings: vec![realm, admin_server, dbname, acl_file, dict_file, stash_file], + }) + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CStr; + + use super::*; + + #[test] + fn build_empty() { + let params = KAdminParams::builder().build().unwrap(); + + assert_eq!(params.params.mask, 0); + } + + #[test] + fn build_realm() { + let params = KAdminParams::builder().realm("EXAMPLE.ORG").build().unwrap(); + + assert_eq!(params.params.mask, 1); + assert_eq!( + unsafe { CStr::from_ptr(params.params.realm).to_owned() }, + CString::new("EXAMPLE.ORG").unwrap() + ); + } + + #[test] + fn build_all() { + let params = KAdminParams::builder() + .realm("EXAMPLE.ORG") + .admin_server("kdc.example.org") + .kadmind_port(750) + .kpasswd_port(465) + .build() + .unwrap(); + + assert_eq!(params.params.mask, 0x94001); + assert_eq!( + unsafe { CStr::from_ptr(params.params.realm).to_owned() }, + CString::new("EXAMPLE.ORG").unwrap() + ); + assert_eq!( + unsafe { CStr::from_ptr(params.params.realm).to_owned() }, + CString::new("EXAMPLE.ORG").unwrap() + ); + assert_eq!(params.params.kadmind_port, 750); + assert_eq!(params.params.kpasswd_port, 465); + } +} diff --git a/kadmin/src/principal.rs b/kadmin/src/principal.rs new file mode 100644 index 0000000..44313a1 --- /dev/null +++ b/kadmin/src/principal.rs @@ -0,0 +1,26 @@ +use kadmin_sys::*; + +use crate::kadmin::KAdmin; + +#[derive(Debug)] +pub struct Principal<'a> { + pub(crate) kadmin: &'a KAdmin, + pub(crate) inner: _kadm5_principal_ent_t, +} + +impl<'a> Principal<'a> { + pub(crate) fn new(kadmin: &'a KAdmin) -> Self { + Self { + kadmin, + inner: _kadm5_principal_ent_t::default(), + } + } +} + +impl Drop for Principal<'_> { + fn drop(&mut self) { + unsafe { + kadm5_free_principal_ent(self.kadmin.server_handle, &mut self.inner); + } + } +} diff --git a/kadmin/src/strconv.rs b/kadmin/src/strconv.rs new file mode 100644 index 0000000..975826a --- /dev/null +++ b/kadmin/src/strconv.rs @@ -0,0 +1,14 @@ +use std::{ffi::CStr, os::raw::c_char}; + +use crate::error::{Error, Result}; + +pub(crate) fn c_string_to_string(c_string: *const c_char) -> Result { + if c_string.is_null() { + return Err(Error::NullPointerDereference); + } + + match unsafe { CStr::from_ptr(c_string) }.to_owned().into_string() { + Ok(string) => Ok(string), + Err(error) => Err(error.into()), + } +} diff --git a/kadmin/src/sync.rs b/kadmin/src/sync.rs new file mode 100644 index 0000000..5a8c4c8 --- /dev/null +++ b/kadmin/src/sync.rs @@ -0,0 +1,172 @@ +use std::{ + panic::resume_unwind, + sync::mpsc::{Sender, channel}, + thread::{JoinHandle, spawn}, +}; + +use crate::{db_args::KAdminDbArgsBuilder, error::Result, params::KAdminParamsBuilder}; + +enum KAdminOperation { + ListPrincipals(String, Sender>>), + ListPolicies(String, Sender>>), + Exit, +} + +impl KAdminOperation { + fn handle(&self, kadmin: &crate::kadmin::KAdmin) { + match self { + Self::Exit => (), + Self::ListPrincipals(query, sender) => { + let _ = sender.send(kadmin.list_principals(query)); + } + Self::ListPolicies(query, sender) => { + let _ = sender.send(kadmin.list_policies(query)); + } + } + } +} + +pub struct KAdmin { + op_sender: Sender, + join_handle: Option>, +} + +impl KAdmin { + pub fn builder() -> KAdminBuilder { + KAdminBuilder::default() + } + + pub fn list_principals(&self, query: &str) -> Result> { + let (sender, receiver) = channel(); + self.op_sender + .send(KAdminOperation::ListPrincipals(query.to_owned(), sender))?; + receiver.recv()? + } + + pub fn list_policies(&self, query: &str) -> Result> { + let (sender, receiver) = channel(); + self.op_sender + .send(KAdminOperation::ListPolicies(query.to_owned(), sender))?; + receiver.recv()? + } +} + +impl Drop for KAdmin { + fn drop(&mut self) { + // Thread might have already exited, so we don't care about the result of this. + let _ = self.op_sender.send(KAdminOperation::Exit); + if let Some(join_handle) = self.join_handle.take() { + if let Err(e) = join_handle.join() { + resume_unwind(e); + } + } + } +} + +#[derive(Debug, Default)] +pub struct KAdminBuilder { + params_builder: Option, + db_args_builder: Option, +} + +impl KAdminBuilder { + pub fn params_builder(mut self, params_builder: KAdminParamsBuilder) -> Self { + self.params_builder = Some(params_builder); + self + } + + pub fn db_args_builder(mut self, db_args_builder: KAdminDbArgsBuilder) -> Self { + self.db_args_builder = Some(db_args_builder); + self + } + + fn get_builder(self) -> Result { + let mut builder = crate::kadmin::KAdmin::builder(); + if let Some(params_builder) = self.params_builder { + builder = builder.params(params_builder.build()?); + } + if let Some(db_args_builder) = self.db_args_builder { + builder = builder.db_args(db_args_builder.build()?); + } + Ok(builder) + } + + fn build(self, kadmin_build: F) -> Result + where F: FnOnce(crate::kadmin::KAdminBuilder) -> Result + Send + 'static { + let (op_sender, op_receiver) = channel(); + let (start_sender, start_receiver) = channel(); + + let join_handle = spawn(move || { + let builder = match self.get_builder() { + Ok(builder) => builder, + Err(e) => { + let _ = start_sender.send(Err(e)); + return; + } + }; + let kadmin = match kadmin_build(builder) { + Ok(kadmin) => { + let _ = start_sender.send(Ok(())); + kadmin + } + Err(e) => { + let _ = start_sender.send(Err(e)); + return; + } + }; + while let Ok(op) = op_receiver.recv() { + match op { + KAdminOperation::Exit => break, + _ => op.handle(&kadmin), + }; + } + }); + + match start_receiver.recv()? { + Ok(_) => Ok(KAdmin { + op_sender, + join_handle: Some(join_handle), + }), + Err(e) => match join_handle.join() { + Ok(_) => Err(e), + Err(e) => resume_unwind(e), + }, + } + } + + #[cfg(feature = "client")] + pub fn with_password(self, client_name: &str, password: &str) -> Result { + let client_name = client_name.to_owned(); + let password = password.to_owned(); + + self.build(move |builder| builder.with_password(&client_name, &password)) + } + + #[cfg(feature = "client")] + pub fn with_keytab(self, client_name: Option<&str>, keytab: Option<&str>) -> Result { + let client_name = client_name.map(String::from); + let keytab = keytab.map(String::from); + + self.build(move |builder| builder.with_keytab(client_name.as_deref(), keytab.as_deref())) + } + + #[cfg(feature = "client")] + pub fn with_ccache(self, client_name: Option<&str>, ccache_name: Option<&str>) -> Result { + let client_name = client_name.map(String::from); + let ccache_name = ccache_name.map(String::from); + + self.build(move |builder| builder.with_ccache(client_name.as_deref(), ccache_name.as_deref())) + } + + #[cfg(feature = "client")] + pub fn with_anonymous(self, client_name: &str) -> Result { + let client_name = client_name.to_owned(); + + self.build(move |builder| builder.with_anonymous(&client_name)) + } + + #[cfg(any(feature = "local", docsrs))] + pub fn with_local(self) -> Result { + self.build(move |builder| builder.with_local()) + } +} diff --git a/kadmin/tests/k5test.rs b/kadmin/tests/k5test.rs new file mode 100644 index 0000000..35ca806 --- /dev/null +++ b/kadmin/tests/k5test.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +#[allow(unused_imports)] +use pyo3::{prelude::*, types::PyDict}; + +#[allow(dead_code)] +const K5REALM_INIT: &str = r#" +import os +from copy import deepcopy +from k5test import realm + +realm = realm.K5Realm(start_kadmind=True) +realm.http_princ = f"HTTP/testserver@{realm.realm}" +realm.http_keytab = os.path.join(realm.tmpdir, "http_keytab") +realm.addprinc(realm.http_princ) +realm.extract_keytab(realm.http_princ, realm.http_keytab) + +saved_env = deepcopy(os.environ) +for k, v in realm.env.items(): + os.environ[k] = v +"#; + +const RESTORE_ENV: &str = r#" +import os +from copy import deepcopy + +def restore_env(saved_env): + for k in deepcopy(os.environ): + if k in saved_env: + os.environ[k] = saved_env[k] + else: + del os.environ[k] +"#; + +pub(crate) struct K5Test { + realm: PyObject, + saved_env: PyObject, +} + +impl K5Test { + #[allow(dead_code)] + pub(crate) fn new() -> Result { + let (realm, saved_env) = Python::with_gil(|py| { + let module = PyModule::from_code_bound(py, K5REALM_INIT, "", "")?; + let realm = module.getattr("realm")?; + let saved_env = module.getattr("saved_env")?; + Ok::<(PyObject, PyObject), PyErr>((realm.into(), saved_env.into())) + })?; + + Ok(Self { realm, saved_env }) + } + + #[allow(dead_code)] + pub(crate) fn tmpdir(&self) -> Result { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + let tmpdir: String = realm.getattr("tmpdir")?.extract()?; + Ok(tmpdir) + }) + } + + #[allow(dead_code)] + pub(crate) fn admin_princ(&self) -> Result { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + let admin_princ: String = realm.getattr("admin_princ")?.extract()?; + Ok(admin_princ) + }) + } + + #[allow(dead_code)] + pub(crate) fn kadmin_ccache(&self) -> Result { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + let kadmin_ccache: String = realm.getattr("kadmin_ccache")?.extract()?; + Ok(kadmin_ccache) + }) + } + + #[allow(dead_code)] + pub(crate) fn password(&self, name: &str) -> Result { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + let password: String = realm.call_method1("password", (name,))?.extract()?; + Ok(password) + }) + } + + #[allow(dead_code)] + pub(crate) fn prep_kadmin(&self) -> Result<()> { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + realm.call_method0("prep_kadmin")?; + Ok(()) + }) + } +} + +impl Drop for K5Test { + fn drop(&mut self) { + Python::with_gil(|py| { + let realm = self.realm.bind(py); + let saved_env = self.saved_env.bind(py); + + realm.call_method0("stop")?; + + let module = PyModule::from_code_bound(py, RESTORE_ENV, "", "")?; + let restore_env = module.getattr("restore_env")?; + restore_env.call1((saved_env,))?; + + Ok::<(), PyErr>(()) + }) + .unwrap(); + } +} diff --git a/kadmin/tests/kadmin_builder.rs b/kadmin/tests/kadmin_builder.rs new file mode 100644 index 0000000..2452af3 --- /dev/null +++ b/kadmin/tests/kadmin_builder.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use kadmin::KAdmin; +#[cfg(feature = "local")] +use kadmin::{KAdminDbArgs, KAdminParams}; +use serial_test::serial; +mod k5test; +use k5test::K5Test; + +#[cfg(feature = "client")] +#[test] +#[serial] +fn with_password() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + kadmin.list_principals("*")?; + Ok(()) +} + +#[cfg(feature = "client")] +#[test] +#[serial] +fn with_keytab() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + kadmin.list_principals("*")?; + Ok(()) +} + +#[cfg(feature = "client")] +#[test] +#[serial] +fn with_ccache() -> Result<()> { + let realm = K5Test::new()?; + realm.prep_kadmin()?; + let kadmin_ccache = realm.kadmin_ccache()?; + let kadmin = KAdmin::builder().with_ccache(Some(&realm.admin_princ()?), Some(&kadmin_ccache))?; + kadmin.list_principals("*")?; + Ok(()) +} + +#[cfg(feature = "local")] +#[test] +#[serial] +fn with_local() -> Result<()> { + let realm = K5Test::new()?; + let db_args = KAdminDbArgs::builder() + .arg("dbname", Some(&format!("{}/db", realm.tmpdir()?))) + .build()?; + let params = KAdminParams::builder() + .dbname(&format!("{}/db", realm.tmpdir()?)) + .acl_file(&format!("{}/acl", realm.tmpdir()?)) + .dict_file(&format!("{}/dict", realm.tmpdir()?)) + .stash_file(&format!("{}/stash", realm.tmpdir()?)) + .build()?; + let _kadmin = KAdmin::builder().db_args(db_args).params(params).with_local()?; + Ok(()) +} + +mod sync { + use anyhow::Result; + use kadmin::sync::KAdmin; + #[cfg(feature = "local")] + use kadmin::{KAdminDbArgs, KAdminParams}; + use serial_test::serial; + + use crate::K5Test; + + #[cfg(feature = "client")] + #[test] + #[serial] + fn with_password() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + kadmin.list_principals("*")?; + Ok(()) + } + + #[cfg(feature = "client")] + #[test] + #[serial] + fn with_keytab() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + kadmin.list_principals("*")?; + Ok(()) + } + + #[cfg(feature = "client")] + #[test] + #[serial] + fn with_ccache() -> Result<()> { + let realm = K5Test::new()?; + realm.prep_kadmin()?; + let kadmin_ccache = realm.kadmin_ccache()?; + let kadmin = KAdmin::builder().with_ccache(Some(&realm.admin_princ()?), Some(&kadmin_ccache))?; + kadmin.list_principals("*")?; + Ok(()) + } + + #[cfg(feature = "local")] + #[test] + #[serial] + fn with_local() -> Result<()> { + let realm = K5Test::new()?; + let db_args_builder = KAdminDbArgs::builder().arg("dbname", Some(&format!("{}/db", realm.tmpdir()?))); + let params_builder = KAdminParams::builder() + .dbname(&format!("{}/db", realm.tmpdir()?)) + .acl_file(&format!("{}/acl", realm.tmpdir()?)) + .dict_file(&format!("{}/dict", realm.tmpdir()?)) + .stash_file(&format!("{}/stash", realm.tmpdir()?)); + let _kadmin = KAdmin::builder() + .db_args_builder(db_args_builder) + .params_builder(params_builder) + .with_local()?; + Ok(()) + } +} diff --git a/kadmin/tests/principals.rs b/kadmin/tests/principals.rs new file mode 100644 index 0000000..e480f32 --- /dev/null +++ b/kadmin/tests/principals.rs @@ -0,0 +1,77 @@ +#[cfg(feature = "client")] +use anyhow::Result; +#[cfg(feature = "client")] +use kadmin::KAdmin; +#[cfg(feature = "client")] +use serial_test::serial; +mod k5test; +#[cfg(feature = "client")] +use k5test::K5Test; + +#[cfg(feature = "client")] +#[test] +#[serial] +fn list_principals() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + let principals = kadmin.list_principals("*")?; + assert_eq!( + principals + .into_iter() + .filter(|princ| !princ.starts_with("host/")) + .collect::>(), + vec![ + "HTTP/testserver@KRBTEST.COM", + "K/M@KRBTEST.COM", + "kadmin/admin@KRBTEST.COM", + "kadmin/changepw@KRBTEST.COM", + "krbtgt/KRBTEST.COM@KRBTEST.COM", + "user/admin@KRBTEST.COM", + "user@KRBTEST.COM", + ] + .into_iter() + .map(String::from) + .collect::>() + ); + Ok(()) +} + +mod sync { + #[cfg(feature = "client")] + use anyhow::Result; + #[cfg(feature = "client")] + use kadmin::sync::KAdmin; + #[cfg(feature = "client")] + use serial_test::serial; + + #[cfg(feature = "client")] + use crate::K5Test; + + #[cfg(feature = "client")] + #[test] + #[serial] + fn list_principals() -> Result<()> { + let realm = K5Test::new()?; + let kadmin = KAdmin::builder().with_password(&realm.admin_princ()?, &realm.password("admin")?)?; + let principals = kadmin.list_principals("*")?; + assert_eq!( + principals + .into_iter() + .filter(|princ| !princ.starts_with("host/")) + .collect::>(), + vec![ + "HTTP/testserver@KRBTEST.COM", + "K/M@KRBTEST.COM", + "kadmin/admin@KRBTEST.COM", + "kadmin/changepw@KRBTEST.COM", + "krbtgt/KRBTEST.COM@KRBTEST.COM", + "user/admin@KRBTEST.COM", + "user@KRBTEST.COM", + ] + .into_iter() + .map(String::from) + .collect::>() + ); + Ok(()) + } +} diff --git a/kadmin/tests/valgrind.supp b/kadmin/tests/valgrind.supp new file mode 100644 index 0000000..b8bfeae --- /dev/null +++ b/kadmin/tests/valgrind.supp @@ -0,0 +1,10 @@ +{ + ignore_python_conditional_jump_errors + Memcheck:Cond + obj:*/libpython* +} +{ + ignore_valgrind_memcheck + Memcheck:Leak + obj:*/vgpreload_memcheck-*.so +} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..63606a8 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,397 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "bashlex" +version = "0.18" +description = "Python parser for bash" +optional = false +python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4" +files = [ + {file = "bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa"}, + {file = "bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee"}, +] + +[[package]] +name = "black" +version = "24.8.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, + {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, + {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, + {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, + {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, + {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, + {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, + {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, + {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, + {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, + {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, + {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, + {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, + {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, + {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, + {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, + {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, + {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, + {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, + {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, + {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, + {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "bracex" +version = "2.5.post1" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6"}, + {file = "bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6"}, +] + +[[package]] +name = "build" +version = "1.2.2.post1" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.8" +files = [ + {file = "build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5"}, + {file = "build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +importlib-metadata = {version = ">=4.6", markers = "python_full_version < \"3.10.2\""} +packaging = ">=19.1" +pyproject_hooks = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] +test = ["build[uv,virtualenv]", "filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "setuptools (>=56.0.0)", "setuptools (>=67.8.0)", "wheel (>=0.36.0)"] +typing = ["build[uv]", "importlib-metadata (>=5.1)", "mypy (>=1.9.0,<1.10.0)", "tomli", "typing-extensions (>=3.7.4.3)"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.0.35)"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cibuildwheel" +version = "2.21.3" +description = "Build Python wheels on CI with minimal configuration." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cibuildwheel-2.21.3-py3-none-any.whl", hash = "sha256:f1d036a13603a6ce4019d8b1bd52c296cf32461a3b3be8441434b60b8b378b80"}, + {file = "cibuildwheel-2.21.3.tar.gz", hash = "sha256:3ce23a9e5406b3eeb80039d7a6fdb218a2450932a8037c0bf76511cd88dfb74e"}, +] + +[package.dependencies] +bashlex = "!=0.13" +bracex = "*" +certifi = "*" +filelock = "*" +packaging = ">=20.9" +platformdirs = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +bin = ["click", "packaging (>=21.0)", "pip-tools", "pygithub", "pyyaml", "requests", "rich (>=9.6)"] +dev = ["build", "click", "jinja2", "packaging (>=21.0)", "pip-tools", "pygithub", "pytest (>=6)", "pytest-timeout", "pytest-xdist", "pyyaml", "requests", "rich (>=9.6)", "setuptools", "tomli-w", "validate-pyproject"] +docs = ["jinja2 (>=3.1.2)", "mkdocs (==1.6.1)", "mkdocs-include-markdown-plugin (==6.2.2)", "mkdocs-macros-plugin", "pymdown-extensions"] +test = ["build", "jinja2", "pytest (>=6)", "pytest-timeout", "pytest-xdist", "setuptools", "tomli-w", "validate-pyproject"] +uv = ["uv"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "k5test" +version = "0.10.4" +description = "A library for testing Python applications in self-contained Kerberos 5 environments" +optional = false +python-versions = ">=3.6" +files = [ + {file = "k5test-0.10.4-py2.py3-none-any.whl", hash = "sha256:33de7ff10bf99155fe8ee5d5976798ad1db6237214306dadf5a0ae9d6bb0ad03"}, + {file = "k5test-0.10.4.tar.gz", hash = "sha256:e152491e6602f6a93b3d533d387bd4590f2476093b6842170ff0b93de64bef30"}, +] + +[package.extras] +extension-test = ["gssapi"] + +[[package]] +name = "mypy" +version = "1.13.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + +[[package]] +name = "ruff" +version = "0.7.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, + {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, + {file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"}, + {file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"}, + {file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"}, + {file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"}, + {file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"}, + {file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"}, + {file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"}, + {file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"}, + {file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"}, + {file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"}, +] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8" +content-hash = "eb9341bbfc45163dbc002f9dc777abb9d2d4d1609c8a224769d135fcaf7e4150" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..64f0dc1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "python-kadmin-rs" +version = "0.0.0" +description = "Python interface to the Kerberos administration interface (kadm5)" +requires-python = ">=3.8,<=3.13" +readme = "README.md" +license = { file = "LICENSE" } +authors = [ + { name = "Marc 'risson' Schmitt", email = "marc.schmitt@risson.space" }, + { name = "authentik community", email = "hello@goauthentik.io" }, +] +keywords = ["krb5", "kadmin", "kadm5", "kerberos"] + +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: System Administrators", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python", + "Programming Language :: Rust", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Systems Administration :: Authentication/Directory", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/authentik-community/kadmin-rs" +Documentation = "https://github.com/authentik-community/kadmin-rs" +Repository = "https://github.com/authentik-community/kadmin-rs.git" + +[build-system] +requires = ["setuptools", "setuptools-rust", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages] +find = { where = ["python-kadmin-rs/python"] } + +[[tool.setuptools-rust.ext-modules]] +target = "kadmin._lib" +path = "python-kadmin-rs/Cargo.toml" +strip = "All" +args = ["--no-default-features"] +features = ["client"] + +[[tool.setuptools-rust.ext-modules]] +target = "kadmin_local._lib" +path = "python-kadmin-rs/Cargo.toml" +strip = "All" +args = ["--no-default-features"] +features = ["local"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.poetry] +package-mode = false +name = "kadmin-rs" + +[tool.poetry.dependencies] +python = ">=3.8" + +[tool.poetry.group.dev.dependencies] +black = "*" +build = "*" +cibuildwheel = "*" +mypy = "*" +ruff = "*" + +[tool.poetry.group.test.dependencies] +k5test = "*" diff --git a/python-kadmin-rs/Cargo.toml b/python-kadmin-rs/Cargo.toml new file mode 100644 index 0000000..15dd497 --- /dev/null +++ b/python-kadmin-rs/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "python-kadmin-rs" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +[features] +default = ["client"] +client = ["kadmin/client"] +local = ["kadmin/local"] + +[dependencies] +kadmin = { path = "../kadmin", version = "0.0.0", default-features = false } +pyo3 = { version = "0.22", features = ["extension-module"] } + +[lints] +workspace = true + +[package.metadata.release] +pre-release-replacements = [ + { file = "../pyproject.toml", search = "^version = \"[a-z0-9\\.-]+\"", replace = "version = \"{{ version }}\"", exactly = 1 }, +] diff --git a/python-kadmin-rs/python/kadmin/__init__.py b/python-kadmin-rs/python/kadmin/__init__.py new file mode 100644 index 0000000..0505610 --- /dev/null +++ b/python-kadmin-rs/python/kadmin/__init__.py @@ -0,0 +1,8 @@ +from kadmin._lib import KAdmin, Params, DbArgs, __version__ + +__all__ = ( + "__version__", + "KAdmin", + "Params", + "DbArgs", +) diff --git a/python-kadmin-rs/python/kadmin/__init__.pyi b/python-kadmin-rs/python/kadmin/__init__.pyi new file mode 100644 index 0000000..b8b64e3 --- /dev/null +++ b/python-kadmin-rs/python/kadmin/__init__.pyi @@ -0,0 +1,52 @@ +from typing import List, final + +__version__: str + +@final +class KAdmin: + def add_principal(self): ... + def delete_principal(self): ... + def modify_principal(self): ... + def rename_principal(self): ... + def get_principal(self): ... + def list_principals(self, query: str) -> List[str]: ... + def add_policy(self): ... + def modify_policy(self): ... + def delete_policy(self): ... + def get_policy(self): ... + def list_policies(self, query: str) -> List[str]: ... + def get_privs(self): ... + @staticmethod + def with_password( + client_name: str, + password: str, + params: Params | None = None, + db_args: DbArgs | None = None, + ) -> KAdmin: ... + @staticmethod + def with_keytab( + client_name: str | None = None, + keytab: str | None = None, + params: Params | None = None, + db_args: DbArgs | None = None, + ) -> KAdmin: ... + @staticmethod + def with_ccache( + client_name: str | None = None, + ccache_name: str | None = None, + params: Params | None = None, + db_args: DbArgs | None = None, + ) -> KAdmin: ... + @staticmethod + def with_anonymous( + client_name: str, params: Params | None = None, db_args: DbArgs | None = None + ) -> KAdmin: ... + + # @staticmethod + # def with_local(params: Params | None = None, db_args: DbArgs | None = None) -> KAdmin: ... + +@final +class Params: ... + +@final +class DbArgs: ... diff --git a/python-kadmin-rs/python/kadmin/exceptions/__init__.py b/python-kadmin-rs/python/kadmin/exceptions/__init__.py new file mode 100644 index 0000000..b5b1df0 --- /dev/null +++ b/python-kadmin-rs/python/kadmin/exceptions/__init__.py @@ -0,0 +1,19 @@ +from kadmin._lib import exceptions + +PyKAdminException = exceptions.PyKAdminException +KAdminException = exceptions.KAdminException +KerberosException = exceptions.KerberosException +NullPointerDereference = exceptions.NullPointerDereference +CStringConversion = exceptions.CStringConversion +CStringImportFromVec = exceptions.CStringImportFromVec +StringConversion = exceptions.StringConversion + +__all__ = ( + "PyKAdminException", + "KAdminException", + "KerberosException", + "NullPointerDereference", + "CStringConversion", + "CStringImportFromVec", + "StringConversion", +) diff --git a/python-kadmin-rs/python/kadmin/exceptions/__init__.pyi b/python-kadmin-rs/python/kadmin/exceptions/__init__.pyi new file mode 100644 index 0000000..b269670 --- /dev/null +++ b/python-kadmin-rs/python/kadmin/exceptions/__init__.pyi @@ -0,0 +1,7 @@ +class PyKAdminException(Exception): ... +class KAdminException(PyKAdminException): ... +class KerberosException(PyKAdminException): ... +class NullPointerDereference(PyKAdminException): ... +class CStringConversion(PyKAdminException): ... +class CStringImportFromVec(PyKAdminException): ... +class StringConversion(PyKAdminException): ... diff --git a/python-kadmin-rs/python/kadmin/py.typed b/python-kadmin-rs/python/kadmin/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python-kadmin-rs/python/kadmin_local/__init__.py b/python-kadmin-rs/python/kadmin_local/__init__.py new file mode 100644 index 0000000..f95be01 --- /dev/null +++ b/python-kadmin-rs/python/kadmin_local/__init__.py @@ -0,0 +1,8 @@ +from kadmin_local._lib import KAdmin, Params, DbArgs, __version__ + +__all__ = ( + "__version__", + "KAdmin", + "Params", + "DbArgs", +) diff --git a/python-kadmin-rs/python/kadmin_local/__init__.pyi b/python-kadmin-rs/python/kadmin_local/__init__.pyi new file mode 100644 index 0000000..c6fc801 --- /dev/null +++ b/python-kadmin-rs/python/kadmin_local/__init__.pyi @@ -0,0 +1,28 @@ +from typing import List, final + +__version__: str + +@final +class KAdmin: + def add_principal(self): ... + def delete_principal(self): ... + def modify_principal(self): ... + def rename_principal(self): ... + def get_principal(self): ... + def list_principals(self, query: str) -> List[str]: ... + def add_policy(self): ... + def modify_policy(self): ... + def delete_policy(self): ... + def get_policy(self): ... + def list_policies(self, query: str) -> List[str]: ... + def get_privs(self): ... + @staticmethod + def with_local( + params: Params | None = None, db_args: DbArgs | None = None + ) -> KAdmin: ... + +@final +class Params: ... + +@final +class DbArgs: ... diff --git a/python-kadmin-rs/python/kadmin_local/exceptions/__init__.py b/python-kadmin-rs/python/kadmin_local/exceptions/__init__.py new file mode 100644 index 0000000..a2c26fd --- /dev/null +++ b/python-kadmin-rs/python/kadmin_local/exceptions/__init__.py @@ -0,0 +1,19 @@ +from kadmin_local._lib import exceptions + +PyKAdminException = exceptions.PyKAdminException +KAdminException = exceptions.KAdminException +KerberosException = exceptions.KerberosException +NullPointerDereference = exceptions.NullPointerDereference +CStringConversion = exceptions.CStringConversion +CStringImportFromVec = exceptions.CStringImportFromVec +StringConversion = exceptions.StringConversion + +__all__ = ( + "PyKAdminException", + "KAdminException", + "KerberosException", + "NullPointerDereference", + "CStringConversion", + "CStringImportFromVec", + "StringConversion", +) diff --git a/python-kadmin-rs/python/kadmin_local/exceptions/__init__.pyi b/python-kadmin-rs/python/kadmin_local/exceptions/__init__.pyi new file mode 100644 index 0000000..b269670 --- /dev/null +++ b/python-kadmin-rs/python/kadmin_local/exceptions/__init__.pyi @@ -0,0 +1,7 @@ +class PyKAdminException(Exception): ... +class KAdminException(PyKAdminException): ... +class KerberosException(PyKAdminException): ... +class NullPointerDereference(PyKAdminException): ... +class CStringConversion(PyKAdminException): ... +class CStringImportFromVec(PyKAdminException): ... +class StringConversion(PyKAdminException): ... diff --git a/python-kadmin-rs/python/kadmin_local/py.typed b/python-kadmin-rs/python/kadmin_local/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python-kadmin-rs/src/lib.rs b/python-kadmin-rs/src/lib.rs new file mode 100644 index 0000000..96ee9d2 --- /dev/null +++ b/python-kadmin-rs/src/lib.rs @@ -0,0 +1,294 @@ +use pyo3::prelude::*; + +#[pymodule(name = "_lib")] +mod kadmin { + use kadmin::{ + db_args::KAdminDbArgsBuilder, + params::KAdminParamsBuilder, + sync::{KAdmin as SyncKAdmin, KAdminBuilder}, + }; + use pyo3::{ + prelude::*, + types::{PyDict, PyString}, + }; + + type Result = std::result::Result; + + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) + } + + #[pyclass] + #[derive(Clone)] + struct Params(KAdminParamsBuilder); + + #[pymethods] + impl Params { + #[new] + #[pyo3(signature = (realm=None, kadmind_port=None, kpasswd_port=None, admin_server=None, dbname=None, acl_file=None, dict_file=None, stash_file=None))] + #[allow(clippy::too_many_arguments)] + fn new( + realm: Option<&str>, + kadmind_port: Option, + kpasswd_port: Option, + admin_server: Option<&str>, + dbname: Option<&str>, + acl_file: Option<&str>, + dict_file: Option<&str>, + stash_file: Option<&str>, + ) -> Self { + let mut builder = KAdminParamsBuilder::default(); + if let Some(realm) = realm { + builder = builder.realm(realm); + } + if let Some(kadmind_port) = kadmind_port { + builder = builder.kadmind_port(kadmind_port); + } + if let Some(kpasswd_port) = kpasswd_port { + builder = builder.kpasswd_port(kpasswd_port); + } + if let Some(admin_server) = admin_server { + builder = builder.admin_server(admin_server); + } + if let Some(dbname) = dbname { + builder = builder.dbname(dbname); + } + if let Some(acl_file) = acl_file { + builder = builder.acl_file(acl_file); + } + if let Some(dict_file) = dict_file { + builder = builder.dict_file(dict_file); + } + if let Some(stash_file) = stash_file { + builder = builder.stash_file(stash_file); + } + Self(builder) + } + } + + #[pyclass] + #[derive(Clone)] + struct DbArgs(KAdminDbArgsBuilder); + + #[pymethods] + impl DbArgs { + #[new] + #[pyo3(signature = (**kwargs))] + fn new(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let mut builder = KAdminDbArgsBuilder::default(); + if let Some(kwargs) = kwargs { + for (name, value) in kwargs.iter() { + let name = if !name.is_instance_of::() { + name.str()? + } else { + name.extract()? + }; + builder = if !value.is_none() { + let value = value.str()?; + builder.arg(name.to_str()?, Some(value.to_str()?)) + } else { + builder.arg(name.to_str()?, None) + }; + } + } + Ok(Self(builder)) + } + } + + #[pyclass] + struct KAdmin(SyncKAdmin); + + impl KAdmin { + fn get_builder(params: Option, db_args: Option) -> KAdminBuilder { + let mut builder = KAdminBuilder::default(); + if let Some(params) = params { + builder = builder.params_builder(params.0); + } + if let Some(db_args) = db_args { + builder = builder.db_args_builder(db_args.0); + } + builder + } + } + + #[pymethods] + impl KAdmin { + fn add_principal(&self) { + unimplemented!(); + } + + fn delete_principal(&self) { + unimplemented!(); + } + + fn modify_principal(&self) { + unimplemented!(); + } + + fn rename_principal(&self) { + unimplemented!(); + } + + fn get_principal(&self) { + unimplemented!(); + } + + fn list_principals(&self, query: &str) -> Result> { + Ok(self.0.list_principals(query)?) + } + + fn add_policy(&self) { + unimplemented!(); + } + + fn modify_policy(&self) { + unimplemented!(); + } + + fn delete_policy(&self) { + unimplemented!(); + } + + fn get_policy(&self) { + unimplemented!(); + } + + fn list_policies(&self, query: &str) -> Result> { + Ok(self.0.list_policies(query)?) + } + + fn get_privs(&self) { + unimplemented!(); + } + + #[cfg(feature = "client")] + #[staticmethod] + #[pyo3(signature = (client_name, password, params=None, db_args=None))] + fn with_password( + client_name: &str, + password: &str, + params: Option, + db_args: Option, + ) -> Result { + Ok(Self( + Self::get_builder(params, db_args).with_password(client_name, password)?, + )) + } + + #[cfg(feature = "client")] + #[staticmethod] + #[pyo3(signature = (client_name=None, keytab=None, params=None, db_args=None))] + fn with_keytab( + client_name: Option<&str>, + keytab: Option<&str>, + params: Option, + db_args: Option, + ) -> Result { + Ok(Self( + Self::get_builder(params, db_args).with_keytab(client_name, keytab)?, + )) + } + + #[cfg(feature = "client")] + #[staticmethod] + #[pyo3(signature = (client_name=None, ccache_name=None, params=None, db_args=None))] + fn with_ccache( + client_name: Option<&str>, + ccache_name: Option<&str>, + params: Option, + db_args: Option, + ) -> Result { + Ok(Self( + Self::get_builder(params, db_args).with_ccache(client_name, ccache_name)?, + )) + } + + #[cfg(feature = "client")] + #[staticmethod] + #[pyo3(signature = (client_name, params=None, db_args=None))] + fn with_anonymous(client_name: &str, params: Option, db_args: Option) -> Result { + Ok(Self(Self::get_builder(params, db_args).with_anonymous(client_name)?)) + } + + #[cfg(feature = "local")] + #[staticmethod] + #[pyo3(signature = (params=None, db_args=None))] + fn with_local(params: Option, db_args: Option) -> Result { + Ok(Self(Self::get_builder(params, db_args).with_local()?)) + } + } + + #[pymodule] + mod exceptions { + use kadmin::Error; + use pyo3::{create_exception, exceptions::PyException, intern, prelude::*}; + + #[pymodule_init] + fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("PyKAdminException", m.py().get_type_bound::())?; + m.add("KAdminException", m.py().get_type_bound::())?; + m.add("KerberosException", m.py().get_type_bound::())?; + m.add("NullPointerDereference", m.py().get_type_bound::())?; + m.add("CStringConversion", m.py().get_type_bound::())?; + m.add("CStringImportFromVec", m.py().get_type_bound::())?; + m.add("StringConversion", m.py().get_type_bound::())?; + Ok(()) + } + + create_exception!(exceptions, PyKAdminException, PyException); + create_exception!(exceptions, KAdminException, PyKAdminException); + create_exception!(exceptions, KerberosException, PyKAdminException); + create_exception!(exceptions, NullPointerDereference, PyKAdminException); + create_exception!(exceptions, CStringConversion, PyKAdminException); + create_exception!(exceptions, CStringImportFromVec, PyKAdminException); + create_exception!(exceptions, StringConversion, PyKAdminException); + create_exception!(exceptions, ThreadSendError, PyKAdminException); + create_exception!(exceptions, ThreadRecvError, PyKAdminException); + + pub(crate) struct PyKAdminError(Error); + + impl From for PyKAdminError { + fn from(error: Error) -> Self { + Self(error) + } + } + + impl From for PyErr { + fn from(error: PyKAdminError) -> Self { + Python::with_gil(|py| { + let error = error.0; + let (exc, extras) = match &error { + Error::Kerberos { code, message } => ( + KerberosException::new_err(error.to_string()), + Some((*code as i64, message)), + ), + Error::KAdmin { code, message } => { + (KAdminException::new_err(error.to_string()), Some((*code, message))) + } + Error::NullPointerDereference => (NullPointerDereference::new_err(error.to_string()), None), + Error::CStringConversion(_) => (CStringConversion::new_err(error.to_string()), None), + Error::CStringImportFromVec(_) => (CStringImportFromVec::new_err(error.to_string()), None), + Error::StringConversion(_) => (StringConversion::new_err(error.to_string()), None), + Error::ThreadSendError => (ThreadSendError::new_err(error.to_string()), None), + Error::ThreadRecvError(_) => (ThreadRecvError::new_err(error.to_string()), None), + _ => (PyKAdminException::new_err("Unknown error: {}"), None), + }; + + if let Some((code, message)) = extras { + let bound_exc = exc.value_bound(py); + if let Err(err) = bound_exc.setattr(intern!(py, "code"), code) { + return err; + } + if let Err(err) = bound_exc.setattr(intern!(py, "origin_message"), message) { + return err; + } + } + + exc + }) + } + } + } +} diff --git a/python-kadmin-rs/tests/__init__.py b/python-kadmin-rs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-kadmin-rs/tests/test_init.py b/python-kadmin-rs/tests/test_init.py new file mode 100644 index 0000000..44df309 --- /dev/null +++ b/python-kadmin-rs/tests/test_init.py @@ -0,0 +1,36 @@ +from .utils import KerberosTestCase + +import kadmin +import kadmin_local + + +class TestInit(KerberosTestCase): + def test_with_password(self): + kadm = kadmin.KAdmin.with_password( + self.realm.admin_princ, self.realm.password("admin") + ) + kadm.list_principals("*") + + def test_with_keytab(self): + kadm = kadmin.KAdmin.with_password( + self.realm.admin_princ, self.realm.password("admin") + ) + kadm.list_principals("*") + + def test_with_ccache(self): + self.realm.prep_kadmin() + kadm = kadmin.KAdmin.with_ccache( + self.realm.admin_princ, self.realm.kadmin_ccache + ) + kadm.list_principals("*") + + def test_with_local(self): + db_args = kadmin_local.DbArgs(dbname=f"{self.realm.tmpdir}/db") + params = kadmin_local.Params( + dbname=f"{self.realm.tmpdir}/db", + acl_file=f"{self.realm.tmpdir}/acl", + dict_file=f"{self.realm.tmpdir}/dict", + stash_file=f"{self.realm.tmpdir}/stash", + ) + kadm = kadmin_local.KAdmin.with_local(db_args=db_args, params=params) + kadm.list_principals("*") diff --git a/python-kadmin-rs/tests/test_principal.py b/python-kadmin-rs/tests/test_principal.py new file mode 100644 index 0000000..311ae6c --- /dev/null +++ b/python-kadmin-rs/tests/test_principal.py @@ -0,0 +1,26 @@ +from .utils import KerberosTestCase + +import kadmin + + +class TestInit(KerberosTestCase): + def test_list_principals(self): + kadm = kadmin.KAdmin.with_password( + self.realm.admin_princ, self.realm.password("admin") + ) + self.assertEqual( + [ + princ + for princ in kadm.list_principals("*") + if not princ.startswith("host/") + ], + [ + "HTTP/testserver@KRBTEST.COM", + "K/M@KRBTEST.COM", + "kadmin/admin@KRBTEST.COM", + "kadmin/changepw@KRBTEST.COM", + "krbtgt/KRBTEST.COM@KRBTEST.COM", + "user/admin@KRBTEST.COM", + "user@KRBTEST.COM", + ], + ) diff --git a/python-kadmin-rs/tests/utils.py b/python-kadmin-rs/tests/utils.py new file mode 100644 index 0000000..1d3b87e --- /dev/null +++ b/python-kadmin-rs/tests/utils.py @@ -0,0 +1,31 @@ +import os +from copy import deepcopy +from k5test import realm +from unittest import TestCase + + +class KerberosTestCase(TestCase): + @classmethod + def setUpClass(cls): + cls.realm = realm.K5Realm(start_kadmind=True) + + cls.realm.http_princ = f"HTTP/testserver@{cls.realm.realm}" + cls.realm.http_keytab = os.path.join(cls.realm.tmpdir, "http_keytab") + cls.realm.addprinc(cls.realm.http_princ) + cls.realm.extract_keytab(cls.realm.http_princ, cls.realm.http_keytab) + + cls._saved_env = deepcopy(os.environ) + for k, v in cls.realm.env.items(): + os.environ[k] = v + + @classmethod + def tearDownClass(cls): + cls.realm.stop() + del cls.realm + + for k in deepcopy(os.environ): + if k in cls._saved_env: + os.environ[k] = cls._saved_env[k] + else: + del os.environ[k] + cls._saved_env = None diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"]